diff --git a/.github/ISSUE_TEMPLATE/00-bug.yml b/.github/ISSUE_TEMPLATE/00-bug.yml index 1aab31da..5f52226f 100644 --- a/.github/ISSUE_TEMPLATE/00-bug.yml +++ b/.github/ISSUE_TEMPLATE/00-bug.yml @@ -1,6 +1,5 @@ name: "Bug Report" description: Report a bug -title: "" labels: [ "bug" ] @@ -23,6 +22,16 @@ body: 3. See error validations: required: false + - type: dropdown + id: experimental + attributes: + label: Experimental settings + description: Do you have experiment settings turn on? + options: + - "Yes" + - "No" + validations: + required: true - type: input id: app-version attributes: diff --git a/.github/ISSUE_TEMPLATE/01-playback.yml b/.github/ISSUE_TEMPLATE/01-playback.yml index cbb287b5..389d4e8a 100644 --- a/.github/ISSUE_TEMPLATE/01-playback.yml +++ b/.github/ISSUE_TEMPLATE/01-playback.yml @@ -1,6 +1,5 @@ name: "Playback Problem" description: Report an issue with playback -title: "<title>" labels: [ "bug", "playback" ] @@ -12,12 +11,14 @@ body: description: Please enter a description of the playback issue validations: required: true - - type: textarea - id: mediainfo + - type: dropdown + id: experimental attributes: - label: "Media info" - description: Please enter details about what media you are trying to play (video or audio codecs, container, etc) - placeholder: Use mediainfo or "Send media info to server" in the app to gather this + label: Experimental settings + description: Do you have experiment settings turn on? + options: + - "Yes" + - "No" validations: required: true - type: dropdown @@ -32,6 +33,14 @@ body: - External App (please specify) validations: required: true + - type: textarea + id: mediainfo + attributes: + label: "Media info" + description: Please enter details about what media you are trying to play (video or audio codecs, container, etc) + placeholder: Use mediainfo or "Send media info to server" in the app to gather this + validations: + required: true - type: input id: app-version attributes: diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index d7b2f290..7cd04ce3 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -6,11 +6,11 @@ runs: steps: # Setup the SDKs - name: Set up Python - uses: actions/setup-python@v6 + uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7 with: python-version: '3.14' - name: Setup JDK - uses: actions/setup-java@v5 + uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6 with: distribution: zulu java-version: '21' @@ -21,9 +21,9 @@ runs: echo "BUILD_TOOLS_VERSION=36.0.0" >> $GITHUB_ENV echo "NDK_VERSION=29.0.14206865" >> $GITHUB_ENV - name: Setup Android SDK - uses: android-actions/setup-android@v4 + uses: android-actions/setup-android@be39fa834029ff78f1a44aa3bb0819b8fc2bd8fd # v4 with: - packages: "tools platform-tools build-tools;${{ env.BUILD_TOOLS_VERSION }} ndk;${{ env.NDK_VERSION }}" + packages: "platform-tools build-tools;${{ env.BUILD_TOOLS_VERSION }} ndk;${{ env.NDK_VERSION }}" - name: Add NDK to path shell: bash run: | diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 348b6bf1..cafd4889 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -16,7 +16,7 @@ concurrency: env: BRANCH_NAME: ${{ github.ref_name }} - GRADLE_OPTS: "-Dorg.gradle.jvmargs='-Xmx8g -XX:MaxMetaspaceSize=512m'" + GRADLE_OPTS: "-Dorg.gradle.jvmargs='-Xmx12g -XX:MaxMetaspaceSize=1024m'" jobs: build: @@ -25,7 +25,7 @@ jobs: contents: write steps: - name: Checkout the code - uses: actions/checkout@v6 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: fetch-depth: 0 # Need the tags to build - name: Setup @@ -54,7 +54,8 @@ jobs: ORG_GRADLE_PROJECT_WholphinExtensionsUsername: "${{ secrets.EXTENSIONS_USERNAME }}" ORG_GRADLE_PROJECT_WholphinExtensionsPassword: "${{ secrets.EXTENSIONS_PASSWORD }}" run: | - ./gradlew clean assembleDefaultRelease assembleDefaultDebug --no-daemon + ./gradlew clean assembleDefaultDebug + ./gradlew assembleDefaultRelease - name: Verify signatures run: | @@ -75,6 +76,13 @@ jobs: run: | echo "SHA256 checksums:" find app/build/outputs/apk -name '*.apk' -print0 | xargs -0 sha256sum + - name: Upload mapping files + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 + with: + name: mapping + path: | + app/build/outputs/**/mapping.txt + compression-level: 6 - name: Delete ${{ env.TAG_NAME }} tag and release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 8607abd8..e423cccf 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -9,29 +9,29 @@ defaults: env: BUILD_DIRS_ARTIFACT: build-dirs - GRADLE_OPTS: "-Dorg.gradle.jvmargs='-Xmx8g -XX:MaxMetaspaceSize=512m'" + GRADLE_OPTS: "-Dorg.gradle.jvmargs='-Xmx12g -XX:MaxMetaspaceSize=1024m'" jobs: pre-commit: runs-on: ubuntu-latest steps: - name: Checkout the code - uses: actions/checkout@v6 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: fetch-depth: 0 # Need the tags to build - name: Setup Python - uses: actions/setup-python@v6 + uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7 with: python-version: '3.14' - name: Run pre-commit - uses: pre-commit/action@v3.0.1 + uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd # v3.0.1 build: runs-on: ubuntu-latest needs: pre-commit steps: - name: Checkout the code - uses: actions/checkout@v6 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: fetch-depth: 0 # Need the tags to build - name: Setup diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4f4fc429..5b108ce5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,7 +11,7 @@ defaults: env: APK_SHORT_NAME: Wholphin.apk - GRADLE_OPTS: "-Dorg.gradle.jvmargs='-Xmx8g -XX:MaxMetaspaceSize=512m'" + GRADLE_OPTS: "-Dorg.gradle.jvmargs='-Xmx12g -XX:MaxMetaspaceSize=1024m'" TAG_NAME: ${{ github.ref_name }} jobs: @@ -21,7 +21,7 @@ jobs: contents: write steps: - name: Checkout the code - uses: actions/checkout@v6 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: fetch-depth: 0 # Need the tags to build - name: Setup @@ -36,8 +36,9 @@ jobs: ORG_GRADLE_PROJECT_WholphinExtensionsUsername: "${{ secrets.EXTENSIONS_USERNAME }}" ORG_GRADLE_PROJECT_WholphinExtensionsPassword: "${{ secrets.EXTENSIONS_PASSWORD }}" run: | - ./gradlew clean assembleDefaultRelease bundleAppstoreRelease bundleFiretvRelease --no-daemon - + ./gradlew clean bundleAppstoreRelease + ./gradlew bundleFiretvRelease + ./gradlew assembleDefaultRelease - name: Verify signatures run: | echo "Verify APK signatures" @@ -58,12 +59,19 @@ jobs: echo "SHA256 checksums:" find app/build/outputs \( -name '*.apk' -or -name '*.aab' \) -print0 | xargs -0 sha256sum - name: Upload AAB - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: AAB path: | app/build/outputs/bundle/**/*.aab compression-level: 0 + - name: Upload mapping files + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 + with: + name: mapping + path: | + app/build/outputs/**/mapping.txt + compression-level: 6 - name: Create GitHub release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index a2338108..0d818e21 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -42,6 +42,8 @@ If you want to include these in a local build, see the [instructions here](https You can also build the extensions locally from https://github.com/damontecres/wholphin-extensions and include them in `app/libs`. The gradle build dependency resolution prefers these local files over fetching from the remote maven registry. +Finally, if no `wholphin-mpv` implementation is found, `wholphin-mpv-stub` will be used. This allows the app to compile, but any runtime usage of MPV will throw an exception. + ## Code organization Code is split into several packages: @@ -95,3 +97,9 @@ Some of the models, such as `GetItemsFilter` or `ViewOptions`, are stored as JSO These settings are similar to Room ones and are usually stored per user. These can be accessed via `KeyValueService`. The values are `@Serializable` objects saved as JSON. This should be used sparingly. + +#### Experimental + +Some settings are considered "experimental". These may cause instabilities or may need further testing. They may be changed or removed any time. + +These are stored in the `ExperimentalPreferences` object in `AppPreferences`. Accessing them should always be gated with `ExperimentalPreferences.enabled`. There are a few helper functions for this purpose. diff --git a/Intents.md b/Intents.md new file mode 100644 index 00000000..7e358955 --- /dev/null +++ b/Intents.md @@ -0,0 +1,168 @@ +# Intents + +> [!IMPORTANT] +> This feature is experimental and the exact behaviors and/or parameters may change at any time + +Wholphin accepts [intents](https://developer.android.com/guide/components/intents-filters) to load or play specified media. + +Parameters can be specified as "extras" (eg `--es query value`) or as query parameters to the data URI (eg `-d wholphin://search?query=value`). + +UUID parameters can be specified with or without hyphens. + +If using data URI query parameters on the command line, you need to escape ampersands: `itemId=...\&userId=...` + +## Server/user switching + +You can choose a server and user to switch to with the `serverId` and `userId` UUID parameters. If the profile is not protected, the app will switch to it. + +If not specified, the app will use the current user if auto sign in is enabled. + +## Search + +Action: `android.intent.action.SEARCH` + +Shorthand action: `search` + +Parameters: +- `query` - Optional, prepopulate the search field + + +### Examples + +Full format +```bash +adb shell am start \ + -a android.intent.action.SEARCH \ + -n 'org.justinvisser.wolfskin.tv/com.github.damontecres.wholphin.MainActivity' \ + --es query "Avengers" +``` + +URI based +```bash +adb shell am start -d 'wholphin://search?query=Avengers' +``` + +Multi-word query: +```bash +# 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' \ + --es query 'Avengers Endgame'" + +adb shell am start -d 'wholphin://search?query=Avengers+Endgame' +adb shell am start -d 'wholphin://search?query=Avengers%20Endgame' +``` + +## View + +This opens the app to the specified item such as an episode or movie + +Action: `android.intent.action.VIEW` + +Shorthand action: `view` + +Parameters: +- `itemId` - Optional, the UUID of the media item, if not set, goes to home screen + + +### Examples + +Full format +```bash +adb shell am start \ + -a android.intent.action.VIEW \ + -n 'org.justinvisser.wolfskin.tv/com.github.damontecres.wholphin.MainActivity' \ + --es itemId "5cf8f8e7-2a5f-4aa9-8c12-ddf63d42ee6d" +``` + +URI based +```bash +adb shell am start -d 'wholphin://view?itemId=5cf8f8e7-2a5f-4aa9-8c12-ddf63d42ee6d' +``` + +## Playback + +Action: `com.github.damontecres.wholphin.PLAYBACK` + +Shorthand action: `play` + +Parameters: +- `itemId` - Required, the UUID of the media item +- `position` - Optional, the start position for playback in milliseconds +- `shuffle` - Optional, whether to shuffle the resulting items + +### Examples + +Full format +```bash +adb shell am start \ + -a com.github.damontecres.wholphin.PLAYBACK \ + -n 'org.justinvisser.wolfskin.tv/com.github.damontecres.wholphin.MainActivity' \ + --es itemId "5cf8f8e7-2a5f-4aa9-8c12-ddf63d42ee6d" +``` + +URI based +```bash +adb shell am start -d 'wholphin://play?itemId=5cf8f8e7-2a5f-4aa9-8c12-ddf63d42ee6d' +``` + +Start at 4m30s: +```bash +adb shell am start \ + -a com.github.damontecres.wholphin.PLAYBACK \ + -n 'org.justinvisser.wolfskin.tv/com.github.damontecres.wholphin.MainActivity' \ + --es itemId "5cf8f8e7-2a5f-4aa9-8c12-ddf63d42ee6d" \ + --el position 270000 + +adb shell am start -d 'wholphin://play?itemId=5cf8f8e7-2a5f-4aa9-8c12-ddf63d42ee6d\&position=270000' +``` + +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' \ + --es itemId "5cf8f8e7-2a5f-4aa9-8c12-ddf63d42ee6d" \ + --ez shuffle true + +adb shell am start -d 'wholphin://play?itemId=5cf8f8e7-2a5f-4aa9-8c12-ddf63d42ee6d\&shuffle=true' +``` + +## More examples + +### Switch user + +Full format +```bash +# Switch user, go to item +adb shell am start \ + -a android.intent.action.VIEW \ + -n 'org.justinvisser.wolfskin.tv/com.github.damontecres.wholphin.MainActivity' \ + --es itemId "5cf8f8e7-2a5f-4aa9-8c12-ddf63d42ee6d" \ + --es serverId "341880f9-ca88-4038-8718-f6b2407950b1" \ + --es userId "5c8575e0-468d-44fb-8c70-00aa79160587" + +# 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' \ + --es serverId "341880f9-ca88-4038-8718-f6b2407950b1" \ + --es userId "5c8575e0-468d-44fb-8c70-00aa79160587" +``` + +URI based +```bash +adb shell am start -d 'wholphin://view?itemId=66c80aaac9c17d3761782d2205520229\&serverId=341880f9-ca88-4038-8718-f6b2407950b1\&userId=5c8575e0-468d-44fb-8c70-00aa79160587' +``` + +### Debug build + +Debug builds have a different package, so you must specify the component by its full name: + +```bash +adb shell am start \ + -a android.intent.action.VIEW \ + -n 'org.justinvisser.wolfskin.tv.debug/com.github.damontecres.wholphin.MainActivity' \ + --es itemId "5cf8f8e7-2a5f-4aa9-8c12-ddf63d42ee6d" +``` diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..df6bd824 --- /dev/null +++ b/Makefile @@ -0,0 +1,21 @@ +.DEFAULT_GOAL := apk +WOLFSKIN_BUILD_IMAGE ?= wolfskin-build:sdk37-jdk21 +WOLFSKIN_GRADLE_VOLUME ?= wolfskin-gradle +WOLFSKIN_SECRETS_DIR ?= /opt/stacks/ventoy/machine-parity/manifest-tank/secrets/wolfskin +export WOLFSKIN_BUILD_IMAGE WOLFSKIN_GRADLE_VOLUME WOLFSKIN_SECRETS_DIR + +.PHONY: build-image apk apk-debug test release-tag +build-image: + docker build --platform linux/amd64 --tag "$(WOLFSKIN_BUILD_IMAGE)" docker + +apk: build-image + @bash docker/gradle.sh release clean assembleDefaultRelease + +apk-debug: build-image + @bash docker/gradle.sh debug assembleDefaultDebug + +test: build-image + @bash docker/gradle.sh debug testDefaultDebugUnitTest + +release-tag: + @bash docker/release-tag.sh diff --git a/README.md b/README.md index 0957c4ed..b8f04e12 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,15 @@ +# Wolfskin + +Wolfskin is a personal fork of [Wholphin](https://github.com/damontecres/Wholphin) by damontecres. + +Licensed under GPL-2.0, the same licence as upstream (see [LICENSE](LICENSE)). + +Wolfskin is an Android TV client for one household. It is released through Forgejo releases on this repo, and it tracks upstream by merging each upstream release tag. + +The app uses Wolfskin launcher artwork and a lilac-on-`#07070a` theme by default on a fresh install. Existing theme choices and all upstream colour themes remain available under Settings → App theme. Settings → About credits and links to Wholphin by damontecres. + +--- + # Wholphin - an OSS Android TV client for Jellyfin > "Never half-phin two jellies. Always wholphin one jelly." @@ -32,31 +44,39 @@ This is not a fork of the [official client](https://github.com/jellyfin/jellyfin - Customize the home page to see the content you are interested in - Use poster or thumb images, show/hide titles, add/remove/re-order different types of rows! + - Option to combine the Continue Watching & Next Up into a single row + - Pin collections, playlists, favorites, genres, and studios + - Remove series from next up - A navigation drawer for quick access to libraries, favorites, search, and settings from almost anywhere in the app -- Integration with [Jellyseerr/Seerr](https://github.com/seerr-team/seerr) to discover new movies and TV shows +- Integration with [Seerr](https://github.com/seerr-team/seerr) to discover new movies and TV shows - Note: only available when installed from [GitHub](https://github.com/damontecres/Wholphin/releases/latest) or the [Play store](https://play.google.com/store/apps/details?id=com.github.damontecres.wholphin) -- Option to combine Continue Watching & Next Up rows -- Show Movie/TV Show titles when browsing libraries +- Customize library display + - Option to show Movie/TV Show titles + - Choose image types & size + - Choose from grid or list layouts - Play theme music, if available -- Customize subtitle style for plain text subtitles +- Customize subtitle style for plain text subtitles with separate HDR settings - Search & download subtitles (requires compatible server plugin such as [OpenSubtitles](https://github.com/jellyfin/jellyfin-plugin-opensubtitles)) -- Customize layout grids for libraries - Multiple app color themes -- Protect user profile switches with PIN code +- Protect user profile switches with PIN code or require server login +- Change the user interface language per user +- In-app & Android OS screensaver support ### Playback - Different media playback engines: - - **ExoPlayer** w/ optional extra audio & AV1 software decoding - - **MPV** for direct playing anything plus ASS subtitle support + - **ExoPlayer** w/ optional extra audio, SSA/ASS, & AV1 software decoding + - **MPV** for direct playing anything with great SSA/ASS subtitle support - Plex inspired playback controls: - Using D-Pad left/right for seeking during playback - Quickly access video chapters & queue during playback - Optionally skip back a few seconds when resuming playback - Live TV & DVR support +- Music & music video support +- Cinema mode/pre-roll intro support (requires compatible server plugin such as [Intros](https://github.com/jellyfin/jellyfin-plugin-intros)) - Auto play next episodes with pass out protection - Option for automatic refresh rate & resolution switching on supported displays -- Trickplay support +- Trickplay (video preview) support - Subtly show playback position along the bottom of the screen while seeking w/ D-Pad ### Roadmap @@ -95,7 +115,7 @@ Requires Android 6+ (or Fire TV OS 6+) and Jellyfin server `10.10.x` or `10.11.x The app is tested on a variety of Android TV/Fire TV OS devices, but if you encounter issues, please file an issue! -Jellyseerr integration is tested with `v2.7.3`. Older versions may not work. +Seerr integration is generally tested with the latest stable version. Older versions may not work as expected. The Seerr server should configured with integration to your Jellyfin server for full compatibility. ## Contributions diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7f5bbcec..98f03a3a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,3 +1,4 @@ +import com.android.build.api.artifact.SingleArtifact import com.android.build.api.dsl.ApplicationExtension import com.android.build.api.dsl.ProductFlavor import com.android.build.api.variant.FilterConfiguration @@ -5,6 +6,7 @@ import com.google.protobuf.gradle.id import com.mikepenz.aboutlibraries.plugin.DuplicateMode import com.mikepenz.aboutlibraries.plugin.DuplicateRule import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import java.io.File import java.util.Base64 import java.util.Properties @@ -20,12 +22,27 @@ plugins { alias(libs.plugins.openapi.generator) } -val isCI = if (System.getenv("CI") != null) System.getenv("CI").toBoolean() else false -val shouldSign = isCI && System.getenv("KEY_ALIAS") != null -val ffmpegModuleExists = project.file("libs/lib-decoder-ffmpeg-release.aar").exists() -val av1ModuleExists = project.file("libs/lib-decoder-av1-release.aar").exists() -val mpvModuleExists = project.file("libs/wholphin-mpv-release.aar").exists() -val extensionsRepoActive = project.hasProperty("WholphinExtensionsUsername") +val isCI = providers.environmentVariable("CI").orElse("false").map { it.toBoolean() } +val shouldSign = + isCI.zip( + providers.environmentVariable("KEY_ALIAS").orElse("").map { it.isNotBlank() }, + ) { isCI, hasKey -> + isCI && hasKey + } +val ffmpegModuleExists = + providers.provider { project.file("libs/lib-decoder-ffmpeg-release.aar").exists() } +val av1ModuleExists = + providers.provider { project.file("libs/lib-decoder-av1-release.aar").exists() } +val mpvModuleExists = + providers.provider { project.file("libs/wholphin-mpv-release.aar").exists() } +val extensionsRepoActive = + providers.provider { project.hasProperty("WholphinExtensionsUsername") } + +// See https://issuetracker.google.com/issues/402800800 +val isBuildingBundle = + providers.provider { + gradle.startParameter.taskNames.any { it.lowercase().contains("bundle") } + } val gitTags = providers @@ -44,26 +61,32 @@ kotlin { languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_3 jvmTarget = JvmTarget.JVM_11 javaParameters = true + + // Workaround for https://github.com/google/ksp/issues/2964 + moduleName = "${project.group}_${project.name}" } } +private fun Provider<String>.getInt() = get().toInt() + configure<ApplicationExtension> { namespace = "com.github.damontecres.wholphin" - compileSdk = 36 + compileSdk = libs.versions.compileSdk.getInt() defaultConfig { - applicationId = "com.github.damontecres.wholphin" - minSdk = 23 - targetSdk = 36 + applicationId = "org.justinvisser.wolfskin.tv" + minSdk = libs.versions.minSdk.getInt() + targetSdk = libs.versions.targetSdk.getInt() versionCode = gitTags.trim().lines().size versionName = gitDescribe.trim().removePrefix("v").ifBlank { "0.0.0" } testInstrumentationRunner = "com.github.damontecres.wholphin.test.WholphinTestRunner" buildConfigField("long", "BUILD_TIME", System.currentTimeMillis().toString()) + buildConfigField("String", "CONDUCTARR_BASE_URL", "\"https://wolfskin-tv.justinvisser.org\"") } signingConfigs { - if (shouldSign) { + if (shouldSign.get()) { create("ci") { file("ci.keystore").writeBytes( Base64.getDecoder().decode(System.getenv("SIGNING_KEY")), @@ -82,13 +105,14 @@ configure<ApplicationExtension> { buildTypes { release { - isMinifyEnabled = false + isMinifyEnabled = true + isShrinkResources = true proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro", ) isDebuggable = false - if (shouldSign) { + if (shouldSign.get()) { signingConfig = signingConfigs.getByName("ci") } else { val localPropertiesFile = project.rootProject.file("local.properties") @@ -105,9 +129,17 @@ configure<ApplicationExtension> { debug { isMinifyEnabled = false + isShrinkResources = false isDebuggable = true applicationIdSuffix = ".debug" } + + create("optimized") { + initWith(getByName("release")) + signingConfig = signingConfigs.getByName("debug") + applicationIdSuffix = ".opt" + matchingFallbacks += "release" + } } flavorDimensions += "version" productFlavors { @@ -154,9 +186,11 @@ configure<ApplicationExtension> { splits { abi { - isEnable = true + // Disable split abis when building bundles + isEnable = !isBuildingBundle.get() + reset() - include("armeabi-v7a", "arm64-v8a") + include("armeabi-v7a", "arm64-v8a", "x86_64") isUniversalApk = true } } @@ -170,6 +204,11 @@ configure<ApplicationExtension> { sourceSets { getByName("main") { kotlin.directories += "$buildDir/generated/seerr_api/src/main/kotlin" + kotlin.directories += "src/wolfskin/java" + } + getByName("optimized") { + kotlin.directories += "src/release/java" + baselineProfiles.directories += "src/optimized/baselineProfiles" } } @@ -182,6 +221,9 @@ configure<ApplicationExtension> { lint { disable.add("MissingTranslation") } + androidResources { + generateLocaleConfig = true + } } androidComponents { @@ -197,6 +239,52 @@ androidComponents { "Wholphin-${variant.flavorName}-${variant.buildType}-${output.versionName.get()}-${output.versionCode.get()}$abi.apk" output.outputFileName = outputFileName } + if (variant.name == "defaultOptimized") { + tasks.withType<Test>().configureEach { + systemProperty("wolfskin.optimizedVersionName", variant.outputs.first().versionName.get()) + } + } + if (variant.name == "defaultDebug") { + val packagedApks = variant.artifacts.get(SingleArtifact.APK) + val artifactLoader = variant.artifacts.getBuiltArtifactsLoader() + val aapt2 = sdkComponents.aapt2.flatMap { it.executable } + val checkWolfskinBranding = tasks.register("checkWolfskinBranding") { + inputs.dir(packagedApks) + inputs.file(aapt2) + doLast { + val artifacts = checkNotNull(artifactLoader.load(packagedApks.get())) { + "Packaged default/debug APK metadata was not found; branding is unverified" + }.elements + check(artifacts.isNotEmpty()) { "No default/debug APKs were found; branding is unverified" } + val header = Regex("""^\s*resource 0x[\da-fA-F]+ (?:[^\s:]+:)?([^\s/]+)/([^\s]+)""") + val visibleTypes = setOf("string", "plurals", "array") + // Upstream legal attribution and its project URL must survive the branding cutover. + val legalNames = setOf("wolfskin_upstream_credit", "wolfskin_upstream_url") + val violations = sortedSetOf<String>() + artifacts.forEach { artifact -> + val dump = providers.exec { + commandLine(aapt2.get().asFile, "dump", "resources", artifact.outputFile) + }.standardOutput.asText.get() + var currentName: String? = null + var inspected = 0 + dump.lineSequence().forEach { line -> + val resource = header.find(line) + if (resource != null) { + currentName = resource.groupValues[2].takeIf { resource.groupValues[1] in visibleTypes } + if (currentName != null) inspected++ + } else if (currentName != null && currentName !in legalNames && line.contains("Wholphin", ignoreCase = true)) { + violations += "${File(artifact.outputFile).name}:$currentName" + } + } + check(inspected > 0) { "No packaged string resources were decoded; branding is unverified" } + logger.lifecycle("Inspected $inspected string/plural/array resources in ${File(artifact.outputFile).name}") + } + check(violations.isEmpty()) { "User-visible upstream branding remains: ${violations.joinToString()}" } + logger.lifecycle("Wolfskin branding check passed (${artifacts.size} packaged debug APKs; legal attribution preserved)") + } + } + tasks.matching { it.name == "assembleDefaultDebug" }.configureEach { dependsOn(checkWolfskinBranding) } + } } } @@ -261,6 +349,8 @@ tasks.named("preBuild") { } dependencies { + implementation(project(":wolfskin-design")) + implementation(project(":conductarr-client")) implementation(libs.androidx.core.ktx) implementation(libs.androidx.appcompat) implementation(platform(libs.androidx.compose.bom)) @@ -268,11 +358,9 @@ dependencies { implementation(libs.androidx.compose.ui.graphics) implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.compose.runtime) - implementation(libs.androidx.compose.runtime.livedata) implementation(libs.androidx.tv.foundation) implementation(libs.androidx.tv.material) implementation(libs.androidx.lifecycle.runtime.ktx) - implementation(libs.androidx.lifecycle.livedata.ktx) implementation(libs.androidx.activity.compose) implementation(libs.androidx.datastore) implementation(libs.androidx.datastore.preferences) @@ -280,6 +368,7 @@ dependencies { implementation(libs.androidx.tvprovider) implementation(libs.androidx.work.runtime.ktx) implementation(libs.androidx.hilt.work) + implementation(libs.androidx.profileinstaller) implementation(libs.androidx.media3.exoplayer) implementation(libs.androidx.media3.session) @@ -289,6 +378,7 @@ dependencies { implementation(libs.androidx.media3.ui) implementation(libs.androidx.media3.ui.compose) implementation(libs.ass.media) + implementation(libs.exoplayerhdrutils) implementation(libs.coil.core) implementation(libs.coil.compose) @@ -312,7 +402,6 @@ dependencies { implementation(libs.androidx.room.common.jvm) implementation(libs.androidx.room.ktx) implementation(libs.androidx.compose.material3) - implementation(libs.androidx.lifecycle.viewmodel.navigation3) implementation(libs.androidx.hilt.navigation.compose) implementation(libs.androidx.preference.ktx) implementation(libs.androidx.room.testing) @@ -340,48 +429,59 @@ dependencies { implementation(libs.kache) implementation(libs.kache.file) - androidTestImplementation(platform(libs.androidx.compose.bom)) - androidTestImplementation(libs.androidx.compose.ui.test.junit4) - debugImplementation(libs.androidx.compose.ui.tooling) - debugImplementation(libs.androidx.compose.ui.test.manifest) coreLibraryDesugaring(libs.desugar.jdk.libs) - if (ffmpegModuleExists) { + if (ffmpegModuleExists.get()) { logger.info("Using local ffmpeg decoder") implementation(files("libs/lib-decoder-ffmpeg-release.aar")) - } else if (extensionsRepoActive) { + } else if (extensionsRepoActive.get()) { logger.info("Using prebuilt ffmpeg decoder") implementation(libs.wholphin.extensions.ffmpeg) } else { logger.warn("Media3 ffmpeg decoder was NOT found") } - if (av1ModuleExists) { + if (av1ModuleExists.get()) { logger.info("Using local av1 decoder") implementation(files("libs/lib-decoder-av1-release.aar")) - } else if (extensionsRepoActive) { + } else if (extensionsRepoActive.get()) { logger.info("Using prebuilt av1 decoder") implementation(libs.wholphin.extensions.av1) } else { logger.warn("Media3 av1 decoder was NOT found") } - if (mpvModuleExists) { + if (mpvModuleExists.get()) { logger.info("Using local libMPV build") implementation(files("libs/wholphin-mpv-release.aar")) - } else if (extensionsRepoActive) { + } else if (extensionsRepoActive.get()) { logger.info("Using prebuilt libMPV") implementation(libs.wholphin.extensions.mpv) } else { - logger.warn("libMPV was NOT found") + logger.warn("libMPV was NOT found, using stub library") + implementation(project(":wholphin-mpv-stub")) } + debugImplementation(libs.androidx.compose.ui.test.manifest) + debugImplementation(libs.androidx.compose.ui.tooling) testImplementation(libs.mockk.android) testImplementation(libs.mockk.agent) testImplementation(libs.kotlinx.coroutines.test) + testImplementation("com.squareup.okhttp3:mockwebserver3") testImplementation(libs.androidx.core.testing) testImplementation(libs.robolectric) testImplementation(libs.hilt.android.testing) testImplementation(libs.androidx.compose.ui.test.junit4) + testImplementation("io.github.takahirom.roborazzi:roborazzi:1.77.0") + testImplementation("io.github.takahirom.roborazzi:roborazzi-compose:1.77.0") + androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(libs.mockk.android) androidTestImplementation(libs.hilt.android.testing) - androidTestImplementation(libs.androidx.ui.test.manifest) + androidTestImplementation(libs.androidx.compose.ui.test.manifest) +} + +tasks.withType<Test>().configureEach { + dependsOn(":wolfskin-design:testDebugUnitTest") + systemProperty("robolectric.dependency.dir", project(":wolfskin-design").layout.buildDirectory.dir("robolectric-sdk").get().asFile.absolutePath) + systemProperty("robolectric.offline", "true") + systemProperty("roborazzi.test.record", "true") + systemProperty("wolfskin.captureDirectory", rootProject.layout.buildDirectory.dir("wolfskin-captures").get().asFile.absolutePath) } diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 462dd204..b2a0c548 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -1,21 +1,27 @@ -# Add project specific ProGuard rules here. -# You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle. -# -# For more details, see -# http://developer.android.com/guide/developing/tools/proguard.html - -# If your project uses WebView with JS, uncomment the following -# and specify the fully qualified class name to the JavaScript interface -# class: -#-keepclassmembers class fqcn.of.javascript.interface.for.webview { -# public *; -#} - -# Uncomment this to preserve the line number information for -# debugging stack traces. -keepattributes SourceFile,LineNumberTable -# If you keep the line number information, uncomment this to -# hide the original source file name. -#-renamesourcefileattribute SourceFile +-keep class com.github.damontecres.wholphin.mpv.MPVLib { *; } + +-keep class * extends com.google.protobuf.GeneratedMessageLite { *; } +-keepclassmembers class * extends com.google.protobuf.GeneratedMessageLite { + <fields>; + <methods>; +} +-dontwarn com.google.protobuf.** + +# TODO investigate using smaller scope +-keep class com.google.common.cache.** { *; } + +# Media3SubtitleOverride uses reflection to access/modify these fields +-keep class androidx.media3.ui.SubtitleView { + private androidx.media3.ui.SubtitleView$Output output; +} +-keep class androidx.media3.ui.CanvasSubtitleOutput { + private final java.util.List painters; +} +-keep class androidx.media3.ui.SubtitlePainter { + private final float outlineWidth; +} + +# Keep external/native functions +-keep class com.suyashbelekar.exoplayerhdrutils.libdovi.LibDovi { *; } diff --git a/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/32.json b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/32.json new file mode 100644 index 00000000..5e58b40e --- /dev/null +++ b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/32.json @@ -0,0 +1,659 @@ +{ + "formatVersion": 1, + "database": { + "version": 32, + "identityHash": "934c38e7f4eb68c9a223ddb5c7e3e35d", + "entities": [ + { + "tableName": "servers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `url` TEXT NOT NULL, `version` TEXT, `lastUsed` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT" + }, + { + "fieldPath": "lastUsed", + "columnName": "lastUsed", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `id` TEXT NOT NULL, `name` TEXT, `serverId` TEXT NOT NULL, `accessToken` TEXT, `pin` TEXT, `requireLogin` INTEGER NOT NULL DEFAULT false, `lastUsed` TEXT, FOREIGN KEY(`serverId`) REFERENCES `servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "rowId", + "columnName": "rowId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT" + }, + { + "fieldPath": "pin", + "columnName": "pin", + "affinity": "TEXT" + }, + { + "fieldPath": "requireLogin", + "columnName": "requireLogin", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "lastUsed", + "columnName": "lastUsed", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "rowId" + ] + }, + "indices": [ + { + "name": "index_users_id_serverId", + "unique": true, + "columnNames": [ + "id", + "serverId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_users_id_serverId` ON `${TABLE_NAME}` (`id`, `serverId`)" + }, + { + "name": "index_users_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_users_id` ON `${TABLE_NAME}` (`id`)" + }, + { + "name": "index_users_serverId", + "unique": false, + "columnNames": [ + "serverId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_users_serverId` ON `${TABLE_NAME}` (`serverId`)" + } + ], + "foreignKeys": [ + { + "table": "servers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serverId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ItemPlayback", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sourceId` TEXT, `audioIndex` INTEGER NOT NULL, `subtitleIndex` INTEGER NOT NULL, FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "rowId", + "columnName": "rowId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceId", + "columnName": "sourceId", + "affinity": "TEXT" + }, + { + "fieldPath": "audioIndex", + "columnName": "audioIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subtitleIndex", + "columnName": "subtitleIndex", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "rowId" + ] + }, + "indices": [ + { + "name": "index_ItemPlayback_userId_itemId", + "unique": true, + "columnNames": [ + "userId", + "itemId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_ItemPlayback_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)" + } + ], + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "NavDrawerPinnedItem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `type` TEXT NOT NULL, `order` INTEGER NOT NULL DEFAULT -1, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "itemId" + ] + }, + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "LibraryDisplayInfo", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sort` TEXT NOT NULL, `direction` TEXT NOT NULL, `filter` TEXT NOT NULL DEFAULT '{}', `viewOptions` TEXT, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sort", + "columnName": "sort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "direction", + "columnName": "direction", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "filter", + "columnName": "filter", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'{}'" + }, + { + "fieldPath": "viewOptions", + "columnName": "viewOptions", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "itemId" + ] + }, + "indices": [ + { + "name": "index_LibraryDisplayInfo_userId_itemId", + "unique": true, + "columnNames": [ + "userId", + "itemId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_LibraryDisplayInfo_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)" + } + ], + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "playback_effects", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`jellyfinUserRowId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `type` TEXT NOT NULL, `rotation` INTEGER NOT NULL, `brightness` INTEGER NOT NULL, `contrast` INTEGER NOT NULL, `saturation` INTEGER NOT NULL, `hue` INTEGER NOT NULL, `red` INTEGER NOT NULL, `green` INTEGER NOT NULL, `blue` INTEGER NOT NULL, `blur` INTEGER NOT NULL, PRIMARY KEY(`jellyfinUserRowId`, `itemId`, `type`))", + "fields": [ + { + "fieldPath": "jellyfinUserRowId", + "columnName": "jellyfinUserRowId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "videoFilter.rotation", + "columnName": "rotation", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.brightness", + "columnName": "brightness", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.contrast", + "columnName": "contrast", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.saturation", + "columnName": "saturation", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.hue", + "columnName": "hue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.red", + "columnName": "red", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.green", + "columnName": "green", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.blue", + "columnName": "blue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.blur", + "columnName": "blur", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "jellyfinUserRowId", + "itemId", + "type" + ] + } + }, + { + "tableName": "PlaybackLanguageChoice", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `seriesId` TEXT NOT NULL, `itemId` TEXT, `audioLanguage` TEXT, `subtitleLanguage` TEXT, `subtitlesDisabled` INTEGER, PRIMARY KEY(`userId`, `seriesId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "seriesId", + "columnName": "seriesId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT" + }, + { + "fieldPath": "audioLanguage", + "columnName": "audioLanguage", + "affinity": "TEXT" + }, + { + "fieldPath": "subtitleLanguage", + "columnName": "subtitleLanguage", + "affinity": "TEXT" + }, + { + "fieldPath": "subtitlesDisabled", + "columnName": "subtitlesDisabled", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "seriesId" + ] + }, + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "ItemTrackModification", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `trackIndex` INTEGER NOT NULL, `delayMs` INTEGER NOT NULL, PRIMARY KEY(`userId`, `itemId`, `trackIndex`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "trackIndex", + "columnName": "trackIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "delayMs", + "columnName": "delayMs", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "itemId", + "trackIndex" + ] + }, + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "seerr_servers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `url` TEXT NOT NULL, `name` TEXT, `version` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_seerr_servers_url", + "unique": true, + "columnNames": [ + "url" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_seerr_servers_url` ON `${TABLE_NAME}` (`url`)" + } + ] + }, + { + "tableName": "seerr_users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`jellyfinUserRowId` INTEGER NOT NULL, `serverId` INTEGER NOT NULL, `authMethod` TEXT NOT NULL, `username` TEXT, `password` TEXT, `credential` TEXT, PRIMARY KEY(`jellyfinUserRowId`, `serverId`), FOREIGN KEY(`serverId`) REFERENCES `seerr_servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`jellyfinUserRowId`) REFERENCES `users`(`rowId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "jellyfinUserRowId", + "columnName": "jellyfinUserRowId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authMethod", + "columnName": "authMethod", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT" + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT" + }, + { + "fieldPath": "credential", + "columnName": "credential", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "jellyfinUserRowId", + "serverId" + ] + }, + "foreignKeys": [ + { + "table": "seerr_servers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serverId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "jellyfinUserRowId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '934c38e7f4eb68c9a223ddb5c7e3e35d')" + ] + } +} diff --git a/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/33.json b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/33.json new file mode 100644 index 00000000..c8d73321 --- /dev/null +++ b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/33.json @@ -0,0 +1,664 @@ +{ + "formatVersion": 1, + "database": { + "version": 33, + "identityHash": "a65eec85c027fac13948a0dd6f99d3cb", + "entities": [ + { + "tableName": "servers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `url` TEXT NOT NULL, `version` TEXT, `lastUsed` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT" + }, + { + "fieldPath": "lastUsed", + "columnName": "lastUsed", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `id` TEXT NOT NULL, `name` TEXT, `serverId` TEXT NOT NULL, `accessToken` TEXT, `pin` TEXT, `requireLogin` INTEGER NOT NULL DEFAULT false, `lastUsed` TEXT, `uiLanguage` TEXT, FOREIGN KEY(`serverId`) REFERENCES `servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "rowId", + "columnName": "rowId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT" + }, + { + "fieldPath": "pin", + "columnName": "pin", + "affinity": "TEXT" + }, + { + "fieldPath": "requireLogin", + "columnName": "requireLogin", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "lastUsed", + "columnName": "lastUsed", + "affinity": "TEXT" + }, + { + "fieldPath": "uiLanguage", + "columnName": "uiLanguage", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "rowId" + ] + }, + "indices": [ + { + "name": "index_users_id_serverId", + "unique": true, + "columnNames": [ + "id", + "serverId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_users_id_serverId` ON `${TABLE_NAME}` (`id`, `serverId`)" + }, + { + "name": "index_users_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_users_id` ON `${TABLE_NAME}` (`id`)" + }, + { + "name": "index_users_serverId", + "unique": false, + "columnNames": [ + "serverId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_users_serverId` ON `${TABLE_NAME}` (`serverId`)" + } + ], + "foreignKeys": [ + { + "table": "servers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serverId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ItemPlayback", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sourceId` TEXT, `audioIndex` INTEGER NOT NULL, `subtitleIndex` INTEGER NOT NULL, FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "rowId", + "columnName": "rowId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceId", + "columnName": "sourceId", + "affinity": "TEXT" + }, + { + "fieldPath": "audioIndex", + "columnName": "audioIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subtitleIndex", + "columnName": "subtitleIndex", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "rowId" + ] + }, + "indices": [ + { + "name": "index_ItemPlayback_userId_itemId", + "unique": true, + "columnNames": [ + "userId", + "itemId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_ItemPlayback_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)" + } + ], + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "NavDrawerPinnedItem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `type` TEXT NOT NULL, `order` INTEGER NOT NULL DEFAULT -1, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "itemId" + ] + }, + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "LibraryDisplayInfo", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sort` TEXT NOT NULL, `direction` TEXT NOT NULL, `filter` TEXT NOT NULL DEFAULT '{}', `viewOptions` TEXT, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sort", + "columnName": "sort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "direction", + "columnName": "direction", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "filter", + "columnName": "filter", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'{}'" + }, + { + "fieldPath": "viewOptions", + "columnName": "viewOptions", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "itemId" + ] + }, + "indices": [ + { + "name": "index_LibraryDisplayInfo_userId_itemId", + "unique": true, + "columnNames": [ + "userId", + "itemId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_LibraryDisplayInfo_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)" + } + ], + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "playback_effects", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`jellyfinUserRowId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `type` TEXT NOT NULL, `rotation` INTEGER NOT NULL, `brightness` INTEGER NOT NULL, `contrast` INTEGER NOT NULL, `saturation` INTEGER NOT NULL, `hue` INTEGER NOT NULL, `red` INTEGER NOT NULL, `green` INTEGER NOT NULL, `blue` INTEGER NOT NULL, `blur` INTEGER NOT NULL, PRIMARY KEY(`jellyfinUserRowId`, `itemId`, `type`))", + "fields": [ + { + "fieldPath": "jellyfinUserRowId", + "columnName": "jellyfinUserRowId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "videoFilter.rotation", + "columnName": "rotation", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.brightness", + "columnName": "brightness", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.contrast", + "columnName": "contrast", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.saturation", + "columnName": "saturation", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.hue", + "columnName": "hue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.red", + "columnName": "red", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.green", + "columnName": "green", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.blue", + "columnName": "blue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.blur", + "columnName": "blur", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "jellyfinUserRowId", + "itemId", + "type" + ] + } + }, + { + "tableName": "PlaybackLanguageChoice", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `seriesId` TEXT NOT NULL, `itemId` TEXT, `audioLanguage` TEXT, `subtitleLanguage` TEXT, `subtitlesDisabled` INTEGER, PRIMARY KEY(`userId`, `seriesId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "seriesId", + "columnName": "seriesId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT" + }, + { + "fieldPath": "audioLanguage", + "columnName": "audioLanguage", + "affinity": "TEXT" + }, + { + "fieldPath": "subtitleLanguage", + "columnName": "subtitleLanguage", + "affinity": "TEXT" + }, + { + "fieldPath": "subtitlesDisabled", + "columnName": "subtitlesDisabled", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "seriesId" + ] + }, + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "ItemTrackModification", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `trackIndex` INTEGER NOT NULL, `delayMs` INTEGER NOT NULL, PRIMARY KEY(`userId`, `itemId`, `trackIndex`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "trackIndex", + "columnName": "trackIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "delayMs", + "columnName": "delayMs", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "itemId", + "trackIndex" + ] + }, + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "seerr_servers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `url` TEXT NOT NULL, `name` TEXT, `version` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_seerr_servers_url", + "unique": true, + "columnNames": [ + "url" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_seerr_servers_url` ON `${TABLE_NAME}` (`url`)" + } + ] + }, + { + "tableName": "seerr_users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`jellyfinUserRowId` INTEGER NOT NULL, `serverId` INTEGER NOT NULL, `authMethod` TEXT NOT NULL, `username` TEXT, `password` TEXT, `credential` TEXT, PRIMARY KEY(`jellyfinUserRowId`, `serverId`), FOREIGN KEY(`serverId`) REFERENCES `seerr_servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`jellyfinUserRowId`) REFERENCES `users`(`rowId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "jellyfinUserRowId", + "columnName": "jellyfinUserRowId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authMethod", + "columnName": "authMethod", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT" + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT" + }, + { + "fieldPath": "credential", + "columnName": "credential", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "jellyfinUserRowId", + "serverId" + ] + }, + "foreignKeys": [ + { + "table": "seerr_servers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serverId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "jellyfinUserRowId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'a65eec85c027fac13948a0dd6f99d3cb')" + ] + } +} diff --git a/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/34.json b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/34.json new file mode 100644 index 00000000..a5709ec2 --- /dev/null +++ b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/34.json @@ -0,0 +1,720 @@ +{ + "formatVersion": 1, + "database": { + "version": 34, + "identityHash": "278e485064fc9f184a2181599f0f8efb", + "entities": [ + { + "tableName": "servers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `url` TEXT NOT NULL, `version` TEXT, `lastUsed` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT" + }, + { + "fieldPath": "lastUsed", + "columnName": "lastUsed", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `id` TEXT NOT NULL, `name` TEXT, `serverId` TEXT NOT NULL, `accessToken` TEXT, `pin` TEXT, `requireLogin` INTEGER NOT NULL DEFAULT false, `lastUsed` TEXT, `uiLanguage` TEXT, FOREIGN KEY(`serverId`) REFERENCES `servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "rowId", + "columnName": "rowId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT" + }, + { + "fieldPath": "pin", + "columnName": "pin", + "affinity": "TEXT" + }, + { + "fieldPath": "requireLogin", + "columnName": "requireLogin", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "lastUsed", + "columnName": "lastUsed", + "affinity": "TEXT" + }, + { + "fieldPath": "uiLanguage", + "columnName": "uiLanguage", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "rowId" + ] + }, + "indices": [ + { + "name": "index_users_id_serverId", + "unique": true, + "columnNames": [ + "id", + "serverId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_users_id_serverId` ON `${TABLE_NAME}` (`id`, `serverId`)" + }, + { + "name": "index_users_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_users_id` ON `${TABLE_NAME}` (`id`)" + }, + { + "name": "index_users_serverId", + "unique": false, + "columnNames": [ + "serverId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_users_serverId` ON `${TABLE_NAME}` (`serverId`)" + } + ], + "foreignKeys": [ + { + "table": "servers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serverId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ItemPlayback", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sourceId` TEXT, `audioIndex` INTEGER NOT NULL, `subtitleIndex` INTEGER NOT NULL, FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "rowId", + "columnName": "rowId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceId", + "columnName": "sourceId", + "affinity": "TEXT" + }, + { + "fieldPath": "audioIndex", + "columnName": "audioIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subtitleIndex", + "columnName": "subtitleIndex", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "rowId" + ] + }, + "indices": [ + { + "name": "index_ItemPlayback_userId_itemId", + "unique": true, + "columnNames": [ + "userId", + "itemId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_ItemPlayback_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)" + } + ], + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "NavDrawerPinnedItem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `type` TEXT NOT NULL, `order` INTEGER NOT NULL DEFAULT -1, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "itemId" + ] + }, + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "LibraryDisplayInfo", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sort` TEXT NOT NULL, `direction` TEXT NOT NULL, `filter` TEXT NOT NULL DEFAULT '{}', `viewOptions` TEXT, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sort", + "columnName": "sort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "direction", + "columnName": "direction", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "filter", + "columnName": "filter", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'{}'" + }, + { + "fieldPath": "viewOptions", + "columnName": "viewOptions", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "itemId" + ] + }, + "indices": [ + { + "name": "index_LibraryDisplayInfo_userId_itemId", + "unique": true, + "columnNames": [ + "userId", + "itemId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_LibraryDisplayInfo_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)" + } + ], + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "playback_effects", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`jellyfinUserRowId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `type` TEXT NOT NULL, `rotation` INTEGER NOT NULL, `brightness` INTEGER NOT NULL, `contrast` INTEGER NOT NULL, `saturation` INTEGER NOT NULL, `hue` INTEGER NOT NULL, `red` INTEGER NOT NULL, `green` INTEGER NOT NULL, `blue` INTEGER NOT NULL, `blur` INTEGER NOT NULL, PRIMARY KEY(`jellyfinUserRowId`, `itemId`, `type`))", + "fields": [ + { + "fieldPath": "jellyfinUserRowId", + "columnName": "jellyfinUserRowId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "videoFilter.rotation", + "columnName": "rotation", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.brightness", + "columnName": "brightness", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.contrast", + "columnName": "contrast", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.saturation", + "columnName": "saturation", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.hue", + "columnName": "hue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.red", + "columnName": "red", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.green", + "columnName": "green", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.blue", + "columnName": "blue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.blur", + "columnName": "blur", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "jellyfinUserRowId", + "itemId", + "type" + ] + } + }, + { + "tableName": "PlaybackLanguageChoice", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `seriesId` TEXT NOT NULL, `itemId` TEXT, `audioLanguage` TEXT, `subtitleLanguage` TEXT, `subtitlesDisabled` INTEGER, PRIMARY KEY(`userId`, `seriesId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "seriesId", + "columnName": "seriesId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT" + }, + { + "fieldPath": "audioLanguage", + "columnName": "audioLanguage", + "affinity": "TEXT" + }, + { + "fieldPath": "subtitleLanguage", + "columnName": "subtitleLanguage", + "affinity": "TEXT" + }, + { + "fieldPath": "subtitlesDisabled", + "columnName": "subtitlesDisabled", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "seriesId" + ] + }, + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "ItemTrackModification", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `trackIndex` INTEGER NOT NULL, `delayMs` INTEGER NOT NULL, PRIMARY KEY(`userId`, `itemId`, `trackIndex`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "trackIndex", + "columnName": "trackIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "delayMs", + "columnName": "delayMs", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "itemId", + "trackIndex" + ] + }, + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "seerr_servers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `url` TEXT NOT NULL, `name` TEXT, `version` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_seerr_servers_url", + "unique": true, + "columnNames": [ + "url" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_seerr_servers_url` ON `${TABLE_NAME}` (`url`)" + } + ] + }, + { + "tableName": "seerr_users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`jellyfinUserRowId` INTEGER NOT NULL, `serverId` INTEGER NOT NULL, `authMethod` TEXT NOT NULL, `username` TEXT, `password` TEXT, `credential` TEXT, PRIMARY KEY(`jellyfinUserRowId`, `serverId`), FOREIGN KEY(`serverId`) REFERENCES `seerr_servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`jellyfinUserRowId`) REFERENCES `users`(`rowId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "jellyfinUserRowId", + "columnName": "jellyfinUserRowId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authMethod", + "columnName": "authMethod", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT" + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT" + }, + { + "fieldPath": "credential", + "columnName": "credential", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "jellyfinUserRowId", + "serverId" + ] + }, + "foreignKeys": [ + { + "table": "seerr_servers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serverId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "jellyfinUserRowId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "RememberedTab", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `index` INTEGER NOT NULL, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "itemId" + ] + }, + "indices": [ + { + "name": "index_RememberedTab_userId_itemId", + "unique": true, + "columnNames": [ + "userId", + "itemId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_RememberedTab_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)" + } + ], + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '278e485064fc9f184a2181599f0f8efb')" + ] + } +} diff --git a/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/35.json b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/35.json new file mode 100644 index 00000000..f82e6bdf --- /dev/null +++ b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/35.json @@ -0,0 +1,741 @@ +{ + "formatVersion": 1, + "database": { + "version": 35, + "identityHash": "ff9ade58d4ac39e36335ac0df1405a5f", + "entities": [ + { + "tableName": "servers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `url` TEXT NOT NULL, `version` TEXT, `lastUsed` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT" + }, + { + "fieldPath": "lastUsed", + "columnName": "lastUsed", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `id` TEXT NOT NULL, `name` TEXT, `serverId` TEXT NOT NULL, `accessToken` TEXT, `pin` TEXT, `requireLogin` INTEGER NOT NULL DEFAULT false, `lastUsed` TEXT, `uiLanguage` TEXT, `preferredAudioLanguage` TEXT NOT NULL DEFAULT '', `preferredSubtitleLanguage` TEXT NOT NULL DEFAULT '', `subtitleMode` TEXT NOT NULL DEFAULT 'USE_USER_PROFILE', FOREIGN KEY(`serverId`) REFERENCES `servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "rowId", + "columnName": "rowId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT" + }, + { + "fieldPath": "pin", + "columnName": "pin", + "affinity": "TEXT" + }, + { + "fieldPath": "requireLogin", + "columnName": "requireLogin", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "lastUsed", + "columnName": "lastUsed", + "affinity": "TEXT" + }, + { + "fieldPath": "uiLanguage", + "columnName": "uiLanguage", + "affinity": "TEXT" + }, + { + "fieldPath": "appPreferences.preferredAudioLanguage", + "columnName": "preferredAudioLanguage", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "appPreferences.preferredSubtitleLanguage", + "columnName": "preferredSubtitleLanguage", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "appPreferences.subtitleMode", + "columnName": "subtitleMode", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'USE_USER_PROFILE'" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "rowId" + ] + }, + "indices": [ + { + "name": "index_users_id_serverId", + "unique": true, + "columnNames": [ + "id", + "serverId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_users_id_serverId` ON `${TABLE_NAME}` (`id`, `serverId`)" + }, + { + "name": "index_users_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_users_id` ON `${TABLE_NAME}` (`id`)" + }, + { + "name": "index_users_serverId", + "unique": false, + "columnNames": [ + "serverId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_users_serverId` ON `${TABLE_NAME}` (`serverId`)" + } + ], + "foreignKeys": [ + { + "table": "servers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serverId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ItemPlayback", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sourceId` TEXT, `audioIndex` INTEGER NOT NULL, `subtitleIndex` INTEGER NOT NULL, FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "rowId", + "columnName": "rowId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceId", + "columnName": "sourceId", + "affinity": "TEXT" + }, + { + "fieldPath": "audioIndex", + "columnName": "audioIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subtitleIndex", + "columnName": "subtitleIndex", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "rowId" + ] + }, + "indices": [ + { + "name": "index_ItemPlayback_userId_itemId", + "unique": true, + "columnNames": [ + "userId", + "itemId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_ItemPlayback_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)" + } + ], + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "NavDrawerPinnedItem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `type` TEXT NOT NULL, `order` INTEGER NOT NULL DEFAULT -1, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "itemId" + ] + }, + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "LibraryDisplayInfo", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sort` TEXT NOT NULL, `direction` TEXT NOT NULL, `filter` TEXT NOT NULL DEFAULT '{}', `viewOptions` TEXT, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sort", + "columnName": "sort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "direction", + "columnName": "direction", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "filter", + "columnName": "filter", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'{}'" + }, + { + "fieldPath": "viewOptions", + "columnName": "viewOptions", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "itemId" + ] + }, + "indices": [ + { + "name": "index_LibraryDisplayInfo_userId_itemId", + "unique": true, + "columnNames": [ + "userId", + "itemId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_LibraryDisplayInfo_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)" + } + ], + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "playback_effects", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`jellyfinUserRowId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `type` TEXT NOT NULL, `rotation` INTEGER NOT NULL, `brightness` INTEGER NOT NULL, `contrast` INTEGER NOT NULL, `saturation` INTEGER NOT NULL, `hue` INTEGER NOT NULL, `red` INTEGER NOT NULL, `green` INTEGER NOT NULL, `blue` INTEGER NOT NULL, `blur` INTEGER NOT NULL, PRIMARY KEY(`jellyfinUserRowId`, `itemId`, `type`))", + "fields": [ + { + "fieldPath": "jellyfinUserRowId", + "columnName": "jellyfinUserRowId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "videoFilter.rotation", + "columnName": "rotation", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.brightness", + "columnName": "brightness", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.contrast", + "columnName": "contrast", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.saturation", + "columnName": "saturation", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.hue", + "columnName": "hue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.red", + "columnName": "red", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.green", + "columnName": "green", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.blue", + "columnName": "blue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.blur", + "columnName": "blur", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "jellyfinUserRowId", + "itemId", + "type" + ] + } + }, + { + "tableName": "PlaybackLanguageChoice", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `seriesId` TEXT NOT NULL, `itemId` TEXT, `audioLanguage` TEXT, `subtitleLanguage` TEXT, `subtitlesDisabled` INTEGER, PRIMARY KEY(`userId`, `seriesId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "seriesId", + "columnName": "seriesId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT" + }, + { + "fieldPath": "audioLanguage", + "columnName": "audioLanguage", + "affinity": "TEXT" + }, + { + "fieldPath": "subtitleLanguage", + "columnName": "subtitleLanguage", + "affinity": "TEXT" + }, + { + "fieldPath": "subtitlesDisabled", + "columnName": "subtitlesDisabled", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "seriesId" + ] + }, + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "ItemTrackModification", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `trackIndex` INTEGER NOT NULL, `delayMs` INTEGER NOT NULL, PRIMARY KEY(`userId`, `itemId`, `trackIndex`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "trackIndex", + "columnName": "trackIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "delayMs", + "columnName": "delayMs", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "itemId", + "trackIndex" + ] + }, + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "seerr_servers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `url` TEXT NOT NULL, `name` TEXT, `version` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_seerr_servers_url", + "unique": true, + "columnNames": [ + "url" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_seerr_servers_url` ON `${TABLE_NAME}` (`url`)" + } + ] + }, + { + "tableName": "seerr_users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`jellyfinUserRowId` INTEGER NOT NULL, `serverId` INTEGER NOT NULL, `authMethod` TEXT NOT NULL, `username` TEXT, `password` TEXT, `credential` TEXT, PRIMARY KEY(`jellyfinUserRowId`, `serverId`), FOREIGN KEY(`serverId`) REFERENCES `seerr_servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`jellyfinUserRowId`) REFERENCES `users`(`rowId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "jellyfinUserRowId", + "columnName": "jellyfinUserRowId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authMethod", + "columnName": "authMethod", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT" + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT" + }, + { + "fieldPath": "credential", + "columnName": "credential", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "jellyfinUserRowId", + "serverId" + ] + }, + "foreignKeys": [ + { + "table": "seerr_servers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serverId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "jellyfinUserRowId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "RememberedTab", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `index` INTEGER NOT NULL, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "itemId" + ] + }, + "indices": [ + { + "name": "index_RememberedTab_userId_itemId", + "unique": true, + "columnNames": [ + "userId", + "itemId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_RememberedTab_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)" + } + ], + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'ff9ade58d4ac39e36335ac0df1405a5f')" + ] + } +} diff --git a/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/40.json b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/40.json new file mode 100644 index 00000000..3e874e4e --- /dev/null +++ b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/40.json @@ -0,0 +1,834 @@ +{ + "formatVersion": 1, + "database": { + "version": 40, + "identityHash": "b297c087485321c3ee8282ad1ff675a0", + "entities": [ + { + "tableName": "servers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `url` TEXT NOT NULL, `version` TEXT, `lastUsed` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT" + }, + { + "fieldPath": "lastUsed", + "columnName": "lastUsed", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `id` TEXT NOT NULL, `name` TEXT, `serverId` TEXT NOT NULL, `accessToken` TEXT, `pin` TEXT, `requireLogin` INTEGER NOT NULL DEFAULT false, `lastUsed` TEXT, `uiLanguage` TEXT, `preferredAudioLanguage` TEXT NOT NULL DEFAULT '', `preferredSubtitleLanguage` TEXT NOT NULL DEFAULT '', `subtitleMode` TEXT NOT NULL DEFAULT 'USE_USER_PROFILE', FOREIGN KEY(`serverId`) REFERENCES `servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "rowId", + "columnName": "rowId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT" + }, + { + "fieldPath": "pin", + "columnName": "pin", + "affinity": "TEXT" + }, + { + "fieldPath": "requireLogin", + "columnName": "requireLogin", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "lastUsed", + "columnName": "lastUsed", + "affinity": "TEXT" + }, + { + "fieldPath": "uiLanguage", + "columnName": "uiLanguage", + "affinity": "TEXT" + }, + { + "fieldPath": "appPreferences.preferredAudioLanguage", + "columnName": "preferredAudioLanguage", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "appPreferences.preferredSubtitleLanguage", + "columnName": "preferredSubtitleLanguage", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "appPreferences.subtitleMode", + "columnName": "subtitleMode", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'USE_USER_PROFILE'" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "rowId" + ] + }, + "indices": [ + { + "name": "index_users_id_serverId", + "unique": true, + "columnNames": [ + "id", + "serverId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_users_id_serverId` ON `${TABLE_NAME}` (`id`, `serverId`)" + }, + { + "name": "index_users_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_users_id` ON `${TABLE_NAME}` (`id`)" + }, + { + "name": "index_users_serverId", + "unique": false, + "columnNames": [ + "serverId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_users_serverId` ON `${TABLE_NAME}` (`serverId`)" + } + ], + "foreignKeys": [ + { + "table": "servers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serverId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ItemPlayback", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sourceId` TEXT, `audioIndex` INTEGER NOT NULL, `subtitleIndex` INTEGER NOT NULL, FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "rowId", + "columnName": "rowId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceId", + "columnName": "sourceId", + "affinity": "TEXT" + }, + { + "fieldPath": "audioIndex", + "columnName": "audioIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subtitleIndex", + "columnName": "subtitleIndex", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "rowId" + ] + }, + "indices": [ + { + "name": "index_ItemPlayback_userId_itemId", + "unique": true, + "columnNames": [ + "userId", + "itemId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_ItemPlayback_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)" + } + ], + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "NavDrawerPinnedItem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `type` TEXT NOT NULL, `order` INTEGER NOT NULL DEFAULT -1, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "itemId" + ] + }, + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "LibraryDisplayInfo", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sort` TEXT NOT NULL, `direction` TEXT NOT NULL, `filter` TEXT NOT NULL DEFAULT '{}', `viewOptions` TEXT, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sort", + "columnName": "sort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "direction", + "columnName": "direction", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "filter", + "columnName": "filter", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'{}'" + }, + { + "fieldPath": "viewOptions", + "columnName": "viewOptions", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "itemId" + ] + }, + "indices": [ + { + "name": "index_LibraryDisplayInfo_userId_itemId", + "unique": true, + "columnNames": [ + "userId", + "itemId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_LibraryDisplayInfo_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)" + } + ], + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "playback_effects", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`jellyfinUserRowId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `type` TEXT NOT NULL, `rotation` INTEGER NOT NULL, `brightness` INTEGER NOT NULL, `contrast` INTEGER NOT NULL, `saturation` INTEGER NOT NULL, `hue` INTEGER NOT NULL, `red` INTEGER NOT NULL, `green` INTEGER NOT NULL, `blue` INTEGER NOT NULL, `blur` INTEGER NOT NULL, PRIMARY KEY(`jellyfinUserRowId`, `itemId`, `type`))", + "fields": [ + { + "fieldPath": "jellyfinUserRowId", + "columnName": "jellyfinUserRowId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "videoFilter.rotation", + "columnName": "rotation", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.brightness", + "columnName": "brightness", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.contrast", + "columnName": "contrast", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.saturation", + "columnName": "saturation", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.hue", + "columnName": "hue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.red", + "columnName": "red", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.green", + "columnName": "green", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.blue", + "columnName": "blue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.blur", + "columnName": "blur", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "jellyfinUserRowId", + "itemId", + "type" + ] + } + }, + { + "tableName": "PlaybackLanguageChoice", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `seriesId` TEXT NOT NULL, `itemId` TEXT, `audioLanguage` TEXT, `subtitleLanguage` TEXT, `subtitlesDisabled` INTEGER, PRIMARY KEY(`userId`, `seriesId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "seriesId", + "columnName": "seriesId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT" + }, + { + "fieldPath": "audioLanguage", + "columnName": "audioLanguage", + "affinity": "TEXT" + }, + { + "fieldPath": "subtitleLanguage", + "columnName": "subtitleLanguage", + "affinity": "TEXT" + }, + { + "fieldPath": "subtitlesDisabled", + "columnName": "subtitlesDisabled", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "seriesId" + ] + }, + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "ItemTrackModification", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `trackIndex` INTEGER NOT NULL, `delayMs` INTEGER NOT NULL, PRIMARY KEY(`userId`, `itemId`, `trackIndex`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "trackIndex", + "columnName": "trackIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "delayMs", + "columnName": "delayMs", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "itemId", + "trackIndex" + ] + }, + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "seerr_servers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `url` TEXT NOT NULL, `name` TEXT, `version` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_seerr_servers_url", + "unique": true, + "columnNames": [ + "url" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_seerr_servers_url` ON `${TABLE_NAME}` (`url`)" + } + ] + }, + { + "tableName": "seerr_users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`jellyfinUserRowId` INTEGER NOT NULL, `serverId` INTEGER NOT NULL, `authMethod` TEXT NOT NULL, `username` TEXT, `password` TEXT, `credential` TEXT, PRIMARY KEY(`jellyfinUserRowId`, `serverId`), FOREIGN KEY(`serverId`) REFERENCES `seerr_servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`jellyfinUserRowId`) REFERENCES `users`(`rowId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "jellyfinUserRowId", + "columnName": "jellyfinUserRowId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authMethod", + "columnName": "authMethod", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT" + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT" + }, + { + "fieldPath": "credential", + "columnName": "credential", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "jellyfinUserRowId", + "serverId" + ] + }, + "foreignKeys": [ + { + "table": "seerr_servers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serverId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "jellyfinUserRowId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "RememberedTab", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `index` INTEGER NOT NULL, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "itemId" + ] + }, + "indices": [ + { + "name": "index_RememberedTab_userId_itemId", + "unique": true, + "columnNames": [ + "userId", + "itemId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_RememberedTab_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)" + } + ], + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "SeriesTrackChoice", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `parentId` TEXT NOT NULL, `type` TEXT NOT NULL, `parentType` TEXT NOT NULL, `itemId` TEXT, `language` TEXT, `activation` TEXT NOT NULL, `trackFlags` INTEGER NOT NULL, `codec` TEXT, `relativeTrackIndex` INTEGER, `title` TEXT, `audioChannels` INTEGER, PRIMARY KEY(`userId`, `parentId`, `type`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentType", + "columnName": "parentType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT" + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT" + }, + { + "fieldPath": "activation", + "columnName": "activation", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "trackFlags", + "columnName": "trackFlags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "codec", + "columnName": "codec", + "affinity": "TEXT" + }, + { + "fieldPath": "relativeTrackIndex", + "columnName": "relativeTrackIndex", + "affinity": "INTEGER" + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT" + }, + { + "fieldPath": "audioChannels", + "columnName": "audioChannels", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "parentId", + "type" + ] + }, + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'b297c087485321c3ee8282ad1ff675a0')" + ] + } +} diff --git a/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/41.json b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/41.json new file mode 100644 index 00000000..4a41a052 --- /dev/null +++ b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/41.json @@ -0,0 +1,841 @@ +{ + "formatVersion": 1, + "database": { + "version": 41, + "identityHash": "1f2fe7912f9e3ab83b77029e07488e84", + "entities": [ + { + "tableName": "servers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `url` TEXT NOT NULL, `version` TEXT, `lastUsed` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT" + }, + { + "fieldPath": "lastUsed", + "columnName": "lastUsed", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `id` TEXT NOT NULL, `name` TEXT, `serverId` TEXT NOT NULL, `accessToken` TEXT, `pin` TEXT, `requireLogin` INTEGER NOT NULL DEFAULT false, `lastUsed` TEXT, `uiLanguage` TEXT, `preferredAudioLanguage` TEXT NOT NULL DEFAULT '', `preferredSubtitleLanguage` TEXT NOT NULL DEFAULT '', `subtitleMode` TEXT NOT NULL DEFAULT 'USE_USER_PROFILE', `c_homeSettingsSource` TEXT NOT NULL DEFAULT 'UNSET', FOREIGN KEY(`serverId`) REFERENCES `servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "rowId", + "columnName": "rowId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT" + }, + { + "fieldPath": "pin", + "columnName": "pin", + "affinity": "TEXT" + }, + { + "fieldPath": "requireLogin", + "columnName": "requireLogin", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "lastUsed", + "columnName": "lastUsed", + "affinity": "TEXT" + }, + { + "fieldPath": "uiLanguage", + "columnName": "uiLanguage", + "affinity": "TEXT" + }, + { + "fieldPath": "appPreferences.preferredAudioLanguage", + "columnName": "preferredAudioLanguage", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "appPreferences.preferredSubtitleLanguage", + "columnName": "preferredSubtitleLanguage", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "appPreferences.subtitleMode", + "columnName": "subtitleMode", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'USE_USER_PROFILE'" + }, + { + "fieldPath": "config.homeSettingsSource", + "columnName": "c_homeSettingsSource", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'UNSET'" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "rowId" + ] + }, + "indices": [ + { + "name": "index_users_id_serverId", + "unique": true, + "columnNames": [ + "id", + "serverId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_users_id_serverId` ON `${TABLE_NAME}` (`id`, `serverId`)" + }, + { + "name": "index_users_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_users_id` ON `${TABLE_NAME}` (`id`)" + }, + { + "name": "index_users_serverId", + "unique": false, + "columnNames": [ + "serverId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_users_serverId` ON `${TABLE_NAME}` (`serverId`)" + } + ], + "foreignKeys": [ + { + "table": "servers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serverId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ItemPlayback", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sourceId` TEXT, `audioIndex` INTEGER NOT NULL, `subtitleIndex` INTEGER NOT NULL, FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "rowId", + "columnName": "rowId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceId", + "columnName": "sourceId", + "affinity": "TEXT" + }, + { + "fieldPath": "audioIndex", + "columnName": "audioIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subtitleIndex", + "columnName": "subtitleIndex", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "rowId" + ] + }, + "indices": [ + { + "name": "index_ItemPlayback_userId_itemId", + "unique": true, + "columnNames": [ + "userId", + "itemId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_ItemPlayback_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)" + } + ], + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "NavDrawerPinnedItem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `type` TEXT NOT NULL, `order` INTEGER NOT NULL DEFAULT -1, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "itemId" + ] + }, + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "LibraryDisplayInfo", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sort` TEXT NOT NULL, `direction` TEXT NOT NULL, `filter` TEXT NOT NULL DEFAULT '{}', `viewOptions` TEXT, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sort", + "columnName": "sort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "direction", + "columnName": "direction", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "filter", + "columnName": "filter", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'{}'" + }, + { + "fieldPath": "viewOptions", + "columnName": "viewOptions", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "itemId" + ] + }, + "indices": [ + { + "name": "index_LibraryDisplayInfo_userId_itemId", + "unique": true, + "columnNames": [ + "userId", + "itemId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_LibraryDisplayInfo_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)" + } + ], + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "playback_effects", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`jellyfinUserRowId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `type` TEXT NOT NULL, `rotation` INTEGER NOT NULL, `brightness` INTEGER NOT NULL, `contrast` INTEGER NOT NULL, `saturation` INTEGER NOT NULL, `hue` INTEGER NOT NULL, `red` INTEGER NOT NULL, `green` INTEGER NOT NULL, `blue` INTEGER NOT NULL, `blur` INTEGER NOT NULL, PRIMARY KEY(`jellyfinUserRowId`, `itemId`, `type`))", + "fields": [ + { + "fieldPath": "jellyfinUserRowId", + "columnName": "jellyfinUserRowId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "videoFilter.rotation", + "columnName": "rotation", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.brightness", + "columnName": "brightness", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.contrast", + "columnName": "contrast", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.saturation", + "columnName": "saturation", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.hue", + "columnName": "hue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.red", + "columnName": "red", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.green", + "columnName": "green", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.blue", + "columnName": "blue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.blur", + "columnName": "blur", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "jellyfinUserRowId", + "itemId", + "type" + ] + } + }, + { + "tableName": "PlaybackLanguageChoice", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `seriesId` TEXT NOT NULL, `itemId` TEXT, `audioLanguage` TEXT, `subtitleLanguage` TEXT, `subtitlesDisabled` INTEGER, PRIMARY KEY(`userId`, `seriesId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "seriesId", + "columnName": "seriesId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT" + }, + { + "fieldPath": "audioLanguage", + "columnName": "audioLanguage", + "affinity": "TEXT" + }, + { + "fieldPath": "subtitleLanguage", + "columnName": "subtitleLanguage", + "affinity": "TEXT" + }, + { + "fieldPath": "subtitlesDisabled", + "columnName": "subtitlesDisabled", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "seriesId" + ] + }, + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "ItemTrackModification", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `trackIndex` INTEGER NOT NULL, `delayMs` INTEGER NOT NULL, PRIMARY KEY(`userId`, `itemId`, `trackIndex`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "trackIndex", + "columnName": "trackIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "delayMs", + "columnName": "delayMs", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "itemId", + "trackIndex" + ] + }, + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "seerr_servers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `url` TEXT NOT NULL, `name` TEXT, `version` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_seerr_servers_url", + "unique": true, + "columnNames": [ + "url" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_seerr_servers_url` ON `${TABLE_NAME}` (`url`)" + } + ] + }, + { + "tableName": "seerr_users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`jellyfinUserRowId` INTEGER NOT NULL, `serverId` INTEGER NOT NULL, `authMethod` TEXT NOT NULL, `username` TEXT, `password` TEXT, `credential` TEXT, PRIMARY KEY(`jellyfinUserRowId`, `serverId`), FOREIGN KEY(`serverId`) REFERENCES `seerr_servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`jellyfinUserRowId`) REFERENCES `users`(`rowId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "jellyfinUserRowId", + "columnName": "jellyfinUserRowId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authMethod", + "columnName": "authMethod", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT" + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT" + }, + { + "fieldPath": "credential", + "columnName": "credential", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "jellyfinUserRowId", + "serverId" + ] + }, + "foreignKeys": [ + { + "table": "seerr_servers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serverId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "jellyfinUserRowId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "RememberedTab", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `index` INTEGER NOT NULL, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "itemId" + ] + }, + "indices": [ + { + "name": "index_RememberedTab_userId_itemId", + "unique": true, + "columnNames": [ + "userId", + "itemId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_RememberedTab_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)" + } + ], + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "SeriesTrackChoice", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `parentId` TEXT NOT NULL, `type` TEXT NOT NULL, `parentType` TEXT NOT NULL, `itemId` TEXT, `language` TEXT, `activation` TEXT NOT NULL, `trackFlags` INTEGER NOT NULL, `codec` TEXT, `relativeTrackIndex` INTEGER, `title` TEXT, `audioChannels` INTEGER, PRIMARY KEY(`userId`, `parentId`, `type`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentType", + "columnName": "parentType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT" + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT" + }, + { + "fieldPath": "activation", + "columnName": "activation", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "trackFlags", + "columnName": "trackFlags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "codec", + "columnName": "codec", + "affinity": "TEXT" + }, + { + "fieldPath": "relativeTrackIndex", + "columnName": "relativeTrackIndex", + "affinity": "INTEGER" + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT" + }, + { + "fieldPath": "audioChannels", + "columnName": "audioChannels", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "parentId", + "type" + ] + }, + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '1f2fe7912f9e3ab83b77029e07488e84')" + ] + } +} diff --git a/app/src/appstore/AndroidManifest.xml b/app/src/appstore/AndroidManifest.xml index 8a762a28..e024ca83 100644 --- a/app/src/appstore/AndroidManifest.xml +++ b/app/src/appstore/AndroidManifest.xml @@ -3,5 +3,8 @@ xmlns:tools="http://schemas.android.com/tools"> <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" tools:node="remove" /> + <uses-permission + android:name="com.amazon.permission.media.session.voicecommandcontrol" + tools:node="remove" /> </manifest> diff --git a/app/src/debug/assets/library/assets/movie-101-dalmatians-ii-patch-s-london-adventure-2003-poster.jpg b/app/src/debug/assets/library/assets/movie-101-dalmatians-ii-patch-s-london-adventure-2003-poster.jpg new file mode 100644 index 00000000..b9b72894 Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-101-dalmatians-ii-patch-s-london-adventure-2003-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-12-years-a-slave-2013-poster.jpg b/app/src/debug/assets/library/assets/movie-12-years-a-slave-2013-poster.jpg new file mode 100644 index 00000000..ff1f55e6 Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-12-years-a-slave-2013-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-a-bronx-tale-1993-poster.jpg b/app/src/debug/assets/library/assets/movie-a-bronx-tale-1993-poster.jpg new file mode 100644 index 00000000..f7444271 Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-a-bronx-tale-1993-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-a-silent-voice-the-movie-2016-poster.jpg b/app/src/debug/assets/library/assets/movie-a-silent-voice-the-movie-2016-poster.jpg new file mode 100644 index 00000000..580b22f4 Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-a-silent-voice-the-movie-2016-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-absolution-2024-poster.jpg b/app/src/debug/assets/library/assets/movie-absolution-2024-poster.jpg new file mode 100644 index 00000000..3ce49112 Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-absolution-2024-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-across-the-hall-2009-poster.jpg b/app/src/debug/assets/library/assets/movie-across-the-hall-2009-poster.jpg new file mode 100644 index 00000000..52362b4e Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-across-the-hall-2009-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-addicted-2014-poster.jpg b/app/src/debug/assets/library/assets/movie-addicted-2014-poster.jpg new file mode 100644 index 00000000..e9a03c14 Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-addicted-2014-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-adulterers-2016-poster.jpg b/app/src/debug/assets/library/assets/movie-adulterers-2016-poster.jpg new file mode 100644 index 00000000..8b65745b Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-adulterers-2016-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-all-the-money-in-the-world-2017-poster.jpg b/app/src/debug/assets/library/assets/movie-all-the-money-in-the-world-2017-poster.jpg new file mode 100644 index 00000000..8beb2f7f Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-all-the-money-in-the-world-2017-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-alone-with-her-2006-poster.jpg b/app/src/debug/assets/library/assets/movie-alone-with-her-2006-poster.jpg new file mode 100644 index 00000000..7ce6fa96 Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-alone-with-her-2006-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-american-gangster-2007-poster.jpg b/app/src/debug/assets/library/assets/movie-american-gangster-2007-poster.jpg new file mode 100644 index 00000000..0f41d299 Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-american-gangster-2007-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-annie-hall-1977-poster.jpg b/app/src/debug/assets/library/assets/movie-annie-hall-1977-poster.jpg new file mode 100644 index 00000000..9023a691 Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-annie-hall-1977-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-ant-man-and-the-wasp-quantumania-2023-poster.jpg b/app/src/debug/assets/library/assets/movie-ant-man-and-the-wasp-quantumania-2023-poster.jpg new file mode 100644 index 00000000..030addf0 Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-ant-man-and-the-wasp-quantumania-2023-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-apocalypse-now-1979-poster.jpg b/app/src/debug/assets/library/assets/movie-apocalypse-now-1979-poster.jpg new file mode 100644 index 00000000..09cd6aa8 Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-apocalypse-now-1979-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-appleseed-alpha-2014-poster.jpg b/app/src/debug/assets/library/assets/movie-appleseed-alpha-2014-poster.jpg new file mode 100644 index 00000000..62bd0afa Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-appleseed-alpha-2014-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-aquaman-and-the-lost-kingdom-2023-poster.jpg b/app/src/debug/assets/library/assets/movie-aquaman-and-the-lost-kingdom-2023-poster.jpg new file mode 100644 index 00000000..4550bdaa Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-aquaman-and-the-lost-kingdom-2023-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-arctic-dogs-2019-poster.jpg b/app/src/debug/assets/library/assets/movie-arctic-dogs-2019-poster.jpg new file mode 100644 index 00000000..07552443 Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-arctic-dogs-2019-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-arrival-2016-poster.jpg b/app/src/debug/assets/library/assets/movie-arrival-2016-poster.jpg new file mode 100644 index 00000000..fe224a15 Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-arrival-2016-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-avatar-aang-the-last-airbender-2026-poster.jpg b/app/src/debug/assets/library/assets/movie-avatar-aang-the-last-airbender-2026-poster.jpg new file mode 100644 index 00000000..f57f1052 Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-avatar-aang-the-last-airbender-2026-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-avengers-age-of-ultron-2015-poster.jpg b/app/src/debug/assets/library/assets/movie-avengers-age-of-ultron-2015-poster.jpg new file mode 100644 index 00000000..300840ff Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-avengers-age-of-ultron-2015-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-avengers-confidential-black-widow-punisher-2014-poster.jpg b/app/src/debug/assets/library/assets/movie-avengers-confidential-black-widow-punisher-2014-poster.jpg new file mode 100644 index 00000000..b096fff2 Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-avengers-confidential-black-widow-punisher-2014-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-avengers-infinity-war-2018-poster.jpg b/app/src/debug/assets/library/assets/movie-avengers-infinity-war-2018-poster.jpg new file mode 100644 index 00000000..57df9298 Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-avengers-infinity-war-2018-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-awake-2007-poster.jpg b/app/src/debug/assets/library/assets/movie-awake-2007-poster.jpg new file mode 100644 index 00000000..32595897 Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-awake-2007-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-batman-ninja-2018-poster.jpg b/app/src/debug/assets/library/assets/movie-batman-ninja-2018-poster.jpg new file mode 100644 index 00000000..7f59127b Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-batman-ninja-2018-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-batman-the-dark-knight-returns-part-1-2012-poster.jpg b/app/src/debug/assets/library/assets/movie-batman-the-dark-knight-returns-part-1-2012-poster.jpg new file mode 100644 index 00000000..ce5623fe Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-batman-the-dark-knight-returns-part-1-2012-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-batman-v-superman-dawn-of-justice-2016-poster.jpg b/app/src/debug/assets/library/assets/movie-batman-v-superman-dawn-of-justice-2016-poster.jpg new file mode 100644 index 00000000..f8115170 Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-batman-v-superman-dawn-of-justice-2016-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-black-adam-2022-poster.jpg b/app/src/debug/assets/library/assets/movie-black-adam-2022-poster.jpg new file mode 100644 index 00000000..dde008b5 Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-black-adam-2022-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-black-bear-2020-poster.jpg b/app/src/debug/assets/library/assets/movie-black-bear-2020-poster.jpg new file mode 100644 index 00000000..61c8860f Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-black-bear-2020-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-black-panther-2018-poster.jpg b/app/src/debug/assets/library/assets/movie-black-panther-2018-poster.jpg new file mode 100644 index 00000000..6fdaa3d2 Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-black-panther-2018-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-black-panther-wakanda-forever-2022-poster.jpg b/app/src/debug/assets/library/assets/movie-black-panther-wakanda-forever-2022-poster.jpg new file mode 100644 index 00000000..d0219a75 Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-black-panther-wakanda-forever-2022-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-blame-2017-poster.jpg b/app/src/debug/assets/library/assets/movie-blame-2017-poster.jpg new file mode 100644 index 00000000..8d50255f Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-blame-2017-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-bleach-the-movie-fade-to-black-2008-poster.jpg b/app/src/debug/assets/library/assets/movie-bleach-the-movie-fade-to-black-2008-poster.jpg new file mode 100644 index 00000000..d3619fbd Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-bleach-the-movie-fade-to-black-2008-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-bohemian-rhapsody-2018-poster.jpg b/app/src/debug/assets/library/assets/movie-bohemian-rhapsody-2018-poster.jpg new file mode 100644 index 00000000..20d2f9ee Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-bohemian-rhapsody-2018-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-braveheart-1995-poster.jpg b/app/src/debug/assets/library/assets/movie-braveheart-1995-poster.jpg new file mode 100644 index 00000000..32fd8d4d Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-braveheart-1995-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-butterfly-on-a-wheel-2007-poster.jpg b/app/src/debug/assets/library/assets/movie-butterfly-on-a-wheel-2007-poster.jpg new file mode 100644 index 00000000..b54cc5c7 Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-butterfly-on-a-wheel-2007-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-by-any-means-2026-poster.jpg b/app/src/debug/assets/library/assets/movie-by-any-means-2026-poster.jpg new file mode 100644 index 00000000..92fd601f Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-by-any-means-2026-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-captain-america-civil-war-2016-poster.jpg b/app/src/debug/assets/library/assets/movie-captain-america-civil-war-2016-poster.jpg new file mode 100644 index 00000000..426142ef Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-captain-america-civil-war-2016-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-captain-america-the-first-avenger-2011-poster.jpg b/app/src/debug/assets/library/assets/movie-captain-america-the-first-avenger-2011-poster.jpg new file mode 100644 index 00000000..bba96574 Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-captain-america-the-first-avenger-2011-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-captain-america-the-winter-soldier-2014-poster.jpg b/app/src/debug/assets/library/assets/movie-captain-america-the-winter-soldier-2014-poster.jpg new file mode 100644 index 00000000..c2dc5612 Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-captain-america-the-winter-soldier-2014-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-careful-what-you-wish-for-2015-poster.jpg b/app/src/debug/assets/library/assets/movie-careful-what-you-wish-for-2015-poster.jpg new file mode 100644 index 00000000..b58ce600 Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-careful-what-you-wish-for-2015-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-castle-in-the-sky-1986-poster.jpg b/app/src/debug/assets/library/assets/movie-castle-in-the-sky-1986-poster.jpg new file mode 100644 index 00000000..a6a99fee Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-castle-in-the-sky-1986-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-chicago-2002-poster.jpg b/app/src/debug/assets/library/assets/movie-chicago-2002-poster.jpg new file mode 100644 index 00000000..1def9414 Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-chicago-2002-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-citizen-kane-1941-poster.jpg b/app/src/debug/assets/library/assets/movie-citizen-kane-1941-poster.jpg new file mode 100644 index 00000000..f26414a7 Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-citizen-kane-1941-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-coherence-2014-poster.jpg b/app/src/debug/assets/library/assets/movie-coherence-2014-poster.jpg new file mode 100644 index 00000000..f3601b9b Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-coherence-2014-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-conclave-2024-poster.jpg b/app/src/debug/assets/library/assets/movie-conclave-2024-poster.jpg new file mode 100644 index 00000000..c6aecefa Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-conclave-2024-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-crime-of-passion-1956-poster.jpg b/app/src/debug/assets/library/assets/movie-crime-of-passion-1956-poster.jpg new file mode 100644 index 00000000..4acfa871 Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-crime-of-passion-1956-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-deep-water-2022-poster.jpg b/app/src/debug/assets/library/assets/movie-deep-water-2022-poster.jpg new file mode 100644 index 00000000..018d4f4b Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-deep-water-2022-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-derailed-2005-poster.jpg b/app/src/debug/assets/library/assets/movie-derailed-2005-poster.jpg new file mode 100644 index 00000000..d93f3124 Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-derailed-2005-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-die-my-love-2025-poster.jpg b/app/src/debug/assets/library/assets/movie-die-my-love-2025-poster.jpg new file mode 100644 index 00000000..f6eca818 Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-die-my-love-2025-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-dream-house-2011-poster.jpg b/app/src/debug/assets/library/assets/movie-dream-house-2011-poster.jpg new file mode 100644 index 00000000..d3444637 Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-dream-house-2011-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-dream-lover-1994-poster.jpg b/app/src/debug/assets/library/assets/movie-dream-lover-1994-poster.jpg new file mode 100644 index 00000000..3b563cf9 Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-dream-lover-1994-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-drop-2025-poster.jpg b/app/src/debug/assets/library/assets/movie-drop-2025-poster.jpg new file mode 100644 index 00000000..4cf7d66b Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-drop-2025-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-eastern-promises-2007-poster.jpg b/app/src/debug/assets/library/assets/movie-eastern-promises-2007-poster.jpg new file mode 100644 index 00000000..01a26f3b Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-eastern-promises-2007-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-easy-a-2010-poster.jpg b/app/src/debug/assets/library/assets/movie-easy-a-2010-poster.jpg new file mode 100644 index 00000000..45e13a32 Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-easy-a-2010-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-el-camino-a-breaking-bad-movie-2019-poster.jpg b/app/src/debug/assets/library/assets/movie-el-camino-a-breaking-bad-movie-2019-poster.jpg new file mode 100644 index 00000000..7a62df45 Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-el-camino-a-breaking-bad-movie-2019-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-emancipation-2022-poster.jpg b/app/src/debug/assets/library/assets/movie-emancipation-2022-poster.jpg new file mode 100644 index 00000000..c1bee971 Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-emancipation-2022-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-faces-1968-poster.jpg b/app/src/debug/assets/library/assets/movie-faces-1968-poster.jpg new file mode 100644 index 00000000..43f996bd Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-faces-1968-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-fatale-2020-poster.jpg b/app/src/debug/assets/library/assets/movie-fatale-2020-poster.jpg new file mode 100644 index 00000000..dda9d622 Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-fatale-2020-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-fighting-with-my-family-2019-poster.jpg b/app/src/debug/assets/library/assets/movie-fighting-with-my-family-2019-poster.jpg new file mode 100644 index 00000000..169c466d Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-fighting-with-my-family-2019-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-force-of-nature-the-dry-2-2024-poster.jpg b/app/src/debug/assets/library/assets/movie-force-of-nature-the-dry-2-2024-poster.jpg new file mode 100644 index 00000000..93ba0d71 Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-force-of-nature-the-dry-2-2024-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-free-state-of-jones-2016-poster.jpg b/app/src/debug/assets/library/assets/movie-free-state-of-jones-2016-poster.jpg new file mode 100644 index 00000000..e69af7c0 Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-free-state-of-jones-2016-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-from-up-on-poppy-hill-2011-poster.jpg b/app/src/debug/assets/library/assets/movie-from-up-on-poppy-hill-2011-poster.jpg new file mode 100644 index 00000000..264076e2 Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-from-up-on-poppy-hill-2011-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-ghost-in-the-shell-1995-poster.jpg b/app/src/debug/assets/library/assets/movie-ghost-in-the-shell-1995-poster.jpg new file mode 100644 index 00000000..58d221ba Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-ghost-in-the-shell-1995-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-ghost-in-the-shell-2-innocence-2004-poster.jpg b/app/src/debug/assets/library/assets/movie-ghost-in-the-shell-2-innocence-2004-poster.jpg new file mode 100644 index 00000000..ae262147 Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-ghost-in-the-shell-2-innocence-2004-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-gosford-park-2001-poster.jpg b/app/src/debug/assets/library/assets/movie-gosford-park-2001-poster.jpg new file mode 100644 index 00000000..6a6aa651 Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-gosford-park-2001-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-grave-of-the-fireflies-1988-poster.jpg b/app/src/debug/assets/library/assets/movie-grave-of-the-fireflies-1988-poster.jpg new file mode 100644 index 00000000..1b71996a Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-grave-of-the-fireflies-1988-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-howl-s-moving-castle-2004-poster.jpg b/app/src/debug/assets/library/assets/movie-howl-s-moving-castle-2004-poster.jpg new file mode 100644 index 00000000..042a6c80 Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-howl-s-moving-castle-2004-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-in-the-heights-2021-poster.jpg b/app/src/debug/assets/library/assets/movie-in-the-heights-2021-poster.jpg new file mode 100644 index 00000000..e05bfdb0 Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-in-the-heights-2021-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-jennifer-s-body-2009-poster.jpg b/app/src/debug/assets/library/assets/movie-jennifer-s-body-2009-poster.jpg new file mode 100644 index 00000000..41cc1eb5 Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-jennifer-s-body-2009-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-kiki-s-delivery-service-1989-poster.jpg b/app/src/debug/assets/library/assets/movie-kiki-s-delivery-service-1989-poster.jpg new file mode 100644 index 00000000..80a59b1e Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-kiki-s-delivery-service-1989-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-lemony-snicket-s-a-series-of-unfortunate-events-2004-poster.jpg b/app/src/debug/assets/library/assets/movie-lemony-snicket-s-a-series-of-unfortunate-events-2004-poster.jpg new file mode 100644 index 00000000..4c78fee9 Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-lemony-snicket-s-a-series-of-unfortunate-events-2004-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-locke-2014-poster.jpg b/app/src/debug/assets/library/assets/movie-locke-2014-poster.jpg new file mode 100644 index 00000000..94ebe97d Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-locke-2014-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-look-who-s-back-2015-poster.jpg b/app/src/debug/assets/library/assets/movie-look-who-s-back-2015-poster.jpg new file mode 100644 index 00000000..423043cf Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-look-who-s-back-2015-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-memories-1995-poster.jpg b/app/src/debug/assets/library/assets/movie-memories-1995-poster.jpg new file mode 100644 index 00000000..f3725b42 Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-memories-1995-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-mercy-2026-poster.jpg b/app/src/debug/assets/library/assets/movie-mercy-2026-poster.jpg new file mode 100644 index 00000000..cfc0d812 Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-mercy-2026-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-night-is-short-walk-on-girl-2017-poster.jpg b/app/src/debug/assets/library/assets/movie-night-is-short-walk-on-girl-2017-poster.jpg new file mode 100644 index 00000000..c6fa6a26 Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-night-is-short-walk-on-girl-2017-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-normal-2026-poster.jpg b/app/src/debug/assets/library/assets/movie-normal-2026-poster.jpg new file mode 100644 index 00000000..f4c89db7 Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-normal-2026-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-outlaw-king-2018-poster.jpg b/app/src/debug/assets/library/assets/movie-outlaw-king-2018-poster.jpg new file mode 100644 index 00000000..d6760468 Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-outlaw-king-2018-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-panic-room-2002-poster.jpg b/app/src/debug/assets/library/assets/movie-panic-room-2002-poster.jpg new file mode 100644 index 00000000..0b2a0d30 Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-panic-room-2002-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-peaky-blinders-the-immortal-man-2026-poster.jpg b/app/src/debug/assets/library/assets/movie-peaky-blinders-the-immortal-man-2026-poster.jpg new file mode 100644 index 00000000..a4814472 Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-peaky-blinders-the-immortal-man-2026-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-ponyo-2008-poster.jpg b/app/src/debug/assets/library/assets/movie-ponyo-2008-poster.jpg new file mode 100644 index 00000000..f548e831 Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-ponyo-2008-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-porco-rosso-1992-poster.jpg b/app/src/debug/assets/library/assets/movie-porco-rosso-1992-poster.jpg new file mode 100644 index 00000000..4a6a2bd1 Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-porco-rosso-1992-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-princess-mononoke-1997-poster.jpg b/app/src/debug/assets/library/assets/movie-princess-mononoke-1997-poster.jpg new file mode 100644 index 00000000..21dd9359 Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-princess-mononoke-1997-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-prisoners-2013-poster.jpg b/app/src/debug/assets/library/assets/movie-prisoners-2013-poster.jpg new file mode 100644 index 00000000..411b149b Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-prisoners-2013-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-racing-stripes-2005-poster.jpg b/app/src/debug/assets/library/assets/movie-racing-stripes-2005-poster.jpg new file mode 100644 index 00000000..cc2ef2ce Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-racing-stripes-2005-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-reincarnated-2012-poster.jpg b/app/src/debug/assets/library/assets/movie-reincarnated-2012-poster.jpg new file mode 100644 index 00000000..e9ab449c Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-reincarnated-2012-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-requiem-for-a-dream-2000-poster.jpg b/app/src/debug/assets/library/assets/movie-requiem-for-a-dream-2000-poster.jpg new file mode 100644 index 00000000..6f89abc3 Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-requiem-for-a-dream-2000-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-rogue-one-a-star-wars-story-2016-poster.jpg b/app/src/debug/assets/library/assets/movie-rogue-one-a-star-wars-story-2016-poster.jpg new file mode 100644 index 00000000..c79e70a1 Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-rogue-one-a-star-wars-story-2016-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-secondhand-lions-2003-poster.jpg b/app/src/debug/assets/library/assets/movie-secondhand-lions-2003-poster.jpg new file mode 100644 index 00000000..33408fa0 Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-secondhand-lions-2003-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-self-reliance-2024-poster.jpg b/app/src/debug/assets/library/assets/movie-self-reliance-2024-poster.jpg new file mode 100644 index 00000000..0f6017ed Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-self-reliance-2024-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-serpico-1973-poster.jpg b/app/src/debug/assets/library/assets/movie-serpico-1973-poster.jpg new file mode 100644 index 00000000..8ddd6e52 Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-serpico-1973-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-shaft-2000-poster.jpg b/app/src/debug/assets/library/assets/movie-shaft-2000-poster.jpg new file mode 100644 index 00000000..6b2b79ce Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-shaft-2000-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-shipwrecked-nightmare-at-sea-2026-poster.jpg b/app/src/debug/assets/library/assets/movie-shipwrecked-nightmare-at-sea-2026-poster.jpg new file mode 100644 index 00000000..f200a28d Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-shipwrecked-nightmare-at-sea-2026-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-sinbad-legend-of-the-seven-seas-2003-poster.jpg b/app/src/debug/assets/library/assets/movie-sinbad-legend-of-the-seven-seas-2003-poster.jpg new file mode 100644 index 00000000..428bb38b Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-sinbad-legend-of-the-seven-seas-2003-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-sleeping-with-other-people-2015-poster.jpg b/app/src/debug/assets/library/assets/movie-sleeping-with-other-people-2015-poster.jpg new file mode 100644 index 00000000..a5ffff1f Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-sleeping-with-other-people-2015-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-snowpiercer-2013-poster.jpg b/app/src/debug/assets/library/assets/movie-snowpiercer-2013-poster.jpg new file mode 100644 index 00000000..be1873fd Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-snowpiercer-2013-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-spider-man-brand-new-day-2026-poster.jpg b/app/src/debug/assets/library/assets/movie-spider-man-brand-new-day-2026-poster.jpg new file mode 100644 index 00000000..51fa155f Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-spider-man-brand-new-day-2026-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-spider-man-far-from-home-2019-poster.jpg b/app/src/debug/assets/library/assets/movie-spider-man-far-from-home-2019-poster.jpg new file mode 100644 index 00000000..28521997 Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-spider-man-far-from-home-2019-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-spider-man-homecoming-2017-poster.jpg b/app/src/debug/assets/library/assets/movie-spider-man-homecoming-2017-poster.jpg new file mode 100644 index 00000000..97fc9c05 Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-spider-man-homecoming-2017-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-spider-man-no-way-home-2021-poster.jpg b/app/src/debug/assets/library/assets/movie-spider-man-no-way-home-2021-poster.jpg new file mode 100644 index 00000000..bc92a4cd Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-spider-man-no-way-home-2021-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-spirited-away-2001-poster.jpg b/app/src/debug/assets/library/assets/movie-spirited-away-2001-poster.jpg new file mode 100644 index 00000000..5a79da62 Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-spirited-away-2001-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-steins-gate-the-movie-load-region-of-deja-vu-2013-poster.jpg b/app/src/debug/assets/library/assets/movie-steins-gate-the-movie-load-region-of-deja-vu-2013-poster.jpg new file mode 100644 index 00000000..e85ddb90 Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-steins-gate-the-movie-load-region-of-deja-vu-2013-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-taxi-driver-1976-poster.jpg b/app/src/debug/assets/library/assets/movie-taxi-driver-1976-poster.jpg new file mode 100644 index 00000000..c410b981 Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-taxi-driver-1976-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-the-alto-knights-2025-poster.jpg b/app/src/debug/assets/library/assets/movie-the-alto-knights-2025-poster.jpg new file mode 100644 index 00000000..bb009cbf Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-the-alto-knights-2025-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-the-amazing-spider-man-2-2014-poster.jpg b/app/src/debug/assets/library/assets/movie-the-amazing-spider-man-2-2014-poster.jpg new file mode 100644 index 00000000..4de2711f Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-the-amazing-spider-man-2-2014-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-the-amazing-spider-man-2012-poster.jpg b/app/src/debug/assets/library/assets/movie-the-amazing-spider-man-2012-poster.jpg new file mode 100644 index 00000000..c1aec876 Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-the-amazing-spider-man-2012-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-the-animatrix-2003-poster.jpg b/app/src/debug/assets/library/assets/movie-the-animatrix-2003-poster.jpg new file mode 100644 index 00000000..65553d47 Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-the-animatrix-2003-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-the-avengers-2012-poster.jpg b/app/src/debug/assets/library/assets/movie-the-avengers-2012-poster.jpg new file mode 100644 index 00000000..be87067c Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-the-avengers-2012-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-the-boy-and-the-heron-2023-poster.jpg b/app/src/debug/assets/library/assets/movie-the-boy-and-the-heron-2023-poster.jpg new file mode 100644 index 00000000..094df64e Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-the-boy-and-the-heron-2023-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-the-breakfast-club-1985-poster.jpg b/app/src/debug/assets/library/assets/movie-the-breakfast-club-1985-poster.jpg new file mode 100644 index 00000000..d1823120 Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-the-breakfast-club-1985-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-the-dry-2021-poster.jpg b/app/src/debug/assets/library/assets/movie-the-dry-2021-poster.jpg new file mode 100644 index 00000000..a0379f2f Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-the-dry-2021-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-the-girl-who-leapt-through-time-2006-poster.jpg b/app/src/debug/assets/library/assets/movie-the-girl-who-leapt-through-time-2006-poster.jpg new file mode 100644 index 00000000..ded6cd2a Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-the-girl-who-leapt-through-time-2006-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-the-godfather-1972-poster.jpg b/app/src/debug/assets/library/assets/movie-the-godfather-1972-poster.jpg new file mode 100644 index 00000000..94111d9b Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-the-godfather-1972-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-the-godfather-part-ii-1974-poster.jpg b/app/src/debug/assets/library/assets/movie-the-godfather-part-ii-1974-poster.jpg new file mode 100644 index 00000000..1328c99e Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-the-godfather-part-ii-1974-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-the-godfather-part-iii-1990-poster.jpg b/app/src/debug/assets/library/assets/movie-the-godfather-part-iii-1990-poster.jpg new file mode 100644 index 00000000..abbe5e99 Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-the-godfather-part-iii-1990-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-the-great-gatsby-2013-poster.jpg b/app/src/debug/assets/library/assets/movie-the-great-gatsby-2013-poster.jpg new file mode 100644 index 00000000..e949fb3e Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-the-great-gatsby-2013-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-the-hours-2002-poster.jpg b/app/src/debug/assets/library/assets/movie-the-hours-2002-poster.jpg new file mode 100644 index 00000000..f87e919f Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-the-hours-2002-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-the-killing-of-a-sacred-deer-2017-poster.jpg b/app/src/debug/assets/library/assets/movie-the-killing-of-a-sacred-deer-2017-poster.jpg new file mode 100644 index 00000000..83464d4c Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-the-killing-of-a-sacred-deer-2017-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-the-last-of-the-mohicans-1992-poster.jpg b/app/src/debug/assets/library/assets/movie-the-last-of-the-mohicans-1992-poster.jpg new file mode 100644 index 00000000..950e9ba1 Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-the-last-of-the-mohicans-1992-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-the-last-samurai-2003-poster.jpg b/app/src/debug/assets/library/assets/movie-the-last-samurai-2003-poster.jpg new file mode 100644 index 00000000..0d58440c Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-the-last-samurai-2003-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-the-lighthouse-2019-poster.jpg b/app/src/debug/assets/library/assets/movie-the-lighthouse-2019-poster.jpg new file mode 100644 index 00000000..15f26533 Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-the-lighthouse-2019-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-the-matrix-1999-poster.jpg b/app/src/debug/assets/library/assets/movie-the-matrix-1999-poster.jpg new file mode 100644 index 00000000..c292dce5 Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-the-matrix-1999-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-the-matrix-reloaded-2003-poster.jpg b/app/src/debug/assets/library/assets/movie-the-matrix-reloaded-2003-poster.jpg new file mode 100644 index 00000000..ab11f50f Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-the-matrix-reloaded-2003-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-the-matrix-resurrections-2021-poster.jpg b/app/src/debug/assets/library/assets/movie-the-matrix-resurrections-2021-poster.jpg new file mode 100644 index 00000000..4477ef1e Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-the-matrix-resurrections-2021-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-the-matrix-revolutions-2003-poster.jpg b/app/src/debug/assets/library/assets/movie-the-matrix-revolutions-2003-poster.jpg new file mode 100644 index 00000000..7236cc4e Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-the-matrix-revolutions-2003-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-the-patriot-2000-poster.jpg b/app/src/debug/assets/library/assets/movie-the-patriot-2000-poster.jpg new file mode 100644 index 00000000..c8582133 Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-the-patriot-2000-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-the-secret-life-of-pets-2-2019-poster.jpg b/app/src/debug/assets/library/assets/movie-the-secret-life-of-pets-2-2019-poster.jpg new file mode 100644 index 00000000..fe50c2f8 Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-the-secret-life-of-pets-2-2019-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-the-substance-2024-poster.jpg b/app/src/debug/assets/library/assets/movie-the-substance-2024-poster.jpg new file mode 100644 index 00000000..72accfed Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-the-substance-2024-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-the-tale-of-the-princess-kaguya-2013-poster.jpg b/app/src/debug/assets/library/assets/movie-the-tale-of-the-princess-kaguya-2013-poster.jpg new file mode 100644 index 00000000..882621cb Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-the-tale-of-the-princess-kaguya-2013-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-the-uprising-2026-poster.jpg b/app/src/debug/assets/library/assets/movie-the-uprising-2026-poster.jpg new file mode 100644 index 00000000..21976395 Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-the-uprising-2026-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-the-wild-robot-2024-poster.jpg b/app/src/debug/assets/library/assets/movie-the-wild-robot-2024-poster.jpg new file mode 100644 index 00000000..0894754d Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-the-wild-robot-2024-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-the-wind-rises-2013-poster.jpg b/app/src/debug/assets/library/assets/movie-the-wind-rises-2013-poster.jpg new file mode 100644 index 00000000..b7688c77 Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-the-wind-rises-2013-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-the-witcher-nightmare-of-the-wolf-2021-poster.jpg b/app/src/debug/assets/library/assets/movie-the-witcher-nightmare-of-the-wolf-2021-poster.jpg new file mode 100644 index 00000000..6db33db1 Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-the-witcher-nightmare-of-the-wolf-2021-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-the-witcher-sirens-of-the-deep-2025-poster.jpg b/app/src/debug/assets/library/assets/movie-the-witcher-sirens-of-the-deep-2025-poster.jpg new file mode 100644 index 00000000..8a54bc7f Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-the-witcher-sirens-of-the-deep-2025-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-thelma-louise-1991-poster.jpg b/app/src/debug/assets/library/assets/movie-thelma-louise-1991-poster.jpg new file mode 100644 index 00000000..5a4ff0ab Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-thelma-louise-1991-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-triangle-of-sadness-2022-poster.jpg b/app/src/debug/assets/library/assets/movie-triangle-of-sadness-2022-poster.jpg new file mode 100644 index 00000000..aee092c6 Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-triangle-of-sadness-2022-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-tsotsi-2005-poster.jpg b/app/src/debug/assets/library/assets/movie-tsotsi-2005-poster.jpg new file mode 100644 index 00000000..362ddd82 Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-tsotsi-2005-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-unknown-number-the-high-school-catfish-2025-poster.jpg b/app/src/debug/assets/library/assets/movie-unknown-number-the-high-school-catfish-2025-poster.jpg new file mode 100644 index 00000000..e32685f3 Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-unknown-number-the-high-school-catfish-2025-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-vice-2018-poster.jpg b/app/src/debug/assets/library/assets/movie-vice-2018-poster.jpg new file mode 100644 index 00000000..76855126 Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-vice-2018-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-vicky-cristina-barcelona-2008-poster.jpg b/app/src/debug/assets/library/assets/movie-vicky-cristina-barcelona-2008-poster.jpg new file mode 100644 index 00000000..1351cad1 Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-vicky-cristina-barcelona-2008-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-warriors-of-the-wind-1984-poster.jpg b/app/src/debug/assets/library/assets/movie-warriors-of-the-wind-1984-poster.jpg new file mode 100644 index 00000000..918e424f Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-warriors-of-the-wind-1984-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-we-bury-the-dead-2026-poster.jpg b/app/src/debug/assets/library/assets/movie-we-bury-the-dead-2026-poster.jpg new file mode 100644 index 00000000..a0d65904 Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-we-bury-the-dead-2026-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/movie-white-hot-the-rise-fall-of-abercrombie-fitch-2022-poster.jpg b/app/src/debug/assets/library/assets/movie-white-hot-the-rise-fall-of-abercrombie-fitch-2022-poster.jpg new file mode 100644 index 00000000..3a123e80 Binary files /dev/null and b/app/src/debug/assets/library/assets/movie-white-hot-the-rise-fall-of-abercrombie-fitch-2022-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-12-monkeys-2015-poster.jpg b/app/src/debug/assets/library/assets/tv-12-monkeys-2015-poster.jpg new file mode 100644 index 00000000..96e3596e Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-12-monkeys-2015-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-56-days-2026-poster.jpg b/app/src/debug/assets/library/assets/tv-56-days-2026-poster.jpg new file mode 100644 index 00000000..64ca587b Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-56-days-2026-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-90-day-fiance-before-the-90-days-2017-poster.jpg b/app/src/debug/assets/library/assets/tv-90-day-fiance-before-the-90-days-2017-poster.jpg new file mode 100644 index 00000000..3362da59 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-90-day-fiance-before-the-90-days-2017-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-90-day-fiance-the-other-way-2019-poster.jpg b/app/src/debug/assets/library/assets/tv-90-day-fiance-the-other-way-2019-poster.jpg new file mode 100644 index 00000000..b4d256eb Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-90-day-fiance-the-other-way-2019-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-a-knight-of-the-seven-kingdoms-2026-poster.jpg b/app/src/debug/assets/library/assets/tv-a-knight-of-the-seven-kingdoms-2026-poster.jpg new file mode 100644 index 00000000..79f1351c Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-a-knight-of-the-seven-kingdoms-2026-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-absolutely-fabulous-1992-poster.jpg b/app/src/debug/assets/library/assets/tv-absolutely-fabulous-1992-poster.jpg new file mode 100644 index 00000000..afd371a9 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-absolutely-fabulous-1992-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-accel-world-2012-poster.jpg b/app/src/debug/assets/library/assets/tv-accel-world-2012-poster.jpg new file mode 100644 index 00000000..207b1dd1 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-accel-world-2012-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-accused-2023-poster.jpg b/app/src/debug/assets/library/assets/tv-accused-2023-poster.jpg new file mode 100644 index 00000000..74af010a Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-accused-2023-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-adults-2025-poster.jpg b/app/src/debug/assets/library/assets/tv-adults-2025-poster.jpg new file mode 100644 index 00000000..53a7ba33 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-adults-2025-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-after-life-2019-poster.jpg b/app/src/debug/assets/library/assets/tv-after-life-2019-poster.jpg new file mode 100644 index 00000000..4fae4e33 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-after-life-2019-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-agatha-christie-s-marple-2004-poster.jpg b/app/src/debug/assets/library/assets/tv-agatha-christie-s-marple-2004-poster.jpg new file mode 100644 index 00000000..ea7dda99 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-agatha-christie-s-marple-2004-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-ahsoka-2023-poster.jpg b/app/src/debug/assets/library/assets/tv-ahsoka-2023-poster.jpg new file mode 100644 index 00000000..a29d6cb4 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-ahsoka-2023-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-all-american-homecoming-2022-poster.jpg b/app/src/debug/assets/library/assets/tv-all-american-homecoming-2022-poster.jpg new file mode 100644 index 00000000..09ccfc33 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-all-american-homecoming-2022-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-all-creatures-great-and-small-1978-poster.jpg b/app/src/debug/assets/library/assets/tv-all-creatures-great-and-small-1978-poster.jpg new file mode 100644 index 00000000..5b1666e6 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-all-creatures-great-and-small-1978-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-all-creatures-great-small-2020-poster.jpg b/app/src/debug/assets/library/assets/tv-all-creatures-great-small-2020-poster.jpg new file mode 100644 index 00000000..b506f990 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-all-creatures-great-small-2020-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-american-crime-story-2016-poster.jpg b/app/src/debug/assets/library/assets/tv-american-crime-story-2016-poster.jpg new file mode 100644 index 00000000..4086bf17 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-american-crime-story-2016-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-american-dad-2005-poster.jpg b/app/src/debug/assets/library/assets/tv-american-dad-2005-poster.jpg new file mode 100644 index 00000000..1f9e239a Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-american-dad-2005-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-american-gods-2017-poster.jpg b/app/src/debug/assets/library/assets/tv-american-gods-2017-poster.jpg new file mode 100644 index 00000000..c02c46da Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-american-gods-2017-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-american-housewife-2016-poster.jpg b/app/src/debug/assets/library/assets/tv-american-housewife-2016-poster.jpg new file mode 100644 index 00000000..5f92bf05 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-american-housewife-2016-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-american-primeval-2025-poster.jpg b/app/src/debug/assets/library/assets/tv-american-primeval-2025-poster.jpg new file mode 100644 index 00000000..7f10e6aa Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-american-primeval-2025-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-and-just-like-that-2021-poster.jpg b/app/src/debug/assets/library/assets/tv-and-just-like-that-2021-poster.jpg new file mode 100644 index 00000000..9904c74b Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-and-just-like-that-2021-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-andor-2022-poster.jpg b/app/src/debug/assets/library/assets/tv-andor-2022-poster.jpg new file mode 100644 index 00000000..df5dc498 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-andor-2022-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-andromeda-2000-poster.jpg b/app/src/debug/assets/library/assets/tv-andromeda-2000-poster.jpg new file mode 100644 index 00000000..67b766b0 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-andromeda-2000-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-anne-with-an-e-2017-poster.jpg b/app/src/debug/assets/library/assets/tv-anne-with-an-e-2017-poster.jpg new file mode 100644 index 00000000..55d443b4 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-anne-with-an-e-2017-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-aqua-teen-hunger-force-2000-poster.jpg b/app/src/debug/assets/library/assets/tv-aqua-teen-hunger-force-2000-poster.jpg new file mode 100644 index 00000000..e0c97321 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-aqua-teen-hunger-force-2000-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-arifureta-from-commonplace-to-world-s-strongest-2019-poster.jpg b/app/src/debug/assets/library/assets/tv-arifureta-from-commonplace-to-world-s-strongest-2019-poster.jpg new file mode 100644 index 00000000..6a8f9a24 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-arifureta-from-commonplace-to-world-s-strongest-2019-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-arrested-development-2003-poster.jpg b/app/src/debug/assets/library/assets/tv-arrested-development-2003-poster.jpg new file mode 100644 index 00000000..02b0dd43 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-arrested-development-2003-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-arrow-2012-poster.jpg b/app/src/debug/assets/library/assets/tv-arrow-2012-poster.jpg new file mode 100644 index 00000000..adee3bb7 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-arrow-2012-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-atlantis-2013-poster.jpg b/app/src/debug/assets/library/assets/tv-atlantis-2013-poster.jpg new file mode 100644 index 00000000..5cd5f098 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-atlantis-2013-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-avatar-seven-havens-2026-poster.jpg b/app/src/debug/assets/library/assets/tv-avatar-seven-havens-2026-poster.jpg new file mode 100644 index 00000000..61f459c6 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-avatar-seven-havens-2026-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-avatar-the-last-airbender-2005-poster.jpg b/app/src/debug/assets/library/assets/tv-avatar-the-last-airbender-2005-poster.jpg new file mode 100644 index 00000000..6f99263a Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-avatar-the-last-airbender-2005-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-avatar-the-last-airbender-2024-poster.jpg b/app/src/debug/assets/library/assets/tv-avatar-the-last-airbender-2024-poster.jpg new file mode 100644 index 00000000..9d46c2ff Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-avatar-the-last-airbender-2024-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-awkward-2011-poster.jpg b/app/src/debug/assets/library/assets/tv-awkward-2011-poster.jpg new file mode 100644 index 00000000..8da6e8e2 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-awkward-2011-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-b-b-vol-liefde-2021-poster.jpg b/app/src/debug/assets/library/assets/tv-b-b-vol-liefde-2021-poster.jpg new file mode 100644 index 00000000..a12ac33e Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-b-b-vol-liefde-2021-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-back-to-life-2019-poster.jpg b/app/src/debug/assets/library/assets/tv-back-to-life-2019-poster.jpg new file mode 100644 index 00000000..b00730a8 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-back-to-life-2019-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-ballers-2015-poster.jpg b/app/src/debug/assets/library/assets/tv-ballers-2015-poster.jpg new file mode 100644 index 00000000..f1c18f69 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-ballers-2015-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-baretta-1975-poster.jpg b/app/src/debug/assets/library/assets/tv-baretta-1975-poster.jpg new file mode 100644 index 00000000..ceef0c9d Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-baretta-1975-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-based-on-a-true-story-2023-poster.jpg b/app/src/debug/assets/library/assets/tv-based-on-a-true-story-2023-poster.jpg new file mode 100644 index 00000000..386d3d90 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-based-on-a-true-story-2023-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-batman-the-brave-and-the-bold-2008-poster.jpg b/app/src/debug/assets/library/assets/tv-batman-the-brave-and-the-bold-2008-poster.jpg new file mode 100644 index 00000000..a864f50c Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-batman-the-brave-and-the-bold-2008-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-batwoman-2019-poster.jpg b/app/src/debug/assets/library/assets/tv-batwoman-2019-poster.jpg new file mode 100644 index 00000000..7ca86592 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-batwoman-2019-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-beat-bobby-flay-2013-poster.jpg b/app/src/debug/assets/library/assets/tv-beat-bobby-flay-2013-poster.jpg new file mode 100644 index 00000000..fe11122d Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-beat-bobby-flay-2013-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-benson-1979-poster.jpg b/app/src/debug/assets/library/assets/tv-benson-1979-poster.jpg new file mode 100644 index 00000000..fe264c76 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-benson-1979-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-big-boys-2022-poster.jpg b/app/src/debug/assets/library/assets/tv-big-boys-2022-poster.jpg new file mode 100644 index 00000000..93e1e86a Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-big-boys-2022-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-big-city-greens-2018-poster.jpg b/app/src/debug/assets/library/assets/tv-big-city-greens-2018-poster.jpg new file mode 100644 index 00000000..ef024946 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-big-city-greens-2018-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-big-mouth-2017-poster.jpg b/app/src/debug/assets/library/assets/tv-big-mouth-2017-poster.jpg new file mode 100644 index 00000000..4f535fec Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-big-mouth-2017-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-black-ish-2014-poster.jpg b/app/src/debug/assets/library/assets/tv-black-ish-2014-poster.jpg new file mode 100644 index 00000000..97c885ad Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-black-ish-2014-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-black-lightning-2018-poster.jpg b/app/src/debug/assets/library/assets/tv-black-lightning-2018-poster.jpg new file mode 100644 index 00000000..733f5cbc Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-black-lightning-2018-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-black-rabbit-2025-poster.jpg b/app/src/debug/assets/library/assets/tv-black-rabbit-2025-poster.jpg new file mode 100644 index 00000000..3ff9aeed Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-black-rabbit-2025-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-bleach-2004-poster.jpg b/app/src/debug/assets/library/assets/tv-bleach-2004-poster.jpg new file mode 100644 index 00000000..b233e033 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-bleach-2004-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-blue-planet-ii-2017-poster.jpg b/app/src/debug/assets/library/assets/tv-blue-planet-ii-2017-poster.jpg new file mode 100644 index 00000000..5e193ab2 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-blue-planet-ii-2017-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-brassic-2019-poster.jpg b/app/src/debug/assets/library/assets/tv-brassic-2019-poster.jpg new file mode 100644 index 00000000..73b77df2 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-brassic-2019-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-breaking-bad-2008-poster.jpg b/app/src/debug/assets/library/assets/tv-breaking-bad-2008-poster.jpg new file mode 100644 index 00000000..c4f7217c Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-breaking-bad-2008-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-breeders-2020-poster.jpg b/app/src/debug/assets/library/assets/tv-breeders-2020-poster.jpg new file mode 100644 index 00000000..49c61208 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-breeders-2020-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-californication-2007-poster.jpg b/app/src/debug/assets/library/assets/tv-californication-2007-poster.jpg new file mode 100644 index 00000000..38428e76 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-californication-2007-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-call-the-midwife-2012-poster.jpg b/app/src/debug/assets/library/assets/tv-call-the-midwife-2012-poster.jpg new file mode 100644 index 00000000..466a1a7b Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-call-the-midwife-2012-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-chainsaw-man-2022-poster.jpg b/app/src/debug/assets/library/assets/tv-chainsaw-man-2022-poster.jpg new file mode 100644 index 00000000..a3779772 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-chainsaw-man-2022-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-chernobyl-2019-poster.jpg b/app/src/debug/assets/library/assets/tv-chernobyl-2019-poster.jpg new file mode 100644 index 00000000..7664f4b0 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-chernobyl-2019-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-community-2009-poster.jpg b/app/src/debug/assets/library/assets/tv-community-2009-poster.jpg new file mode 100644 index 00000000..e75072ae Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-community-2009-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-cyberpunk-edgerunners-2022-poster.jpg b/app/src/debug/assets/library/assets/tv-cyberpunk-edgerunners-2022-poster.jpg new file mode 100644 index 00000000..8d338532 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-cyberpunk-edgerunners-2022-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-dan-da-dan-2024-poster.jpg b/app/src/debug/assets/library/assets/tv-dan-da-dan-2024-poster.jpg new file mode 100644 index 00000000..f42cec16 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-dan-da-dan-2024-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-dead-end-job-2026-poster.jpg b/app/src/debug/assets/library/assets/tv-dead-end-job-2026-poster.jpg new file mode 100644 index 00000000..f5546d88 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-dead-end-job-2026-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-derry-girls-2018-poster.jpg b/app/src/debug/assets/library/assets/tv-derry-girls-2018-poster.jpg new file mode 100644 index 00000000..56a8cd79 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-derry-girls-2018-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-desperate-housewives-2004-poster.jpg b/app/src/debug/assets/library/assets/tv-desperate-housewives-2004-poster.jpg new file mode 100644 index 00000000..0c4c38bc Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-desperate-housewives-2004-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-dopesick-2021-poster.jpg b/app/src/debug/assets/library/assets/tv-dopesick-2021-poster.jpg new file mode 100644 index 00000000..ac0b5900 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-dopesick-2021-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-dr-stone-2019-poster.jpg b/app/src/debug/assets/library/assets/tv-dr-stone-2019-poster.jpg new file mode 100644 index 00000000..0783ed8c Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-dr-stone-2019-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-dune-prophecy-2024-poster.jpg b/app/src/debug/assets/library/assets/tv-dune-prophecy-2024-poster.jpg new file mode 100644 index 00000000..eef2a4f3 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-dune-prophecy-2024-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-east-of-eden-2026-poster.jpg b/app/src/debug/assets/library/assets/tv-east-of-eden-2026-poster.jpg new file mode 100644 index 00000000..6f06f2c9 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-east-of-eden-2026-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-etoile-2025-poster.jpg b/app/src/debug/assets/library/assets/tv-etoile-2025-poster.jpg new file mode 100644 index 00000000..f078bc89 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-etoile-2025-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-fallout-2024-poster.jpg b/app/src/debug/assets/library/assets/tv-fallout-2024-poster.jpg new file mode 100644 index 00000000..5e87a07d Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-fallout-2024-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-fawlty-towers-1975-poster.jpg b/app/src/debug/assets/library/assets/tv-fawlty-towers-1975-poster.jpg new file mode 100644 index 00000000..53b406da Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-fawlty-towers-1975-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-firefly-2002-poster.jpg b/app/src/debug/assets/library/assets/tv-firefly-2002-poster.jpg new file mode 100644 index 00000000..9ad599b3 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-firefly-2002-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-fleabag-2016-poster.jpg b/app/src/debug/assets/library/assets/tv-fleabag-2016-poster.jpg new file mode 100644 index 00000000..bd3ccea5 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-fleabag-2016-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-frieren-beyond-journey-s-end-2023-poster.jpg b/app/src/debug/assets/library/assets/tv-frieren-beyond-journey-s-end-2023-poster.jpg new file mode 100644 index 00000000..cb94104e Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-frieren-beyond-journey-s-end-2023-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-fullmetal-alchemist-brotherhood-2009-poster.jpg b/app/src/debug/assets/library/assets/tv-fullmetal-alchemist-brotherhood-2009-poster.jpg new file mode 100644 index 00000000..935e4944 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-fullmetal-alchemist-brotherhood-2009-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-gilmore-girls-2000-poster.jpg b/app/src/debug/assets/library/assets/tv-gilmore-girls-2000-poster.jpg new file mode 100644 index 00000000..23b668cb Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-gilmore-girls-2000-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-ginny-georgia-2021-poster.jpg b/app/src/debug/assets/library/assets/tv-ginny-georgia-2021-poster.jpg new file mode 100644 index 00000000..1fca7bf6 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-ginny-georgia-2021-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-gravity-falls-2012-poster.jpg b/app/src/debug/assets/library/assets/tv-gravity-falls-2012-poster.jpg new file mode 100644 index 00000000..41c051e0 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-gravity-falls-2012-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-hellcats-2010-poster.jpg b/app/src/debug/assets/library/assets/tv-hellcats-2010-poster.jpg new file mode 100644 index 00000000..2427108b Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-hellcats-2010-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-high-potential-2024-poster.jpg b/app/src/debug/assets/library/assets/tv-high-potential-2024-poster.jpg new file mode 100644 index 00000000..00a07ca7 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-high-potential-2024-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-how-to-change-your-mind-2022-poster.jpg b/app/src/debug/assets/library/assets/tv-how-to-change-your-mind-2022-poster.jpg new file mode 100644 index 00000000..e80c7999 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-how-to-change-your-mind-2022-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-how-to-get-to-heaven-from-belfast-2026-poster.jpg b/app/src/debug/assets/library/assets/tv-how-to-get-to-heaven-from-belfast-2026-poster.jpg new file mode 100644 index 00000000..eb5fc487 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-how-to-get-to-heaven-from-belfast-2026-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-interrogation-2020-poster.jpg b/app/src/debug/assets/library/assets/tv-interrogation-2020-poster.jpg new file mode 100644 index 00000000..f3a21b66 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-interrogation-2020-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-invincible-2021-poster.jpg b/app/src/debug/assets/library/assets/tv-invincible-2021-poster.jpg new file mode 100644 index 00000000..c278e37e Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-invincible-2021-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-it-s-always-sunny-in-philadelphia-2005-poster.jpg b/app/src/debug/assets/library/assets/tv-it-s-always-sunny-in-philadelphia-2005-poster.jpg new file mode 100644 index 00000000..652923ee Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-it-s-always-sunny-in-philadelphia-2005-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-jujutsu-kaisen-2020-poster.jpg b/app/src/debug/assets/library/assets/tv-jujutsu-kaisen-2020-poster.jpg new file mode 100644 index 00000000..45f5ff22 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-jujutsu-kaisen-2020-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-kaiju-no-8-2024-poster.jpg b/app/src/debug/assets/library/assets/tv-kaiju-no-8-2024-poster.jpg new file mode 100644 index 00000000..062496fa Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-kaiju-no-8-2024-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-landman-2024-poster.jpg b/app/src/debug/assets/library/assets/tv-landman-2024-poster.jpg new file mode 100644 index 00000000..3d348798 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-landman-2024-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-last-samurai-standing-2025-poster.jpg b/app/src/debug/assets/library/assets/tv-last-samurai-standing-2025-poster.jpg new file mode 100644 index 00000000..a9789909 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-last-samurai-standing-2025-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-lord-of-the-flies-2026-poster.jpg b/app/src/debug/assets/library/assets/tv-lord-of-the-flies-2026-poster.jpg new file mode 100644 index 00000000..b1229ed8 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-lord-of-the-flies-2026-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-love-death-robots-2019-poster.jpg b/app/src/debug/assets/library/assets/tv-love-death-robots-2019-poster.jpg new file mode 100644 index 00000000..08449aa9 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-love-death-robots-2019-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-love-is-blind-2020-poster.jpg b/app/src/debug/assets/library/assets/tv-love-is-blind-2020-poster.jpg new file mode 100644 index 00000000..62ccf357 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-love-is-blind-2020-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-maniac-2018-poster.jpg b/app/src/debug/assets/library/assets/tv-maniac-2018-poster.jpg new file mode 100644 index 00000000..a1985217 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-maniac-2018-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-maximum-pleasure-guaranteed-2026-poster.jpg b/app/src/debug/assets/library/assets/tv-maximum-pleasure-guaranteed-2026-poster.jpg new file mode 100644 index 00000000..abe1b2e2 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-maximum-pleasure-guaranteed-2026-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-minx-2022-poster.jpg b/app/src/debug/assets/library/assets/tv-minx-2022-poster.jpg new file mode 100644 index 00000000..a8797fe5 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-minx-2022-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-mussolini-son-of-the-century-2024-poster.jpg b/app/src/debug/assets/library/assets/tv-mussolini-son-of-the-century-2024-poster.jpg new file mode 100644 index 00000000..925d5c49 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-mussolini-son-of-the-century-2024-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-my-hero-academia-2016-poster.jpg b/app/src/debug/assets/library/assets/tv-my-hero-academia-2016-poster.jpg new file mode 100644 index 00000000..cf571b93 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-my-hero-academia-2016-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-neon-genesis-evangelion-1995-poster.jpg b/app/src/debug/assets/library/assets/tv-neon-genesis-evangelion-1995-poster.jpg new file mode 100644 index 00000000..f86cd698 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-neon-genesis-evangelion-1995-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-norsemen-2016-poster.jpg b/app/src/debug/assets/library/assets/tv-norsemen-2016-poster.jpg new file mode 100644 index 00000000..e7206b88 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-norsemen-2016-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-offspring-2010-poster.jpg b/app/src/debug/assets/library/assets/tv-offspring-2010-poster.jpg new file mode 100644 index 00000000..ae305043 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-offspring-2010-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-one-piece-2023-poster.jpg b/app/src/debug/assets/library/assets/tv-one-piece-2023-poster.jpg new file mode 100644 index 00000000..286e6260 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-one-piece-2023-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-only-murders-in-the-building-2021-poster.jpg b/app/src/debug/assets/library/assets/tv-only-murders-in-the-building-2021-poster.jpg new file mode 100644 index 00000000..a6b147a4 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-only-murders-in-the-building-2021-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-outlander-blood-of-my-blood-2025-poster.jpg b/app/src/debug/assets/library/assets/tv-outlander-blood-of-my-blood-2025-poster.jpg new file mode 100644 index 00000000..930943b0 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-outlander-blood-of-my-blood-2025-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-over-the-garden-wall-2014-poster.jpg b/app/src/debug/assets/library/assets/tv-over-the-garden-wall-2014-poster.jpg new file mode 100644 index 00000000..34b1ed84 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-over-the-garden-wall-2014-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-peaky-blinders-2013-poster.jpg b/app/src/debug/assets/library/assets/tv-peaky-blinders-2013-poster.jpg new file mode 100644 index 00000000..25ceea88 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-peaky-blinders-2013-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-pluribus-2025-poster.jpg b/app/src/debug/assets/library/assets/tv-pluribus-2025-poster.jpg new file mode 100644 index 00000000..1ea4711b Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-pluribus-2025-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-record-of-ragnarok-2021-poster.jpg b/app/src/debug/assets/library/assets/tv-record-of-ragnarok-2021-poster.jpg new file mode 100644 index 00000000..97d481d6 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-record-of-ragnarok-2021-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-rick-and-morty-2013-poster.jpg b/app/src/debug/assets/library/assets/tv-rick-and-morty-2013-poster.jpg new file mode 100644 index 00000000..b95c4890 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-rick-and-morty-2013-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-ripley-2024-poster.jpg b/app/src/debug/assets/library/assets/tv-ripley-2024-poster.jpg new file mode 100644 index 00000000..de0b32dd Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-ripley-2024-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-rivals-2024-poster.jpg b/app/src/debug/assets/library/assets/tv-rivals-2024-poster.jpg new file mode 100644 index 00000000..296ac67a Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-rivals-2024-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-rooster-2026-poster.jpg b/app/src/debug/assets/library/assets/tv-rooster-2026-poster.jpg new file mode 100644 index 00000000..b698f8a6 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-rooster-2026-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-sakamoto-days-2025-poster.jpg b/app/src/debug/assets/library/assets/tv-sakamoto-days-2025-poster.jpg new file mode 100644 index 00000000..ed9358b3 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-sakamoto-days-2025-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-say-nothing-2024-poster.jpg b/app/src/debug/assets/library/assets/tv-say-nothing-2024-poster.jpg new file mode 100644 index 00000000..cf50fc09 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-say-nothing-2024-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-secret-level-2024-poster.jpg b/app/src/debug/assets/library/assets/tv-secret-level-2024-poster.jpg new file mode 100644 index 00000000..1b6e67f6 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-secret-level-2024-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-sentenced-to-be-a-hero-2025-poster.jpg b/app/src/debug/assets/library/assets/tv-sentenced-to-be-a-hero-2025-poster.jpg new file mode 100644 index 00000000..2fb1c815 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-sentenced-to-be-a-hero-2025-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-severance-2022-poster.jpg b/app/src/debug/assets/library/assets/tv-severance-2022-poster.jpg new file mode 100644 index 00000000..5a29d6f9 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-severance-2022-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-sex-and-the-city-1998-poster.jpg b/app/src/debug/assets/library/assets/tv-sex-and-the-city-1998-poster.jpg new file mode 100644 index 00000000..e546cc6d Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-sex-and-the-city-1998-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-silo-2023-poster.jpg b/app/src/debug/assets/library/assets/tv-silo-2023-poster.jpg new file mode 100644 index 00000000..b493bc7b Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-silo-2023-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-six-feet-under-2001-poster.jpg b/app/src/debug/assets/library/assets/tv-six-feet-under-2001-poster.jpg new file mode 100644 index 00000000..559d6bd1 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-six-feet-under-2001-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-skymed-2022-poster.jpg b/app/src/debug/assets/library/assets/tv-skymed-2022-poster.jpg new file mode 100644 index 00000000..2540dad7 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-skymed-2022-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-so-you-think-you-can-dance-2008-poster.jpg b/app/src/debug/assets/library/assets/tv-so-you-think-you-can-dance-2008-poster.jpg new file mode 100644 index 00000000..a2e91496 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-so-you-think-you-can-dance-2008-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-south-park-1997-poster.jpg b/app/src/debug/assets/library/assets/tv-south-park-1997-poster.jpg new file mode 100644 index 00000000..d8568d54 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-south-park-1997-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-spy-x-family-2022-poster.jpg b/app/src/debug/assets/library/assets/tv-spy-x-family-2022-poster.jpg new file mode 100644 index 00000000..fc326930 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-spy-x-family-2022-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-the-100-2014-poster.jpg b/app/src/debug/assets/library/assets/tv-the-100-2014-poster.jpg new file mode 100644 index 00000000..4d254cb0 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-the-100-2014-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-the-abbott-and-costello-show-1952-poster.jpg b/app/src/debug/assets/library/assets/tv-the-abbott-and-costello-show-1952-poster.jpg new file mode 100644 index 00000000..7d6768ea Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-the-abbott-and-costello-show-1952-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-the-afterparty-2022-poster.jpg b/app/src/debug/assets/library/assets/tv-the-afterparty-2022-poster.jpg new file mode 100644 index 00000000..9beacde2 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-the-afterparty-2022-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-the-baby-sitters-club-2020-poster.jpg b/app/src/debug/assets/library/assets/tv-the-baby-sitters-club-2020-poster.jpg new file mode 100644 index 00000000..7e4e7be2 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-the-baby-sitters-club-2020-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-the-bear-2022-poster.jpg b/app/src/debug/assets/library/assets/tv-the-bear-2022-poster.jpg new file mode 100644 index 00000000..bbf0ab28 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-the-bear-2022-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-the-beast-in-me-2025-poster.jpg b/app/src/debug/assets/library/assets/tv-the-beast-in-me-2025-poster.jpg new file mode 100644 index 00000000..c78c5cf0 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-the-beast-in-me-2025-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-the-bold-type-2017-poster.jpg b/app/src/debug/assets/library/assets/tv-the-bold-type-2017-poster.jpg new file mode 100644 index 00000000..f663eb8b Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-the-bold-type-2017-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-the-boys-2019-poster.jpg b/app/src/debug/assets/library/assets/tv-the-boys-2019-poster.jpg new file mode 100644 index 00000000..f67ff16f Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-the-boys-2019-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-the-capture-2019-poster.jpg b/app/src/debug/assets/library/assets/tv-the-capture-2019-poster.jpg new file mode 100644 index 00000000..a4039011 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-the-capture-2019-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-the-diplomat-2023-poster.jpg b/app/src/debug/assets/library/assets/tv-the-diplomat-2023-poster.jpg new file mode 100644 index 00000000..2eb48b62 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-the-diplomat-2023-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-the-durrells-2016-poster.jpg b/app/src/debug/assets/library/assets/tv-the-durrells-2016-poster.jpg new file mode 100644 index 00000000..d29448bb Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-the-durrells-2016-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-the-final-problem-2026-poster.jpg b/app/src/debug/assets/library/assets/tv-the-final-problem-2026-poster.jpg new file mode 100644 index 00000000..f0332826 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-the-final-problem-2026-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-the-four-seasons-2025-poster.jpg b/app/src/debug/assets/library/assets/tv-the-four-seasons-2025-poster.jpg new file mode 100644 index 00000000..fd252088 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-the-four-seasons-2025-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-the-get-down-2016-poster.jpg b/app/src/debug/assets/library/assets/tv-the-get-down-2016-poster.jpg new file mode 100644 index 00000000..7bb38abd Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-the-get-down-2016-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-the-hunting-wives-2025-poster.jpg b/app/src/debug/assets/library/assets/tv-the-hunting-wives-2025-poster.jpg new file mode 100644 index 00000000..034273eb Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-the-hunting-wives-2025-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-the-last-of-us-2023-poster.jpg b/app/src/debug/assets/library/assets/tv-the-last-of-us-2023-poster.jpg new file mode 100644 index 00000000..7da462da Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-the-last-of-us-2023-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-the-legend-of-korra-2012-poster.jpg b/app/src/debug/assets/library/assets/tv-the-legend-of-korra-2012-poster.jpg new file mode 100644 index 00000000..a7086d5e Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-the-legend-of-korra-2012-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-the-lord-of-the-rings-the-rings-of-power-2022-poster.jpg b/app/src/debug/assets/library/assets/tv-the-lord-of-the-rings-the-rings-of-power-2022-poster.jpg new file mode 100644 index 00000000..dc387fcd Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-the-lord-of-the-rings-the-rings-of-power-2022-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-the-lost-flowers-of-alice-hart-2023-poster.jpg b/app/src/debug/assets/library/assets/tv-the-lost-flowers-of-alice-hart-2023-poster.jpg new file mode 100644 index 00000000..71407f36 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-the-lost-flowers-of-alice-hart-2023-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-the-night-manager-2016-poster.jpg b/app/src/debug/assets/library/assets/tv-the-night-manager-2016-poster.jpg new file mode 100644 index 00000000..7ab2dbf5 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-the-night-manager-2016-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-the-pitt-2025-poster.jpg b/app/src/debug/assets/library/assets/tv-the-pitt-2025-poster.jpg new file mode 100644 index 00000000..ad1ec8ff Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-the-pitt-2025-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-the-tatami-galaxy-2010-poster.jpg b/app/src/debug/assets/library/assets/tv-the-tatami-galaxy-2010-poster.jpg new file mode 100644 index 00000000..5324dc75 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-the-tatami-galaxy-2010-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-the-walk-in-2022-poster.jpg b/app/src/debug/assets/library/assets/tv-the-walk-in-2022-poster.jpg new file mode 100644 index 00000000..715d0c80 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-the-walk-in-2022-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-the-white-lotus-2021-poster.jpg b/app/src/debug/assets/library/assets/tv-the-white-lotus-2021-poster.jpg new file mode 100644 index 00000000..2f23d1fa Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-the-white-lotus-2021-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-the-witcher-2019-poster.jpg b/app/src/debug/assets/library/assets/tv-the-witcher-2019-poster.jpg new file mode 100644 index 00000000..f5eb8f59 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-the-witcher-2019-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-undercover-2019-poster.jpg b/app/src/debug/assets/library/assets/tv-undercover-2019-poster.jpg new file mode 100644 index 00000000..8611eab4 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-undercover-2019-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-upload-2020-poster.jpg b/app/src/debug/assets/library/assets/tv-upload-2020-poster.jpg new file mode 100644 index 00000000..547d3b7a Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-upload-2020-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-vinland-saga-2019-poster.jpg b/app/src/debug/assets/library/assets/tv-vinland-saga-2019-poster.jpg new file mode 100644 index 00000000..7d0ba17a Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-vinland-saga-2019-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-widow-s-bay-2026-poster.jpg b/app/src/debug/assets/library/assets/tv-widow-s-bay-2026-poster.jpg new file mode 100644 index 00000000..8c02a0d7 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-widow-s-bay-2026-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-yellowjackets-2021-poster.jpg b/app/src/debug/assets/library/assets/tv-yellowjackets-2021-poster.jpg new file mode 100644 index 00000000..c7e16a57 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-yellowjackets-2021-poster.jpg differ diff --git a/app/src/debug/assets/library/assets/tv-yu-yu-hakusho-1992-poster.jpg b/app/src/debug/assets/library/assets/tv-yu-yu-hakusho-1992-poster.jpg new file mode 100644 index 00000000..f5843766 Binary files /dev/null and b/app/src/debug/assets/library/assets/tv-yu-yu-hakusho-1992-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/episode-all-hands-on-deck-class-1-a-2021-s5e1-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/episode-all-hands-on-deck-class-1-a-2021-s5e1-backdrop.jpg new file mode 100644 index 00000000..f5b2f94c Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/episode-all-hands-on-deck-class-1-a-2021-s5e1-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/episode-all-hands-on-deck-class-1-a-2021-s5e1-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/episode-all-hands-on-deck-class-1-a-2021-s5e1-poster.jpg new file mode 100644 index 00000000..cf571b93 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/episode-all-hands-on-deck-class-1-a-2021-s5e1-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/episode-announcement-2022-s1e7-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/episode-announcement-2022-s1e7-backdrop.jpg new file mode 100644 index 00000000..a874a0c0 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/episode-announcement-2022-s1e7-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/episode-announcement-2022-s1e7-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/episode-announcement-2022-s1e7-poster.jpg new file mode 100644 index 00000000..df5dc498 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/episode-announcement-2022-s1e7-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/episode-episode-1-2025-s1e1-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/episode-episode-1-2025-s1e1-backdrop.jpg new file mode 100644 index 00000000..f2519b04 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/episode-episode-1-2025-s1e1-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/episode-episode-1-2025-s1e1-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/episode-episode-1-2025-s1e1-poster.jpg new file mode 100644 index 00000000..77febd79 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/episode-episode-1-2025-s1e1-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/episode-episode-2-2016-s1e2-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/episode-episode-2-2016-s1e2-backdrop.jpg new file mode 100644 index 00000000..3f8bdeec Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/episode-episode-2-2016-s1e2-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/episode-episode-2-2016-s1e2-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/episode-episode-2-2016-s1e2-poster.jpg new file mode 100644 index 00000000..d29448bb Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/episode-episode-2-2016-s1e2-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/episode-erickerhead-2026-s9e6-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/episode-erickerhead-2026-s9e6-backdrop.jpg new file mode 100644 index 00000000..1c4c0ffc Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/episode-erickerhead-2026-s9e6-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/episode-erickerhead-2026-s9e6-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/episode-erickerhead-2026-s9e6-poster.jpg new file mode 100644 index 00000000..b95c4890 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/episode-erickerhead-2026-s9e6-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/episode-girls-interrupted-2007-s1e7-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/episode-girls-interrupted-2007-s1e7-backdrop.jpg new file mode 100644 index 00000000..ffff979b Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/episode-girls-interrupted-2007-s1e7-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/episode-girls-interrupted-2007-s1e7-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/episode-girls-interrupted-2007-s1e7-poster.jpg new file mode 100644 index 00000000..38428e76 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/episode-girls-interrupted-2007-s1e7-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/episode-i-heart-ny-2002-s4e18-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/episode-i-heart-ny-2002-s4e18-backdrop.jpg new file mode 100644 index 00000000..57beacc1 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/episode-i-heart-ny-2002-s4e18-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/episode-i-heart-ny-2002-s4e18-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/episode-i-heart-ny-2002-s4e18-poster.jpg new file mode 100644 index 00000000..e546cc6d Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/episode-i-heart-ny-2002-s4e18-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/episode-new-day-2021-s1e2-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/episode-new-day-2021-s1e2-backdrop.jpg new file mode 100644 index 00000000..1f747323 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/episode-new-day-2021-s1e2-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/episode-new-day-2021-s1e2-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/episode-new-day-2021-s1e2-poster.jpg new file mode 100644 index 00000000..2f23d1fa Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/episode-new-day-2021-s1e2-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/episode-no-honeymoon-2020-s1e8-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/episode-no-honeymoon-2020-s1e8-backdrop.jpg new file mode 100644 index 00000000..9d95ba77 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/episode-no-honeymoon-2020-s1e8-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/episode-no-honeymoon-2020-s1e8-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/episode-no-honeymoon-2020-s1e8-poster.jpg new file mode 100644 index 00000000..49c61208 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/episode-no-honeymoon-2020-s1e8-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/episode-notapusy-2005-s3e4-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/episode-notapusy-2005-s3e4-backdrop.jpg new file mode 100644 index 00000000..ecb7e8cd Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/episode-notapusy-2005-s3e4-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/episode-notapusy-2005-s3e4-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/episode-notapusy-2005-s3e4-poster.jpg new file mode 100644 index 00000000..02b0dd43 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/episode-notapusy-2005-s3e4-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/episode-one-of-us-2024-s1e7-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/episode-one-of-us-2024-s1e7-backdrop.jpg new file mode 100644 index 00000000..66bb4d00 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/episode-one-of-us-2024-s1e7-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/episode-one-of-us-2024-s1e7-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/episode-one-of-us-2024-s1e7-poster.jpg new file mode 100644 index 00000000..00a07ca7 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/episode-one-of-us-2024-s1e7-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/episode-remembrance-day-2026-s2e1-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/episode-remembrance-day-2026-s2e1-backdrop.jpg new file mode 100644 index 00000000..23a242da Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/episode-remembrance-day-2026-s2e1-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/episode-remembrance-day-2026-s2e1-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/episode-remembrance-day-2026-s2e1-poster.jpg new file mode 100644 index 00000000..930943b0 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/episode-remembrance-day-2026-s2e1-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/episode-sex-and-the-country-2001-s4e9-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/episode-sex-and-the-country-2001-s4e9-backdrop.jpg new file mode 100644 index 00000000..57beacc1 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/episode-sex-and-the-country-2001-s4e9-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/episode-sex-and-the-country-2001-s4e9-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/episode-sex-and-the-country-2001-s4e9-poster.jpg new file mode 100644 index 00000000..e546cc6d Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/episode-sex-and-the-country-2001-s4e9-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/episode-south-american-biker-gangs-2026-s29e1-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/episode-south-american-biker-gangs-2026-s29e1-backdrop.jpg new file mode 100644 index 00000000..cd0aec39 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/episode-south-american-biker-gangs-2026-s29e1-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/episode-south-american-biker-gangs-2026-s29e1-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/episode-south-american-biker-gangs-2026-s29e1-poster.jpg new file mode 100644 index 00000000..d8568d54 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/episode-south-american-biker-gangs-2026-s29e1-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/episode-the-breakup-part-2-2001-s1e17-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/episode-the-breakup-part-2-2001-s1e17-backdrop.jpg new file mode 100644 index 00000000..b42ba953 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/episode-the-breakup-part-2-2001-s1e17-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/episode-the-breakup-part-2-2001-s1e17-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/episode-the-breakup-part-2-2001-s1e17-poster.jpg new file mode 100644 index 00000000..23b668cb Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/episode-the-breakup-part-2-2001-s1e17-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/episode-the-man-who-would-be-vogue-2018-s2e1-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/episode-the-man-who-would-be-vogue-2018-s2e1-backdrop.jpg new file mode 100644 index 00000000..84944d39 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/episode-the-man-who-would-be-vogue-2018-s2e1-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/episode-the-man-who-would-be-vogue-2018-s2e1-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/episode-the-man-who-would-be-vogue-2018-s2e1-poster.jpg new file mode 100644 index 00000000..4086bf17 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/episode-the-man-who-would-be-vogue-2018-s2e1-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/episode-the-uncomfortability-2010-s1e2-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/episode-the-uncomfortability-2010-s1e2-backdrop.jpg new file mode 100644 index 00000000..bc22e11a Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/episode-the-uncomfortability-2010-s1e2-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/episode-the-uncomfortability-2010-s1e2-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/episode-the-uncomfortability-2010-s1e2-poster.jpg new file mode 100644 index 00000000..ae305043 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/episode-the-uncomfortability-2010-s1e2-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/episode-three-monsters-1992-s1e6-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/episode-three-monsters-1992-s1e6-backdrop.jpg new file mode 100644 index 00000000..ba65ca2d Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/episode-three-monsters-1992-s1e6-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/episode-three-monsters-1992-s1e6-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/episode-three-monsters-1992-s1e6-poster.jpg new file mode 100644 index 00000000..f5843766 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/episode-three-monsters-1992-s1e6-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/episode-tomorrow-2024-s3e1-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/episode-tomorrow-2024-s3e1-backdrop.jpg new file mode 100644 index 00000000..9c9cca57 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/episode-tomorrow-2024-s3e1-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/episode-tomorrow-2024-s3e1-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/episode-tomorrow-2024-s3e1-poster.jpg new file mode 100644 index 00000000..bbf0ab28 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/episode-tomorrow-2024-s3e1-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/episode-what-more-do-i-need-2008-s5e7-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/episode-what-more-do-i-need-2008-s5e7-backdrop.jpg new file mode 100644 index 00000000..40ec3001 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/episode-what-more-do-i-need-2008-s5e7-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/episode-what-more-do-i-need-2008-s5e7-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/episode-what-more-do-i-need-2008-s5e7-poster.jpg new file mode 100644 index 00000000..13f0f7b3 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/episode-what-more-do-i-need-2008-s5e7-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-101-dalmatians-ii-patch-s-london-adventure-2003-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-101-dalmatians-ii-patch-s-london-adventure-2003-backdrop.jpg new file mode 100644 index 00000000..f0d6f752 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-101-dalmatians-ii-patch-s-london-adventure-2003-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-101-dalmatians-ii-patch-s-london-adventure-2003-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-101-dalmatians-ii-patch-s-london-adventure-2003-poster.jpg new file mode 100644 index 00000000..b9b72894 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-101-dalmatians-ii-patch-s-london-adventure-2003-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-12-years-a-slave-2013-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-12-years-a-slave-2013-backdrop.jpg new file mode 100644 index 00000000..d93357e5 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-12-years-a-slave-2013-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-12-years-a-slave-2013-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-12-years-a-slave-2013-poster.jpg new file mode 100644 index 00000000..ff1f55e6 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-12-years-a-slave-2013-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-a-bronx-tale-1993-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-a-bronx-tale-1993-backdrop.jpg new file mode 100644 index 00000000..15c11ff7 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-a-bronx-tale-1993-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-a-bronx-tale-1993-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-a-bronx-tale-1993-poster.jpg new file mode 100644 index 00000000..f7444271 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-a-bronx-tale-1993-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-a-silent-voice-the-movie-2016-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-a-silent-voice-the-movie-2016-backdrop.jpg new file mode 100644 index 00000000..b386b136 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-a-silent-voice-the-movie-2016-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-a-silent-voice-the-movie-2016-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-a-silent-voice-the-movie-2016-poster.jpg new file mode 100644 index 00000000..580b22f4 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-a-silent-voice-the-movie-2016-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-absolution-2024-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-absolution-2024-backdrop.jpg new file mode 100644 index 00000000..eb28037c Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-absolution-2024-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-absolution-2024-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-absolution-2024-poster.jpg new file mode 100644 index 00000000..3ce49112 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-absolution-2024-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-across-the-hall-2009-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-across-the-hall-2009-backdrop.jpg new file mode 100644 index 00000000..6c1ee8ac Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-across-the-hall-2009-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-across-the-hall-2009-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-across-the-hall-2009-poster.jpg new file mode 100644 index 00000000..52362b4e Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-across-the-hall-2009-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-addicted-2014-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-addicted-2014-backdrop.jpg new file mode 100644 index 00000000..5c016976 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-addicted-2014-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-addicted-2014-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-addicted-2014-poster.jpg new file mode 100644 index 00000000..e9a03c14 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-addicted-2014-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-adulterers-2016-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-adulterers-2016-backdrop.jpg new file mode 100644 index 00000000..331391ed Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-adulterers-2016-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-adulterers-2016-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-adulterers-2016-poster.jpg new file mode 100644 index 00000000..8b65745b Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-adulterers-2016-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-all-the-money-in-the-world-2017-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-all-the-money-in-the-world-2017-backdrop.jpg new file mode 100644 index 00000000..ac4b828a Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-all-the-money-in-the-world-2017-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-all-the-money-in-the-world-2017-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-all-the-money-in-the-world-2017-poster.jpg new file mode 100644 index 00000000..8beb2f7f Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-all-the-money-in-the-world-2017-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-alone-with-her-2006-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-alone-with-her-2006-backdrop.jpg new file mode 100644 index 00000000..ddf8f1dc Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-alone-with-her-2006-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-alone-with-her-2006-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-alone-with-her-2006-poster.jpg new file mode 100644 index 00000000..7ce6fa96 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-alone-with-her-2006-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-american-gangster-2007-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-american-gangster-2007-backdrop.jpg new file mode 100644 index 00000000..cb5c5de7 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-american-gangster-2007-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-american-gangster-2007-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-american-gangster-2007-poster.jpg new file mode 100644 index 00000000..0f41d299 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-american-gangster-2007-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-annie-hall-1977-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-annie-hall-1977-backdrop.jpg new file mode 100644 index 00000000..16d0b27d Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-annie-hall-1977-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-annie-hall-1977-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-annie-hall-1977-poster.jpg new file mode 100644 index 00000000..9023a691 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-annie-hall-1977-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-ant-man-and-the-wasp-quantumania-2023-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-ant-man-and-the-wasp-quantumania-2023-backdrop.jpg new file mode 100644 index 00000000..2a99846d Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-ant-man-and-the-wasp-quantumania-2023-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-ant-man-and-the-wasp-quantumania-2023-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-ant-man-and-the-wasp-quantumania-2023-poster.jpg new file mode 100644 index 00000000..030addf0 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-ant-man-and-the-wasp-quantumania-2023-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-apocalypse-now-1979-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-apocalypse-now-1979-backdrop.jpg new file mode 100644 index 00000000..93292da3 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-apocalypse-now-1979-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-apocalypse-now-1979-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-apocalypse-now-1979-poster.jpg new file mode 100644 index 00000000..09cd6aa8 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-apocalypse-now-1979-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-appleseed-alpha-2014-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-appleseed-alpha-2014-backdrop.jpg new file mode 100644 index 00000000..82579b86 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-appleseed-alpha-2014-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-appleseed-alpha-2014-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-appleseed-alpha-2014-poster.jpg new file mode 100644 index 00000000..62bd0afa Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-appleseed-alpha-2014-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-aquaman-and-the-lost-kingdom-2023-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-aquaman-and-the-lost-kingdom-2023-backdrop.jpg new file mode 100644 index 00000000..6693cb6d Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-aquaman-and-the-lost-kingdom-2023-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-aquaman-and-the-lost-kingdom-2023-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-aquaman-and-the-lost-kingdom-2023-poster.jpg new file mode 100644 index 00000000..4550bdaa Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-aquaman-and-the-lost-kingdom-2023-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-arctic-dogs-2019-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-arctic-dogs-2019-backdrop.jpg new file mode 100644 index 00000000..1794c99d Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-arctic-dogs-2019-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-arctic-dogs-2019-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-arctic-dogs-2019-poster.jpg new file mode 100644 index 00000000..07552443 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-arctic-dogs-2019-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-arrival-2016-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-arrival-2016-backdrop.jpg new file mode 100644 index 00000000..605724e7 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-arrival-2016-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-arrival-2016-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-arrival-2016-poster.jpg new file mode 100644 index 00000000..fe224a15 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-arrival-2016-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-avatar-aang-the-last-airbender-2026-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-avatar-aang-the-last-airbender-2026-backdrop.jpg new file mode 100644 index 00000000..d3b12fe0 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-avatar-aang-the-last-airbender-2026-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-avatar-aang-the-last-airbender-2026-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-avatar-aang-the-last-airbender-2026-poster.jpg new file mode 100644 index 00000000..f57f1052 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-avatar-aang-the-last-airbender-2026-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-avengers-age-of-ultron-2015-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-avengers-age-of-ultron-2015-backdrop.jpg new file mode 100644 index 00000000..f739d620 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-avengers-age-of-ultron-2015-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-avengers-age-of-ultron-2015-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-avengers-age-of-ultron-2015-poster.jpg new file mode 100644 index 00000000..300840ff Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-avengers-age-of-ultron-2015-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-avengers-confidential-black-widow-punisher-2014-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-avengers-confidential-black-widow-punisher-2014-backdrop.jpg new file mode 100644 index 00000000..8163931a Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-avengers-confidential-black-widow-punisher-2014-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-avengers-confidential-black-widow-punisher-2014-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-avengers-confidential-black-widow-punisher-2014-poster.jpg new file mode 100644 index 00000000..b096fff2 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-avengers-confidential-black-widow-punisher-2014-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-avengers-infinity-war-2018-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-avengers-infinity-war-2018-backdrop.jpg new file mode 100644 index 00000000..90dc4672 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-avengers-infinity-war-2018-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-avengers-infinity-war-2018-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-avengers-infinity-war-2018-poster.jpg new file mode 100644 index 00000000..57df9298 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-avengers-infinity-war-2018-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-awake-2007-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-awake-2007-backdrop.jpg new file mode 100644 index 00000000..6ddd7f59 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-awake-2007-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-awake-2007-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-awake-2007-poster.jpg new file mode 100644 index 00000000..32595897 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-awake-2007-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-batman-ninja-2018-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-batman-ninja-2018-backdrop.jpg new file mode 100644 index 00000000..9b538d9f Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-batman-ninja-2018-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-batman-ninja-2018-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-batman-ninja-2018-poster.jpg new file mode 100644 index 00000000..7f59127b Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-batman-ninja-2018-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-batman-the-dark-knight-returns-part-1-2012-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-batman-the-dark-knight-returns-part-1-2012-backdrop.jpg new file mode 100644 index 00000000..9d385801 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-batman-the-dark-knight-returns-part-1-2012-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-batman-the-dark-knight-returns-part-1-2012-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-batman-the-dark-knight-returns-part-1-2012-poster.jpg new file mode 100644 index 00000000..ce5623fe Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-batman-the-dark-knight-returns-part-1-2012-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-batman-v-superman-dawn-of-justice-2016-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-batman-v-superman-dawn-of-justice-2016-backdrop.jpg new file mode 100644 index 00000000..b4fa63b9 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-batman-v-superman-dawn-of-justice-2016-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-batman-v-superman-dawn-of-justice-2016-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-batman-v-superman-dawn-of-justice-2016-poster.jpg new file mode 100644 index 00000000..f8115170 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-batman-v-superman-dawn-of-justice-2016-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-black-adam-2022-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-black-adam-2022-backdrop.jpg new file mode 100644 index 00000000..3232fb22 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-black-adam-2022-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-black-adam-2022-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-black-adam-2022-poster.jpg new file mode 100644 index 00000000..dde008b5 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-black-adam-2022-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-black-bear-2020-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-black-bear-2020-backdrop.jpg new file mode 100644 index 00000000..4b0b262f Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-black-bear-2020-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-black-bear-2020-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-black-bear-2020-poster.jpg new file mode 100644 index 00000000..61c8860f Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-black-bear-2020-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-black-panther-2018-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-black-panther-2018-backdrop.jpg new file mode 100644 index 00000000..f0b8de8a Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-black-panther-2018-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-black-panther-2018-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-black-panther-2018-poster.jpg new file mode 100644 index 00000000..6fdaa3d2 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-black-panther-2018-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-black-panther-wakanda-forever-2022-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-black-panther-wakanda-forever-2022-backdrop.jpg new file mode 100644 index 00000000..ac7ad532 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-black-panther-wakanda-forever-2022-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-black-panther-wakanda-forever-2022-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-black-panther-wakanda-forever-2022-poster.jpg new file mode 100644 index 00000000..d0219a75 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-black-panther-wakanda-forever-2022-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-blame-2017-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-blame-2017-backdrop.jpg new file mode 100644 index 00000000..f39cf5ce Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-blame-2017-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-blame-2017-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-blame-2017-poster.jpg new file mode 100644 index 00000000..8d50255f Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-blame-2017-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-bleach-the-movie-fade-to-black-2008-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-bleach-the-movie-fade-to-black-2008-backdrop.jpg new file mode 100644 index 00000000..f426d0ce Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-bleach-the-movie-fade-to-black-2008-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-bleach-the-movie-fade-to-black-2008-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-bleach-the-movie-fade-to-black-2008-poster.jpg new file mode 100644 index 00000000..d3619fbd Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-bleach-the-movie-fade-to-black-2008-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-bohemian-rhapsody-2018-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-bohemian-rhapsody-2018-backdrop.jpg new file mode 100644 index 00000000..b6b5a7cd Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-bohemian-rhapsody-2018-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-bohemian-rhapsody-2018-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-bohemian-rhapsody-2018-poster.jpg new file mode 100644 index 00000000..20d2f9ee Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-bohemian-rhapsody-2018-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-braveheart-1995-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-braveheart-1995-backdrop.jpg new file mode 100644 index 00000000..c1c84c59 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-braveheart-1995-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-braveheart-1995-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-braveheart-1995-poster.jpg new file mode 100644 index 00000000..32fd8d4d Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-braveheart-1995-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-butterfly-on-a-wheel-2007-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-butterfly-on-a-wheel-2007-backdrop.jpg new file mode 100644 index 00000000..e49fe1d5 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-butterfly-on-a-wheel-2007-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-butterfly-on-a-wheel-2007-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-butterfly-on-a-wheel-2007-poster.jpg new file mode 100644 index 00000000..b54cc5c7 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-butterfly-on-a-wheel-2007-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-by-any-means-2026-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-by-any-means-2026-backdrop.jpg new file mode 100644 index 00000000..e536359f Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-by-any-means-2026-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-by-any-means-2026-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-by-any-means-2026-poster.jpg new file mode 100644 index 00000000..92fd601f Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-by-any-means-2026-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-captain-america-civil-war-2016-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-captain-america-civil-war-2016-backdrop.jpg new file mode 100644 index 00000000..5700c4e7 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-captain-america-civil-war-2016-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-captain-america-civil-war-2016-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-captain-america-civil-war-2016-poster.jpg new file mode 100644 index 00000000..426142ef Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-captain-america-civil-war-2016-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-captain-america-the-first-avenger-2011-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-captain-america-the-first-avenger-2011-backdrop.jpg new file mode 100644 index 00000000..411ceebe Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-captain-america-the-first-avenger-2011-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-captain-america-the-first-avenger-2011-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-captain-america-the-first-avenger-2011-poster.jpg new file mode 100644 index 00000000..bba96574 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-captain-america-the-first-avenger-2011-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-captain-america-the-winter-soldier-2014-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-captain-america-the-winter-soldier-2014-backdrop.jpg new file mode 100644 index 00000000..5661fcff Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-captain-america-the-winter-soldier-2014-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-captain-america-the-winter-soldier-2014-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-captain-america-the-winter-soldier-2014-poster.jpg new file mode 100644 index 00000000..c2dc5612 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-captain-america-the-winter-soldier-2014-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-careful-what-you-wish-for-2015-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-careful-what-you-wish-for-2015-backdrop.jpg new file mode 100644 index 00000000..988e20d5 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-careful-what-you-wish-for-2015-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-careful-what-you-wish-for-2015-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-careful-what-you-wish-for-2015-poster.jpg new file mode 100644 index 00000000..b58ce600 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-careful-what-you-wish-for-2015-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-castle-in-the-sky-1986-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-castle-in-the-sky-1986-backdrop.jpg new file mode 100644 index 00000000..f785c549 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-castle-in-the-sky-1986-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-castle-in-the-sky-1986-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-castle-in-the-sky-1986-poster.jpg new file mode 100644 index 00000000..a6a99fee Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-castle-in-the-sky-1986-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-chicago-2002-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-chicago-2002-backdrop.jpg new file mode 100644 index 00000000..6b3d02a3 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-chicago-2002-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-chicago-2002-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-chicago-2002-poster.jpg new file mode 100644 index 00000000..1def9414 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-chicago-2002-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-citizen-kane-1941-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-citizen-kane-1941-backdrop.jpg new file mode 100644 index 00000000..d41a10ef Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-citizen-kane-1941-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-citizen-kane-1941-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-citizen-kane-1941-poster.jpg new file mode 100644 index 00000000..f26414a7 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-citizen-kane-1941-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-coherence-2014-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-coherence-2014-backdrop.jpg new file mode 100644 index 00000000..37da64f9 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-coherence-2014-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-coherence-2014-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-coherence-2014-poster.jpg new file mode 100644 index 00000000..f3601b9b Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-coherence-2014-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-conclave-2024-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-conclave-2024-backdrop.jpg new file mode 100644 index 00000000..db0a7fcd Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-conclave-2024-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-conclave-2024-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-conclave-2024-poster.jpg new file mode 100644 index 00000000..c6aecefa Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-conclave-2024-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-crime-of-passion-1956-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-crime-of-passion-1956-backdrop.jpg new file mode 100644 index 00000000..6c70a452 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-crime-of-passion-1956-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-crime-of-passion-1956-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-crime-of-passion-1956-poster.jpg new file mode 100644 index 00000000..4acfa871 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-crime-of-passion-1956-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-deep-water-2022-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-deep-water-2022-backdrop.jpg new file mode 100644 index 00000000..bec98992 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-deep-water-2022-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-deep-water-2022-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-deep-water-2022-poster.jpg new file mode 100644 index 00000000..018d4f4b Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-deep-water-2022-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-derailed-2005-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-derailed-2005-backdrop.jpg new file mode 100644 index 00000000..e958449b Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-derailed-2005-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-derailed-2005-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-derailed-2005-poster.jpg new file mode 100644 index 00000000..d93f3124 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-derailed-2005-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-die-my-love-2025-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-die-my-love-2025-backdrop.jpg new file mode 100644 index 00000000..754e713f Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-die-my-love-2025-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-die-my-love-2025-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-die-my-love-2025-poster.jpg new file mode 100644 index 00000000..f6eca818 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-die-my-love-2025-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-dream-house-2011-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-dream-house-2011-backdrop.jpg new file mode 100644 index 00000000..29ecc103 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-dream-house-2011-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-dream-house-2011-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-dream-house-2011-poster.jpg new file mode 100644 index 00000000..d3444637 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-dream-house-2011-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-dream-lover-1994-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-dream-lover-1994-backdrop.jpg new file mode 100644 index 00000000..7f5a31ac Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-dream-lover-1994-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-dream-lover-1994-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-dream-lover-1994-poster.jpg new file mode 100644 index 00000000..3b563cf9 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-dream-lover-1994-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-drop-2025-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-drop-2025-backdrop.jpg new file mode 100644 index 00000000..ee642530 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-drop-2025-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-drop-2025-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-drop-2025-poster.jpg new file mode 100644 index 00000000..4cf7d66b Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-drop-2025-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-eastern-promises-2007-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-eastern-promises-2007-backdrop.jpg new file mode 100644 index 00000000..9096a0fa Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-eastern-promises-2007-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-eastern-promises-2007-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-eastern-promises-2007-poster.jpg new file mode 100644 index 00000000..01a26f3b Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-eastern-promises-2007-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-easy-a-2010-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-easy-a-2010-backdrop.jpg new file mode 100644 index 00000000..c27fa57b Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-easy-a-2010-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-easy-a-2010-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-easy-a-2010-poster.jpg new file mode 100644 index 00000000..45e13a32 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-easy-a-2010-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-el-camino-a-breaking-bad-movie-2019-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-el-camino-a-breaking-bad-movie-2019-backdrop.jpg new file mode 100644 index 00000000..8edc02a6 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-el-camino-a-breaking-bad-movie-2019-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-el-camino-a-breaking-bad-movie-2019-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-el-camino-a-breaking-bad-movie-2019-poster.jpg new file mode 100644 index 00000000..7a62df45 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-el-camino-a-breaking-bad-movie-2019-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-emancipation-2022-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-emancipation-2022-backdrop.jpg new file mode 100644 index 00000000..ac0777c7 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-emancipation-2022-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-emancipation-2022-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-emancipation-2022-poster.jpg new file mode 100644 index 00000000..c1bee971 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-emancipation-2022-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-faces-1968-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-faces-1968-backdrop.jpg new file mode 100644 index 00000000..60273dd9 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-faces-1968-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-faces-1968-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-faces-1968-poster.jpg new file mode 100644 index 00000000..43f996bd Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-faces-1968-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-fatale-2020-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-fatale-2020-backdrop.jpg new file mode 100644 index 00000000..b13c33c6 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-fatale-2020-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-fatale-2020-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-fatale-2020-poster.jpg new file mode 100644 index 00000000..dda9d622 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-fatale-2020-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-fighting-with-my-family-2019-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-fighting-with-my-family-2019-backdrop.jpg new file mode 100644 index 00000000..46835996 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-fighting-with-my-family-2019-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-fighting-with-my-family-2019-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-fighting-with-my-family-2019-poster.jpg new file mode 100644 index 00000000..169c466d Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-fighting-with-my-family-2019-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-force-of-nature-the-dry-2-2024-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-force-of-nature-the-dry-2-2024-backdrop.jpg new file mode 100644 index 00000000..3735ee63 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-force-of-nature-the-dry-2-2024-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-force-of-nature-the-dry-2-2024-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-force-of-nature-the-dry-2-2024-poster.jpg new file mode 100644 index 00000000..93ba0d71 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-force-of-nature-the-dry-2-2024-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-free-state-of-jones-2016-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-free-state-of-jones-2016-backdrop.jpg new file mode 100644 index 00000000..a7e5ddff Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-free-state-of-jones-2016-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-free-state-of-jones-2016-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-free-state-of-jones-2016-poster.jpg new file mode 100644 index 00000000..e69af7c0 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-free-state-of-jones-2016-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-from-up-on-poppy-hill-2011-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-from-up-on-poppy-hill-2011-backdrop.jpg new file mode 100644 index 00000000..32a28bd7 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-from-up-on-poppy-hill-2011-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-from-up-on-poppy-hill-2011-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-from-up-on-poppy-hill-2011-poster.jpg new file mode 100644 index 00000000..264076e2 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-from-up-on-poppy-hill-2011-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-ghost-in-the-shell-1995-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-ghost-in-the-shell-1995-backdrop.jpg new file mode 100644 index 00000000..716b8a87 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-ghost-in-the-shell-1995-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-ghost-in-the-shell-1995-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-ghost-in-the-shell-1995-poster.jpg new file mode 100644 index 00000000..58d221ba Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-ghost-in-the-shell-1995-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-ghost-in-the-shell-2-innocence-2004-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-ghost-in-the-shell-2-innocence-2004-backdrop.jpg new file mode 100644 index 00000000..4a9931ed Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-ghost-in-the-shell-2-innocence-2004-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-ghost-in-the-shell-2-innocence-2004-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-ghost-in-the-shell-2-innocence-2004-poster.jpg new file mode 100644 index 00000000..ae262147 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-ghost-in-the-shell-2-innocence-2004-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-gosford-park-2001-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-gosford-park-2001-backdrop.jpg new file mode 100644 index 00000000..42c4cea6 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-gosford-park-2001-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-gosford-park-2001-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-gosford-park-2001-poster.jpg new file mode 100644 index 00000000..6a6aa651 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-gosford-park-2001-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-grave-of-the-fireflies-1988-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-grave-of-the-fireflies-1988-backdrop.jpg new file mode 100644 index 00000000..0e1add06 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-grave-of-the-fireflies-1988-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-grave-of-the-fireflies-1988-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-grave-of-the-fireflies-1988-poster.jpg new file mode 100644 index 00000000..1b71996a Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-grave-of-the-fireflies-1988-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-howl-s-moving-castle-2004-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-howl-s-moving-castle-2004-backdrop.jpg new file mode 100644 index 00000000..0aeef2ba Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-howl-s-moving-castle-2004-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-howl-s-moving-castle-2004-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-howl-s-moving-castle-2004-poster.jpg new file mode 100644 index 00000000..042a6c80 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-howl-s-moving-castle-2004-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-in-the-heights-2021-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-in-the-heights-2021-backdrop.jpg new file mode 100644 index 00000000..7cd4ee24 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-in-the-heights-2021-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-in-the-heights-2021-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-in-the-heights-2021-poster.jpg new file mode 100644 index 00000000..e05bfdb0 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-in-the-heights-2021-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-jennifer-s-body-2009-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-jennifer-s-body-2009-backdrop.jpg new file mode 100644 index 00000000..032771a0 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-jennifer-s-body-2009-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-jennifer-s-body-2009-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-jennifer-s-body-2009-poster.jpg new file mode 100644 index 00000000..41cc1eb5 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-jennifer-s-body-2009-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-kiki-s-delivery-service-1989-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-kiki-s-delivery-service-1989-backdrop.jpg new file mode 100644 index 00000000..2a108f0c Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-kiki-s-delivery-service-1989-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-kiki-s-delivery-service-1989-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-kiki-s-delivery-service-1989-poster.jpg new file mode 100644 index 00000000..80a59b1e Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-kiki-s-delivery-service-1989-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-lemony-snicket-s-a-series-of-unfortunate-events-2004-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-lemony-snicket-s-a-series-of-unfortunate-events-2004-backdrop.jpg new file mode 100644 index 00000000..38203b77 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-lemony-snicket-s-a-series-of-unfortunate-events-2004-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-lemony-snicket-s-a-series-of-unfortunate-events-2004-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-lemony-snicket-s-a-series-of-unfortunate-events-2004-poster.jpg new file mode 100644 index 00000000..4c78fee9 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-lemony-snicket-s-a-series-of-unfortunate-events-2004-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-locke-2014-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-locke-2014-backdrop.jpg new file mode 100644 index 00000000..a24e64ab Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-locke-2014-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-locke-2014-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-locke-2014-poster.jpg new file mode 100644 index 00000000..94ebe97d Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-locke-2014-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-look-who-s-back-2015-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-look-who-s-back-2015-backdrop.jpg new file mode 100644 index 00000000..a064686f Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-look-who-s-back-2015-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-look-who-s-back-2015-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-look-who-s-back-2015-poster.jpg new file mode 100644 index 00000000..423043cf Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-look-who-s-back-2015-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-memories-1995-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-memories-1995-backdrop.jpg new file mode 100644 index 00000000..2fcaeaad Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-memories-1995-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-memories-1995-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-memories-1995-poster.jpg new file mode 100644 index 00000000..f3725b42 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-memories-1995-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-mercy-2026-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-mercy-2026-backdrop.jpg new file mode 100644 index 00000000..45591cc8 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-mercy-2026-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-mercy-2026-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-mercy-2026-poster.jpg new file mode 100644 index 00000000..cfc0d812 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-mercy-2026-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-night-is-short-walk-on-girl-2017-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-night-is-short-walk-on-girl-2017-backdrop.jpg new file mode 100644 index 00000000..da38be48 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-night-is-short-walk-on-girl-2017-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-night-is-short-walk-on-girl-2017-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-night-is-short-walk-on-girl-2017-poster.jpg new file mode 100644 index 00000000..c6fa6a26 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-night-is-short-walk-on-girl-2017-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-normal-2026-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-normal-2026-backdrop.jpg new file mode 100644 index 00000000..51711864 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-normal-2026-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-normal-2026-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-normal-2026-poster.jpg new file mode 100644 index 00000000..f4c89db7 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-normal-2026-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-outlaw-king-2018-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-outlaw-king-2018-backdrop.jpg new file mode 100644 index 00000000..54ac5a2a Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-outlaw-king-2018-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-outlaw-king-2018-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-outlaw-king-2018-poster.jpg new file mode 100644 index 00000000..d6760468 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-outlaw-king-2018-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-panic-room-2002-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-panic-room-2002-backdrop.jpg new file mode 100644 index 00000000..ec877751 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-panic-room-2002-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-panic-room-2002-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-panic-room-2002-poster.jpg new file mode 100644 index 00000000..0b2a0d30 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-panic-room-2002-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-peaky-blinders-the-immortal-man-2026-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-peaky-blinders-the-immortal-man-2026-backdrop.jpg new file mode 100644 index 00000000..e9d7155a Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-peaky-blinders-the-immortal-man-2026-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-peaky-blinders-the-immortal-man-2026-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-peaky-blinders-the-immortal-man-2026-poster.jpg new file mode 100644 index 00000000..a4814472 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-peaky-blinders-the-immortal-man-2026-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-ponyo-2008-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-ponyo-2008-backdrop.jpg new file mode 100644 index 00000000..8aa4ecbd Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-ponyo-2008-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-ponyo-2008-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-ponyo-2008-poster.jpg new file mode 100644 index 00000000..f548e831 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-ponyo-2008-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-porco-rosso-1992-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-porco-rosso-1992-backdrop.jpg new file mode 100644 index 00000000..63d8112e Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-porco-rosso-1992-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-porco-rosso-1992-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-porco-rosso-1992-poster.jpg new file mode 100644 index 00000000..4a6a2bd1 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-porco-rosso-1992-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-princess-mononoke-1997-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-princess-mononoke-1997-backdrop.jpg new file mode 100644 index 00000000..9486d2cf Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-princess-mononoke-1997-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-princess-mononoke-1997-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-princess-mononoke-1997-poster.jpg new file mode 100644 index 00000000..21dd9359 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-princess-mononoke-1997-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-prisoners-2013-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-prisoners-2013-backdrop.jpg new file mode 100644 index 00000000..724008c5 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-prisoners-2013-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-prisoners-2013-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-prisoners-2013-poster.jpg new file mode 100644 index 00000000..411b149b Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-prisoners-2013-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-racing-stripes-2005-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-racing-stripes-2005-backdrop.jpg new file mode 100644 index 00000000..1d332cec Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-racing-stripes-2005-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-racing-stripes-2005-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-racing-stripes-2005-poster.jpg new file mode 100644 index 00000000..cc2ef2ce Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-racing-stripes-2005-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-reincarnated-2012-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-reincarnated-2012-backdrop.jpg new file mode 100644 index 00000000..326e8ebc Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-reincarnated-2012-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-reincarnated-2012-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-reincarnated-2012-poster.jpg new file mode 100644 index 00000000..e9ab449c Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-reincarnated-2012-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-requiem-for-a-dream-2000-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-requiem-for-a-dream-2000-backdrop.jpg new file mode 100644 index 00000000..21842ef2 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-requiem-for-a-dream-2000-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-requiem-for-a-dream-2000-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-requiem-for-a-dream-2000-poster.jpg new file mode 100644 index 00000000..6f89abc3 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-requiem-for-a-dream-2000-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-rogue-one-a-star-wars-story-2016-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-rogue-one-a-star-wars-story-2016-backdrop.jpg new file mode 100644 index 00000000..ff0c14c0 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-rogue-one-a-star-wars-story-2016-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-rogue-one-a-star-wars-story-2016-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-rogue-one-a-star-wars-story-2016-poster.jpg new file mode 100644 index 00000000..c79e70a1 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-rogue-one-a-star-wars-story-2016-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-secondhand-lions-2003-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-secondhand-lions-2003-backdrop.jpg new file mode 100644 index 00000000..7ea43937 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-secondhand-lions-2003-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-secondhand-lions-2003-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-secondhand-lions-2003-poster.jpg new file mode 100644 index 00000000..33408fa0 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-secondhand-lions-2003-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-self-reliance-2024-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-self-reliance-2024-backdrop.jpg new file mode 100644 index 00000000..3cc0d123 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-self-reliance-2024-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-self-reliance-2024-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-self-reliance-2024-poster.jpg new file mode 100644 index 00000000..0f6017ed Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-self-reliance-2024-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-serpico-1973-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-serpico-1973-backdrop.jpg new file mode 100644 index 00000000..c9649485 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-serpico-1973-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-serpico-1973-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-serpico-1973-poster.jpg new file mode 100644 index 00000000..8ddd6e52 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-serpico-1973-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-shaft-2000-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-shaft-2000-backdrop.jpg new file mode 100644 index 00000000..13f14f93 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-shaft-2000-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-shaft-2000-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-shaft-2000-poster.jpg new file mode 100644 index 00000000..6b2b79ce Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-shaft-2000-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-shipwrecked-nightmare-at-sea-2026-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-shipwrecked-nightmare-at-sea-2026-backdrop.jpg new file mode 100644 index 00000000..6e98e242 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-shipwrecked-nightmare-at-sea-2026-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-shipwrecked-nightmare-at-sea-2026-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-shipwrecked-nightmare-at-sea-2026-poster.jpg new file mode 100644 index 00000000..f200a28d Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-shipwrecked-nightmare-at-sea-2026-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-sinbad-legend-of-the-seven-seas-2003-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-sinbad-legend-of-the-seven-seas-2003-backdrop.jpg new file mode 100644 index 00000000..d04685fe Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-sinbad-legend-of-the-seven-seas-2003-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-sinbad-legend-of-the-seven-seas-2003-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-sinbad-legend-of-the-seven-seas-2003-poster.jpg new file mode 100644 index 00000000..428bb38b Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-sinbad-legend-of-the-seven-seas-2003-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-sleeping-with-other-people-2015-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-sleeping-with-other-people-2015-backdrop.jpg new file mode 100644 index 00000000..df5bb046 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-sleeping-with-other-people-2015-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-sleeping-with-other-people-2015-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-sleeping-with-other-people-2015-poster.jpg new file mode 100644 index 00000000..a5ffff1f Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-sleeping-with-other-people-2015-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-snowpiercer-2013-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-snowpiercer-2013-backdrop.jpg new file mode 100644 index 00000000..c1281a9c Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-snowpiercer-2013-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-snowpiercer-2013-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-snowpiercer-2013-poster.jpg new file mode 100644 index 00000000..be1873fd Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-snowpiercer-2013-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-spider-man-brand-new-day-2026-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-spider-man-brand-new-day-2026-backdrop.jpg new file mode 100644 index 00000000..2fdaadb4 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-spider-man-brand-new-day-2026-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-spider-man-brand-new-day-2026-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-spider-man-brand-new-day-2026-poster.jpg new file mode 100644 index 00000000..51fa155f Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-spider-man-brand-new-day-2026-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-spider-man-far-from-home-2019-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-spider-man-far-from-home-2019-backdrop.jpg new file mode 100644 index 00000000..d09f655a Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-spider-man-far-from-home-2019-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-spider-man-far-from-home-2019-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-spider-man-far-from-home-2019-poster.jpg new file mode 100644 index 00000000..28521997 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-spider-man-far-from-home-2019-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-spider-man-homecoming-2017-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-spider-man-homecoming-2017-backdrop.jpg new file mode 100644 index 00000000..6088fadf Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-spider-man-homecoming-2017-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-spider-man-homecoming-2017-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-spider-man-homecoming-2017-poster.jpg new file mode 100644 index 00000000..97fc9c05 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-spider-man-homecoming-2017-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-spider-man-no-way-home-2021-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-spider-man-no-way-home-2021-backdrop.jpg new file mode 100644 index 00000000..a76afec9 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-spider-man-no-way-home-2021-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-spider-man-no-way-home-2021-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-spider-man-no-way-home-2021-poster.jpg new file mode 100644 index 00000000..bc92a4cd Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-spider-man-no-way-home-2021-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-spirited-away-2001-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-spirited-away-2001-backdrop.jpg new file mode 100644 index 00000000..af91c123 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-spirited-away-2001-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-spirited-away-2001-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-spirited-away-2001-poster.jpg new file mode 100644 index 00000000..5a79da62 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-spirited-away-2001-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-steins-gate-the-movie-load-region-of-deja-vu-2013-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-steins-gate-the-movie-load-region-of-deja-vu-2013-backdrop.jpg new file mode 100644 index 00000000..f6675060 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-steins-gate-the-movie-load-region-of-deja-vu-2013-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-steins-gate-the-movie-load-region-of-deja-vu-2013-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-steins-gate-the-movie-load-region-of-deja-vu-2013-poster.jpg new file mode 100644 index 00000000..e85ddb90 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-steins-gate-the-movie-load-region-of-deja-vu-2013-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-taxi-driver-1976-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-taxi-driver-1976-backdrop.jpg new file mode 100644 index 00000000..7946ea40 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-taxi-driver-1976-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-taxi-driver-1976-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-taxi-driver-1976-poster.jpg new file mode 100644 index 00000000..c410b981 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-taxi-driver-1976-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-alto-knights-2025-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-alto-knights-2025-backdrop.jpg new file mode 100644 index 00000000..ea619c14 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-the-alto-knights-2025-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-alto-knights-2025-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-alto-knights-2025-poster.jpg new file mode 100644 index 00000000..bb009cbf Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-the-alto-knights-2025-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-amazing-spider-man-2-2014-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-amazing-spider-man-2-2014-backdrop.jpg new file mode 100644 index 00000000..7c5878b7 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-the-amazing-spider-man-2-2014-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-amazing-spider-man-2-2014-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-amazing-spider-man-2-2014-poster.jpg new file mode 100644 index 00000000..4de2711f Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-the-amazing-spider-man-2-2014-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-amazing-spider-man-2012-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-amazing-spider-man-2012-backdrop.jpg new file mode 100644 index 00000000..75ae9b64 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-the-amazing-spider-man-2012-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-amazing-spider-man-2012-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-amazing-spider-man-2012-poster.jpg new file mode 100644 index 00000000..c1aec876 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-the-amazing-spider-man-2012-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-animatrix-2003-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-animatrix-2003-backdrop.jpg new file mode 100644 index 00000000..7d67ccb0 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-the-animatrix-2003-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-animatrix-2003-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-animatrix-2003-poster.jpg new file mode 100644 index 00000000..65553d47 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-the-animatrix-2003-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-avengers-2012-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-avengers-2012-backdrop.jpg new file mode 100644 index 00000000..83636f25 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-the-avengers-2012-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-avengers-2012-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-avengers-2012-poster.jpg new file mode 100644 index 00000000..be87067c Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-the-avengers-2012-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-boy-and-the-heron-2023-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-boy-and-the-heron-2023-backdrop.jpg new file mode 100644 index 00000000..a19dc77b Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-the-boy-and-the-heron-2023-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-boy-and-the-heron-2023-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-boy-and-the-heron-2023-poster.jpg new file mode 100644 index 00000000..094df64e Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-the-boy-and-the-heron-2023-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-breakfast-club-1985-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-breakfast-club-1985-backdrop.jpg new file mode 100644 index 00000000..9f979cd8 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-the-breakfast-club-1985-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-breakfast-club-1985-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-breakfast-club-1985-poster.jpg new file mode 100644 index 00000000..d1823120 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-the-breakfast-club-1985-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-dry-2021-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-dry-2021-backdrop.jpg new file mode 100644 index 00000000..2f016498 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-the-dry-2021-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-dry-2021-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-dry-2021-poster.jpg new file mode 100644 index 00000000..a0379f2f Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-the-dry-2021-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-girl-who-leapt-through-time-2006-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-girl-who-leapt-through-time-2006-backdrop.jpg new file mode 100644 index 00000000..6e920467 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-the-girl-who-leapt-through-time-2006-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-girl-who-leapt-through-time-2006-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-girl-who-leapt-through-time-2006-poster.jpg new file mode 100644 index 00000000..ded6cd2a Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-the-girl-who-leapt-through-time-2006-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-godfather-1972-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-godfather-1972-backdrop.jpg new file mode 100644 index 00000000..8fbb8f2a Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-the-godfather-1972-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-godfather-1972-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-godfather-1972-poster.jpg new file mode 100644 index 00000000..94111d9b Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-the-godfather-1972-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-godfather-part-ii-1974-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-godfather-part-ii-1974-backdrop.jpg new file mode 100644 index 00000000..6dbff534 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-the-godfather-part-ii-1974-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-godfather-part-ii-1974-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-godfather-part-ii-1974-poster.jpg new file mode 100644 index 00000000..1328c99e Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-the-godfather-part-ii-1974-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-godfather-part-iii-1990-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-godfather-part-iii-1990-backdrop.jpg new file mode 100644 index 00000000..d2080487 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-the-godfather-part-iii-1990-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-godfather-part-iii-1990-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-godfather-part-iii-1990-poster.jpg new file mode 100644 index 00000000..abbe5e99 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-the-godfather-part-iii-1990-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-great-gatsby-2013-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-great-gatsby-2013-backdrop.jpg new file mode 100644 index 00000000..7a4f8a32 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-the-great-gatsby-2013-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-great-gatsby-2013-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-great-gatsby-2013-poster.jpg new file mode 100644 index 00000000..e949fb3e Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-the-great-gatsby-2013-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-hours-2002-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-hours-2002-backdrop.jpg new file mode 100644 index 00000000..507a78c4 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-the-hours-2002-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-hours-2002-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-hours-2002-poster.jpg new file mode 100644 index 00000000..f87e919f Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-the-hours-2002-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-killing-of-a-sacred-deer-2017-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-killing-of-a-sacred-deer-2017-backdrop.jpg new file mode 100644 index 00000000..dd755ee8 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-the-killing-of-a-sacred-deer-2017-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-killing-of-a-sacred-deer-2017-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-killing-of-a-sacred-deer-2017-poster.jpg new file mode 100644 index 00000000..83464d4c Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-the-killing-of-a-sacred-deer-2017-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-last-of-the-mohicans-1992-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-last-of-the-mohicans-1992-backdrop.jpg new file mode 100644 index 00000000..3256d38d Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-the-last-of-the-mohicans-1992-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-last-of-the-mohicans-1992-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-last-of-the-mohicans-1992-poster.jpg new file mode 100644 index 00000000..950e9ba1 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-the-last-of-the-mohicans-1992-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-last-samurai-2003-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-last-samurai-2003-backdrop.jpg new file mode 100644 index 00000000..509af2d6 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-the-last-samurai-2003-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-last-samurai-2003-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-last-samurai-2003-poster.jpg new file mode 100644 index 00000000..0d58440c Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-the-last-samurai-2003-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-lighthouse-2019-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-lighthouse-2019-backdrop.jpg new file mode 100644 index 00000000..862c064a Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-the-lighthouse-2019-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-lighthouse-2019-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-lighthouse-2019-poster.jpg new file mode 100644 index 00000000..15f26533 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-the-lighthouse-2019-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-matrix-1999-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-matrix-1999-backdrop.jpg new file mode 100644 index 00000000..c3bf8f52 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-the-matrix-1999-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-matrix-1999-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-matrix-1999-poster.jpg new file mode 100644 index 00000000..c292dce5 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-the-matrix-1999-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-matrix-reloaded-2003-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-matrix-reloaded-2003-backdrop.jpg new file mode 100644 index 00000000..dcbf7b96 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-the-matrix-reloaded-2003-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-matrix-reloaded-2003-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-matrix-reloaded-2003-poster.jpg new file mode 100644 index 00000000..ab11f50f Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-the-matrix-reloaded-2003-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-matrix-resurrections-2021-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-matrix-resurrections-2021-backdrop.jpg new file mode 100644 index 00000000..99d85a40 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-the-matrix-resurrections-2021-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-matrix-resurrections-2021-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-matrix-resurrections-2021-poster.jpg new file mode 100644 index 00000000..4477ef1e Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-the-matrix-resurrections-2021-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-matrix-revolutions-2003-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-matrix-revolutions-2003-backdrop.jpg new file mode 100644 index 00000000..2cc02692 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-the-matrix-revolutions-2003-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-matrix-revolutions-2003-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-matrix-revolutions-2003-poster.jpg new file mode 100644 index 00000000..7236cc4e Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-the-matrix-revolutions-2003-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-patriot-2000-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-patriot-2000-backdrop.jpg new file mode 100644 index 00000000..ce7ce498 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-the-patriot-2000-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-patriot-2000-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-patriot-2000-poster.jpg new file mode 100644 index 00000000..c8582133 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-the-patriot-2000-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-secret-life-of-pets-2-2019-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-secret-life-of-pets-2-2019-backdrop.jpg new file mode 100644 index 00000000..698f4ad2 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-the-secret-life-of-pets-2-2019-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-secret-life-of-pets-2-2019-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-secret-life-of-pets-2-2019-poster.jpg new file mode 100644 index 00000000..fe50c2f8 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-the-secret-life-of-pets-2-2019-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-substance-2024-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-substance-2024-backdrop.jpg new file mode 100644 index 00000000..47a8b372 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-the-substance-2024-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-substance-2024-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-substance-2024-poster.jpg new file mode 100644 index 00000000..72accfed Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-the-substance-2024-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-tale-of-the-princess-kaguya-2013-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-tale-of-the-princess-kaguya-2013-backdrop.jpg new file mode 100644 index 00000000..b9157b91 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-the-tale-of-the-princess-kaguya-2013-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-tale-of-the-princess-kaguya-2013-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-tale-of-the-princess-kaguya-2013-poster.jpg new file mode 100644 index 00000000..882621cb Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-the-tale-of-the-princess-kaguya-2013-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-uprising-2026-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-uprising-2026-backdrop.jpg new file mode 100644 index 00000000..638eab3d Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-the-uprising-2026-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-uprising-2026-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-uprising-2026-poster.jpg new file mode 100644 index 00000000..21976395 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-the-uprising-2026-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-wild-robot-2024-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-wild-robot-2024-backdrop.jpg new file mode 100644 index 00000000..bb48460b Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-the-wild-robot-2024-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-wild-robot-2024-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-wild-robot-2024-poster.jpg new file mode 100644 index 00000000..0894754d Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-the-wild-robot-2024-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-wind-rises-2013-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-wind-rises-2013-backdrop.jpg new file mode 100644 index 00000000..2af40e28 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-the-wind-rises-2013-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-wind-rises-2013-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-wind-rises-2013-poster.jpg new file mode 100644 index 00000000..b7688c77 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-the-wind-rises-2013-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-witcher-nightmare-of-the-wolf-2021-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-witcher-nightmare-of-the-wolf-2021-backdrop.jpg new file mode 100644 index 00000000..cb5bfb28 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-the-witcher-nightmare-of-the-wolf-2021-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-witcher-nightmare-of-the-wolf-2021-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-witcher-nightmare-of-the-wolf-2021-poster.jpg new file mode 100644 index 00000000..6db33db1 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-the-witcher-nightmare-of-the-wolf-2021-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-witcher-sirens-of-the-deep-2025-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-witcher-sirens-of-the-deep-2025-backdrop.jpg new file mode 100644 index 00000000..aad8ea2c Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-the-witcher-sirens-of-the-deep-2025-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-witcher-sirens-of-the-deep-2025-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-witcher-sirens-of-the-deep-2025-poster.jpg new file mode 100644 index 00000000..8a54bc7f Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-the-witcher-sirens-of-the-deep-2025-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-thelma-louise-1991-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-thelma-louise-1991-backdrop.jpg new file mode 100644 index 00000000..a35327a9 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-thelma-louise-1991-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-thelma-louise-1991-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-thelma-louise-1991-poster.jpg new file mode 100644 index 00000000..5a4ff0ab Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-thelma-louise-1991-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-triangle-of-sadness-2022-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-triangle-of-sadness-2022-backdrop.jpg new file mode 100644 index 00000000..7c9b445d Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-triangle-of-sadness-2022-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-triangle-of-sadness-2022-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-triangle-of-sadness-2022-poster.jpg new file mode 100644 index 00000000..aee092c6 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-triangle-of-sadness-2022-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-tsotsi-2005-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-tsotsi-2005-backdrop.jpg new file mode 100644 index 00000000..e119de53 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-tsotsi-2005-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-tsotsi-2005-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-tsotsi-2005-poster.jpg new file mode 100644 index 00000000..362ddd82 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-tsotsi-2005-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-unknown-number-the-high-school-catfish-2025-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-unknown-number-the-high-school-catfish-2025-backdrop.jpg new file mode 100644 index 00000000..f456c01d Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-unknown-number-the-high-school-catfish-2025-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-unknown-number-the-high-school-catfish-2025-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-unknown-number-the-high-school-catfish-2025-poster.jpg new file mode 100644 index 00000000..e32685f3 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-unknown-number-the-high-school-catfish-2025-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-vice-2018-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-vice-2018-backdrop.jpg new file mode 100644 index 00000000..2918db3a Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-vice-2018-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-vice-2018-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-vice-2018-poster.jpg new file mode 100644 index 00000000..76855126 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-vice-2018-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-vicky-cristina-barcelona-2008-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-vicky-cristina-barcelona-2008-backdrop.jpg new file mode 100644 index 00000000..20eaa159 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-vicky-cristina-barcelona-2008-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-vicky-cristina-barcelona-2008-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-vicky-cristina-barcelona-2008-poster.jpg new file mode 100644 index 00000000..1351cad1 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-vicky-cristina-barcelona-2008-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-warriors-of-the-wind-1984-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-warriors-of-the-wind-1984-backdrop.jpg new file mode 100644 index 00000000..1fb22b96 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-warriors-of-the-wind-1984-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-warriors-of-the-wind-1984-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-warriors-of-the-wind-1984-poster.jpg new file mode 100644 index 00000000..918e424f Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-warriors-of-the-wind-1984-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-we-bury-the-dead-2026-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-we-bury-the-dead-2026-backdrop.jpg new file mode 100644 index 00000000..4a24720d Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-we-bury-the-dead-2026-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-we-bury-the-dead-2026-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-we-bury-the-dead-2026-poster.jpg new file mode 100644 index 00000000..a0d65904 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-we-bury-the-dead-2026-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-white-hot-the-rise-fall-of-abercrombie-fitch-2022-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-white-hot-the-rise-fall-of-abercrombie-fitch-2022-backdrop.jpg new file mode 100644 index 00000000..2f186030 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-white-hot-the-rise-fall-of-abercrombie-fitch-2022-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-white-hot-the-rise-fall-of-abercrombie-fitch-2022-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-white-hot-the-rise-fall-of-abercrombie-fitch-2022-poster.jpg new file mode 100644 index 00000000..3a123e80 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/movie-white-hot-the-rise-fall-of-abercrombie-fitch-2022-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-12-monkeys-2015-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-12-monkeys-2015-backdrop.jpg new file mode 100644 index 00000000..3d2825f5 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-12-monkeys-2015-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-12-monkeys-2015-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-12-monkeys-2015-poster.jpg new file mode 100644 index 00000000..96e3596e Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-12-monkeys-2015-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-56-days-2026-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-56-days-2026-backdrop.jpg new file mode 100644 index 00000000..dcda667a Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-56-days-2026-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-56-days-2026-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-56-days-2026-poster.jpg new file mode 100644 index 00000000..64ca587b Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-56-days-2026-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-90-day-fiance-before-the-90-days-2017-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-90-day-fiance-before-the-90-days-2017-backdrop.jpg new file mode 100644 index 00000000..24f66f2d Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-90-day-fiance-before-the-90-days-2017-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-90-day-fiance-before-the-90-days-2017-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-90-day-fiance-before-the-90-days-2017-poster.jpg new file mode 100644 index 00000000..3362da59 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-90-day-fiance-before-the-90-days-2017-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-90-day-fiance-the-other-way-2019-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-90-day-fiance-the-other-way-2019-backdrop.jpg new file mode 100644 index 00000000..b18891fd Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-90-day-fiance-the-other-way-2019-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-90-day-fiance-the-other-way-2019-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-90-day-fiance-the-other-way-2019-poster.jpg new file mode 100644 index 00000000..b4d256eb Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-90-day-fiance-the-other-way-2019-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-a-knight-of-the-seven-kingdoms-2026-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-a-knight-of-the-seven-kingdoms-2026-backdrop.jpg new file mode 100644 index 00000000..ce12fc71 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-a-knight-of-the-seven-kingdoms-2026-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-a-knight-of-the-seven-kingdoms-2026-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-a-knight-of-the-seven-kingdoms-2026-poster.jpg new file mode 100644 index 00000000..79f1351c Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-a-knight-of-the-seven-kingdoms-2026-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-absolutely-fabulous-1992-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-absolutely-fabulous-1992-backdrop.jpg new file mode 100644 index 00000000..2df9722d Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-absolutely-fabulous-1992-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-absolutely-fabulous-1992-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-absolutely-fabulous-1992-poster.jpg new file mode 100644 index 00000000..afd371a9 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-absolutely-fabulous-1992-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-accel-world-2012-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-accel-world-2012-backdrop.jpg new file mode 100644 index 00000000..1f5f6bdc Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-accel-world-2012-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-accel-world-2012-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-accel-world-2012-poster.jpg new file mode 100644 index 00000000..207b1dd1 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-accel-world-2012-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-accused-2023-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-accused-2023-backdrop.jpg new file mode 100644 index 00000000..d41c6397 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-accused-2023-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-accused-2023-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-accused-2023-poster.jpg new file mode 100644 index 00000000..74af010a Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-accused-2023-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-adults-2025-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-adults-2025-backdrop.jpg new file mode 100644 index 00000000..9cc0ffe2 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-adults-2025-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-adults-2025-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-adults-2025-poster.jpg new file mode 100644 index 00000000..53a7ba33 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-adults-2025-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-after-life-2019-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-after-life-2019-backdrop.jpg new file mode 100644 index 00000000..eaf9a4fa Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-after-life-2019-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-after-life-2019-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-after-life-2019-poster.jpg new file mode 100644 index 00000000..4fae4e33 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-after-life-2019-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-agatha-christie-s-marple-2004-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-agatha-christie-s-marple-2004-backdrop.jpg new file mode 100644 index 00000000..0c52f2e6 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-agatha-christie-s-marple-2004-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-agatha-christie-s-marple-2004-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-agatha-christie-s-marple-2004-poster.jpg new file mode 100644 index 00000000..ea7dda99 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-agatha-christie-s-marple-2004-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-ahsoka-2023-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-ahsoka-2023-backdrop.jpg new file mode 100644 index 00000000..7343fb32 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-ahsoka-2023-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-ahsoka-2023-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-ahsoka-2023-poster.jpg new file mode 100644 index 00000000..a29d6cb4 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-ahsoka-2023-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-all-american-homecoming-2022-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-all-american-homecoming-2022-backdrop.jpg new file mode 100644 index 00000000..09fbb9dc Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-all-american-homecoming-2022-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-all-american-homecoming-2022-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-all-american-homecoming-2022-poster.jpg new file mode 100644 index 00000000..09ccfc33 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-all-american-homecoming-2022-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-all-creatures-great-and-small-1978-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-all-creatures-great-and-small-1978-backdrop.jpg new file mode 100644 index 00000000..909ae1ba Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-all-creatures-great-and-small-1978-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-all-creatures-great-and-small-1978-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-all-creatures-great-and-small-1978-poster.jpg new file mode 100644 index 00000000..5b1666e6 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-all-creatures-great-and-small-1978-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-all-creatures-great-small-2020-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-all-creatures-great-small-2020-backdrop.jpg new file mode 100644 index 00000000..d2bcc88d Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-all-creatures-great-small-2020-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-all-creatures-great-small-2020-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-all-creatures-great-small-2020-poster.jpg new file mode 100644 index 00000000..b506f990 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-all-creatures-great-small-2020-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-american-crime-story-2016-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-american-crime-story-2016-backdrop.jpg new file mode 100644 index 00000000..84944d39 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-american-crime-story-2016-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-american-crime-story-2016-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-american-crime-story-2016-poster.jpg new file mode 100644 index 00000000..4086bf17 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-american-crime-story-2016-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-american-dad-2005-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-american-dad-2005-backdrop.jpg new file mode 100644 index 00000000..75a1948b Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-american-dad-2005-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-american-dad-2005-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-american-dad-2005-poster.jpg new file mode 100644 index 00000000..1f9e239a Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-american-dad-2005-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-american-gods-2017-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-american-gods-2017-backdrop.jpg new file mode 100644 index 00000000..4817c690 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-american-gods-2017-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-american-gods-2017-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-american-gods-2017-poster.jpg new file mode 100644 index 00000000..c02c46da Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-american-gods-2017-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-american-housewife-2016-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-american-housewife-2016-backdrop.jpg new file mode 100644 index 00000000..bf758ae0 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-american-housewife-2016-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-american-housewife-2016-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-american-housewife-2016-poster.jpg new file mode 100644 index 00000000..5f92bf05 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-american-housewife-2016-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-american-primeval-2025-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-american-primeval-2025-backdrop.jpg new file mode 100644 index 00000000..c3bfe5e1 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-american-primeval-2025-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-american-primeval-2025-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-american-primeval-2025-poster.jpg new file mode 100644 index 00000000..7f10e6aa Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-american-primeval-2025-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-and-just-like-that-2021-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-and-just-like-that-2021-backdrop.jpg new file mode 100644 index 00000000..59c981e5 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-and-just-like-that-2021-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-and-just-like-that-2021-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-and-just-like-that-2021-poster.jpg new file mode 100644 index 00000000..9904c74b Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-and-just-like-that-2021-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-andor-2022-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-andor-2022-backdrop.jpg new file mode 100644 index 00000000..a874a0c0 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-andor-2022-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-andor-2022-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-andor-2022-poster.jpg new file mode 100644 index 00000000..df5dc498 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-andor-2022-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-andromeda-2000-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-andromeda-2000-backdrop.jpg new file mode 100644 index 00000000..199249d7 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-andromeda-2000-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-andromeda-2000-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-andromeda-2000-poster.jpg new file mode 100644 index 00000000..67b766b0 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-andromeda-2000-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-anne-with-an-e-2017-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-anne-with-an-e-2017-backdrop.jpg new file mode 100644 index 00000000..15e325c9 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-anne-with-an-e-2017-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-anne-with-an-e-2017-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-anne-with-an-e-2017-poster.jpg new file mode 100644 index 00000000..55d443b4 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-anne-with-an-e-2017-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-aqua-teen-hunger-force-2000-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-aqua-teen-hunger-force-2000-backdrop.jpg new file mode 100644 index 00000000..d0e37959 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-aqua-teen-hunger-force-2000-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-aqua-teen-hunger-force-2000-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-aqua-teen-hunger-force-2000-poster.jpg new file mode 100644 index 00000000..e0c97321 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-aqua-teen-hunger-force-2000-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-arifureta-from-commonplace-to-world-s-strongest-2019-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-arifureta-from-commonplace-to-world-s-strongest-2019-backdrop.jpg new file mode 100644 index 00000000..9300e20f Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-arifureta-from-commonplace-to-world-s-strongest-2019-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-arifureta-from-commonplace-to-world-s-strongest-2019-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-arifureta-from-commonplace-to-world-s-strongest-2019-poster.jpg new file mode 100644 index 00000000..6a8f9a24 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-arifureta-from-commonplace-to-world-s-strongest-2019-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-arrested-development-2003-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-arrested-development-2003-backdrop.jpg new file mode 100644 index 00000000..ecb7e8cd Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-arrested-development-2003-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-arrested-development-2003-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-arrested-development-2003-poster.jpg new file mode 100644 index 00000000..02b0dd43 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-arrested-development-2003-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-arrow-2012-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-arrow-2012-backdrop.jpg new file mode 100644 index 00000000..b32f5aca Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-arrow-2012-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-arrow-2012-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-arrow-2012-poster.jpg new file mode 100644 index 00000000..adee3bb7 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-arrow-2012-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-atlantis-2013-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-atlantis-2013-backdrop.jpg new file mode 100644 index 00000000..39ecc0d6 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-atlantis-2013-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-atlantis-2013-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-atlantis-2013-poster.jpg new file mode 100644 index 00000000..5cd5f098 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-atlantis-2013-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-avatar-seven-havens-2026-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-avatar-seven-havens-2026-backdrop.jpg new file mode 100644 index 00000000..dacfd59e Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-avatar-seven-havens-2026-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-avatar-seven-havens-2026-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-avatar-seven-havens-2026-poster.jpg new file mode 100644 index 00000000..61f459c6 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-avatar-seven-havens-2026-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-avatar-the-last-airbender-2005-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-avatar-the-last-airbender-2005-backdrop.jpg new file mode 100644 index 00000000..f60120da Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-avatar-the-last-airbender-2005-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-avatar-the-last-airbender-2005-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-avatar-the-last-airbender-2005-poster.jpg new file mode 100644 index 00000000..6f99263a Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-avatar-the-last-airbender-2005-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-avatar-the-last-airbender-2024-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-avatar-the-last-airbender-2024-backdrop.jpg new file mode 100644 index 00000000..ad740218 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-avatar-the-last-airbender-2024-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-avatar-the-last-airbender-2024-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-avatar-the-last-airbender-2024-poster.jpg new file mode 100644 index 00000000..9d46c2ff Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-avatar-the-last-airbender-2024-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-awkward-2011-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-awkward-2011-backdrop.jpg new file mode 100644 index 00000000..1ca5e56c Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-awkward-2011-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-awkward-2011-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-awkward-2011-poster.jpg new file mode 100644 index 00000000..8da6e8e2 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-awkward-2011-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-b-b-vol-liefde-2021-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-b-b-vol-liefde-2021-backdrop.jpg new file mode 100644 index 00000000..57ece15e Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-b-b-vol-liefde-2021-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-b-b-vol-liefde-2021-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-b-b-vol-liefde-2021-poster.jpg new file mode 100644 index 00000000..a12ac33e Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-b-b-vol-liefde-2021-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-back-to-life-2019-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-back-to-life-2019-backdrop.jpg new file mode 100644 index 00000000..7ffef681 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-back-to-life-2019-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-back-to-life-2019-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-back-to-life-2019-poster.jpg new file mode 100644 index 00000000..b00730a8 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-back-to-life-2019-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-ballers-2015-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-ballers-2015-backdrop.jpg new file mode 100644 index 00000000..c4685e84 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-ballers-2015-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-ballers-2015-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-ballers-2015-poster.jpg new file mode 100644 index 00000000..f1c18f69 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-ballers-2015-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-baretta-1975-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-baretta-1975-backdrop.jpg new file mode 100644 index 00000000..4d61dfbe Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-baretta-1975-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-baretta-1975-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-baretta-1975-poster.jpg new file mode 100644 index 00000000..ceef0c9d Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-baretta-1975-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-based-on-a-true-story-2023-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-based-on-a-true-story-2023-backdrop.jpg new file mode 100644 index 00000000..b59c0e58 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-based-on-a-true-story-2023-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-based-on-a-true-story-2023-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-based-on-a-true-story-2023-poster.jpg new file mode 100644 index 00000000..386d3d90 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-based-on-a-true-story-2023-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-batman-the-brave-and-the-bold-2008-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-batman-the-brave-and-the-bold-2008-backdrop.jpg new file mode 100644 index 00000000..69e567cb Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-batman-the-brave-and-the-bold-2008-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-batman-the-brave-and-the-bold-2008-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-batman-the-brave-and-the-bold-2008-poster.jpg new file mode 100644 index 00000000..a864f50c Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-batman-the-brave-and-the-bold-2008-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-batwoman-2019-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-batwoman-2019-backdrop.jpg new file mode 100644 index 00000000..d04a1a20 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-batwoman-2019-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-batwoman-2019-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-batwoman-2019-poster.jpg new file mode 100644 index 00000000..7ca86592 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-batwoman-2019-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-beat-bobby-flay-2013-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-beat-bobby-flay-2013-backdrop.jpg new file mode 100644 index 00000000..e6c36ddd Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-beat-bobby-flay-2013-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-beat-bobby-flay-2013-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-beat-bobby-flay-2013-poster.jpg new file mode 100644 index 00000000..fe11122d Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-beat-bobby-flay-2013-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-benson-1979-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-benson-1979-backdrop.jpg new file mode 100644 index 00000000..32bd63a9 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-benson-1979-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-benson-1979-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-benson-1979-poster.jpg new file mode 100644 index 00000000..fe264c76 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-benson-1979-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-big-boys-2022-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-big-boys-2022-backdrop.jpg new file mode 100644 index 00000000..c7c91e2d Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-big-boys-2022-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-big-boys-2022-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-big-boys-2022-poster.jpg new file mode 100644 index 00000000..93e1e86a Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-big-boys-2022-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-big-city-greens-2018-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-big-city-greens-2018-backdrop.jpg new file mode 100644 index 00000000..381774e7 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-big-city-greens-2018-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-big-city-greens-2018-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-big-city-greens-2018-poster.jpg new file mode 100644 index 00000000..ef024946 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-big-city-greens-2018-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-big-mouth-2017-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-big-mouth-2017-backdrop.jpg new file mode 100644 index 00000000..760e0262 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-big-mouth-2017-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-big-mouth-2017-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-big-mouth-2017-poster.jpg new file mode 100644 index 00000000..4f535fec Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-big-mouth-2017-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-black-ish-2014-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-black-ish-2014-backdrop.jpg new file mode 100644 index 00000000..967fb621 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-black-ish-2014-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-black-ish-2014-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-black-ish-2014-poster.jpg new file mode 100644 index 00000000..97c885ad Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-black-ish-2014-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-black-lightning-2018-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-black-lightning-2018-backdrop.jpg new file mode 100644 index 00000000..3a0854c9 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-black-lightning-2018-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-black-lightning-2018-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-black-lightning-2018-poster.jpg new file mode 100644 index 00000000..733f5cbc Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-black-lightning-2018-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-black-rabbit-2025-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-black-rabbit-2025-backdrop.jpg new file mode 100644 index 00000000..48df9d46 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-black-rabbit-2025-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-black-rabbit-2025-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-black-rabbit-2025-poster.jpg new file mode 100644 index 00000000..3ff9aeed Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-black-rabbit-2025-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-bleach-2004-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-bleach-2004-backdrop.jpg new file mode 100644 index 00000000..ef8eaaa3 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-bleach-2004-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-bleach-2004-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-bleach-2004-poster.jpg new file mode 100644 index 00000000..b233e033 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-bleach-2004-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-blue-planet-ii-2017-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-blue-planet-ii-2017-backdrop.jpg new file mode 100644 index 00000000..8175e568 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-blue-planet-ii-2017-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-blue-planet-ii-2017-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-blue-planet-ii-2017-poster.jpg new file mode 100644 index 00000000..5e193ab2 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-blue-planet-ii-2017-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-brassic-2019-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-brassic-2019-backdrop.jpg new file mode 100644 index 00000000..86d3d806 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-brassic-2019-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-brassic-2019-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-brassic-2019-poster.jpg new file mode 100644 index 00000000..73b77df2 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-brassic-2019-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-breaking-bad-2008-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-breaking-bad-2008-backdrop.jpg new file mode 100644 index 00000000..e4ac4ae3 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-breaking-bad-2008-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-breaking-bad-2008-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-breaking-bad-2008-poster.jpg new file mode 100644 index 00000000..c4f7217c Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-breaking-bad-2008-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-breeders-2020-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-breeders-2020-backdrop.jpg new file mode 100644 index 00000000..9d95ba77 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-breeders-2020-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-breeders-2020-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-breeders-2020-poster.jpg new file mode 100644 index 00000000..49c61208 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-breeders-2020-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-californication-2007-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-californication-2007-backdrop.jpg new file mode 100644 index 00000000..ffff979b Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-californication-2007-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-californication-2007-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-californication-2007-poster.jpg new file mode 100644 index 00000000..38428e76 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-californication-2007-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-call-the-midwife-2012-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-call-the-midwife-2012-backdrop.jpg new file mode 100644 index 00000000..1ee28b8d Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-call-the-midwife-2012-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-call-the-midwife-2012-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-call-the-midwife-2012-poster.jpg new file mode 100644 index 00000000..466a1a7b Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-call-the-midwife-2012-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-chainsaw-man-2022-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-chainsaw-man-2022-backdrop.jpg new file mode 100644 index 00000000..f1fbee3e Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-chainsaw-man-2022-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-chainsaw-man-2022-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-chainsaw-man-2022-poster.jpg new file mode 100644 index 00000000..a3779772 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-chainsaw-man-2022-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-chernobyl-2019-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-chernobyl-2019-backdrop.jpg new file mode 100644 index 00000000..818a6665 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-chernobyl-2019-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-chernobyl-2019-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-chernobyl-2019-poster.jpg new file mode 100644 index 00000000..7664f4b0 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-chernobyl-2019-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-community-2009-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-community-2009-backdrop.jpg new file mode 100644 index 00000000..8deb8d5c Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-community-2009-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-community-2009-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-community-2009-poster.jpg new file mode 100644 index 00000000..e75072ae Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-community-2009-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-cyberpunk-edgerunners-2022-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-cyberpunk-edgerunners-2022-backdrop.jpg new file mode 100644 index 00000000..2920cf5a Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-cyberpunk-edgerunners-2022-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-cyberpunk-edgerunners-2022-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-cyberpunk-edgerunners-2022-poster.jpg new file mode 100644 index 00000000..8d338532 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-cyberpunk-edgerunners-2022-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-dan-da-dan-2024-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-dan-da-dan-2024-backdrop.jpg new file mode 100644 index 00000000..0cbc4b07 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-dan-da-dan-2024-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-dan-da-dan-2024-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-dan-da-dan-2024-poster.jpg new file mode 100644 index 00000000..f42cec16 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-dan-da-dan-2024-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-dead-end-job-2026-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-dead-end-job-2026-backdrop.jpg new file mode 100644 index 00000000..3525c2fc Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-dead-end-job-2026-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-dead-end-job-2026-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-dead-end-job-2026-poster.jpg new file mode 100644 index 00000000..f5546d88 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-dead-end-job-2026-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-derry-girls-2018-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-derry-girls-2018-backdrop.jpg new file mode 100644 index 00000000..76fae98c Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-derry-girls-2018-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-derry-girls-2018-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-derry-girls-2018-poster.jpg new file mode 100644 index 00000000..56a8cd79 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-derry-girls-2018-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-desperate-housewives-2004-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-desperate-housewives-2004-backdrop.jpg new file mode 100644 index 00000000..40ec3001 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-desperate-housewives-2004-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-desperate-housewives-2004-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-desperate-housewives-2004-poster.jpg new file mode 100644 index 00000000..0c4c38bc Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-desperate-housewives-2004-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-dopesick-2021-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-dopesick-2021-backdrop.jpg new file mode 100644 index 00000000..8d9f2727 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-dopesick-2021-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-dopesick-2021-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-dopesick-2021-poster.jpg new file mode 100644 index 00000000..ac0b5900 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-dopesick-2021-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-dr-stone-2019-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-dr-stone-2019-backdrop.jpg new file mode 100644 index 00000000..5ac396b6 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-dr-stone-2019-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-dr-stone-2019-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-dr-stone-2019-poster.jpg new file mode 100644 index 00000000..0783ed8c Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-dr-stone-2019-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-dune-prophecy-2024-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-dune-prophecy-2024-backdrop.jpg new file mode 100644 index 00000000..5b940b9f Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-dune-prophecy-2024-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-dune-prophecy-2024-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-dune-prophecy-2024-poster.jpg new file mode 100644 index 00000000..eef2a4f3 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-dune-prophecy-2024-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-east-of-eden-2026-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-east-of-eden-2026-backdrop.jpg new file mode 100644 index 00000000..89dfafbf Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-east-of-eden-2026-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-east-of-eden-2026-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-east-of-eden-2026-poster.jpg new file mode 100644 index 00000000..6f06f2c9 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-east-of-eden-2026-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-etoile-2025-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-etoile-2025-backdrop.jpg new file mode 100644 index 00000000..a8e9576c Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-etoile-2025-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-etoile-2025-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-etoile-2025-poster.jpg new file mode 100644 index 00000000..f078bc89 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-etoile-2025-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-fallout-2024-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-fallout-2024-backdrop.jpg new file mode 100644 index 00000000..c9d6a190 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-fallout-2024-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-fallout-2024-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-fallout-2024-poster.jpg new file mode 100644 index 00000000..5e87a07d Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-fallout-2024-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-fawlty-towers-1975-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-fawlty-towers-1975-backdrop.jpg new file mode 100644 index 00000000..2c9c258f Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-fawlty-towers-1975-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-fawlty-towers-1975-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-fawlty-towers-1975-poster.jpg new file mode 100644 index 00000000..53b406da Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-fawlty-towers-1975-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-firefly-2002-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-firefly-2002-backdrop.jpg new file mode 100644 index 00000000..fa3b7604 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-firefly-2002-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-firefly-2002-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-firefly-2002-poster.jpg new file mode 100644 index 00000000..9ad599b3 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-firefly-2002-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-fleabag-2016-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-fleabag-2016-backdrop.jpg new file mode 100644 index 00000000..e5a83462 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-fleabag-2016-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-fleabag-2016-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-fleabag-2016-poster.jpg new file mode 100644 index 00000000..bd3ccea5 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-fleabag-2016-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-frieren-beyond-journey-s-end-2023-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-frieren-beyond-journey-s-end-2023-backdrop.jpg new file mode 100644 index 00000000..a42a2c94 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-frieren-beyond-journey-s-end-2023-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-frieren-beyond-journey-s-end-2023-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-frieren-beyond-journey-s-end-2023-poster.jpg new file mode 100644 index 00000000..cb94104e Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-frieren-beyond-journey-s-end-2023-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-fullmetal-alchemist-brotherhood-2009-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-fullmetal-alchemist-brotherhood-2009-backdrop.jpg new file mode 100644 index 00000000..91ce4909 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-fullmetal-alchemist-brotherhood-2009-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-fullmetal-alchemist-brotherhood-2009-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-fullmetal-alchemist-brotherhood-2009-poster.jpg new file mode 100644 index 00000000..935e4944 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-fullmetal-alchemist-brotherhood-2009-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-gilmore-girls-2000-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-gilmore-girls-2000-backdrop.jpg new file mode 100644 index 00000000..b42ba953 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-gilmore-girls-2000-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-gilmore-girls-2000-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-gilmore-girls-2000-poster.jpg new file mode 100644 index 00000000..23b668cb Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-gilmore-girls-2000-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-ginny-georgia-2021-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-ginny-georgia-2021-backdrop.jpg new file mode 100644 index 00000000..1271d577 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-ginny-georgia-2021-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-ginny-georgia-2021-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-ginny-georgia-2021-poster.jpg new file mode 100644 index 00000000..1fca7bf6 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-ginny-georgia-2021-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-gravity-falls-2012-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-gravity-falls-2012-backdrop.jpg new file mode 100644 index 00000000..b9ef40fa Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-gravity-falls-2012-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-gravity-falls-2012-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-gravity-falls-2012-poster.jpg new file mode 100644 index 00000000..41c051e0 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-gravity-falls-2012-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-hellcats-2010-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-hellcats-2010-backdrop.jpg new file mode 100644 index 00000000..38e856b0 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-hellcats-2010-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-hellcats-2010-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-hellcats-2010-poster.jpg new file mode 100644 index 00000000..2427108b Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-hellcats-2010-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-high-potential-2024-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-high-potential-2024-backdrop.jpg new file mode 100644 index 00000000..66bb4d00 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-high-potential-2024-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-high-potential-2024-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-high-potential-2024-poster.jpg new file mode 100644 index 00000000..00a07ca7 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-high-potential-2024-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-how-to-change-your-mind-2022-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-how-to-change-your-mind-2022-backdrop.jpg new file mode 100644 index 00000000..9e4171e3 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-how-to-change-your-mind-2022-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-how-to-change-your-mind-2022-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-how-to-change-your-mind-2022-poster.jpg new file mode 100644 index 00000000..e80c7999 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-how-to-change-your-mind-2022-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-how-to-get-to-heaven-from-belfast-2026-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-how-to-get-to-heaven-from-belfast-2026-backdrop.jpg new file mode 100644 index 00000000..a605895b Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-how-to-get-to-heaven-from-belfast-2026-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-how-to-get-to-heaven-from-belfast-2026-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-how-to-get-to-heaven-from-belfast-2026-poster.jpg new file mode 100644 index 00000000..eb5fc487 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-how-to-get-to-heaven-from-belfast-2026-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-interrogation-2020-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-interrogation-2020-backdrop.jpg new file mode 100644 index 00000000..c347e016 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-interrogation-2020-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-interrogation-2020-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-interrogation-2020-poster.jpg new file mode 100644 index 00000000..f3a21b66 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-interrogation-2020-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-invincible-2021-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-invincible-2021-backdrop.jpg new file mode 100644 index 00000000..e8120387 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-invincible-2021-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-invincible-2021-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-invincible-2021-poster.jpg new file mode 100644 index 00000000..c278e37e Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-invincible-2021-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-it-s-always-sunny-in-philadelphia-2005-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-it-s-always-sunny-in-philadelphia-2005-backdrop.jpg new file mode 100644 index 00000000..cbdc6525 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-it-s-always-sunny-in-philadelphia-2005-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-it-s-always-sunny-in-philadelphia-2005-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-it-s-always-sunny-in-philadelphia-2005-poster.jpg new file mode 100644 index 00000000..652923ee Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-it-s-always-sunny-in-philadelphia-2005-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-jujutsu-kaisen-2020-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-jujutsu-kaisen-2020-backdrop.jpg new file mode 100644 index 00000000..79e7d6bd Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-jujutsu-kaisen-2020-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-jujutsu-kaisen-2020-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-jujutsu-kaisen-2020-poster.jpg new file mode 100644 index 00000000..45f5ff22 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-jujutsu-kaisen-2020-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-kaiju-no-8-2024-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-kaiju-no-8-2024-backdrop.jpg new file mode 100644 index 00000000..480b2a8e Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-kaiju-no-8-2024-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-kaiju-no-8-2024-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-kaiju-no-8-2024-poster.jpg new file mode 100644 index 00000000..062496fa Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-kaiju-no-8-2024-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-landman-2024-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-landman-2024-backdrop.jpg new file mode 100644 index 00000000..009fbfc2 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-landman-2024-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-landman-2024-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-landman-2024-poster.jpg new file mode 100644 index 00000000..3d348798 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-landman-2024-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-last-samurai-standing-2025-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-last-samurai-standing-2025-backdrop.jpg new file mode 100644 index 00000000..c85a210b Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-last-samurai-standing-2025-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-last-samurai-standing-2025-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-last-samurai-standing-2025-poster.jpg new file mode 100644 index 00000000..a9789909 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-last-samurai-standing-2025-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-lord-of-the-flies-2026-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-lord-of-the-flies-2026-backdrop.jpg new file mode 100644 index 00000000..1249a8f3 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-lord-of-the-flies-2026-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-lord-of-the-flies-2026-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-lord-of-the-flies-2026-poster.jpg new file mode 100644 index 00000000..b1229ed8 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-lord-of-the-flies-2026-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-love-death-robots-2019-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-love-death-robots-2019-backdrop.jpg new file mode 100644 index 00000000..3694bfc4 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-love-death-robots-2019-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-love-death-robots-2019-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-love-death-robots-2019-poster.jpg new file mode 100644 index 00000000..08449aa9 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-love-death-robots-2019-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-love-is-blind-2020-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-love-is-blind-2020-backdrop.jpg new file mode 100644 index 00000000..5cea5b52 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-love-is-blind-2020-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-love-is-blind-2020-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-love-is-blind-2020-poster.jpg new file mode 100644 index 00000000..62ccf357 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-love-is-blind-2020-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-maniac-2018-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-maniac-2018-backdrop.jpg new file mode 100644 index 00000000..9d46de1d Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-maniac-2018-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-maniac-2018-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-maniac-2018-poster.jpg new file mode 100644 index 00000000..a1985217 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-maniac-2018-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-maximum-pleasure-guaranteed-2026-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-maximum-pleasure-guaranteed-2026-backdrop.jpg new file mode 100644 index 00000000..20748a70 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-maximum-pleasure-guaranteed-2026-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-maximum-pleasure-guaranteed-2026-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-maximum-pleasure-guaranteed-2026-poster.jpg new file mode 100644 index 00000000..abe1b2e2 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-maximum-pleasure-guaranteed-2026-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-minx-2022-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-minx-2022-backdrop.jpg new file mode 100644 index 00000000..bd459fde Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-minx-2022-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-minx-2022-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-minx-2022-poster.jpg new file mode 100644 index 00000000..a8797fe5 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-minx-2022-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-mussolini-son-of-the-century-2024-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-mussolini-son-of-the-century-2024-backdrop.jpg new file mode 100644 index 00000000..21674163 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-mussolini-son-of-the-century-2024-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-mussolini-son-of-the-century-2024-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-mussolini-son-of-the-century-2024-poster.jpg new file mode 100644 index 00000000..925d5c49 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-mussolini-son-of-the-century-2024-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-my-hero-academia-2016-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-my-hero-academia-2016-backdrop.jpg new file mode 100644 index 00000000..f5b2f94c Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-my-hero-academia-2016-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-my-hero-academia-2016-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-my-hero-academia-2016-poster.jpg new file mode 100644 index 00000000..cf571b93 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-my-hero-academia-2016-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-neon-genesis-evangelion-1995-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-neon-genesis-evangelion-1995-backdrop.jpg new file mode 100644 index 00000000..d80e17c1 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-neon-genesis-evangelion-1995-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-neon-genesis-evangelion-1995-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-neon-genesis-evangelion-1995-poster.jpg new file mode 100644 index 00000000..f86cd698 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-neon-genesis-evangelion-1995-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-norsemen-2016-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-norsemen-2016-backdrop.jpg new file mode 100644 index 00000000..58805e67 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-norsemen-2016-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-norsemen-2016-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-norsemen-2016-poster.jpg new file mode 100644 index 00000000..e7206b88 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-norsemen-2016-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-offspring-2010-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-offspring-2010-backdrop.jpg new file mode 100644 index 00000000..bc22e11a Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-offspring-2010-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-offspring-2010-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-offspring-2010-poster.jpg new file mode 100644 index 00000000..ae305043 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-offspring-2010-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-one-piece-2023-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-one-piece-2023-backdrop.jpg new file mode 100644 index 00000000..3fa4a282 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-one-piece-2023-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-one-piece-2023-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-one-piece-2023-poster.jpg new file mode 100644 index 00000000..286e6260 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-one-piece-2023-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-only-murders-in-the-building-2021-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-only-murders-in-the-building-2021-backdrop.jpg new file mode 100644 index 00000000..0b768d54 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-only-murders-in-the-building-2021-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-only-murders-in-the-building-2021-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-only-murders-in-the-building-2021-poster.jpg new file mode 100644 index 00000000..a6b147a4 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-only-murders-in-the-building-2021-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-outlander-blood-of-my-blood-2025-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-outlander-blood-of-my-blood-2025-backdrop.jpg new file mode 100644 index 00000000..23a242da Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-outlander-blood-of-my-blood-2025-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-outlander-blood-of-my-blood-2025-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-outlander-blood-of-my-blood-2025-poster.jpg new file mode 100644 index 00000000..930943b0 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-outlander-blood-of-my-blood-2025-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-over-the-garden-wall-2014-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-over-the-garden-wall-2014-backdrop.jpg new file mode 100644 index 00000000..43ac4428 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-over-the-garden-wall-2014-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-over-the-garden-wall-2014-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-over-the-garden-wall-2014-poster.jpg new file mode 100644 index 00000000..34b1ed84 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-over-the-garden-wall-2014-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-peaky-blinders-2013-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-peaky-blinders-2013-backdrop.jpg new file mode 100644 index 00000000..da587ec3 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-peaky-blinders-2013-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-peaky-blinders-2013-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-peaky-blinders-2013-poster.jpg new file mode 100644 index 00000000..25ceea88 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-peaky-blinders-2013-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-pluribus-2025-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-pluribus-2025-backdrop.jpg new file mode 100644 index 00000000..e64c0268 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-pluribus-2025-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-pluribus-2025-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-pluribus-2025-poster.jpg new file mode 100644 index 00000000..1ea4711b Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-pluribus-2025-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-record-of-ragnarok-2021-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-record-of-ragnarok-2021-backdrop.jpg new file mode 100644 index 00000000..d68a4060 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-record-of-ragnarok-2021-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-record-of-ragnarok-2021-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-record-of-ragnarok-2021-poster.jpg new file mode 100644 index 00000000..97d481d6 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-record-of-ragnarok-2021-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-rick-and-morty-2013-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-rick-and-morty-2013-backdrop.jpg new file mode 100644 index 00000000..1c4c0ffc Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-rick-and-morty-2013-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-rick-and-morty-2013-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-rick-and-morty-2013-poster.jpg new file mode 100644 index 00000000..b95c4890 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-rick-and-morty-2013-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-ripley-2024-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-ripley-2024-backdrop.jpg new file mode 100644 index 00000000..2e7399c1 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-ripley-2024-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-ripley-2024-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-ripley-2024-poster.jpg new file mode 100644 index 00000000..de0b32dd Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-ripley-2024-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-rivals-2024-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-rivals-2024-backdrop.jpg new file mode 100644 index 00000000..fe7025e4 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-rivals-2024-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-rivals-2024-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-rivals-2024-poster.jpg new file mode 100644 index 00000000..296ac67a Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-rivals-2024-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-rooster-2026-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-rooster-2026-backdrop.jpg new file mode 100644 index 00000000..f0f7a19e Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-rooster-2026-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-rooster-2026-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-rooster-2026-poster.jpg new file mode 100644 index 00000000..b698f8a6 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-rooster-2026-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-sakamoto-days-2025-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-sakamoto-days-2025-backdrop.jpg new file mode 100644 index 00000000..3186e89a Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-sakamoto-days-2025-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-sakamoto-days-2025-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-sakamoto-days-2025-poster.jpg new file mode 100644 index 00000000..ed9358b3 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-sakamoto-days-2025-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-say-nothing-2024-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-say-nothing-2024-backdrop.jpg new file mode 100644 index 00000000..445862dc Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-say-nothing-2024-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-say-nothing-2024-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-say-nothing-2024-poster.jpg new file mode 100644 index 00000000..cf50fc09 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-say-nothing-2024-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-secret-level-2024-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-secret-level-2024-backdrop.jpg new file mode 100644 index 00000000..54d2a13e Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-secret-level-2024-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-secret-level-2024-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-secret-level-2024-poster.jpg new file mode 100644 index 00000000..1b6e67f6 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-secret-level-2024-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-sentenced-to-be-a-hero-2025-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-sentenced-to-be-a-hero-2025-backdrop.jpg new file mode 100644 index 00000000..8d081a15 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-sentenced-to-be-a-hero-2025-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-sentenced-to-be-a-hero-2025-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-sentenced-to-be-a-hero-2025-poster.jpg new file mode 100644 index 00000000..2fb1c815 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-sentenced-to-be-a-hero-2025-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-severance-2022-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-severance-2022-backdrop.jpg new file mode 100644 index 00000000..a24c03bd Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-severance-2022-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-severance-2022-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-severance-2022-poster.jpg new file mode 100644 index 00000000..5a29d6f9 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-severance-2022-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-sex-and-the-city-1998-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-sex-and-the-city-1998-backdrop.jpg new file mode 100644 index 00000000..57beacc1 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-sex-and-the-city-1998-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-sex-and-the-city-1998-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-sex-and-the-city-1998-poster.jpg new file mode 100644 index 00000000..e546cc6d Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-sex-and-the-city-1998-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-silo-2023-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-silo-2023-backdrop.jpg new file mode 100644 index 00000000..bcc1c4b7 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-silo-2023-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-silo-2023-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-silo-2023-poster.jpg new file mode 100644 index 00000000..b493bc7b Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-silo-2023-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-six-feet-under-2001-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-six-feet-under-2001-backdrop.jpg new file mode 100644 index 00000000..4d2c02ad Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-six-feet-under-2001-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-six-feet-under-2001-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-six-feet-under-2001-poster.jpg new file mode 100644 index 00000000..559d6bd1 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-six-feet-under-2001-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-skymed-2022-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-skymed-2022-backdrop.jpg new file mode 100644 index 00000000..c7ec326b Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-skymed-2022-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-skymed-2022-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-skymed-2022-poster.jpg new file mode 100644 index 00000000..2540dad7 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-skymed-2022-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-so-you-think-you-can-dance-2008-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-so-you-think-you-can-dance-2008-backdrop.jpg new file mode 100644 index 00000000..feb777b2 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-so-you-think-you-can-dance-2008-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-so-you-think-you-can-dance-2008-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-so-you-think-you-can-dance-2008-poster.jpg new file mode 100644 index 00000000..a2e91496 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-so-you-think-you-can-dance-2008-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-south-park-1997-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-south-park-1997-backdrop.jpg new file mode 100644 index 00000000..cd0aec39 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-south-park-1997-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-south-park-1997-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-south-park-1997-poster.jpg new file mode 100644 index 00000000..d8568d54 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-south-park-1997-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-spy-x-family-2022-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-spy-x-family-2022-backdrop.jpg new file mode 100644 index 00000000..8764e6eb Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-spy-x-family-2022-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-spy-x-family-2022-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-spy-x-family-2022-poster.jpg new file mode 100644 index 00000000..fc326930 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-spy-x-family-2022-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-the-100-2014-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-the-100-2014-backdrop.jpg new file mode 100644 index 00000000..8d41cb22 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-the-100-2014-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-the-100-2014-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-the-100-2014-poster.jpg new file mode 100644 index 00000000..4d254cb0 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-the-100-2014-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-the-abbott-and-costello-show-1952-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-the-abbott-and-costello-show-1952-backdrop.jpg new file mode 100644 index 00000000..53e1d593 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-the-abbott-and-costello-show-1952-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-the-abbott-and-costello-show-1952-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-the-abbott-and-costello-show-1952-poster.jpg new file mode 100644 index 00000000..7d6768ea Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-the-abbott-and-costello-show-1952-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-the-afterparty-2022-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-the-afterparty-2022-backdrop.jpg new file mode 100644 index 00000000..72ca19db Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-the-afterparty-2022-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-the-afterparty-2022-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-the-afterparty-2022-poster.jpg new file mode 100644 index 00000000..9beacde2 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-the-afterparty-2022-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-the-baby-sitters-club-2020-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-the-baby-sitters-club-2020-backdrop.jpg new file mode 100644 index 00000000..d72d7c7e Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-the-baby-sitters-club-2020-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-the-baby-sitters-club-2020-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-the-baby-sitters-club-2020-poster.jpg new file mode 100644 index 00000000..7e4e7be2 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-the-baby-sitters-club-2020-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-the-bear-2022-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-the-bear-2022-backdrop.jpg new file mode 100644 index 00000000..9c9cca57 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-the-bear-2022-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-the-bear-2022-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-the-bear-2022-poster.jpg new file mode 100644 index 00000000..bbf0ab28 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-the-bear-2022-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-the-beast-in-me-2025-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-the-beast-in-me-2025-backdrop.jpg new file mode 100644 index 00000000..e7727a79 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-the-beast-in-me-2025-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-the-beast-in-me-2025-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-the-beast-in-me-2025-poster.jpg new file mode 100644 index 00000000..c78c5cf0 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-the-beast-in-me-2025-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-the-bold-type-2017-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-the-bold-type-2017-backdrop.jpg new file mode 100644 index 00000000..dd84deab Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-the-bold-type-2017-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-the-bold-type-2017-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-the-bold-type-2017-poster.jpg new file mode 100644 index 00000000..f663eb8b Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-the-bold-type-2017-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-the-boys-2019-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-the-boys-2019-backdrop.jpg new file mode 100644 index 00000000..a834d9fb Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-the-boys-2019-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-the-boys-2019-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-the-boys-2019-poster.jpg new file mode 100644 index 00000000..f67ff16f Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-the-boys-2019-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-the-capture-2019-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-the-capture-2019-backdrop.jpg new file mode 100644 index 00000000..42256fc1 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-the-capture-2019-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-the-capture-2019-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-the-capture-2019-poster.jpg new file mode 100644 index 00000000..a4039011 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-the-capture-2019-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-the-diplomat-2023-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-the-diplomat-2023-backdrop.jpg new file mode 100644 index 00000000..b25ab5f3 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-the-diplomat-2023-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-the-diplomat-2023-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-the-diplomat-2023-poster.jpg new file mode 100644 index 00000000..2eb48b62 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-the-diplomat-2023-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-the-durrells-2016-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-the-durrells-2016-backdrop.jpg new file mode 100644 index 00000000..3f8bdeec Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-the-durrells-2016-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-the-durrells-2016-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-the-durrells-2016-poster.jpg new file mode 100644 index 00000000..d29448bb Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-the-durrells-2016-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-the-final-problem-2026-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-the-final-problem-2026-backdrop.jpg new file mode 100644 index 00000000..61a310d8 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-the-final-problem-2026-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-the-final-problem-2026-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-the-final-problem-2026-poster.jpg new file mode 100644 index 00000000..f0332826 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-the-final-problem-2026-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-the-four-seasons-2025-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-the-four-seasons-2025-backdrop.jpg new file mode 100644 index 00000000..a358664c Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-the-four-seasons-2025-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-the-four-seasons-2025-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-the-four-seasons-2025-poster.jpg new file mode 100644 index 00000000..fd252088 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-the-four-seasons-2025-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-the-get-down-2016-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-the-get-down-2016-backdrop.jpg new file mode 100644 index 00000000..1de4a3ca Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-the-get-down-2016-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-the-get-down-2016-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-the-get-down-2016-poster.jpg new file mode 100644 index 00000000..7bb38abd Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-the-get-down-2016-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-the-hunting-wives-2025-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-the-hunting-wives-2025-backdrop.jpg new file mode 100644 index 00000000..d1758fef Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-the-hunting-wives-2025-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-the-hunting-wives-2025-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-the-hunting-wives-2025-poster.jpg new file mode 100644 index 00000000..034273eb Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-the-hunting-wives-2025-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-the-last-of-us-2023-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-the-last-of-us-2023-backdrop.jpg new file mode 100644 index 00000000..fa28e38d Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-the-last-of-us-2023-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-the-last-of-us-2023-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-the-last-of-us-2023-poster.jpg new file mode 100644 index 00000000..7da462da Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-the-last-of-us-2023-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-the-legend-of-korra-2012-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-the-legend-of-korra-2012-backdrop.jpg new file mode 100644 index 00000000..ab6086bd Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-the-legend-of-korra-2012-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-the-legend-of-korra-2012-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-the-legend-of-korra-2012-poster.jpg new file mode 100644 index 00000000..a7086d5e Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-the-legend-of-korra-2012-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-the-lord-of-the-rings-the-rings-of-power-2022-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-the-lord-of-the-rings-the-rings-of-power-2022-backdrop.jpg new file mode 100644 index 00000000..0b88fd4b Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-the-lord-of-the-rings-the-rings-of-power-2022-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-the-lord-of-the-rings-the-rings-of-power-2022-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-the-lord-of-the-rings-the-rings-of-power-2022-poster.jpg new file mode 100644 index 00000000..dc387fcd Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-the-lord-of-the-rings-the-rings-of-power-2022-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-the-lost-flowers-of-alice-hart-2023-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-the-lost-flowers-of-alice-hart-2023-backdrop.jpg new file mode 100644 index 00000000..c5d17d97 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-the-lost-flowers-of-alice-hart-2023-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-the-lost-flowers-of-alice-hart-2023-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-the-lost-flowers-of-alice-hart-2023-poster.jpg new file mode 100644 index 00000000..71407f36 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-the-lost-flowers-of-alice-hart-2023-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-the-night-manager-2016-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-the-night-manager-2016-backdrop.jpg new file mode 100644 index 00000000..327aa449 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-the-night-manager-2016-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-the-night-manager-2016-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-the-night-manager-2016-poster.jpg new file mode 100644 index 00000000..7ab2dbf5 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-the-night-manager-2016-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-the-pitt-2025-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-the-pitt-2025-backdrop.jpg new file mode 100644 index 00000000..b593444a Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-the-pitt-2025-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-the-pitt-2025-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-the-pitt-2025-poster.jpg new file mode 100644 index 00000000..ad1ec8ff Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-the-pitt-2025-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-the-tatami-galaxy-2010-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-the-tatami-galaxy-2010-backdrop.jpg new file mode 100644 index 00000000..a267b4cb Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-the-tatami-galaxy-2010-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-the-tatami-galaxy-2010-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-the-tatami-galaxy-2010-poster.jpg new file mode 100644 index 00000000..5324dc75 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-the-tatami-galaxy-2010-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-the-walk-in-2022-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-the-walk-in-2022-backdrop.jpg new file mode 100644 index 00000000..e37f8d5c Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-the-walk-in-2022-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-the-walk-in-2022-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-the-walk-in-2022-poster.jpg new file mode 100644 index 00000000..715d0c80 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-the-walk-in-2022-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-the-white-lotus-2021-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-the-white-lotus-2021-backdrop.jpg new file mode 100644 index 00000000..1f747323 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-the-white-lotus-2021-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-the-white-lotus-2021-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-the-white-lotus-2021-poster.jpg new file mode 100644 index 00000000..2f23d1fa Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-the-white-lotus-2021-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-the-witcher-2019-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-the-witcher-2019-backdrop.jpg new file mode 100644 index 00000000..acca834e Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-the-witcher-2019-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-the-witcher-2019-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-the-witcher-2019-poster.jpg new file mode 100644 index 00000000..f5eb8f59 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-the-witcher-2019-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-undercover-2019-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-undercover-2019-backdrop.jpg new file mode 100644 index 00000000..ad972c17 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-undercover-2019-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-undercover-2019-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-undercover-2019-poster.jpg new file mode 100644 index 00000000..8611eab4 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-undercover-2019-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-upload-2020-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-upload-2020-backdrop.jpg new file mode 100644 index 00000000..f350a49c Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-upload-2020-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-upload-2020-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-upload-2020-poster.jpg new file mode 100644 index 00000000..547d3b7a Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-upload-2020-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-vinland-saga-2019-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-vinland-saga-2019-backdrop.jpg new file mode 100644 index 00000000..9ae58ec4 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-vinland-saga-2019-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-vinland-saga-2019-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-vinland-saga-2019-poster.jpg new file mode 100644 index 00000000..7d0ba17a Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-vinland-saga-2019-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-widow-s-bay-2026-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-widow-s-bay-2026-backdrop.jpg new file mode 100644 index 00000000..03b1c335 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-widow-s-bay-2026-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-widow-s-bay-2026-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-widow-s-bay-2026-poster.jpg new file mode 100644 index 00000000..8c02a0d7 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-widow-s-bay-2026-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-yellowjackets-2021-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-yellowjackets-2021-backdrop.jpg new file mode 100644 index 00000000..960c2d23 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-yellowjackets-2021-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-yellowjackets-2021-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-yellowjackets-2021-poster.jpg new file mode 100644 index 00000000..c7e16a57 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-yellowjackets-2021-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-yu-yu-hakusho-1992-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-yu-yu-hakusho-1992-backdrop.jpg new file mode 100644 index 00000000..ba65ca2d Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-yu-yu-hakusho-1992-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-yu-yu-hakusho-1992-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-yu-yu-hakusho-1992-poster.jpg new file mode 100644 index 00000000..f5843766 Binary files /dev/null and b/app/src/debug/assets/wolfskin-discover/assets/tv-yu-yu-hakusho-1992-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-discover/fixture.json b/app/src/debug/assets/wolfskin-discover/fixture.json new file mode 100644 index 00000000..450f0ece --- /dev/null +++ b/app/src/debug/assets/wolfskin-discover/fixture.json @@ -0,0 +1 @@ +{"schemaVersion": 1, "generatedAt": "2026-11-04T20:42:12.030108+00:00", "profiles": [{"key": "justin", "name": "Justin", "initial": "J", "color": "#62c4ff", "destinations": {"Home": {"kind": "home"}, "Movies": {"kind": "library", "collection": "movies"}, "Shows": {"kind": "library", "collection": "shows"}, "Anime": {"kind": "library", "collection": "anime"}, "Discover": {"kind": "discover", "collection": "discover"}}, "movies": [{"id": "movie-the-girl-who-leapt-through-time-2006", "title": "The Girl Who Leapt Through Time", "year": 2006, "type": "movie", "runtimeSeconds": 5886, "seasonCount": null, "communityRating": 7.772, "criticRating": 87, "genres": ["Fantasy", "Animation", "Drama"], "overview": "When 17-year-old Makoto Konno gains the ability to 'leap' backwards through time, she immediately sets about improving her grades and preventing personal mishaps. However, she soon realises that changing the past\u2026", "poster": "assets/movie-the-girl-who-leapt-through-time-2006-poster.jpg", "backdrop": "assets/movie-the-girl-who-leapt-through-time-2006-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T17:39:11.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 14069, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 87, "rtCriticsLabel": "Fresh", "rtAudience": 90, "rtAudienceLabel": "Upright", "imdb": 7.6, "imdbVotes": 76917, "tmdb": 7.775, "tmdbVotes": 2345}}, {"id": "movie-locke-2014", "title": "Locke", "year": 2014, "type": "movie", "runtimeSeconds": 5090, "seasonCount": null, "communityRating": 6.891, "criticRating": 91, "genres": ["Drama", "Thriller"], "overview": "Ivan Locke has worked hard to craft a good life for himself. Tonight, that life will collapse around him. On the eve of the biggest challenge of his career, Ivan receives a phone call that sets in motion a series of\u2026", "poster": "assets/movie-locke-2014-poster.jpg", "backdrop": "assets/movie-locke-2014-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T17:38:48.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 210479, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 90, "rtCriticsLabel": "Certified Fresh", "rtAudience": 72, "rtAudienceLabel": "Upright", "imdb": 7.1, "imdbVotes": 171265, "tmdb": 6.889, "tmdbVotes": 3088}}, {"id": "movie-look-who-s-back-2015", "title": "Look Who's Back", "year": 2015, "type": "movie", "runtimeSeconds": 6936, "seasonCount": null, "communityRating": 6.793, "criticRating": null, "genres": ["Comedy"], "overview": "When Adolf Hitler reawakens at the site of his former bunker in present-day Berlin, he is mistaken for a comedian and quickly becomes a media phenomenon.", "poster": "assets/movie-look-who-s-back-2015-poster.jpg", "backdrop": "assets/movie-look-who-s-back-2015-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:27:48.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 318917, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": 81, "rtAudienceLabel": "Upright", "imdb": 7, "imdbVotes": 54672, "tmdb": 6.796, "tmdbVotes": 1990}}, {"id": "movie-memories-1995", "title": "Memories", "year": 1995, "type": "movie", "runtimeSeconds": 6805, "seasonCount": null, "communityRating": 7.465, "criticRating": null, "genres": ["Fantasy", "Animation", "Science Fiction"], "overview": "Three back-to-back anime films by three different directors make up this sci-fi trilogy three years in the making.", "poster": "assets/movie-memories-1995-poster.jpg", "backdrop": "assets/movie-memories-1995-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-30T21:36:03.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 42994, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": 82, "rtAudienceLabel": "Upright", "imdb": 7.5, "imdbVotes": 26638, "tmdb": 7.459, "tmdbVotes": 622}}, {"id": "movie-reincarnated-2012", "title": "Reincarnated", "year": 2012, "type": "movie", "runtimeSeconds": 5759, "seasonCount": null, "communityRating": 6.145, "criticRating": 28, "genres": ["Music", "Documentary"], "overview": "Evolution as an artist is often times what separates legends from the more mundane. After being heavily influenced by his experience in Jamaica \u2013 and his subsequent name change from Snoop Dogg to Snoop Lion \u2013 the LBC\u2026", "poster": "assets/movie-reincarnated-2012-poster.jpg", "backdrop": "assets/movie-reincarnated-2012-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:27:26.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 123431, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 28, "rtCriticsLabel": "Rotten", "rtAudience": 41, "rtAudienceLabel": "Spilled", "imdb": 6.2, "imdbVotes": 2563, "tmdb": 6.1, "tmdbVotes": 69}}, {"id": "movie-shipwrecked-nightmare-at-sea-2026", "title": "Shipwrecked: Nightmare at Sea", "year": 2026, "type": "movie", "runtimeSeconds": 5385, "seasonCount": null, "communityRating": 7.181, "criticRating": null, "genres": ["Documentary"], "overview": "Never-before-seen footage and survivor accounts trace the 2012 shipwreck of a luxury cruise and the disaster that ensued, in this immersive documentary.", "poster": "assets/movie-shipwrecked-nightmare-at-sea-2026-poster.jpg", "backdrop": "assets/movie-shipwrecked-nightmare-at-sea-2026-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-20T20:11:43.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 1715492, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": 6.5, "imdbVotes": 5080, "tmdb": 7.172, "tmdbVotes": 87}}, {"id": "movie-steins-gate-the-movie-load-region-of-deja-vu-2013", "title": "Steins;Gate: The Movie - Load Region of D\u00e9j\u00e0 Vu", "year": 2013, "type": "movie", "runtimeSeconds": 5403, "seasonCount": null, "communityRating": 7.8, "criticRating": null, "genres": ["Animation", "Mystery", "Sci-Fi"], "overview": "After the events of the anime, Rintarou begins to feel the repercussions of extensive time travel, and eventually completely fades from reality. Kurisu, being the only companion to remember him, now must find a way\u2026", "poster": "assets/movie-steins-gate-the-movie-load-region-of-deja-vu-2013-poster.jpg", "backdrop": "assets/movie-steins-gate-the-movie-load-region-of-deja-vu-2013-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-22T14:55:54.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}], "shows": [{"id": "tv-andor-2022", "title": "Andor", "year": 2022, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 8.293, "criticRating": null, "genres": ["Sci-Fi & Fantasy", "Action & Adventure", "Drama"], "overview": "In an era filled with danger, deception and intrigue, Cassian Andor will discover the difference he can make in the struggle against the tyrannical Galactic Empire. He embarks on a path that is destined to turn him\u2026", "poster": "assets/tv-andor-2022-poster.jpg", "backdrop": "assets/tv-andor-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0432996Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 83867, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 96, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.321, "tmdbVotes": 2312}}, {"id": "tv-the-boys-2019", "title": "The Boys", "year": 2019, "type": "tv", "runtimeSeconds": null, "seasonCount": 5, "communityRating": 8.443, "criticRating": null, "genres": ["Sci-Fi & Fantasy", "Action & Adventure"], "overview": "A group of vigilantes known informally as \u201cThe Boys\u201d set out to take down corrupt superheroes with no more than blue-collar grit and a willingness to fight dirty.", "poster": "assets/tv-the-boys-2019-poster.jpg", "backdrop": "assets/tv-the-boys-2019-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0410131Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 76479, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 92, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.4, "tmdbVotes": 13628}}, {"id": "tv-californication-2007", "title": "Californication", "year": 2007, "type": "tv", "runtimeSeconds": 1680, "seasonCount": 7, "communityRating": 7.632, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "A self-loathing, alcoholic writer attempts to repair his damaged relationships with his daughter and her mother while combating sex addiction, a budding drug problem, and the seeming inability to avoid making bad\u2026", "poster": "assets/tv-californication-2007-poster.jpg", "backdrop": "assets/tv-californication-2007-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0428037Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 1215, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 57, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.62, "tmdbVotes": 1406}}, {"id": "tv-firefly-2002", "title": "Firefly", "year": 2002, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 8.347, "criticRating": null, "genres": ["Drama", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "In the year 2517, the universe is a vast frontier where lawlessness reigns and adventure awaits! Join Captain Malcolm Reynolds and his ragtag crew aboard the spaceship Serenity as they navigate the fringes of\u2026", "poster": "assets/tv-firefly-2002-poster.jpg", "backdrop": "assets/tv-firefly-2002-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-20T11:40:08.4202369Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 1437, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 77, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.351, "tmdbVotes": 2550}}, {"id": "tv-fleabag-2016", "title": "Fleabag", "year": 2016, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 8.278, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "A portrait into the mind of a dry-witted, sexual, angry, porn-watching, grief-riddled woman, trying to make sense of the world. As she hurls herself headlong at modern living, Fleabag is thrown roughly up against the\u2026", "poster": "assets/tv-fleabag-2016-poster.jpg", "backdrop": "assets/tv-fleabag-2016-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T17:42:25.4848214Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 67070, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.3, "tmdbVotes": 1993}}, {"id": "tv-gravity-falls-2012", "title": "Gravity Falls", "year": 2012, "type": "tv", "runtimeSeconds": 1320, "seasonCount": 2, "communityRating": 8.619, "criticRating": null, "genres": ["Action & Adventure", "Animation", "Comedy"], "overview": "Twin brother and sister Dipper and Mabel Pines are in for an unexpected adventure when they spend the summer helping their great uncle Stan run a tourist trap in the mysterious town of Gravity Falls, Oregon.", "poster": "assets/tv-gravity-falls-2012-poster.jpg", "backdrop": "assets/tv-gravity-falls-2012-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0435827Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 40075, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.624, "tmdbVotes": 3647}}, {"id": "tv-invincible-2021", "title": "INVINCIBLE", "year": 2021, "type": "tv", "runtimeSeconds": null, "seasonCount": 4, "communityRating": 8.638, "criticRating": null, "genres": ["Animation", "Drama", "Sci-Fi & Fantasy"], "overview": "Mark Grayson is a normal teenager except for the fact that his father is the most powerful superhero on the planet. Shortly after his seventeenth birthday, Mark begins to develop powers of his own and enters into his\u2026", "poster": "assets/tv-invincible-2021-poster.jpg", "backdrop": "assets/tv-invincible-2021-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.040159Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 95557, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 99, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.6, "tmdbVotes": 6155}}, {"id": "tv-the-legend-of-korra-2012", "title": "The Legend of Korra", "year": 2012, "type": "tv", "runtimeSeconds": 1380, "seasonCount": 4, "communityRating": 8.2, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "Avatar Korra, a headstrong, rebellious, feisty young woman who continually challenges and breaks with tradition, is on her quest to become a fully realized Avatar. In this story, the Avatar struggles to find balance\u2026", "poster": "assets/tv-the-legend-of-korra-2012-poster.jpg", "backdrop": "assets/tv-the-legend-of-korra-2012-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-20T00:19:23.2494945Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 33880, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 88, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.221, "tmdbVotes": 2482}}, {"id": "tv-love-death-robots-2019", "title": "Love, Death & Robots", "year": 2019, "type": "tv", "runtimeSeconds": null, "seasonCount": 4, "communityRating": 8.205, "criticRating": null, "genres": ["Animation", "Sci-Fi & Fantasy"], "overview": "Terrifying creatures, wicked surprises and dark comedy converge in this NSFW anthology of animated stories presented by Tim Miller and David Fincher.", "poster": "assets/tv-love-death-robots-2019-poster.jpg", "backdrop": "assets/tv-love-death-robots-2019-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-20T11:31:20.8818757Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 86831, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 89, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.203, "tmdbVotes": 4238}}, {"id": "tv-norsemen-2016", "title": "Norsemen", "year": 2016, "type": "tv", "runtimeSeconds": 1800, "seasonCount": 3, "communityRating": 7.177, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "Norsemen is an epic and humorous drama series set in the Viking Age. The residents of an 8th-century Viking village experience political rivalry, social change and innovations that upend their culture and way of life.", "poster": "assets/tv-norsemen-2016-poster.jpg", "backdrop": "assets/tv-norsemen-2016-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.034182Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 68126, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.2, "tmdbVotes": 233}}, {"id": "tv-rick-and-morty-2013", "title": "Rick and Morty", "year": 2013, "type": "tv", "runtimeSeconds": null, "seasonCount": 9, "communityRating": 8.679, "criticRating": null, "genres": ["Animation", "Comedy", "Sci-Fi & Fantasy"], "overview": "Follows a sociopathic genius scientist who drags his inherently timid grandson on adventures across the universe.", "poster": "assets/tv-rick-and-morty-2013-poster.jpg", "backdrop": "assets/tv-rick-and-morty-2013-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0419581Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 60625, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 91, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.7, "tmdbVotes": 11548}}, {"id": "tv-secret-level-2024", "title": "Secret Level", "year": 2024, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.411, "criticRating": null, "genres": ["Animation", "Sci-Fi & Fantasy", "Action & Adventure"], "overview": "Adult animated series of original short stories which are set within the worlds of beloved video games. Each episode serves as a gateway to a new adventure, unlocking exciting worlds from beloved gaming classics and\u2026", "poster": "assets/tv-secret-level-2024-poster.jpg", "backdrop": "assets/tv-secret-level-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0336417Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 261579, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 65, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.392, "tmdbVotes": 612}}, {"id": "tv-silo-2023", "title": "Silo", "year": 2023, "type": "tv", "runtimeSeconds": null, "seasonCount": 3, "communityRating": 8.178, "criticRating": null, "genres": ["Sci-Fi & Fantasy", "Drama"], "overview": "In a ruined and toxic future, thousands live in a giant silo deep underground. After its sheriff breaks a cardinal rule and residents die mysteriously, engineer Juliette starts to uncover shocking secrets and the\u2026", "poster": "assets/tv-silo-2023-poster.jpg", "backdrop": "assets/tv-silo-2023-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0346526Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 125988, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 92, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.232, "tmdbVotes": 2881}}, {"id": "tv-south-park-1997", "title": "South Park", "year": 1997, "type": "tv", "runtimeSeconds": null, "seasonCount": 29, "communityRating": 8.336, "criticRating": null, "genres": ["Animation", "Comedy"], "overview": "Follow the misadventures of four irreverent grade-schoolers in the quiet, dysfunctional town of South Park, Colorado.", "poster": "assets/tv-south-park-1997-poster.jpg", "backdrop": "assets/tv-south-park-1997-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0456124Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 2190, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 80, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.332, "tmdbVotes": 5250}}], "anime": [{"id": "tv-bleach-2004", "title": "Bleach", "year": 2004, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 17, "communityRating": 8.363, "criticRating": null, "genres": ["Action & Adventure", "Animation", "Sci-Fi & Fantasy"], "overview": "For as long as he can remember, Ichigo Kurosaki has been able to see ghosts. But when he meets Rukia, a Soul Reaper who battles evil spirits known as Hollows, he finds his life is changed forever. Now, with a\u2026", "poster": "assets/tv-bleach-2004-poster.jpg", "backdrop": "assets/tv-bleach-2004-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0359827Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 30984, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.4, "tmdbVotes": 2331}}, {"id": "tv-chainsaw-man-2022", "title": "Chainsaw Man", "year": 2022, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 1, "communityRating": 8.515, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "Denji has a simple dream\u2014to live a happy and peaceful life, spending time with a girl he likes. This is a far cry from reality, however, as Denji is forced by the yakuza into killing devils in order to pay off his\u2026", "poster": "assets/tv-chainsaw-man-2022-poster.jpg", "backdrop": "assets/tv-chainsaw-man-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-20T22:28:13.2095501Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 114410, "artwork": {"posterKind": "center_crop", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 97, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.508, "tmdbVotes": 2364}}, {"id": "tv-cyberpunk-edgerunners-2022", "title": "Cyberpunk: Edgerunners", "year": 2022, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 8.466, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "In a dystopia riddled with corruption and cybernetic implants, a talented but reckless street kid strives to become a mercenary outlaw \u2014 an edgerunner.", "poster": "assets/tv-cyberpunk-edgerunners-2022-poster.jpg", "backdrop": "assets/tv-cyberpunk-edgerunners-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-20T22:28:13.2093107Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 105248, "artwork": {"posterKind": "center_crop", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 89, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.507, "tmdbVotes": 2132}}, {"id": "tv-dan-da-dan-2024", "title": "Dan Da Dan", "year": 2024, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 8.538, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Comedy"], "overview": "In a bet to prove whether ghosts or aliens exist, two high schoolers face terrifying paranormal threats, gain superpowers and maybe even fall in love?!", "poster": "assets/tv-dan-da-dan-2024-poster.jpg", "backdrop": "assets/tv-dan-da-dan-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-22T10:31:16.2652252Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 240411, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.517, "tmdbVotes": 986}}, {"id": "tv-dr-stone-2019", "title": "Dr. STONE", "year": 2019, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 4, "communityRating": 8.541, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Comedy"], "overview": "One fateful day, all of humanity was petrified by a blinding flash of light. After several millennia, high schooler Taiju awakens and finds himself lost in a world of statues. However, he\u2019s not alone! His\u2026", "poster": "assets/tv-dr-stone-2019-poster.jpg", "backdrop": "assets/tv-dr-stone-2019-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-20T22:28:13.2091579Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 86031, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.53, "tmdbVotes": 1814}}, {"id": "tv-frieren-beyond-journey-s-end-2023", "title": "Frieren: Beyond Journey's End", "year": 2023, "type": "tv", "runtimeSeconds": 1500, "seasonCount": 2, "communityRating": 8.803, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Drama"], "overview": "After the party of heroes defeated the Demon King, they restored peace to the land and returned to lives of solitude. Generations pass, and the elven mage Frieren comes face to face with humanity\u2019s mortality. She\u2026", "poster": "assets/tv-frieren-beyond-journey-s-end-2023-poster.jpg", "backdrop": "assets/tv-frieren-beyond-journey-s-end-2023-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-20T22:28:13.2094435Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 209867, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.826, "tmdbVotes": 1016}}, {"id": "tv-fullmetal-alchemist-brotherhood-2009", "title": "Fullmetal Alchemist: Brotherhood", "year": 2009, "type": "tv", "runtimeSeconds": 1500, "seasonCount": 1, "communityRating": 8.7, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "Disregard for alchemy\u2019s laws ripped half of Edward Elric\u2019s limbs from his body and left his brother Alphonse\u2019s soul clinging to a suit of armor. To restore what was lost, the brothers seek the Philosopher\u2019s Stone.\u2026", "poster": "assets/tv-fullmetal-alchemist-brotherhood-2009-poster.jpg", "backdrop": "assets/tv-fullmetal-alchemist-brotherhood-2009-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-20T10:27:12.3573939Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 31911, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.7, "tmdbVotes": 2633}}, {"id": "tv-jujutsu-kaisen-2020", "title": "JUJUTSU KAISEN", "year": 2020, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 3, "communityRating": 8.583, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "Yuji Itadori is a boy with tremendous physical strength, though he lives a completely ordinary high school life. One day, to save a classmate who has been attacked by curses, he eats the finger of Ryomen Sukuna,\u2026", "poster": "assets/tv-jujutsu-kaisen-2020-poster.jpg", "backdrop": "assets/tv-jujutsu-kaisen-2020-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-20T22:28:13.207573Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 95479, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.574, "tmdbVotes": 4735}}, {"id": "tv-kaiju-no-8-2024", "title": "Kaiju No. 8", "year": 2024, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 2, "communityRating": 8.436, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "In a world plagued by creatures known as Kaiju, Kafka Hibino aspired to enlist in The Defense Force. He makes a promise to enlist with his childhood friend, Mina Ashiro. Soon, life takes them in separate ways. While\u2026", "poster": "assets/tv-kaiju-no-8-2024-poster.jpg", "backdrop": "assets/tv-kaiju-no-8-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-22T10:31:16.2646566Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 207468, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.4, "tmdbVotes": 836}}, {"id": "tv-my-hero-academia-2016", "title": "My Hero Academia", "year": 2016, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 8, "communityRating": 8.607, "criticRating": null, "genres": ["Action & Adventure", "Animation", "Sci-Fi & Fantasy"], "overview": "After he saves a bully from a Villain, a normal student is granted a superpower that allows him to attend a high school training academy for Heroes.", "poster": "assets/tv-my-hero-academia-2016-poster.jpg", "backdrop": "assets/tv-my-hero-academia-2016-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-20T22:28:13.2089315Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 65930, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.6, "tmdbVotes": 5426}}, {"id": "tv-neon-genesis-evangelion-1995", "title": "Neon Genesis Evangelion", "year": 1995, "type": "tv", "runtimeSeconds": 1500, "seasonCount": 1, "communityRating": 8.568, "criticRating": null, "genres": ["Sci-Fi & Fantasy", "Animation", "Drama"], "overview": "At the turn of the century, the Angels returned to Earth, seeking to wipe out humanity in an apocalyptic fury. Devastated, mankind's last remnants moved underground to wait for the day when the Angels would come back\u2026", "poster": "assets/tv-neon-genesis-evangelion-1995-poster.jpg", "backdrop": "assets/tv-neon-genesis-evangelion-1995-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0340629Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 890, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.56, "tmdbVotes": 2205}}, {"id": "tv-record-of-ragnarok-2021", "title": "Record of Ragnarok", "year": 2021, "type": "tv", "runtimeSeconds": null, "seasonCount": 3, "communityRating": 8.402, "criticRating": null, "genres": ["Action & Adventure", "Animation", "Sci-Fi & Fantasy"], "overview": "Before eradicating humankind from the world, the gods give them one last chance to prove themselves worthy of survival. Let the Ragnarok battles begin.", "poster": "assets/tv-record-of-ragnarok-2021-poster.jpg", "backdrop": "assets/tv-record-of-ragnarok-2021-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-20T22:28:13.2085766Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 114868, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.403, "tmdbVotes": 1929}}, {"id": "tv-sakamoto-days-2025", "title": "SAKAMOTO DAYS", "year": 2025, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 8.071, "criticRating": null, "genres": ["Animation", "Comedy", "Action & Adventure"], "overview": "Once the greatest hitman of all, Taro Sakamoto retired in the name of love. But when his past catches up, he must fight to protect his beloved family.", "poster": "assets/tv-sakamoto-days-2025-poster.jpg", "backdrop": "assets/tv-sakamoto-days-2025-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-22T10:31:16.266066Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 207332, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.057, "tmdbVotes": 375}}, {"id": "tv-sentenced-to-be-a-hero-2025", "title": "Sentenced to Be a Hero", "year": 2025, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 1, "communityRating": 8.338, "criticRating": null, "genres": ["Animation", "Sci-Fi & Fantasy", "Action & Adventure"], "overview": "In a world where heroism is a punishment, Xylo Forbartz, a condemned goddess killer, battles endless hordes of monstrous abominations as part of Penal Hero Unit 9004. Death is no escape, only a cycle of resurrection\u2026", "poster": "assets/tv-sentenced-to-be-a-hero-2025-poster.jpg", "backdrop": "assets/tv-sentenced-to-be-a-hero-2025-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-22T10:31:16.2666791Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 249907, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.244, "tmdbVotes": 129}}, {"id": "tv-spy-x-family-2022", "title": "SPY x FAMILY", "year": 2022, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 3, "communityRating": 8.506, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Comedy"], "overview": "A spy, an assassin and a telepath come together to pose as a family, each for their own reasons, while hiding their true identities from each other.", "poster": "assets/tv-spy-x-family-2022-poster.jpg", "backdrop": "assets/tv-spy-x-family-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-22T10:31:16.2670364Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 120089, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.5, "tmdbVotes": 2382}}, {"id": "tv-the-tatami-galaxy-2010", "title": "The Tatami Galaxy", "year": 2010, "type": "tv", "runtimeSeconds": 1500, "seasonCount": 2, "communityRating": 8.271, "criticRating": null, "genres": ["Animation", "Comedy", "Drama"], "overview": "When a college dropout stops for a late night bite at a mysterious ramen stand, he crosses paths with a self-proclaimed deity of matrimony. This bizarre meeting sends the young man hurtling through a horrifying\u2026", "poster": "assets/tv-the-tatami-galaxy-2010-poster.jpg", "backdrop": "assets/tv-the-tatami-galaxy-2010-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-18T05:52:51.0023256Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 36243, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.3, "tmdbVotes": 152}}, {"id": "tv-vinland-saga-2019", "title": "Vinland Saga", "year": 2019, "type": "tv", "runtimeSeconds": 1500, "seasonCount": 2, "communityRating": 8.527, "criticRating": null, "genres": ["Animation", "War & Politics", "Action & Adventure"], "overview": "For a thousand years, the Vikings have made quite a name and reputation for themselves as the strongest families with a thirst for violence. Thorfinn, the son of one of the Vikings' greatest warriors, spends his\u2026", "poster": "assets/tv-vinland-saga-2019-poster.jpg", "backdrop": "assets/tv-vinland-saga-2019-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-20T22:28:13.207389Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 88803, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.509, "tmdbVotes": 1064}}, {"id": "tv-yu-yu-hakusho-1992", "title": "Yu Yu Hakusho", "year": 1992, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 4, "communityRating": 8.5, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "After dying to save a boy, delinquent tough guy Yusuke Urameshi is granted another chance at life by redeeming himself as a \"Spirit Detective.\"", "poster": "assets/tv-yu-yu-hakusho-1992-poster.jpg", "backdrop": "assets/tv-yu-yu-hakusho-1992-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-19T21:46:01.5733049Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 30669, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.475, "tmdbVotes": 519}}], "resume": [], "nextUp": [{"id": "episode-three-monsters-1992-s1e6", "title": "Three Monsters", "year": 1992, "type": "episode", "runtimeSeconds": 1421, "seasonCount": null, "communityRating": 7.4, "criticRating": null, "genres": ["Animation", "Action", "Adventure"], "overview": "Yusuke receives his first real case as Spirit Detective. Three demon thieves have muscled their way into King Yama's sacred vault, stealing the three Artifacts of Darkness. Yusuke must recover the artifacts and bring\u2026", "poster": "assets/episode-three-monsters-1992-s1e6-poster.jpg", "backdrop": "assets/episode-three-monsters-1992-s1e6-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-19T21:59:21.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "Yu Yu Hakusho", "seasonNumber": 1, "episodeNumber": 6, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}, {"id": "episode-all-hands-on-deck-class-1-a-2021-s5e1", "title": "All Hands on Deck! Class 1-A", "year": 2021, "type": "episode", "runtimeSeconds": 1433, "seasonCount": null, "communityRating": 6.938, "criticRating": null, "genres": ["Animation", "Action", "Adventure"], "overview": "There's an emergency drill at U.A. High School, and Class 1-A is requested to report for duty!", "poster": "assets/episode-all-hands-on-deck-class-1-a-2021-s5e1-poster.jpg", "backdrop": "assets/episode-all-hands-on-deck-class-1-a-2021-s5e1-backdrop.jpg", "played": false, "playCount": 2, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-22T04:04:50.0000000Z", "lastPlayedAt": "2026-10-19T17:47:09.3028025Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "My Hero Academia", "seasonNumber": 5, "episodeNumber": 1, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}, {"id": "episode-erickerhead-2026-s9e6", "title": "Erickerhead", "year": 2026, "type": "episode", "runtimeSeconds": 1357, "seasonCount": null, "communityRating": 8.196, "criticRating": null, "genres": [], "overview": "Rick's stomach is mad, broh. But it's all-you-can-eat-and-drink here, broh!", "poster": "assets/episode-erickerhead-2026-s9e6-poster.jpg", "backdrop": "assets/episode-erickerhead-2026-s9e6-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-28T10:23:23.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "Rick and Morty", "seasonNumber": 9, "episodeNumber": 6, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}, {"id": "episode-girls-interrupted-2007-s1e7", "title": "Girls, Interrupted", "year": 2007, "type": "episode", "runtimeSeconds": 1642, "seasonCount": null, "communityRating": 7.4, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "Hank is disturbed by Becca's negative reaction towards the concept of happy endings, even more so when he finds out that it is partially his fault.\nMarcy comes up with the idea of a threesome with Charlie and Dani,\u2026", "poster": "assets/episode-girls-interrupted-2007-s1e7-poster.jpg", "backdrop": "assets/episode-girls-interrupted-2007-s1e7-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-05T02:46:18.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "Californication", "seasonNumber": 1, "episodeNumber": 7, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}, {"id": "episode-announcement-2022-s1e7", "title": "Announcement", "year": 2022, "type": "episode", "runtimeSeconds": 3012, "seasonCount": null, "communityRating": 7.8, "criticRating": null, "genres": ["Action", "Adventure", "Drama"], "overview": "The repercussions of the Aldhani heist reach all the way to Coruscant, while Cassian returns home.", "poster": "assets/episode-announcement-2022-s1e7-poster.jpg", "backdrop": "assets/episode-announcement-2022-s1e7-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T12:56:24.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "Andor", "seasonNumber": 1, "episodeNumber": 7, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}, {"id": "episode-south-american-biker-gangs-2026-s29e1", "title": "South American Biker Gangs", "year": 2026, "type": "episode", "runtimeSeconds": 1335, "seasonCount": null, "communityRating": 6.4, "criticRating": null, "genres": ["Animation", "Comedy"], "overview": "When e-bikes arrive in South America, the boys take over the streets and the parents of South America can hardly recognize South America.", "poster": "assets/episode-south-american-biker-gangs-2026-s29e1-poster.jpg", "backdrop": "assets/episode-south-american-biker-gangs-2026-s29e1-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-01T06:40:46.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "South Park", "seasonNumber": 29, "episodeNumber": 1, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}], "recentlyAdded": [{"id": "movie-steins-gate-the-movie-load-region-of-deja-vu-2013", "title": "Steins;Gate: The Movie - Load Region of D\u00e9j\u00e0 Vu", "year": 2013, "type": "movie", "runtimeSeconds": 5403, "seasonCount": null, "communityRating": 7.8, "criticRating": null, "genres": ["Animation", "Mystery", "Sci-Fi"], "overview": "After the events of the anime, Rintarou begins to feel the repercussions of extensive time travel, and eventually completely fades from reality. Kurisu, being the only companion to remember him, now must find a way\u2026", "poster": "assets/movie-steins-gate-the-movie-load-region-of-deja-vu-2013-poster.jpg", "backdrop": "assets/movie-steins-gate-the-movie-load-region-of-deja-vu-2013-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-22T14:55:54.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}, {"id": "tv-yu-yu-hakusho-1992", "title": "Yu Yu Hakusho", "year": 1992, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 4, "communityRating": 8.5, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "After dying to save a boy, delinquent tough guy Yusuke Urameshi is granted another chance at life by redeeming himself as a \"Spirit Detective.\"", "poster": "assets/tv-yu-yu-hakusho-1992-poster.jpg", "backdrop": "assets/tv-yu-yu-hakusho-1992-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-19T21:46:01.5733049Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 30669, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.475, "tmdbVotes": 519}}, {"id": "movie-memories-1995", "title": "Memories", "year": 1995, "type": "movie", "runtimeSeconds": 6805, "seasonCount": null, "communityRating": 7.465, "criticRating": null, "genres": ["Fantasy", "Animation", "Science Fiction"], "overview": "Three back-to-back anime films by three different directors make up this sci-fi trilogy three years in the making.", "poster": "assets/movie-memories-1995-poster.jpg", "backdrop": "assets/movie-memories-1995-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-30T21:36:03.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 42994, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": 82, "rtAudienceLabel": "Upright", "imdb": 7.5, "imdbVotes": 26638, "tmdb": 7.459, "tmdbVotes": 622}}, {"id": "tv-firefly-2002", "title": "Firefly", "year": 2002, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 8.347, "criticRating": null, "genres": ["Drama", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "In the year 2517, the universe is a vast frontier where lawlessness reigns and adventure awaits! Join Captain Malcolm Reynolds and his ragtag crew aboard the spaceship Serenity as they navigate the fringes of\u2026", "poster": "assets/tv-firefly-2002-poster.jpg", "backdrop": "assets/tv-firefly-2002-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-20T11:40:08.4202369Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 1437, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 77, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.351, "tmdbVotes": 2550}}, {"id": "tv-love-death-robots-2019", "title": "Love, Death & Robots", "year": 2019, "type": "tv", "runtimeSeconds": null, "seasonCount": 4, "communityRating": 8.205, "criticRating": null, "genres": ["Animation", "Sci-Fi & Fantasy"], "overview": "Terrifying creatures, wicked surprises and dark comedy converge in this NSFW anthology of animated stories presented by Tim Miller and David Fincher.", "poster": "assets/tv-love-death-robots-2019-poster.jpg", "backdrop": "assets/tv-love-death-robots-2019-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-20T11:31:20.8818757Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 86831, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 89, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.203, "tmdbVotes": 4238}}, {"id": "tv-the-legend-of-korra-2012", "title": "The Legend of Korra", "year": 2012, "type": "tv", "runtimeSeconds": 1380, "seasonCount": 4, "communityRating": 8.2, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "Avatar Korra, a headstrong, rebellious, feisty young woman who continually challenges and breaks with tradition, is on her quest to become a fully realized Avatar. In this story, the Avatar struggles to find balance\u2026", "poster": "assets/tv-the-legend-of-korra-2012-poster.jpg", "backdrop": "assets/tv-the-legend-of-korra-2012-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-20T00:19:23.2494945Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 33880, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 88, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.221, "tmdbVotes": 2482}}, {"id": "tv-spy-x-family-2022", "title": "SPY x FAMILY", "year": 2022, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 3, "communityRating": 8.506, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Comedy"], "overview": "A spy, an assassin and a telepath come together to pose as a family, each for their own reasons, while hiding their true identities from each other.", "poster": "assets/tv-spy-x-family-2022-poster.jpg", "backdrop": "assets/tv-spy-x-family-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-22T10:31:16.2670364Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 120089, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.5, "tmdbVotes": 2382}}, {"id": "tv-sentenced-to-be-a-hero-2025", "title": "Sentenced to Be a Hero", "year": 2025, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 1, "communityRating": 8.338, "criticRating": null, "genres": ["Animation", "Sci-Fi & Fantasy", "Action & Adventure"], "overview": "In a world where heroism is a punishment, Xylo Forbartz, a condemned goddess killer, battles endless hordes of monstrous abominations as part of Penal Hero Unit 9004. Death is no escape, only a cycle of resurrection\u2026", "poster": "assets/tv-sentenced-to-be-a-hero-2025-poster.jpg", "backdrop": "assets/tv-sentenced-to-be-a-hero-2025-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-22T10:31:16.2666791Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 249907, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.244, "tmdbVotes": 129}}, {"id": "tv-sakamoto-days-2025", "title": "SAKAMOTO DAYS", "year": 2025, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 8.071, "criticRating": null, "genres": ["Animation", "Comedy", "Action & Adventure"], "overview": "Once the greatest hitman of all, Taro Sakamoto retired in the name of love. But when his past catches up, he must fight to protect his beloved family.", "poster": "assets/tv-sakamoto-days-2025-poster.jpg", "backdrop": "assets/tv-sakamoto-days-2025-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-22T10:31:16.266066Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 207332, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.057, "tmdbVotes": 375}}, {"id": "tv-dan-da-dan-2024", "title": "Dan Da Dan", "year": 2024, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 8.538, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Comedy"], "overview": "In a bet to prove whether ghosts or aliens exist, two high schoolers face terrifying paranormal threats, gain superpowers and maybe even fall in love?!", "poster": "assets/tv-dan-da-dan-2024-poster.jpg", "backdrop": "assets/tv-dan-da-dan-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-22T10:31:16.2652252Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 240411, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.517, "tmdbVotes": 986}}, {"id": "tv-kaiju-no-8-2024", "title": "Kaiju No. 8", "year": 2024, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 2, "communityRating": 8.436, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "In a world plagued by creatures known as Kaiju, Kafka Hibino aspired to enlist in The Defense Force. He makes a promise to enlist with his childhood friend, Mina Ashiro. Soon, life takes them in separate ways. While\u2026", "poster": "assets/tv-kaiju-no-8-2024-poster.jpg", "backdrop": "assets/tv-kaiju-no-8-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-22T10:31:16.2646566Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 207468, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.4, "tmdbVotes": 836}}, {"id": "tv-chainsaw-man-2022", "title": "Chainsaw Man", "year": 2022, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 1, "communityRating": 8.515, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "Denji has a simple dream\u2014to live a happy and peaceful life, spending time with a girl he likes. This is a far cry from reality, however, as Denji is forced by the yakuza into killing devils in order to pay off his\u2026", "poster": "assets/tv-chainsaw-man-2022-poster.jpg", "backdrop": "assets/tv-chainsaw-man-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-20T22:28:13.2095501Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 114410, "artwork": {"posterKind": "center_crop", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 97, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.508, "tmdbVotes": 2364}}], "discover": [{"id": "movie-the-amazing-spider-man-2012", "title": "The Amazing Spider-Man", "year": 2012, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Adventure", "Science Fiction"], "overview": "A teenage Peter Parker grapples with both high school and amazing super-human crises as his alter-ego Spider-Man.", "poster": "assets/movie-the-amazing-spider-man-2012-poster.jpg", "backdrop": "assets/movie-the-amazing-spider-man-2012-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 1930, "ratings": {"rtCritics": 71, "rtCriticsLabel": "Certified Fresh", "rtAudience": 77, "rtAudienceLabel": "Upright", "imdb": 7, "imdbVotes": 791938, "tmdb": 6.768, "tmdbVotes": 19312}}, {"id": "movie-the-amazing-spider-man-2-2014", "title": "The Amazing Spider-Man 2", "year": 2014, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Adventure", "Science Fiction"], "overview": "For Peter Parker, life is busy. Between taking out the bad guys as Spider-Man and spending time with the person he loves, Gwen Stacy, high school graduation cannot come quickly enough. Peter has not forgotten about\u2026", "poster": "assets/movie-the-amazing-spider-man-2-2014-poster.jpg", "backdrop": "assets/movie-the-amazing-spider-man-2-2014-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 102382, "ratings": {"rtCritics": 51, "rtCriticsLabel": "Rotten", "rtAudience": 64, "rtAudienceLabel": "Upright", "imdb": 6.6, "imdbVotes": 611981, "tmdb": 6.567, "tmdbVotes": 14919}}, {"id": "movie-the-animatrix-2003", "title": "The Animatrix", "year": 2003, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Animation", "Science Fiction"], "overview": "Straight from the creators of the groundbreaking Matrix trilogy, this collection of short animated films from the world's leading anime directors fuses computer graphics and Japanese anime to provide the background\u2026", "poster": "assets/movie-the-animatrix-2003-poster.jpg", "backdrop": "assets/movie-the-animatrix-2003-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 55931, "ratings": {"rtCritics": 86, "rtCriticsLabel": "Fresh", "rtAudience": 78, "rtAudienceLabel": "Upright", "imdb": 7.3, "imdbVotes": 91929, "tmdb": 7.221, "tmdbVotes": 1872}}, {"id": "movie-ant-man-and-the-wasp-quantumania-2023", "title": "Ant-Man and the Wasp: Quantumania", "year": 2023, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Adventure", "Science Fiction"], "overview": "Super-Hero partners Scott Lang and Hope van Dyne, along with with Hope's parents Janet van Dyne and Hank Pym, and Scott's daughter Cassie Lang, find themselves exploring the Quantum Realm, interacting with strange\u2026", "poster": "assets/movie-ant-man-and-the-wasp-quantumania-2023-poster.jpg", "backdrop": "assets/movie-ant-man-and-the-wasp-quantumania-2023-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 640146, "ratings": {"rtCritics": 46, "rtCriticsLabel": "Rotten", "rtAudience": 81, "rtAudienceLabel": "Upright", "imdb": 6, "imdbVotes": 281738, "tmdb": 6.197, "tmdbVotes": 6236}}, {"id": "movie-appleseed-alpha-2014", "title": "Appleseed Alpha", "year": 2014, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Animation", "Science Fiction", "Action"], "overview": "The Third World War (2099\u20132126) devastated part of the Earth. Former nations are reforming while new powers emerge, and groups of humans struggle to survive in ruined cities. The story follows Deunan Knute and her\u2026", "poster": "assets/movie-appleseed-alpha-2014-poster.jpg", "backdrop": "assets/movie-appleseed-alpha-2014-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "center_crop", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 269650, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": 59, "rtAudienceLabel": "Spilled", "imdb": 6.5, "imdbVotes": 9689, "tmdb": 6.808, "tmdbVotes": 436}}, {"id": "movie-aquaman-and-the-lost-kingdom-2023", "title": "Aquaman and the Lost Kingdom", "year": 2023, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Adventure", "Fantasy"], "overview": "Black Manta seeks revenge on Aquaman for his father's death. Wielding the Black Trident's power, he becomes a formidable foe. To defend Atlantis, Arthur (Aquaman) forges an alliance with his imprisoned brother. They\u2026", "poster": "assets/movie-aquaman-and-the-lost-kingdom-2023-poster.jpg", "backdrop": "assets/movie-aquaman-and-the-lost-kingdom-2023-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 572802, "ratings": {"rtCritics": 33, "rtCriticsLabel": "Rotten", "rtAudience": 79, "rtAudienceLabel": "Upright", "imdb": 5.6, "imdbVotes": 122422, "tmdb": 6.48, "tmdbVotes": 3748}}, {"id": "movie-the-avengers-2012", "title": "The Avengers", "year": 2012, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Science Fiction", "Action", "Adventure"], "overview": "When an unexpected enemy emerges and threatens global safety and security, Nick Fury, director of the international peacekeeping agency known as S.H.I.E.L.D., finds himself in need of a team to pull the world back\u2026", "poster": "assets/movie-the-avengers-2012-poster.jpg", "backdrop": "assets/movie-the-avengers-2012-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 24428, "ratings": {"rtCritics": 91, "rtCriticsLabel": "Certified Fresh", "rtAudience": 91, "rtAudienceLabel": "Upright", "imdb": 8, "imdbVotes": 1590693, "tmdb": 8.084, "tmdbVotes": 40262}}, {"id": "movie-avengers-confidential-black-widow-punisher-2014", "title": "Avengers Confidential: Black Widow & Punisher", "year": 2014, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Animation", "Science Fiction", "Action"], "overview": "When the Punisher takes out a black-market weapons dealer, he stumbles upon a far-reaching terrorist plot devised by a group known as Leviathan.", "poster": "assets/movie-avengers-confidential-black-widow-punisher-2014-poster.jpg", "backdrop": "assets/movie-avengers-confidential-black-widow-punisher-2014-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 257346, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": 55, "rtAudienceLabel": "Spilled", "imdb": 5.7, "imdbVotes": 5166, "tmdb": 6.368, "tmdbVotes": 303}}, {"id": "movie-avengers-age-of-ultron-2015", "title": "Avengers: Age of Ultron", "year": 2015, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Adventure", "Science Fiction"], "overview": "When Tony Stark tries to jumpstart a dormant peacekeeping program, things go awry and Earth\u2019s Mightiest Heroes are put to the ultimate test as the fate of the planet hangs in the balance. As the villainous Ultron\u2026", "poster": "assets/movie-avengers-age-of-ultron-2015-poster.jpg", "backdrop": "assets/movie-avengers-age-of-ultron-2015-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 99861, "ratings": {"rtCritics": 75, "rtCriticsLabel": "Certified Fresh", "rtAudience": 82, "rtAudienceLabel": "Upright", "imdb": 7.3, "imdbVotes": 1025876, "tmdb": 7.278, "tmdbVotes": 25289}}, {"id": "movie-avengers-infinity-war-2018", "title": "Avengers: Infinity War", "year": 2018, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Adventure", "Action", "Science Fiction"], "overview": "As the Avengers and their allies have continued to protect the world from threats too large for any one hero to handle, a new danger has emerged from the cosmic shadows: Thanos. A despot of intergalactic infamy, his\u2026", "poster": "assets/movie-avengers-infinity-war-2018-poster.jpg", "backdrop": "assets/movie-avengers-infinity-war-2018-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 299536, "ratings": {"rtCritics": 85, "rtCriticsLabel": "Certified Fresh", "rtAudience": 92, "rtAudienceLabel": "Upright", "imdb": 8.4, "imdbVotes": 1421954, "tmdb": 8.246, "tmdbVotes": 33331}}, {"id": "movie-batman-ninja-2018", "title": "Batman Ninja", "year": 2018, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Animation", "Action", "Science Fiction"], "overview": "Batman, along with many of his allies and adversaries, finds himself transported to feudal Japan by Gorilla Grodd's time displacement machine.", "poster": "assets/movie-batman-ninja-2018-poster.jpg", "backdrop": "assets/movie-batman-ninja-2018-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 485942, "ratings": {"rtCritics": 84, "rtCriticsLabel": "Fresh", "rtAudience": 60, "rtAudienceLabel": "Upright", "imdb": 5.6, "imdbVotes": 24579, "tmdb": 5.9, "tmdbVotes": 1106}}, {"id": "movie-batman-v-superman-dawn-of-justice-2016", "title": "Batman v Superman: Dawn of Justice", "year": 2016, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Adventure", "Fantasy"], "overview": "Fearing the actions of a god-like Super Hero left unchecked, Gotham City\u2019s own formidable, forceful vigilante takes on Metropolis\u2019s most revered, modern-day savior, while the world wrestles with what sort of hero it\u2026", "poster": "assets/movie-batman-v-superman-dawn-of-justice-2016-poster.jpg", "backdrop": "assets/movie-batman-v-superman-dawn-of-justice-2016-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 209112, "ratings": {"rtCritics": 28, "rtCriticsLabel": "Rotten", "rtAudience": 63, "rtAudienceLabel": "Upright", "imdb": 6.4, "imdbVotes": 801382, "tmdb": 6.014, "tmdbVotes": 19527}}, {"id": "movie-batman-the-dark-knight-returns-part-1-2012", "title": "Batman: The Dark Knight Returns, Part 1", "year": 2012, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Science Fiction", "Action", "Animation"], "overview": "Batman has not been seen for ten years. A new breed of criminal ravages Gotham City, forcing 55-year-old Bruce Wayne back into the cape and cowl. But, does he still have what it takes to fight crime in a new era?", "poster": "assets/movie-batman-the-dark-knight-returns-part-1-2012-poster.jpg", "backdrop": "assets/movie-batman-the-dark-knight-returns-part-1-2012-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 123025, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": 93, "rtAudienceLabel": "Upright", "imdb": 7.9, "imdbVotes": 68943, "tmdb": 7.742, "tmdbVotes": 1766}}, {"id": "movie-black-adam-2022", "title": "Black Adam", "year": 2022, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Adventure", "Science Fiction"], "overview": "Nearly 5,000 years after he was bestowed with the almighty powers of the Egyptian gods\u2014and imprisoned just as quickly\u2014Black Adam is freed from his earthly tomb, ready to unleash his unique form of justice on the\u2026", "poster": "assets/movie-black-adam-2022-poster.jpg", "backdrop": "assets/movie-black-adam-2022-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 436270, "ratings": {"rtCritics": 39, "rtCriticsLabel": "Rotten", "rtAudience": 87, "rtAudienceLabel": "Upright", "imdb": 6.1, "imdbVotes": 309624, "tmdb": 6.747, "tmdbVotes": 7370}}, {"id": "movie-black-panther-2018", "title": "Black Panther", "year": 2018, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Adventure", "Science Fiction"], "overview": "King T'Challa returns home to the reclusive, technologically advanced African nation of Wakanda to serve as his country's new leader. However, T'Challa soon finds that he is challenged for the throne by factions\u2026", "poster": "assets/movie-black-panther-2018-poster.jpg", "backdrop": "assets/movie-black-panther-2018-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 284054, "ratings": {"rtCritics": 96, "rtCriticsLabel": "Certified Fresh", "rtAudience": null, "rtAudienceLabel": "Spilled", "imdb": 7.3, "imdbVotes": 931120, "tmdb": 7.36, "tmdbVotes": 24190}}, {"id": "movie-black-panther-wakanda-forever-2022", "title": "Black Panther: Wakanda Forever", "year": 2022, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Adventure", "Science Fiction"], "overview": "Queen Ramonda, Shuri, M\u2019Baku, Okoye and the Dora Milaje fight to protect their nation from intervening world powers in the wake of King T\u2019Challa\u2019s death. As the Wakandans strive to embrace their next chapter, the\u2026", "poster": "assets/movie-black-panther-wakanda-forever-2022-poster.jpg", "backdrop": "assets/movie-black-panther-wakanda-forever-2022-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 505642, "ratings": {"rtCritics": 84, "rtCriticsLabel": "Certified Fresh", "rtAudience": 93, "rtAudienceLabel": "Upright", "imdb": 6.6, "imdbVotes": 405088, "tmdb": 6.984, "tmdbVotes": 7920}}, {"id": "movie-blame-2017", "title": "BLAME!", "year": 2017, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Drama", "Science Fiction"], "overview": "In the distant technological future, civilization has reached its ultimate Net-based form. An \"infection\" in the past caused the automated systems to spiral out of order, resulting in a multi-leveled city structure\u2026", "poster": "assets/movie-blame-2017-poster.jpg", "backdrop": "assets/movie-blame-2017-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 409421, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": 61, "rtAudienceLabel": "Upright", "imdb": 6.6, "imdbVotes": 10258, "tmdb": 6.523, "tmdbVotes": 370}}, {"id": "movie-bleach-the-movie-fade-to-black-2008", "title": "Bleach the Movie: Fade to Black", "year": 2008, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Animation", "Fantasy"], "overview": "A strange disturbance in the World of the Living sends Ichigo and Kon to the Soul Society, where the Seireitei has been devastated by an explosion of reishi and Rukia is missing. Stranger still, none of the Soul\u2026", "poster": "assets/movie-bleach-the-movie-fade-to-black-2008-poster.jpg", "backdrop": "assets/movie-bleach-the-movie-fade-to-black-2008-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 17165, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": 67, "rtAudienceLabel": "Upright", "imdb": 7.1, "imdbVotes": 3693, "tmdb": 7.1, "tmdbVotes": 206}}, {"id": "tv-90-day-fiance-before-the-90-days-2017", "title": "90 Day Fianc\u00e9: Before the 90 Days", "year": 2017, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Reality"], "overview": "Follow couples who have an existing relationship online, but haven't met in person. We'll experience their journey as they travel to the other's foreign country for the first time in an attempt to establish an\u2026", "poster": "assets/tv-90-day-fiance-before-the-90-days-2017-poster.jpg", "backdrop": "assets/tv-90-day-fiance-before-the-90-days-2017-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 73319, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.5, "tmdbVotes": 45}}, {"id": "tv-90-day-fiance-the-other-way-2019", "title": "90 Day Fianc\u00e9: The Other Way", "year": 2019, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Reality"], "overview": "Americans move to a foreign country all in the name of love. Will uprooting their lives in the United States pay off, or will they be packing up and moving back to America brokenhearted?", "poster": "assets/tv-90-day-fiance-the-other-way-2019-poster.jpg", "backdrop": "assets/tv-90-day-fiance-the-other-way-2019-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 90046, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.8, "tmdbVotes": 34}}, {"id": "tv-the-100-2014", "title": "The 100", "year": 2014, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Sci-Fi & Fantasy", "Action & Adventure", "Drama"], "overview": "100 years in the future, when the Earth has been abandoned due to radioactivity, the last surviving humans live on an ark orbiting the planet \u2014 but the ark won't last forever. So the repressive regime picks 100\u2026", "poster": "assets/tv-the-100-2014-poster.jpg", "backdrop": "assets/tv-the-100-2014-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 48866, "ratings": {"rtCritics": 93, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.878, "tmdbVotes": 8855}}, {"id": "tv-the-abbott-and-costello-show-1952", "title": "The Abbott and Costello Show", "year": 1952, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Comedy"], "overview": "Bud and Lou are unemployed actors living in Mr. Fields\u2019 boarding house. Lou\u2019s girlfriend Hillary lives across the hall. Many situations arise leading to slapstick and puns.", "poster": "assets/tv-the-abbott-and-costello-show-1952-poster.jpg", "backdrop": "assets/tv-the-abbott-and-costello-show-1952-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 11821, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.2, "tmdbVotes": 22}}, {"id": "tv-accel-world-2012", "title": "Accel World", "year": 2012, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Drama"], "overview": "A short, overweight student uses the Neuro Link, an evolution of the Nerve Gear, to escape himself from the reality. But his skills in the virtual world brings him to the attention of Kuroyukihime, a popular student\u2026", "poster": "assets/tv-accel-world-2012-poster.jpg", "backdrop": "assets/tv-accel-world-2012-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "center_crop", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 43167, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.429, "tmdbVotes": 163}}, {"id": "tv-ahsoka-2023", "title": "Ahsoka", "year": 2023, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Sci-Fi & Fantasy", "Action & Adventure", "Drama"], "overview": "Former Jedi Knight Ahsoka Tano investigates an emerging threat to a vulnerable galaxy.", "poster": "assets/tv-ahsoka-2023-poster.jpg", "backdrop": "assets/tv-ahsoka-2023-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 114461, "ratings": {"rtCritics": 85, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.4, "tmdbVotes": 1351}}, {"id": "tv-american-dad-2005", "title": "American Dad!", "year": 2005, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Animation", "Comedy"], "overview": "The series focuses on an eccentric motley crew that is the Smith family and their three housemates: Father, husband, and breadwinner Stan Smith; his better half housewife, Francine Smith; their college-aged daughter,\u2026", "poster": "assets/tv-american-dad-2005-poster.jpg", "backdrop": "assets/tv-american-dad-2005-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 1433, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.022, "tmdbVotes": 2481}}, {"id": "tv-american-gods-2017", "title": "American Gods", "year": 2017, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Mystery", "Sci-Fi & Fantasy"], "overview": "An ex-con becomes the traveling partner of a conman who turns out to be one of the older gods trying to recruit troops to battle the upstart deities. Based on Neil Gaiman's fantasy novel.", "poster": "assets/tv-american-gods-2017-poster.jpg", "backdrop": "assets/tv-american-gods-2017-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 46639, "ratings": {"rtCritics": 75, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.1, "tmdbVotes": 1852}}, {"id": "tv-andromeda-2000", "title": "Andromeda", "year": 2000, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action & Adventure", "Drama", "Sci-Fi & Fantasy"], "overview": "Captain Dylan Hunt and his crew quest to restore a government that once presided over an extended peace and prosperity.", "poster": "assets/tv-andromeda-2000-poster.jpg", "backdrop": "assets/tv-andromeda-2000-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 2985, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.068, "tmdbVotes": 315}}, {"id": "tv-aqua-teen-hunger-force-2000", "title": "Aqua Teen Hunger Force", "year": 2000, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Animation", "Comedy"], "overview": "The surreal adventures of three anthropomorphic fast food items: Master Shake, Frylock and Meatwad, and their human nextdoor neighbor, Carl Brutananadilewski.", "poster": "assets/tv-aqua-teen-hunger-force-2000-poster.jpg", "backdrop": "assets/tv-aqua-teen-hunger-force-2000-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 251, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.5, "tmdbVotes": 272}}, {"id": "tv-arifureta-from-commonplace-to-world-s-strongest-2019", "title": "Arifureta: From Commonplace to World's Strongest", "year": 2019, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "Hajime Nagumo and his high school class are suddenly summoned to a fantastical land as heroes. But while most of his classmates have powerful stats and abilities, Hajime does not. Underappreciated and unprepared, he\u2026", "poster": "assets/tv-arifureta-from-commonplace-to-world-s-strongest-2019-poster.jpg", "backdrop": "assets/tv-arifureta-from-commonplace-to-world-s-strongest-2019-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 86034, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.382, "tmdbVotes": 662}}, {"id": "tv-arrow-2012", "title": "Arrow", "year": 2012, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Crime", "Drama", "Action & Adventure"], "overview": "Spoiled billionaire playboy Oliver Queen is missing and presumed dead when his yacht is lost at sea. He returns five years later a changed man, determined to clean up the city as a hooded vigilante armed with a bow.", "poster": "assets/tv-arrow-2012-poster.jpg", "backdrop": "assets/tv-arrow-2012-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 1412, "ratings": {"rtCritics": 86, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.834, "tmdbVotes": 6537}}, {"id": "tv-avatar-the-last-airbender-2024", "title": "Avatar: The Last Airbender", "year": 2024, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action & Adventure", "Sci-Fi & Fantasy", "Drama"], "overview": "A young boy known as the Avatar must master the four elemental powers to save a world at war \u2014 and fight a ruthless enemy bent on stopping him.", "poster": "assets/tv-avatar-the-last-airbender-2024-poster.jpg", "backdrop": "assets/tv-avatar-the-last-airbender-2024-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 82452, "ratings": {"rtCritics": 66, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.8, "tmdbVotes": 1406}}, {"id": "tv-avatar-seven-havens-2026", "title": "Avatar: Seven Havens", "year": 2026, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "In a world shattered by a devastating cataclysm, a young Earthbender discovers she's the new Avatar after Korra. But in this dangerous era, that title marks her as humanity's destroyer, not its savior. Hunted by both\u2026", "poster": "assets/tv-avatar-seven-havens-2026-poster.jpg", "backdrop": "assets/tv-avatar-seven-havens-2026-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 284833, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.9, "tmdbVotes": 183}}, {"id": "tv-batman-the-brave-and-the-bold-2008", "title": "Batman: The Brave and the Bold", "year": 2008, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "The Caped Crusader is teamed up with Blue Beetle, Green Arrow, Aquaman and countless others in his quest to uphold justice.", "poster": "assets/tv-batman-the-brave-and-the-bold-2008-poster.jpg", "backdrop": "assets/tv-batman-the-brave-and-the-bold-2008-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 15804, "ratings": {"rtCritics": 96, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.6, "tmdbVotes": 295}}, {"id": "tv-batwoman-2019", "title": "Batwoman", "year": 2019, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Sci-Fi & Fantasy", "Action & Adventure"], "overview": "Kate Kane, armed with a passion for social justice and a flair for speaking her mind, soars onto the streets of Gotham as Batwoman, an out lesbian and highly trained street fighter primed to snuff out the failing\u2026", "poster": "assets/tv-batwoman-2019-poster.jpg", "backdrop": "assets/tv-batwoman-2019-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 89247, "ratings": {"rtCritics": 83, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.8, "tmdbVotes": 1619}}, {"id": "tv-big-mouth-2017", "title": "Big Mouth", "year": 2017, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Comedy", "Animation"], "overview": "Teenage friends find their lives upended by the wonders and horrors of puberty in this edgy comedy from real-life pals Nick Kroll and Andrew Goldberg.", "poster": "assets/tv-big-mouth-2017-poster.jpg", "backdrop": "assets/tv-big-mouth-2017-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 74204, "ratings": {"rtCritics": 95, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.591, "tmdbVotes": 1274}}, {"id": "tv-black-lightning-2018", "title": "Black Lightning", "year": 2018, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action & Adventure", "Sci-Fi & Fantasy", "Drama"], "overview": "Jefferson Pierce is a man wrestling with a secret. As the father of two daughters and principal of a charter high school that also serves as a safe haven for young people in a New Orleans neighborhood overrun by gang\u2026", "poster": "assets/tv-black-lightning-2018-poster.jpg", "backdrop": "assets/tv-black-lightning-2018-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 71663, "ratings": {"rtCritics": 92, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.907, "tmdbVotes": 991}}], "discoverySource": {"state": "materialized_snapshot", "rankKnown": false, "selection": "First 18 alphabetically indexed movie stubs and first 18 series stubs, not ranked feed order", "observedAt": "2026-11-04T20:42:12.030108+00:00"}}, {"key": "mindel", "name": "Mindel", "initial": "M", "color": "#ff9d72", "destinations": {"Home": {"kind": "home"}, "Movies": {"kind": "library", "collection": "movies"}, "Shows": {"kind": "library", "collection": "shows"}, "Discover": {"kind": "discover", "collection": "discover"}}, "movies": [{"id": "movie-chicago-2002", "title": "Chicago", "year": 2002, "type": "movie", "runtimeSeconds": 6808, "seasonCount": null, "communityRating": 7.131, "criticRating": 87, "genres": ["Comedy", "Crime", "Drama"], "overview": "Murderesses Velma Kelly and Roxie Hart find themselves on death row together and fight for the fame that will keep them from the gallows in 1920s Chicago.", "poster": "assets/movie-chicago-2002-poster.jpg", "backdrop": "assets/movie-chicago-2002-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:24:53.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 1574, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 87, "rtCriticsLabel": "Certified Fresh", "rtAudience": 83, "rtAudienceLabel": "Upright", "imdb": 7.2, "imdbVotes": 259272, "tmdb": 7.135, "tmdbVotes": 3025}}, {"id": "movie-coherence-2014", "title": "Coherence", "year": 2014, "type": "movie", "runtimeSeconds": 5286, "seasonCount": null, "communityRating": 7.21, "criticRating": 89, "genres": ["Thriller", "Science Fiction"], "overview": "Four couples gather for dinner the night a mysterious and powerful comet passes overhead.", "poster": "assets/movie-coherence-2014-poster.jpg", "backdrop": "assets/movie-coherence-2014-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2017-08-12T23:49:00.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 220289, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 89, "rtCriticsLabel": "Certified Fresh", "rtAudience": 81, "rtAudienceLabel": "Upright", "imdb": 7.1, "imdbVotes": 180354, "tmdb": 7.208, "tmdbVotes": 3702}}, {"id": "movie-the-killing-of-a-sacred-deer-2017", "title": "The Killing of a Sacred Deer", "year": 2017, "type": "movie", "runtimeSeconds": 7262, "seasonCount": null, "communityRating": 7.003, "criticRating": 79, "genres": ["Drama", "Thriller", "Mystery"], "overview": "Dr. Steven Murphy is a renowned cardiovascular surgeon who presides over a spotless household with his wife and two children. Lurking at the margins of his idyllic suburban existence is Martin, a fatherless teen who\u2026", "poster": "assets/movie-the-killing-of-a-sacred-deer-2017-poster.jpg", "backdrop": "assets/movie-the-killing-of-a-sacred-deer-2017-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-22T21:49:26.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 399057, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 79, "rtCriticsLabel": "Certified Fresh", "rtAudience": 63, "rtAudienceLabel": "Upright", "imdb": 7, "imdbVotes": 219488, "tmdb": 7.001, "tmdbVotes": 4640}}, {"id": "movie-night-is-short-walk-on-girl-2017", "title": "Night Is Short, Walk On Girl", "year": 2017, "type": "movie", "runtimeSeconds": 5548, "seasonCount": null, "communityRating": 7.421, "criticRating": 90, "genres": ["Comedy", "Romance", "Animation"], "overview": "As a group of university students go out for a night on the town, a sophomore known only as \"The Girl with Black Hair\" experiences a series of surreal encounters with the local nightlife \u2013 all the while unaware of\u2026", "poster": "assets/movie-night-is-short-walk-on-girl-2017-poster.jpg", "backdrop": "assets/movie-night-is-short-walk-on-girl-2017-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T17:39:14.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 430214, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 90, "rtCriticsLabel": "Fresh", "rtAudience": 84, "rtAudienceLabel": "Upright", "imdb": 7.5, "imdbVotes": 7640, "tmdb": 7.4, "tmdbVotes": 267}}, {"id": "movie-thelma-louise-1991", "title": "Thelma & Louise", "year": 1991, "type": "movie", "runtimeSeconds": 7786, "seasonCount": null, "communityRating": 7.596, "criticRating": 87, "genres": ["Drama", "Crime", "Adventure"], "overview": "Taking a break from their dreary lives, close friends Thelma and Louise embark on a short weekend trip that ends in unforeseen incriminating circumstances. As fugitives, both women rediscover the strength of their\u2026", "poster": "assets/movie-thelma-louise-1991-poster.jpg", "backdrop": "assets/movie-thelma-louise-1991-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:24:40.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 1541, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 87, "rtCriticsLabel": "Certified Fresh", "rtAudience": 82, "rtAudienceLabel": "Upright", "imdb": 7.6, "imdbVotes": 197947, "tmdb": 7.594, "tmdbVotes": 4063}}, {"id": "movie-vicky-cristina-barcelona-2008", "title": "Vicky Cristina Barcelona", "year": 2008, "type": "movie", "runtimeSeconds": 5804, "seasonCount": null, "communityRating": 6.798, "criticRating": 80, "genres": ["Drama", "Romance"], "overview": "Two girlfriends on a summer holiday in Spain become enamored with the same painter, unaware that his ex-wife, with whom he has a tempestuous relationship, is about to re-enter the picture.", "poster": "assets/movie-vicky-cristina-barcelona-2008-poster.jpg", "backdrop": "assets/movie-vicky-cristina-barcelona-2008-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:25:34.0000000Z", "lastPlayedAt": "2026-08-11T20:12:57.3064089Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 5038, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 80, "rtCriticsLabel": "Certified Fresh", "rtAudience": 74, "rtAudienceLabel": "Upright", "imdb": 7.1, "imdbVotes": 283286, "tmdb": 6.797, "tmdbVotes": 4164}}], "shows": [{"id": "tv-all-creatures-great-small-2020", "title": "All Creatures Great & Small", "year": 2020, "type": "tv", "runtimeSeconds": 3000, "seasonCount": 7, "communityRating": 8.2, "criticRating": null, "genres": ["Drama", "Comedy"], "overview": "The heartwarming and humorous adventures of a young country vet in the Yorkshire Dales in the 1930s. A remake of the 1978 series.", "poster": "assets/tv-all-creatures-great-small-2020-poster.jpg", "backdrop": "assets/tv-all-creatures-great-small-2020-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-11-04T20:13:03.4266156Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 108255, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 99, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.2, "tmdbVotes": 168}}, {"id": "tv-back-to-life-2019", "title": "Back to Life", "year": 2019, "type": "tv", "runtimeSeconds": 1500, "seasonCount": 2, "communityRating": 7.367, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "When Miri Matteson returns home after eighteen years, can she integrate back into her old life? With a terrible event from her past hanging over her, it won\u2019t be easy.", "poster": "assets/tv-back-to-life-2019-poster.jpg", "backdrop": "assets/tv-back-to-life-2019-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0688758Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 88510, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.4, "tmdbVotes": 65}}, {"id": "tv-big-boys-2022", "title": "Big Boys", "year": 2022, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 3, "communityRating": 7.6, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "Two boys from very different ends of the 'spectrum of masculinity' become best friends at Brent University freshers' week; in their first year of uni, they explore, experiment and try to discover themselves, helping\u2026", "poster": "assets/tv-big-boys-2022-poster.jpg", "backdrop": "assets/tv-big-boys-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-11-04T20:25:58.2156837Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 202851, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.6, "tmdbVotes": 49}}, {"id": "tv-call-the-midwife-2012", "title": "Call the Midwife", "year": 2012, "type": "tv", "runtimeSeconds": 3120, "seasonCount": 15, "communityRating": 7.5, "criticRating": null, "genres": ["Drama"], "overview": "Drama following the lives of a group of midwives working in the poverty-stricken East End of London during the 1950s, based on the best-selling memoirs of Jennifer Worth.", "poster": "assets/tv-call-the-midwife-2012-poster.jpg", "backdrop": "assets/tv-call-the-midwife-2012-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0377216Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 39793, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.577, "tmdbVotes": 267}}, {"id": "tv-derry-girls-2018", "title": "Derry Girls", "year": 2018, "type": "tv", "runtimeSeconds": 1380, "seasonCount": 3, "communityRating": 8.144, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "Amidst the political conflict of Northern Ireland in the 1990s, five secondary school students square off with the universal challenges of being a teenager.", "poster": "assets/tv-derry-girls-2018-poster.jpg", "backdrop": "assets/tv-derry-girls-2018-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-02T20:01:55.8955537Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 76148, "artwork": {"posterKind": "center_crop", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 99, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.138, "tmdbVotes": 454}}, {"id": "tv-desperate-housewives-2004", "title": "Desperate Housewives", "year": 2004, "type": "tv", "runtimeSeconds": 2520, "seasonCount": 8, "communityRating": 7.896, "criticRating": null, "genres": ["Mystery", "Drama", "Comedy"], "overview": "Looking down on her friends and family isn't a way of life for Mary Alice Young... it's a way of death. One day, in her perfect house, in the loveliest of suburbs, Mary Alice ended it all. Now she's taking us into\u2026", "poster": "assets/tv-desperate-housewives-2004-poster.jpg", "backdrop": "assets/tv-desperate-housewives-2004-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-29T20:56:40.3505596Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 693, "artwork": {"posterKind": "poster", "backdropKind": "center_crop"}, "ratings": {"rtCritics": 72, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.896, "tmdbVotes": 2171}}, {"id": "tv-the-diplomat-2023", "title": "The Diplomat", "year": 2023, "type": "tv", "runtimeSeconds": null, "seasonCount": 4, "communityRating": 7.569, "criticRating": null, "genres": ["Drama", "War & Politics"], "overview": "Amid an international crisis, a US diplomat contends with her high-profile job as ambassador to the UK and her strained marriage to a political star.", "poster": "assets/tv-the-diplomat-2023-poster.jpg", "backdrop": "assets/tv-the-diplomat-2023-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0640348Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 203857, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 93, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.652, "tmdbVotes": 521}}, {"id": "tv-the-durrells-2016", "title": "The Durrells", "year": 2016, "type": "tv", "runtimeSeconds": 2760, "seasonCount": 4, "communityRating": 7.402, "criticRating": null, "genres": ["Drama", "Comedy", "Family"], "overview": "In 1935, financially strapped widow Louisa Durrell, whose life has fallen apart, decides to move from England, with her four children (three sons, one daughter), to the island of Corfu, Greece. Once there, the family\u2026", "poster": "assets/tv-the-durrells-2016-poster.jpg", "backdrop": "assets/tv-the-durrells-2016-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0619436Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 66134, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 94, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.4, "tmdbVotes": 127}}, {"id": "tv-etoile-2025", "title": "\u00c9toile", "year": 2025, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.197, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "In an ambitious gambit to save their storied institutions, two world-renowned ballet companies in New York City and Paris swap their most talented stars.", "poster": "assets/tv-etoile-2025-poster.jpg", "backdrop": "assets/tv-etoile-2025-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0601383Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 225383, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 86, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.3, "tmdbVotes": 40}}, {"id": "tv-gilmore-girls-2000", "title": "Gilmore Girls", "year": 2000, "type": "tv", "runtimeSeconds": 2640, "seasonCount": 7, "communityRating": 7.893, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "Set in the charming town of Stars Hollow, Connecticut, the series follows the captivating lives of Lorelai and Rory Gilmore, a mother/daughter pair who have a relationship most people only dream of.", "poster": "assets/tv-gilmore-girls-2000-poster.jpg", "backdrop": "assets/tv-gilmore-girls-2000-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0631035Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 4586, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.9, "tmdbVotes": 1339}}, {"id": "tv-ginny-georgia-2021", "title": "Ginny & Georgia", "year": 2021, "type": "tv", "runtimeSeconds": null, "seasonCount": 3, "communityRating": 8.026, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "Free-spirited Georgia and her two kids, Ginny and Austin, move north in search of a fresh start but find that the road to new beginnings can be bumpy.", "poster": "assets/tv-ginny-georgia-2021-poster.jpg", "backdrop": "assets/tv-ginny-georgia-2021-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0694828Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 117581, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 68, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8, "tmdbVotes": 1840}}, {"id": "tv-hellcats-2010", "title": "Hellcats", "year": 2010, "type": "tv", "runtimeSeconds": 2460, "seasonCount": 1, "communityRating": 7.5, "criticRating": null, "genres": ["Drama"], "overview": "Pre-law student Marti Perkins' world is flipped upside down when she loses her scholarship, and realizes the only way she can stay in school is by reigniting her dormant teen gymnastic skills to win a place on Lancer\u2026", "poster": "assets/tv-hellcats-2010-poster.jpg", "backdrop": "assets/tv-hellcats-2010-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-22T20:39:02.7719318Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 32504, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 65, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.5, "tmdbVotes": 116}}, {"id": "tv-high-potential-2024", "title": "High Potential", "year": 2024, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 8.201, "criticRating": null, "genres": ["Drama", "Crime", "Mystery"], "overview": "Morgan is a single mom with an exceptional mind, whose unconventional knack for solving crimes leads to an unusual and unstoppable partnership with a by-the-book seasoned detective.", "poster": "assets/tv-high-potential-2024-poster.jpg", "backdrop": "assets/tv-high-potential-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0686091Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 226637, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 98, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.3, "tmdbVotes": 707}}, {"id": "tv-how-to-get-to-heaven-from-belfast-2026", "title": "How to Get to Heaven from Belfast", "year": 2026, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.027, "criticRating": null, "genres": ["Mystery", "Comedy", "Drama"], "overview": "Three lifelong pals embark on a chaotic quest to solve the mystery of their old friend's suspicious death and keep their own dark secret under wraps.", "poster": "assets/tv-how-to-get-to-heaven-from-belfast-2026-poster.jpg", "backdrop": "assets/tv-how-to-get-to-heaven-from-belfast-2026-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0683192Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 233079, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 95, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.063, "tmdbVotes": 95}}, {"id": "tv-the-hunting-wives-2025", "title": "The Hunting Wives", "year": 2025, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 6.932, "criticRating": null, "genres": ["Drama", "Mystery"], "overview": "Sophie trades New England for East Texas and falls into a wealthy socialite's magnetic orbit \u2014 where a clique of housewives hide deadly secrets.", "poster": "assets/tv-the-hunting-wives-2025-poster.jpg", "backdrop": "assets/tv-the-hunting-wives-2025-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-20T19:42:46.3227892Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 244447, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 81, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.964, "tmdbVotes": 112}}, {"id": "tv-the-lost-flowers-of-alice-hart-2023", "title": "The Lost Flowers of Alice Hart", "year": 2023, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.634, "criticRating": null, "genres": ["Drama", "Mystery"], "overview": "After losing her parents in a mysterious fire, nine-year-old Alice Hart is raised by her grandmother June on a flower farm where she learns there are secrets within secrets. But years on, an unearthed betrayal sees\u2026", "poster": "assets/tv-the-lost-flowers-of-alice-hart-2023-poster.jpg", "backdrop": "assets/tv-the-lost-flowers-of-alice-hart-2023-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0607236Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 125816, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 82, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.64, "tmdbVotes": 161}}, {"id": "tv-love-is-blind-2020", "title": "Love Is Blind", "year": 2020, "type": "tv", "runtimeSeconds": null, "seasonCount": 11, "communityRating": 6.261, "criticRating": null, "genres": ["Reality"], "overview": "Nick and Vanessa Lachey host this social experiment where single men and women look for love and get engaged, all before meeting in person.", "poster": "assets/tv-love-is-blind-2020-poster.jpg", "backdrop": "assets/tv-love-is-blind-2020-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-22T17:11:08.2447226Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 99353, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 66, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.233, "tmdbVotes": 118}}, {"id": "tv-maximum-pleasure-guaranteed-2026", "title": "Maximum Pleasure Guaranteed", "year": 2026, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 6.676, "criticRating": null, "genres": ["Comedy"], "overview": "A newly divorced mom falls down a dangerous rabbit hole of blackmail, murder, and youth soccer.", "poster": "assets/tv-maximum-pleasure-guaranteed-2026-poster.jpg", "backdrop": "assets/tv-maximum-pleasure-guaranteed-2026-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-07T21:04:02.8277793Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 285404, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 93, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.799, "tmdbVotes": 92}}, {"id": "tv-minx-2022", "title": "Minx", "year": 2022, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 6.833, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "An earnest young feminist joins forces with a low-rent publisher to make the world's first erotic magazine for women.", "poster": "assets/tv-minx-2022-poster.jpg", "backdrop": "assets/tv-minx-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0653613Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 118303, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 93, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.9, "tmdbVotes": 123}}, {"id": "tv-offspring-2010", "title": "Offspring", "year": 2010, "type": "tv", "runtimeSeconds": 3600, "seasonCount": 7, "communityRating": 6.9, "criticRating": null, "genres": ["Drama"], "overview": "An exuberant drama set in Melbourne's Fitzroy, centering on Nina Proudman and her struggle to deal with her fabulously messy family, her hunt for a decent love life and her tendency to overthink and fly off into fantasy.", "poster": "assets/tv-offspring-2010-poster.jpg", "backdrop": "assets/tv-offspring-2010-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0664581Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 33155, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.9, "tmdbVotes": 27}}, {"id": "tv-only-murders-in-the-building-2021", "title": "Only Murders in the Building", "year": 2021, "type": "tv", "runtimeSeconds": null, "seasonCount": 5, "communityRating": 8.398, "criticRating": null, "genres": ["Comedy", "Mystery", "Crime"], "overview": "Three strangers share an obsession with true crime and suddenly find themselves wrapped up in one.", "poster": "assets/tv-only-murders-in-the-building-2021-poster.jpg", "backdrop": "assets/tv-only-murders-in-the-building-2021-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-11-04T20:25:58.2163301Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 107113, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 95, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.398, "tmdbVotes": 2333}}, {"id": "tv-outlander-blood-of-my-blood-2025", "title": "Outlander: Blood of My Blood", "year": 2025, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 8.542, "criticRating": null, "genres": ["Drama"], "overview": "From the battlefields of World War I to the rugged Highlands of 18th century Scotland, two fated couples must defy the forces that seek to tear them apart, unfolding in surprising and unforeseen ways in this romantic\u2026", "poster": "assets/tv-outlander-blood-of-my-blood-2025-poster.jpg", "backdrop": "assets/tv-outlander-blood-of-my-blood-2025-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0691373Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 207484, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 94, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.738, "tmdbVotes": 124}}, {"id": "tv-rivals-2024", "title": "Rivals", "year": 2024, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 7.44, "criticRating": null, "genres": ["Drama"], "overview": "It is 1986 and Britain is booming. \"Rivals\" dives headfirst into the cutthroat world of TV where hair-dos are big and ambitions are even bigger. Deals are brokered in boardrooms, as well as bedrooms. Nobody can be\u2026", "poster": "assets/tv-rivals-2024-poster.jpg", "backdrop": "assets/tv-rivals-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0645815Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 208921, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 96, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.4, "tmdbVotes": 93}}, {"id": "tv-rooster-2026", "title": "Rooster", "year": 2026, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.431, "criticRating": null, "genres": ["Comedy"], "overview": "On a college campus, an author navigates a complicated relationship with his daughter.", "poster": "assets/tv-rooster-2026-poster.jpg", "backdrop": "assets/tv-rooster-2026-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.063389Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 254953, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 88, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.423, "tmdbVotes": 144}}, {"id": "tv-sex-and-the-city-1998", "title": "Sex and the City", "year": 1998, "type": "tv", "runtimeSeconds": null, "seasonCount": 6, "communityRating": 7.365, "criticRating": null, "genres": ["Drama", "Comedy"], "overview": "Based on the bestselling book by Candace Bushnell, Sex and the City tells the story of four best friends, all single and in their late thirties, as they pursue their careers and talk about their sex lives, all while\u2026", "poster": "assets/tv-sex-and-the-city-1998-poster.jpg", "backdrop": "assets/tv-sex-and-the-city-1998-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0679324Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 105, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 70, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.351, "tmdbVotes": 2220}}, {"id": "tv-skymed-2022", "title": "SkyMed", "year": 2022, "type": "tv", "runtimeSeconds": 2640, "seasonCount": 4, "communityRating": 6.665, "criticRating": null, "genres": ["Drama"], "overview": "Life, death and drama at 20,000 feet. The series weaves together intense character journeys and high-stakes medical rescues, as we follow the triumphs, heartbreaks and tribulations of budding nurses and pilots flying\u2026", "poster": "assets/tv-skymed-2022-poster.jpg", "backdrop": "assets/tv-skymed-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-22T19:44:29.2431354Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 133908, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.699, "tmdbVotes": 98}}, {"id": "tv-so-you-think-you-can-dance-2008", "title": "So You Think You Can Dance", "year": 2008, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 9.8, "criticRating": null, "genres": ["Family", "Reality"], "overview": "So You Think You Can Dance is a joint Belgian-Dutch dance competition show broadcast on RTL 5 and vtm television stations. The first season was broadcast only in the Netherlands, whereas starting with season 2, the\u2026", "poster": "assets/tv-so-you-think-you-can-dance-2008-poster.jpg", "backdrop": "assets/tv-so-you-think-you-can-dance-2008-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-23T11:11:35.4124418Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 40231, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 9.8, "tmdbVotes": 4}}, {"id": "tv-the-white-lotus-2021", "title": "The White Lotus", "year": 2021, "type": "tv", "runtimeSeconds": null, "seasonCount": 3, "communityRating": 7.597, "criticRating": null, "genres": ["Comedy", "Drama", "Mystery"], "overview": "Follow the exploits of various guests and employees at an exclusive tropical resort over the span of a week as with each passing day, a darker complexity emerges in these picture-perfect travelers, the hotel\u2019s\u2026", "poster": "assets/tv-the-white-lotus-2021-poster.jpg", "backdrop": "assets/tv-the-white-lotus-2021-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-20T02:45:52.8742674Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 111803, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 90, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.599, "tmdbVotes": 1604}}, {"id": "tv-yellowjackets-2021", "title": "Yellowjackets", "year": 2021, "type": "tv", "runtimeSeconds": null, "seasonCount": 3, "communityRating": 7.431, "criticRating": null, "genres": ["Drama", "Mystery"], "overview": "This equal parts survival epic, psychological horror story and coming-of-age drama tells the saga of a team of wildly talented high school girls soccer players who become the (un)lucky survivors of a plane crash deep\u2026", "poster": "assets/tv-yellowjackets-2021-poster.jpg", "backdrop": "assets/tv-yellowjackets-2021-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-11-04T20:31:47.4592756Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 117488, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 92, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.431, "tmdbVotes": 1070}}], "anime": [], "resume": [{"id": "episode-sex-and-the-country-2001-s4e9", "title": "Sex and the Country", "year": 2001, "type": "episode", "runtimeSeconds": 1751, "seasonCount": null, "communityRating": 5.9, "criticRating": null, "genres": ["Comedy", "Drama", "Romance"], "overview": "Carrie is dragged to Aidan's country home in Suffern, New York. Charlotte visits the MacDougal \"compound\" in Connecticut. Miranda yells at Steve when he tells her he has testicular cancer but has no clue about his\u2026", "poster": "assets/episode-sex-and-the-country-2001-s4e9-poster.jpg", "backdrop": "assets/episode-sex-and-the-country-2001-s4e9-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 96.08, "progress": 0.0549, "addedAt": "2026-07-17T10:53:15.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "Sex and the City", "seasonNumber": 4, "episodeNumber": 9, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}], "nextUp": [{"id": "episode-what-more-do-i-need-2008-s5e7", "title": "What More Do I Need?", "year": 2008, "type": "episode", "runtimeSeconds": 2521, "seasonCount": null, "communityRating": 8, "criticRating": null, "genres": ["Comedy", "Drama", "Mystery"], "overview": "Lynette and Tom uncover the unsettling truth about son Porter. Gabrielle suspects Carlos' best client, Virginia, has ulterior motives. Bree's moment of weakness could bring her great embarrassment and shame.\u2026", "poster": "assets/episode-what-more-do-i-need-2008-s5e7-poster.jpg", "backdrop": "assets/episode-what-more-do-i-need-2008-s5e7-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-29T14:37:28.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "Desperate Housewives", "seasonNumber": 5, "episodeNumber": 7, "artwork": {"posterKind": "center_crop", "backdropKind": "backdrop"}}, {"id": "episode-new-day-2021-s1e2", "title": "New Day", "year": 2021, "type": "episode", "runtimeSeconds": 3445, "seasonCount": null, "communityRating": 7.143, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "A cautiously optimistic Belinda walks a fine line with Tanya, while Armond brainstorms ways to deal with Shane. After her husband belittles her work, Rachel approaches Nicole for advice. Paula and Olivia lose track\u2026", "poster": "assets/episode-new-day-2021-s1e2-poster.jpg", "backdrop": "assets/episode-new-day-2021-s1e2-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-19T22:18:57.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "The White Lotus", "seasonNumber": 1, "episodeNumber": 2, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}, {"id": "episode-the-uncomfortability-2010-s1e2", "title": "The Uncomfortability", "year": 2010, "type": "episode", "runtimeSeconds": 2687, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "Nina's insecurities are revealed when a patient poses an awkward request. Meanwhile Geraldine decides to try online dating, and Jimmy is an unexpected saviour to Cherie and Darcy.", "poster": "assets/episode-the-uncomfortability-2010-s1e2-poster.jpg", "backdrop": "assets/episode-the-uncomfortability-2010-s1e2-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T11:47:42.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "Offspring", "seasonNumber": 1, "episodeNumber": 2, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}, {"id": "episode-episode-2-2016-s1e2", "title": "Episode 2", "year": 2016, "type": "episode", "runtimeSeconds": 2766, "seasonCount": null, "communityRating": 8.3, "criticRating": null, "genres": ["Biography", "Comedy", "Drama"], "overview": "Larry falls dangerously ill, and it falls to Theo and his neighbour Sven to put their rudimentary medical skills to the task of saving his life. The family's money worries continue, but Spiros brings them good news\u2026", "poster": "assets/episode-episode-2-2016-s1e2-poster.jpg", "backdrop": "assets/episode-episode-2-2016-s1e2-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-31T07:29:17.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "The Durrells", "seasonNumber": 1, "episodeNumber": 2, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}, {"id": "episode-remembrance-day-2026-s2e1", "title": "Remembrance Day", "year": 2026, "type": "episode", "runtimeSeconds": 3822, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Fantasy", "Romance"], "overview": "The fiery crosses have been lit across the Highlands; lovers must part, as the call to arms brings friends and enemies home and the Jacobite Rebellion begins in earnest.", "poster": "assets/episode-remembrance-day-2026-s2e1-poster.jpg", "backdrop": "assets/episode-remembrance-day-2026-s2e1-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-19T03:50:07.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "Outlander: Blood of My Blood", "seasonNumber": 2, "episodeNumber": 1, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}, {"id": "episode-one-of-us-2024-s1e7", "title": "One of Us", "year": 2024, "type": "episode", "runtimeSeconds": 2555, "seasonCount": null, "communityRating": 7.7, "criticRating": null, "genres": [], "overview": "Morgan agrees to show Ava around the LAPD, but things quickly take a dangerous turn when the precinct is held hostage by the friends of a recently convicted man. Can Morgan and the detectives outsmart their captors\u2026", "poster": "assets/episode-one-of-us-2024-s1e7-poster.jpg", "backdrop": "assets/episode-one-of-us-2024-s1e7-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T13:57:22.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "High Potential", "seasonNumber": 1, "episodeNumber": 7, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}, {"id": "episode-the-breakup-part-2-2001-s1e17", "title": "The Breakup, Part 2", "year": 2001, "type": "episode", "runtimeSeconds": 2669, "seasonCount": null, "communityRating": 7.4, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "After recent events, Rory keeps busy, refusing to give in to self-pity. Meanwhile, Stars Hollow's residents rally around her, much to her dismay.", "poster": "assets/episode-the-breakup-part-2-2001-s1e17-poster.jpg", "backdrop": "assets/episode-the-breakup-part-2-2001-s1e17-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T11:28:05.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "Gilmore Girls", "seasonNumber": 1, "episodeNumber": 17, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}, {"id": "episode-i-heart-ny-2002-s4e18", "title": "I Heart NY", "year": 2002, "type": "episode", "runtimeSeconds": 1905, "seasonCount": null, "communityRating": 6.667, "criticRating": null, "genres": ["Comedy", "Drama", "Romance"], "overview": "Carrie is shocked to find out that Big is moving to Napa, California and plans to give him a New York send-off. Charlotte begins dating another recent divorc\u00e9. Miranda prepares for the new baby as her delivery date\u2026", "poster": "assets/episode-i-heart-ny-2002-s4e18-poster.jpg", "backdrop": "assets/episode-i-heart-ny-2002-s4e18-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T12:35:14.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "Sex and the City", "seasonNumber": 4, "episodeNumber": 18, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}], "recentlyAdded": [{"id": "tv-yellowjackets-2021", "title": "Yellowjackets", "year": 2021, "type": "tv", "runtimeSeconds": null, "seasonCount": 3, "communityRating": 7.431, "criticRating": null, "genres": ["Drama", "Mystery"], "overview": "This equal parts survival epic, psychological horror story and coming-of-age drama tells the saga of a team of wildly talented high school girls soccer players who become the (un)lucky survivors of a plane crash deep\u2026", "poster": "assets/tv-yellowjackets-2021-poster.jpg", "backdrop": "assets/tv-yellowjackets-2021-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-11-04T20:31:47.4592756Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 117488, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 92, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.431, "tmdbVotes": 1070}}, {"id": "tv-only-murders-in-the-building-2021", "title": "Only Murders in the Building", "year": 2021, "type": "tv", "runtimeSeconds": null, "seasonCount": 5, "communityRating": 8.398, "criticRating": null, "genres": ["Comedy", "Mystery", "Crime"], "overview": "Three strangers share an obsession with true crime and suddenly find themselves wrapped up in one.", "poster": "assets/tv-only-murders-in-the-building-2021-poster.jpg", "backdrop": "assets/tv-only-murders-in-the-building-2021-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-11-04T20:25:58.2163301Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 107113, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 95, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.398, "tmdbVotes": 2333}}, {"id": "tv-big-boys-2022", "title": "Big Boys", "year": 2022, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 3, "communityRating": 7.6, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "Two boys from very different ends of the 'spectrum of masculinity' become best friends at Brent University freshers' week; in their first year of uni, they explore, experiment and try to discover themselves, helping\u2026", "poster": "assets/tv-big-boys-2022-poster.jpg", "backdrop": "assets/tv-big-boys-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-11-04T20:25:58.2156837Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 202851, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.6, "tmdbVotes": 49}}, {"id": "tv-all-creatures-great-small-2020", "title": "All Creatures Great & Small", "year": 2020, "type": "tv", "runtimeSeconds": 3000, "seasonCount": 7, "communityRating": 8.2, "criticRating": null, "genres": ["Drama", "Comedy"], "overview": "The heartwarming and humorous adventures of a young country vet in the Yorkshire Dales in the 1930s. A remake of the 1978 series.", "poster": "assets/tv-all-creatures-great-small-2020-poster.jpg", "backdrop": "assets/tv-all-creatures-great-small-2020-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-11-04T20:13:03.4266156Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 108255, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 99, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.2, "tmdbVotes": 168}}, {"id": "movie-the-killing-of-a-sacred-deer-2017", "title": "The Killing of a Sacred Deer", "year": 2017, "type": "movie", "runtimeSeconds": 7262, "seasonCount": null, "communityRating": 7.003, "criticRating": 79, "genres": ["Drama", "Thriller", "Mystery"], "overview": "Dr. Steven Murphy is a renowned cardiovascular surgeon who presides over a spotless household with his wife and two children. Lurking at the margins of his idyllic suburban existence is Martin, a fatherless teen who\u2026", "poster": "assets/movie-the-killing-of-a-sacred-deer-2017-poster.jpg", "backdrop": "assets/movie-the-killing-of-a-sacred-deer-2017-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-22T21:49:26.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 399057, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 79, "rtCriticsLabel": "Certified Fresh", "rtAudience": 63, "rtAudienceLabel": "Upright", "imdb": 7, "imdbVotes": 219488, "tmdb": 7.001, "tmdbVotes": 4640}}, {"id": "tv-derry-girls-2018", "title": "Derry Girls", "year": 2018, "type": "tv", "runtimeSeconds": 1380, "seasonCount": 3, "communityRating": 8.144, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "Amidst the political conflict of Northern Ireland in the 1990s, five secondary school students square off with the universal challenges of being a teenager.", "poster": "assets/tv-derry-girls-2018-poster.jpg", "backdrop": "assets/tv-derry-girls-2018-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-02T20:01:55.8955537Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 76148, "artwork": {"posterKind": "center_crop", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 99, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.138, "tmdbVotes": 454}}, {"id": "tv-so-you-think-you-can-dance-2008", "title": "So You Think You Can Dance", "year": 2008, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 9.8, "criticRating": null, "genres": ["Family", "Reality"], "overview": "So You Think You Can Dance is a joint Belgian-Dutch dance competition show broadcast on RTL 5 and vtm television stations. The first season was broadcast only in the Netherlands, whereas starting with season 2, the\u2026", "poster": "assets/tv-so-you-think-you-can-dance-2008-poster.jpg", "backdrop": "assets/tv-so-you-think-you-can-dance-2008-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-23T11:11:35.4124418Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 40231, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 9.8, "tmdbVotes": 4}}, {"id": "tv-hellcats-2010", "title": "Hellcats", "year": 2010, "type": "tv", "runtimeSeconds": 2460, "seasonCount": 1, "communityRating": 7.5, "criticRating": null, "genres": ["Drama"], "overview": "Pre-law student Marti Perkins' world is flipped upside down when she loses her scholarship, and realizes the only way she can stay in school is by reigniting her dormant teen gymnastic skills to win a place on Lancer\u2026", "poster": "assets/tv-hellcats-2010-poster.jpg", "backdrop": "assets/tv-hellcats-2010-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-22T20:39:02.7719318Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 32504, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 65, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.5, "tmdbVotes": 116}}, {"id": "tv-skymed-2022", "title": "SkyMed", "year": 2022, "type": "tv", "runtimeSeconds": 2640, "seasonCount": 4, "communityRating": 6.665, "criticRating": null, "genres": ["Drama"], "overview": "Life, death and drama at 20,000 feet. The series weaves together intense character journeys and high-stakes medical rescues, as we follow the triumphs, heartbreaks and tribulations of budding nurses and pilots flying\u2026", "poster": "assets/tv-skymed-2022-poster.jpg", "backdrop": "assets/tv-skymed-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-22T19:44:29.2431354Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 133908, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.699, "tmdbVotes": 98}}, {"id": "tv-love-is-blind-2020", "title": "Love Is Blind", "year": 2020, "type": "tv", "runtimeSeconds": null, "seasonCount": 11, "communityRating": 6.261, "criticRating": null, "genres": ["Reality"], "overview": "Nick and Vanessa Lachey host this social experiment where single men and women look for love and get engaged, all before meeting in person.", "poster": "assets/tv-love-is-blind-2020-poster.jpg", "backdrop": "assets/tv-love-is-blind-2020-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-22T17:11:08.2447226Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 99353, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 66, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.233, "tmdbVotes": 118}}, {"id": "tv-the-hunting-wives-2025", "title": "The Hunting Wives", "year": 2025, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 6.932, "criticRating": null, "genres": ["Drama", "Mystery"], "overview": "Sophie trades New England for East Texas and falls into a wealthy socialite's magnetic orbit \u2014 where a clique of housewives hide deadly secrets.", "poster": "assets/tv-the-hunting-wives-2025-poster.jpg", "backdrop": "assets/tv-the-hunting-wives-2025-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-20T19:42:46.3227892Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 244447, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 81, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.964, "tmdbVotes": 112}}, {"id": "tv-the-white-lotus-2021", "title": "The White Lotus", "year": 2021, "type": "tv", "runtimeSeconds": null, "seasonCount": 3, "communityRating": 7.597, "criticRating": null, "genres": ["Comedy", "Drama", "Mystery"], "overview": "Follow the exploits of various guests and employees at an exclusive tropical resort over the span of a week as with each passing day, a darker complexity emerges in these picture-perfect travelers, the hotel\u2019s\u2026", "poster": "assets/tv-the-white-lotus-2021-poster.jpg", "backdrop": "assets/tv-the-white-lotus-2021-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-20T02:45:52.8742674Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 111803, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 90, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.599, "tmdbVotes": 1604}}], "discover": [{"id": "movie-across-the-hall-2009", "title": "Across the Hall", "year": 2009, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Thriller", "Mystery"], "overview": "Convinced that his fiancee is cheating on him, a man follows her to a hotel and calls his best friend to help him avoid a calamity.", "poster": "assets/movie-across-the-hall-2009-poster.jpg", "backdrop": "assets/movie-across-the-hall-2009-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 21469, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": 39, "rtAudienceLabel": "Spilled", "imdb": 5.8, "imdbVotes": 5130, "tmdb": 6, "tmdbVotes": 129}}, {"id": "movie-addicted-2014", "title": "Addicted", "year": 2014, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Thriller"], "overview": "A gallerist risks her family and flourishing career when she enters into an affair with a talented painter and slowly loses control of her life.", "poster": "assets/movie-addicted-2014-poster.jpg", "backdrop": "assets/movie-addicted-2014-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 235271, "ratings": {"rtCritics": 7, "rtCriticsLabel": "Rotten", "rtAudience": 51, "rtAudienceLabel": "Spilled", "imdb": 5, "imdbVotes": 13131, "tmdb": 5.928, "tmdbVotes": 466}}, {"id": "movie-adulterers-2016", "title": "Adulterers", "year": 2016, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Thriller", "Drama"], "overview": "On a steamy New Orleans afternoon, Sam Deuprey decides to surprise his beautiful wife, Ashley, for their anniversary and comes home early. But Ashley is not alone in the bedroom. She and her lover, Damien are about\u2026", "poster": "assets/movie-adulterers-2016-poster.jpg", "backdrop": "assets/movie-adulterers-2016-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 376004, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": 37, "rtAudienceLabel": "Spilled", "imdb": 5.1, "imdbVotes": 2144, "tmdb": 6.2, "tmdbVotes": 116}}, {"id": "movie-alone-with-her-2006", "title": "Alone With Her", "year": 2006, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Crime", "Drama", "Thriller"], "overview": "The harrowing story of a disturbed young man's attempts to win the affections of an unsuspecting young woman. When Doug first sees Amy, he instantly falls for her and begins to watch her every move, going so far as\u2026", "poster": "assets/movie-alone-with-her-2006-poster.jpg", "backdrop": "assets/movie-alone-with-her-2006-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 18206, "ratings": {"rtCritics": 70, "rtCriticsLabel": "Fresh", "rtAudience": 46, "rtAudienceLabel": "Spilled", "imdb": 6, "imdbVotes": 4359, "tmdb": 5.9, "tmdbVotes": 94}}, {"id": "movie-awake-2007", "title": "Awake", "year": 2007, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Thriller", "Crime", "Mystery"], "overview": "While undergoing heart surgery, a man experiences a phenomenon called \u2018anesthetic awareness\u2019, which leaves him awake but paralyzed throughout the operation. As various obstacles present themselves, his wife must make\u2026", "poster": "assets/movie-awake-2007-poster.jpg", "backdrop": "assets/movie-awake-2007-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 13483, "ratings": {"rtCritics": 23, "rtCriticsLabel": "Rotten", "rtAudience": 55, "rtAudienceLabel": "Spilled", "imdb": 6.5, "imdbVotes": 89389, "tmdb": 6.586, "tmdbVotes": 1649}}, {"id": "movie-black-bear-2020", "title": "Black Bear", "year": 2020, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Thriller"], "overview": "At a remote lake house in the Adirondack Mountains, a couple entertains an out-of-town guest looking for inspiration in her filmmaking. The group quickly falls into a calculated game of desire, manipulation, and\u2026", "poster": "assets/movie-black-bear-2020-poster.jpg", "backdrop": "assets/movie-black-bear-2020-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 624788, "ratings": {"rtCritics": 89, "rtCriticsLabel": "Certified Fresh", "rtAudience": 64, "rtAudienceLabel": "Upright", "imdb": 6.5, "imdbVotes": 18408, "tmdb": 6.337, "tmdbVotes": 315}}, {"id": "movie-butterfly-on-a-wheel-2007", "title": "Butterfly on a Wheel", "year": 2007, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Crime", "Drama", "Thriller"], "overview": "A sociopathic kidnapper methodically pushes a desperate pair of parents to their absolute breaking point.", "poster": "assets/movie-butterfly-on-a-wheel-2007-poster.jpg", "backdrop": "assets/movie-butterfly-on-a-wheel-2007-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 13250, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": 61, "rtAudienceLabel": "Upright", "imdb": 6.6, "imdbVotes": 35409, "tmdb": 6.457, "tmdbVotes": 595}}, {"id": "movie-careful-what-you-wish-for-2015", "title": "Careful What You Wish For", "year": 2015, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Thriller"], "overview": "A guy gets more than he bargained for after entering into an affair with the wife of an investment banker. Soon, a suspicious death and substantial life insurance policy embroil him in a scandal.", "poster": "assets/movie-careful-what-you-wish-for-2015-poster.jpg", "backdrop": "assets/movie-careful-what-you-wish-for-2015-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 317744, "ratings": {"rtCritics": 17, "rtCriticsLabel": "Rotten", "rtAudience": 61, "rtAudienceLabel": "Upright", "imdb": 5.6, "imdbVotes": 9452, "tmdb": 6.129, "tmdbVotes": 446}}, {"id": "movie-crime-of-passion-1956", "title": "Crime of Passion", "year": 1956, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Crime", "Thriller"], "overview": "Kathy leaves the newspaper business to marry homicide detective Bill, but is frustrated by his lack of ambition and the banality of life in the suburbs. Her drive to advance Bill's career soon takes her down a\u2026", "poster": "assets/movie-crime-of-passion-1956-poster.jpg", "backdrop": "assets/movie-crime-of-passion-1956-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "center_crop", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 1920, "ratings": {"rtCritics": 80, "rtCriticsLabel": "Fresh", "rtAudience": 34, "rtAudienceLabel": "Spilled", "imdb": 6.4, "imdbVotes": 3750, "tmdb": 6.1, "tmdbVotes": 57}}, {"id": "movie-deep-water-2022", "title": "Deep Water", "year": 2022, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Mystery", "Thriller"], "overview": "A well-to-do husband who allows his wife to have affairs in order to avoid a divorce becomes a prime suspect in the disappearance of her lovers.", "poster": "assets/movie-deep-water-2022-poster.jpg", "backdrop": "assets/movie-deep-water-2022-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 619979, "ratings": {"rtCritics": 35, "rtCriticsLabel": "Rotten", "rtAudience": 25, "rtAudienceLabel": "Spilled", "imdb": 5.5, "imdbVotes": 74098, "tmdb": 5.834, "tmdbVotes": 1755}}, {"id": "movie-derailed-2005", "title": "Derailed", "year": 2005, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Thriller"], "overview": "When two married business executives having an affair are blackmailed by a violent criminal, they are forced to turn the tables on him to save their families.", "poster": "assets/movie-derailed-2005-poster.jpg", "backdrop": "assets/movie-derailed-2005-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 8999, "ratings": {"rtCritics": 20, "rtCriticsLabel": "Rotten", "rtAudience": 58, "rtAudienceLabel": "Spilled", "imdb": 6.6, "imdbVotes": 79563, "tmdb": 6.405, "tmdbVotes": 1193}}, {"id": "movie-die-my-love-2025", "title": "Die My Love", "year": 2025, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama"], "overview": "After inheriting a remote Montana house, Jackson moves there from New York with his partner Grace, and the couple soon welcome a child. As Jackson becomes increasingly absent and rural isolation sets in, Grace\u2026", "poster": "assets/movie-die-my-love-2025-poster.jpg", "backdrop": "assets/movie-die-my-love-2025-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 1033148, "ratings": {"rtCritics": 74, "rtCriticsLabel": "Certified Fresh", "rtAudience": 45, "rtAudienceLabel": "Spilled", "imdb": 6, "imdbVotes": 50664, "tmdb": 6.1, "tmdbVotes": 683}}, {"id": "movie-dream-house-2011", "title": "Dream House", "year": 2011, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Mystery", "Drama", "Thriller"], "overview": "Publisher Will Atenton quits a lucrative job in New York to relocate his wife, Libby, and their daughters to a quaint town in New England. However, as they settle into their home the Atentons discover that a woman\u2026", "poster": "assets/movie-dream-house-2011-poster.jpg", "backdrop": "assets/movie-dream-house-2011-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 69668, "ratings": {"rtCritics": 7, "rtCriticsLabel": "Rotten", "rtAudience": 36, "rtAudienceLabel": "Spilled", "imdb": 6, "imdbVotes": 71386, "tmdb": 6.212, "tmdbVotes": 1480}}, {"id": "movie-dream-lover-1994", "title": "Dream Lover", "year": 1994, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Mystery", "Thriller"], "overview": "Not long after they cross paths at an art gallery, architect Ray Reardon and hypnotically sensual Lena are married with children. But as strange incidents occur, Ray begins to realize he may not really know the woman\u2026", "poster": "assets/movie-dream-lover-1994-poster.jpg", "backdrop": "assets/movie-dream-lover-1994-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 18190, "ratings": {"rtCritics": 57, "rtCriticsLabel": "Rotten", "rtAudience": 38, "rtAudienceLabel": "Spilled", "imdb": 6.2, "imdbVotes": 7865, "tmdb": 6.844, "tmdbVotes": 167}}, {"id": "movie-drop-2025", "title": "Drop", "year": 2025, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Mystery", "Thriller"], "overview": "Violet, a widowed mother on her first date in years, arrives at an upscale restaurant where she is relieved that her date, Henry, is more charming and handsome than she expected. But their chemistry begins to curdle\u2026", "poster": "assets/movie-drop-2025-poster.jpg", "backdrop": "assets/movie-drop-2025-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 1249213, "ratings": {"rtCritics": 83, "rtCriticsLabel": "Certified Fresh", "rtAudience": 79, "rtAudienceLabel": "Upright", "imdb": 6.1, "imdbVotes": 61018, "tmdb": 6.275, "tmdbVotes": 1023}}, {"id": "movie-eastern-promises-2007", "title": "Eastern Promises", "year": 2007, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Thriller", "Crime", "Mystery"], "overview": "A Russian teenager living in London dies during childbirth but leaves clues in her diary that could tie her child to a rape involving a violent Russian mob family.", "poster": "assets/movie-eastern-promises-2007-poster.jpg", "backdrop": "assets/movie-eastern-promises-2007-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 2252, "ratings": {"rtCritics": 89, "rtCriticsLabel": "Certified Fresh", "rtAudience": 83, "rtAudienceLabel": "Upright", "imdb": 7.6, "imdbVotes": 281015, "tmdb": 7.349, "tmdbVotes": 3900}}, {"id": "movie-faces-1968", "title": "Faces", "year": 1968, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama"], "overview": "Middle-aged suburban husband Richard abruptly tells his wife, Maria, that he wants a divorce. As Richard takes up with a younger woman, Maria enjoys a night on the town with her friends and meets a younger man. As\u2026", "poster": "assets/movie-faces-1968-poster.jpg", "backdrop": "assets/movie-faces-1968-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "center_crop", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 753, "ratings": {"rtCritics": 83, "rtCriticsLabel": "Fresh", "rtAudience": 86, "rtAudienceLabel": "Upright", "imdb": 7.4, "imdbVotes": 12950, "tmdb": 7.093, "tmdbVotes": 227}}, {"id": "movie-fatale-2020", "title": "Fatale", "year": 2020, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Thriller"], "overview": "After a wild one-night stand, successful sports agent Derrick Tyler watches his perfect life slowly disappear when he discovers the sexy and mysterious woman he risked everything for is determined police detective\u2026", "poster": "assets/movie-fatale-2020-poster.jpg", "backdrop": "assets/movie-fatale-2020-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 549514, "ratings": {"rtCritics": 46, "rtCriticsLabel": "Rotten", "rtAudience": 87, "rtAudienceLabel": "Upright", "imdb": 5.5, "imdbVotes": 16392, "tmdb": 6, "tmdbVotes": 401}}, {"id": "tv-56-days-2026", "title": "56 Days", "year": 2026, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Crime", "Mystery"], "overview": "When an unidentified body is found in a luxury apartment linked to Oliver Kennedy and his girlfriend Ciara Wyse, Detectives Lee Reardon and Karl Connolly reconstruct the couple's deadly romance across the past 56 days.", "poster": "assets/tv-56-days-2026-poster.jpg", "backdrop": "assets/tv-56-days-2026-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 241372, "ratings": {"rtCritics": 60, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.388, "tmdbVotes": 245}}, {"id": "tv-90-day-fiance-before-the-90-days-2017", "title": "90 Day Fianc\u00e9: Before the 90 Days", "year": 2017, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Reality"], "overview": "Follow couples who have an existing relationship online, but haven't met in person. We'll experience their journey as they travel to the other's foreign country for the first time in an attempt to establish an\u2026", "poster": "assets/tv-90-day-fiance-before-the-90-days-2017-poster.jpg", "backdrop": "assets/tv-90-day-fiance-before-the-90-days-2017-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 73319, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.5, "tmdbVotes": 45}}, {"id": "tv-90-day-fiance-the-other-way-2019", "title": "90 Day Fianc\u00e9: The Other Way", "year": 2019, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Reality"], "overview": "Americans move to a foreign country all in the name of love. Will uprooting their lives in the United States pay off, or will they be packing up and moving back to America brokenhearted?", "poster": "assets/tv-90-day-fiance-the-other-way-2019-poster.jpg", "backdrop": "assets/tv-90-day-fiance-the-other-way-2019-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 90046, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.8, "tmdbVotes": 34}}, {"id": "tv-absolutely-fabulous-1992", "title": "Absolutely Fabulous", "year": 1992, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Comedy"], "overview": "Set in the world of fashion and PR, immature fun-loving mother Edina Monsoon and her best friend Patsy drive Eddie's sensible daughter, Saffron, up the wall with their constant drug abuse and outrageous selfishness.\u2026", "poster": "assets/tv-absolutely-fabulous-1992-poster.jpg", "backdrop": "assets/tv-absolutely-fabulous-1992-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 4493, "ratings": {"rtCritics": 96, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.6, "tmdbVotes": 198}}, {"id": "tv-the-afterparty-2022", "title": "The Afterparty", "year": 2022, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Mystery", "Comedy", "Crime"], "overview": "Nothing is as it seems in this hilarious murder mystery where each suspect's story is told through a different film genre.", "poster": "assets/tv-the-afterparty-2022-poster.jpg", "backdrop": "assets/tv-the-afterparty-2022-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 106454, "ratings": {"rtCritics": 92, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.071, "tmdbVotes": 245}}, {"id": "tv-agatha-christie-s-marple-2004", "title": "Agatha Christie's Marple", "year": 2004, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Crime", "Mystery"], "overview": "The adventures of Miss Jane Marple, an elderly spinster living in the quiet little village of St Mary Mead. During her many visits to friends and relatives in other villages, Miss Marple often stumbles upon\u2026", "poster": "assets/tv-agatha-christie-s-marple-2004-poster.jpg", "backdrop": "assets/tv-agatha-christie-s-marple-2004-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 6127, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.9, "tmdbVotes": 152}}, {"id": "tv-all-american-homecoming-2022", "title": "All American: Homecoming", "year": 2022, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama"], "overview": "Follow the journeys of Simone Hicks, a tennis hopeful from Beverly Hills, and elite baseball player Damon Sims from Chicago, as they navigate life at the prestigious HBCU Bringston University.", "poster": "assets/tv-all-american-homecoming-2022-poster.jpg", "backdrop": "assets/tv-all-american-homecoming-2022-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 153723, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.3, "tmdbVotes": 70}}, {"id": "tv-and-just-like-that-2021", "title": "And Just Like That\u2026", "year": 2021, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "This new chapter of \u201cSex and the City\u201d follows Carrie, Miranda and Charlotte as they navigate the journey from the complicated reality of life and friendship in their 30s to the even more complicated reality of life\u2026", "poster": "assets/tv-and-just-like-that-2021-poster.jpg", "backdrop": "assets/tv-and-just-like-that-2021-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 116450, "ratings": {"rtCritics": 52, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.211, "tmdbVotes": 556}}, {"id": "tv-anne-with-an-e-2017", "title": "Anne with an E", "year": 2017, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Family"], "overview": "A coming-of-age story about an outsider who, against all odds and numerous challenges, fights for love and acceptance and for her place in the world. The series centers on a young orphaned girl in the late 1890s,\u2026", "poster": "assets/tv-anne-with-an-e-2017-poster.jpg", "backdrop": "assets/tv-anne-with-an-e-2017-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 70785, "ratings": {"rtCritics": 63, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.7, "tmdbVotes": 5019}}, {"id": "tv-awkward-2011", "title": "Awkward.", "year": 2011, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "An irreverent look at the conflict, chaos and humor that defines teenage life through the eyes of 15-year-old Jenna Hamilton whose life begins to change when a simple accident becomes an epic misunderstanding and is\u2026", "poster": "assets/tv-awkward-2011-poster.jpg", "backdrop": "assets/tv-awkward-2011-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 39859, "ratings": {"rtCritics": 78, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.4, "tmdbVotes": 427}}, {"id": "tv-the-baby-sitters-club-2020", "title": "The Baby-Sitters Club", "year": 2020, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Kids", "Drama"], "overview": "In this contemporary take on the beloved book series, five best friends launch a baby-sitting business that's big on fun and adventure.", "poster": "assets/tv-the-baby-sitters-club-2020-poster.jpg", "backdrop": "assets/tv-the-baby-sitters-club-2020-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 100412, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.9, "tmdbVotes": 506}}, {"id": "tv-ballers-2015", "title": "Ballers", "year": 2015, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Comedy"], "overview": "Looking at the lives of former and current football players, the show follows former superstar Spencer Strasmore as he gets his life on track in retirement while mentoring other current and former players through the\u2026", "poster": "assets/tv-ballers-2015-poster.jpg", "backdrop": "assets/tv-ballers-2015-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 62704, "ratings": {"rtCritics": 72, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.809, "tmdbVotes": 820}}, {"id": "tv-baretta-1975", "title": "Baretta", "year": 1975, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Crime", "Drama", "Mystery"], "overview": "Baretta is an American detective television series which ran on ABC from 1975 to 1978. The show was a milder version of a successful 1973\u201374 ABC series, Toma, starring Tony Musante as chameleon-like, real-life New\u2026", "poster": "assets/tv-baretta-1975-poster.jpg", "backdrop": "assets/tv-baretta-1975-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 3216, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.5, "tmdbVotes": 43}}, {"id": "tv-based-on-a-true-story-2023", "title": "Based on a True Story", "year": 2023, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "The lives of a realtor, a plumber and a former tennis star unexpectedly collide, exposing America\u2019s obsession with true crime, murder and the slow-close toilet seat.", "poster": "assets/tv-based-on-a-true-story-2023-poster.jpg", "backdrop": "assets/tv-based-on-a-true-story-2023-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 197373, "ratings": {"rtCritics": 79, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7, "tmdbVotes": 160}}, {"id": "tv-the-beast-in-me-2025", "title": "The Beast in Me", "year": 2025, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Mystery"], "overview": "A famous author is pulled into a twisted mind game with her rich, powerful new neighbor \u2014 who might be a murderer.", "poster": "assets/tv-the-beast-in-me-2025-poster.jpg", "backdrop": "assets/tv-the-beast-in-me-2025-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 250504, "ratings": {"rtCritics": 85, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.072, "tmdbVotes": 334}}, {"id": "tv-big-city-greens-2018", "title": "Big City Greens", "year": 2018, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Family", "Animation", "Comedy"], "overview": "The offbeat adventures of 10-year-old Cricket Green, a mischievous and optimistic country boy who moves to the big city with his wildly out of place family \u2013 older sister Tilly, father Bill and Gramma Alice.", "poster": "assets/tv-big-city-greens-2018-poster.jpg", "backdrop": "assets/tv-big-city-greens-2018-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 80587, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.555, "tmdbVotes": 82}}, {"id": "tv-the-bold-type-2017", "title": "The Bold Type", "year": 2017, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Comedy"], "overview": "A glimpse into the outrageous lives of Jane, Kat and Sutton, who are working at the nation's top women's magazine, Scarlet, while navigating their careers, identities and individual voices.", "poster": "assets/tv-the-bold-type-2017-poster.jpg", "backdrop": "assets/tv-the-bold-type-2017-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 69630, "ratings": {"rtCritics": 89, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.7, "tmdbVotes": 204}}, {"id": "tv-brassic-2019", "title": "Brassic", "year": 2019, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Comedy", "Drama", "Crime"], "overview": "A group of working-class friends finding unconventional ways to win at life in suburban northern England. These lads have dealt, scammed, bribed and conned their way through adolescence, but now, their dealing and\u2026", "poster": "assets/tv-brassic-2019-poster.jpg", "backdrop": "assets/tv-brassic-2019-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 92621, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.336, "tmdbVotes": 263}}], "discoverySource": {"state": "materialized_snapshot", "rankKnown": false, "selection": "First 18 alphabetically indexed movie stubs and first 18 series stubs, not ranked feed order", "observedAt": "2026-11-04T20:42:12.030108+00:00"}}, {"key": "shared", "name": "Shared", "initial": "S", "color": "#9be38f", "destinations": {"Home": {"kind": "home"}, "Movies": {"kind": "library", "collection": "movies"}, "Shows": {"kind": "library", "collection": "shows"}, "Discover": {"kind": "discover", "collection": "discover"}}, "movies": [{"id": "movie-american-gangster-2007", "title": "American Gangster", "year": 2007, "type": "movie", "runtimeSeconds": 10546, "seasonCount": null, "communityRating": 7.595, "criticRating": 81, "genres": ["Drama", "Crime"], "overview": "Loosely based on the criminal career of Frank Lucas, a gangster from La Grange, North Carolina, who smuggled heroin into the United States on American service planes returning from the Vietnam War, before being\u2026", "poster": "assets/movie-american-gangster-2007-poster.jpg", "backdrop": "assets/movie-american-gangster-2007-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-08-23T10:22:36.0000000Z", "lastPlayedAt": "2026-08-30T21:05:39.7952451Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 4982, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 81, "rtCriticsLabel": "Certified Fresh", "rtAudience": 90, "rtAudienceLabel": "Upright", "imdb": 7.8, "imdbVotes": 495656, "tmdb": 7.599, "tmdbVotes": 6350}}, {"id": "movie-annie-hall-1977", "title": "Annie Hall", "year": 1977, "type": "movie", "runtimeSeconds": 5604, "seasonCount": null, "communityRating": 7.712, "criticRating": 97, "genres": ["Comedy", "Drama", "Romance"], "overview": "New York comedian Alvy Singer falls in love with the ditsy Annie Hall.", "poster": "assets/movie-annie-hall-1977-poster.jpg", "backdrop": "assets/movie-annie-hall-1977-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:24:23.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 703, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 97, "rtCriticsLabel": "Certified Fresh", "rtAudience": 92, "rtAudienceLabel": "Upright", "imdb": 7.9, "imdbVotes": 293349, "tmdb": 7.71, "tmdbVotes": 4391}}, {"id": "movie-apocalypse-now-1979", "title": "Apocalypse Now", "year": 1979, "type": "movie", "runtimeSeconds": 8835, "seasonCount": null, "communityRating": 8.261, "criticRating": 91, "genres": ["Drama", "War"], "overview": "At the height of the Vietnam war, Captain Benjamin Willard is sent on a dangerous mission that, officially, \"does not exist, nor will it ever exist.\" His goal is to locate - and eliminate - a mysterious Green Beret\u2026", "poster": "assets/movie-apocalypse-now-1979-poster.jpg", "backdrop": "assets/movie-apocalypse-now-1979-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:22:25.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 28, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 91, "rtCriticsLabel": "Certified Fresh", "rtAudience": 94, "rtAudienceLabel": "Upright", "imdb": 8.4, "imdbVotes": 781424, "tmdb": 8.3, "tmdbVotes": 9526}}, {"id": "movie-arrival-2016", "title": "Arrival", "year": 2016, "type": "movie", "runtimeSeconds": 6967, "seasonCount": null, "communityRating": 7.636, "criticRating": 94, "genres": ["Drama", "Science Fiction", "Mystery"], "overview": "Taking place after alien crafts land around the world, an expert linguist is recruited by the military to determine whether they come in peace or are a threat.", "poster": "assets/movie-arrival-2016-poster.jpg", "backdrop": "assets/movie-arrival-2016-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-14T19:54:12.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 329865, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 94, "rtCriticsLabel": "Certified Fresh", "rtAudience": 83, "rtAudienceLabel": "Upright", "imdb": 7.9, "imdbVotes": 883621, "tmdb": 7.638, "tmdbVotes": 20125}}, {"id": "movie-avatar-aang-the-last-airbender-2026", "title": "Avatar Aang: The Last Airbender", "year": 2026, "type": "movie", "runtimeSeconds": 5930, "seasonCount": null, "communityRating": 9.147, "criticRating": 90, "genres": ["Animation", "Adventure", "Fantasy"], "overview": "Avatar Aang, the world's last Airbender, learns of an ancient power that could save his culture from extinction. With the help of his friends, he embarks on a global quest to find it before it falls into the wrong\u2026", "poster": "assets/movie-avatar-aang-the-last-airbender-2026-poster.jpg", "backdrop": "assets/movie-avatar-aang-the-last-airbender-2026-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-25T09:53:09.0000000Z", "lastPlayedAt": "2026-08-02T19:22:33.6155383Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 980431, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 90, "rtCriticsLabel": "Certified Fresh", "rtAudience": 99, "rtAudienceLabel": "Upright", "imdb": 7.7, "imdbVotes": 30792, "tmdb": 9, "tmdbVotes": 1417}}, {"id": "movie-bohemian-rhapsody-2018", "title": "Bohemian Rhapsody", "year": 2018, "type": "movie", "runtimeSeconds": 8077, "seasonCount": null, "communityRating": 7.95, "criticRating": 60, "genres": ["Music", "Drama"], "overview": "Singer Freddie Mercury, guitarist Brian May, drummer Roger Taylor and bass guitarist John Deacon take the music world by storm when they form the rock 'n' roll band Queen in 1970. Hit songs become instant classics.\u2026", "poster": "assets/movie-bohemian-rhapsody-2018-poster.jpg", "backdrop": "assets/movie-bohemian-rhapsody-2018-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:28:20.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 424694, "artwork": {"posterKind": "center_crop", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 60, "rtCriticsLabel": "Fresh", "rtAudience": 85, "rtAudienceLabel": "Upright", "imdb": 7.9, "imdbVotes": 655894, "tmdb": 7.947, "tmdbVotes": 18196}}, {"id": "movie-the-boy-and-the-heron-2023", "title": "The Boy and the Heron", "year": 2023, "type": "movie", "runtimeSeconds": 7437, "seasonCount": null, "communityRating": 7.4, "criticRating": 96, "genres": ["Animation", "Fantasy", "Drama"], "overview": "While the Second World War rages, the teenage Mahito, haunted by his mother's tragic death, is relocated from Tokyo to the serene rural home of his new stepmother Natsuko, a woman who bears a striking resemblance to\u2026", "poster": "assets/movie-the-boy-and-the-heron-2023-poster.jpg", "backdrop": "assets/movie-the-boy-and-the-heron-2023-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:25:15.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 508883, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 96, "rtCriticsLabel": "Certified Fresh", "rtAudience": 88, "rtAudienceLabel": "Upright", "imdb": 7.3, "imdbVotes": 152169, "tmdb": 7.387, "tmdbVotes": 2893}}, {"id": "movie-braveheart-1995", "title": "Braveheart", "year": 1995, "type": "movie", "runtimeSeconds": 10665, "seasonCount": null, "communityRating": 7.945, "criticRating": 74, "genres": ["Drama", "History", "War"], "overview": "Enraged at the slaughter of Murron, his new bride and childhood love, Scottish warrior William Wallace slays a platoon of the local English lord's soldiers. This leads the village to revolt and, eventually, the\u2026", "poster": "assets/movie-braveheart-1995-poster.jpg", "backdrop": "assets/movie-braveheart-1995-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-10T21:24:28.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 197, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 74, "rtCriticsLabel": "Certified Fresh", "rtAudience": 85, "rtAudienceLabel": "Upright", "imdb": 8.3, "imdbVotes": 1174990, "tmdb": 7.947, "tmdbVotes": 11584}}, {"id": "movie-the-breakfast-club-1985", "title": "The Breakfast Club", "year": 1985, "type": "movie", "runtimeSeconds": 5833, "seasonCount": null, "communityRating": 7.7, "criticRating": 87, "genres": ["Comedy", "Drama"], "overview": "Five high school students from different walks of life endure a Saturday detention under a power-hungry principal. The disparate group includes rebel John, princess Claire, outcast Allison, brainy Brian and Andrew,\u2026", "poster": "assets/movie-the-breakfast-club-1985-poster.jpg", "backdrop": "assets/movie-the-breakfast-club-1985-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:25:03.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 2108, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 87, "rtCriticsLabel": "Certified Fresh", "rtAudience": 92, "rtAudienceLabel": "Upright", "imdb": 7.8, "imdbVotes": 478743, "tmdb": 7.713, "tmdbVotes": 8815}}, {"id": "movie-castle-in-the-sky-1986", "title": "Castle in the Sky", "year": 1986, "type": "movie", "runtimeSeconds": 7474, "seasonCount": null, "communityRating": 8, "criticRating": 97, "genres": ["Adventure", "Fantasy", "Animation"], "overview": "A young boy and a girl with a magic crystal must race against pirates and foreign agents in a search for a legendary floating castle.", "poster": "assets/movie-castle-in-the-sky-1986-poster.jpg", "backdrop": "assets/movie-castle-in-the-sky-1986-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:23:09.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 10515, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 96, "rtCriticsLabel": "Fresh", "rtAudience": 91, "rtAudienceLabel": "Upright", "imdb": 8, "imdbVotes": 201545, "tmdb": 7.972, "tmdbVotes": 4868}}, {"id": "movie-citizen-kane-1941", "title": "Citizen Kane", "year": 1941, "type": "movie", "runtimeSeconds": 7173, "seasonCount": null, "communityRating": 7.966, "criticRating": 99, "genres": ["Mystery", "Drama"], "overview": "Newspaper magnate Charles Foster Kane is taken from his mother as a boy and made the ward of a rich industrialist. As a result, every well-meaning, tyrannical or self-destructive move he makes for the rest of his\u2026", "poster": "assets/movie-citizen-kane-1941-poster.jpg", "backdrop": "assets/movie-citizen-kane-1941-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:22:16.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 15, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 99, "rtCriticsLabel": "Certified Fresh", "rtAudience": 98, "rtAudienceLabel": "Upright", "imdb": 8.2, "imdbVotes": 498321, "tmdb": 7.962, "tmdbVotes": 6324}}, {"id": "movie-conclave-2024", "title": "Conclave", "year": 2024, "type": "movie", "runtimeSeconds": 7230, "seasonCount": null, "communityRating": 7.19, "criticRating": 93, "genres": ["Drama", "Thriller"], "overview": "After the unexpected death of the Pope, Cardinal Lawrence is tasked with managing the covert and ancient ritual of electing a new one. Sequestered in the Vatican with the Catholic Church\u2019s most powerful leaders until\u2026", "poster": "assets/movie-conclave-2024-poster.jpg", "backdrop": "assets/movie-conclave-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:30:41.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 974576, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 93, "rtCriticsLabel": "Certified Fresh", "rtAudience": 86, "rtAudienceLabel": "Upright", "imdb": 7.4, "imdbVotes": 290498, "tmdb": 7.19, "tmdbVotes": 3744}}, {"id": "movie-the-dry-2021", "title": "The Dry", "year": 2021, "type": "movie", "runtimeSeconds": 7037, "seasonCount": null, "communityRating": 6.779, "criticRating": null, "genres": ["Crime", "Thriller", "Mystery"], "overview": "Aaron Falk returns to his drought-stricken hometown to attend a tragic funeral. But his return opens a decades-old wound - the unsolved death of a teenage girl.", "poster": "assets/movie-the-dry-2021-poster.jpg", "backdrop": "assets/movie-the-dry-2021-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:29:20.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 567797, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 90, "rtCriticsLabel": "Certified Fresh", "rtAudience": 89, "rtAudienceLabel": "Upright", "imdb": 6.8, "imdbVotes": 41935, "tmdb": 6.8, "tmdbVotes": 892}}, {"id": "movie-easy-a-2010", "title": "Easy A", "year": 2010, "type": "movie", "runtimeSeconds": 5545, "seasonCount": null, "communityRating": 6.8, "criticRating": 85, "genres": ["Comedy"], "overview": "Olive, an average high school student, sees her below-the-radar existence turn around overnight once she decides to use the school's gossip grapevine to advance her social standing. Now her classmates are turning\u2026", "poster": "assets/movie-easy-a-2010-poster.jpg", "backdrop": "assets/movie-easy-a-2010-backdrop.jpg", "played": true, "playCount": 1, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-17T19:59:04.0000000Z", "lastPlayedAt": "2026-10-17T20:02:51.1083337Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 37735, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 85, "rtCriticsLabel": "Certified Fresh", "rtAudience": 77, "rtAudienceLabel": "Upright", "imdb": 7, "imdbVotes": 448780, "tmdb": 6.8, "tmdbVotes": 7619}}, {"id": "movie-el-camino-a-breaking-bad-movie-2019", "title": "El Camino: A Breaking Bad Movie", "year": 2019, "type": "movie", "runtimeSeconds": 7362, "seasonCount": null, "communityRating": 7.055, "criticRating": 92, "genres": ["Crime", "Drama", "Thriller"], "overview": "In the wake of his dramatic escape from captivity, Jesse Pinkman must come to terms with his past in order to forge some kind of future.", "poster": "assets/movie-el-camino-a-breaking-bad-movie-2019-poster.jpg", "backdrop": "assets/movie-el-camino-a-breaking-bad-movie-2019-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-21T23:31:11.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 559969, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 92, "rtCriticsLabel": "Certified Fresh", "rtAudience": 81, "rtAudienceLabel": "Upright", "imdb": 7.3, "imdbVotes": 354488, "tmdb": 7.056, "tmdbVotes": 5950}}, {"id": "movie-emancipation-2022", "title": "Emancipation", "year": 2022, "type": "movie", "runtimeSeconds": 7949, "seasonCount": null, "communityRating": 7.7, "criticRating": 45, "genres": ["Drama", "War", "History"], "overview": "Inspired by the gripping true story of a man who would do anything for his family\u2014and for freedom. When Peter, an enslaved man, risks his life to escape and return to his family, he embarks on a perilous journey of\u2026", "poster": "assets/movie-emancipation-2022-poster.jpg", "backdrop": "assets/movie-emancipation-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-10T20:37:34.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 715931, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 45, "rtCriticsLabel": "Rotten", "rtAudience": 58, "rtAudienceLabel": "Spilled", "imdb": 6.3, "imdbVotes": 32079, "tmdb": 7.744, "tmdbVotes": 1319}}, {"id": "movie-fighting-with-my-family-2019", "title": "Fighting with My Family", "year": 2019, "type": "movie", "runtimeSeconds": 6509, "seasonCount": null, "communityRating": 6.84, "criticRating": 93, "genres": ["Comedy"], "overview": "Born into a tight-knit wrestling family, Paige and her brother Zak are ecstatic when they get the once-in-a-lifetime opportunity to try out for the WWE. But when only Paige earns a spot in the competitive training\u2026", "poster": "assets/movie-fighting-with-my-family-2019-poster.jpg", "backdrop": "assets/movie-fighting-with-my-family-2019-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-22T22:10:59.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 445629, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 93, "rtCriticsLabel": "Certified Fresh", "rtAudience": 86, "rtAudienceLabel": "Upright", "imdb": 7, "imdbVotes": 94576, "tmdb": 6.837, "tmdbVotes": 1952}}, {"id": "movie-force-of-nature-the-dry-2-2024", "title": "Force of Nature: The Dry 2", "year": 2024, "type": "movie", "runtimeSeconds": 6753, "seasonCount": null, "communityRating": 6.3, "criticRating": 66, "genres": ["Mystery", "Thriller"], "overview": "When five women take part in a corporate hiking retreat and only four come out on the other side, Federal Agents Aaron Falk and Carmen Cooper head deep into the Victorian mountain ranges to investigate in the hopes\u2026", "poster": "assets/movie-force-of-nature-the-dry-2-2024-poster.jpg", "backdrop": "assets/movie-force-of-nature-the-dry-2-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:29:49.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 832262, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 66, "rtCriticsLabel": "Fresh", "rtAudience": 43, "rtAudienceLabel": "Spilled", "imdb": 5.9, "imdbVotes": 10343, "tmdb": 6.269, "tmdbVotes": 305}}, {"id": "movie-free-state-of-jones-2016", "title": "Free State of Jones", "year": 2016, "type": "movie", "runtimeSeconds": 8376, "seasonCount": null, "communityRating": 6.79, "criticRating": 48, "genres": ["Action", "Drama", "History"], "overview": "In 1863, Mississippi farmer Newt Knight serves as a medic for the Confederate Army. Opposed to slavery, Knight would rather help the wounded than fight the Union. After his nephew dies in battle, Newt returns home to\u2026", "poster": "assets/movie-free-state-of-jones-2016-poster.jpg", "backdrop": "assets/movie-free-state-of-jones-2016-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-11T17:56:53.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 316152, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 48, "rtCriticsLabel": "Rotten", "rtAudience": 64, "rtAudienceLabel": "Upright", "imdb": 6.9, "imdbVotes": 68931, "tmdb": 6.788, "tmdbVotes": 1730}}, {"id": "movie-from-up-on-poppy-hill-2011", "title": "From Up on Poppy Hill", "year": 2011, "type": "movie", "runtimeSeconds": 5482, "seasonCount": null, "communityRating": 7.5, "criticRating": 87, "genres": ["Animation", "Drama"], "overview": "Yokohama, 1963. Japan is picking itself up from the devastation of World War II and preparing to host the 1964 Olympics\u2014and the mood is one of both optimism and conflict as the young generation struggles to throw off\u2026", "poster": "assets/movie-from-up-on-poppy-hill-2011-poster.jpg", "backdrop": "assets/movie-from-up-on-poppy-hill-2011-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-07T14:41:03.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 83389, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 87, "rtCriticsLabel": "Certified Fresh", "rtAudience": 80, "rtAudienceLabel": "Upright", "imdb": 7.4, "imdbVotes": 60769, "tmdb": 7.509, "tmdbVotes": 2026}}, {"id": "movie-ghost-in-the-shell-1995", "title": "Ghost in the Shell", "year": 1995, "type": "movie", "runtimeSeconds": 4959, "seasonCount": null, "communityRating": 7.929, "criticRating": 95, "genres": ["Action", "Animation", "Science Fiction"], "overview": "In the year 2029, the barriers of our world have been broken down by the net and by cybernetics, but this brings new vulnerability to humans in the form of brain-hacking. When a highly-wanted hacker known as 'The\u2026", "poster": "assets/movie-ghost-in-the-shell-1995-poster.jpg", "backdrop": "assets/movie-ghost-in-the-shell-1995-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:22:57.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 9323, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 97, "rtCriticsLabel": "Certified Fresh", "rtAudience": 90, "rtAudienceLabel": "Upright", "imdb": 7.9, "imdbVotes": 178061, "tmdb": 7.928, "tmdbVotes": 4130}}, {"id": "movie-ghost-in-the-shell-2-innocence-2004", "title": "Ghost in the Shell 2: Innocence", "year": 2004, "type": "movie", "runtimeSeconds": 5950, "seasonCount": null, "communityRating": 7.352, "criticRating": 65, "genres": ["Animation", "Drama", "Science Fiction"], "overview": "Cyborg detective Batou is assigned to investigate a series of murders committed by gynoids\u2014doll-like cyborgs, which all malfunctioned, killed, then self-destructed afterwards. The brains of the gynoids initialize in\u2026", "poster": "assets/movie-ghost-in-the-shell-2-innocence-2004-poster.jpg", "backdrop": "assets/movie-ghost-in-the-shell-2-innocence-2004-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:23:38.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 12140, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 65, "rtCriticsLabel": "Fresh", "rtAudience": 80, "rtAudienceLabel": "Upright", "imdb": 7.4, "imdbVotes": 43280, "tmdb": 7.356, "tmdbVotes": 1049}}, {"id": "movie-the-godfather-1972", "title": "The Godfather", "year": 1972, "type": "movie", "runtimeSeconds": 10603, "seasonCount": null, "communityRating": 8.687, "criticRating": 97, "genres": ["Drama", "Crime"], "overview": "Spanning the years 1945 to 1955, a chronicle of the fictional Italian-American Corleone crime family. When organized crime family patriarch, Vito Corleone barely survives an attempt on his life, his youngest son,\u2026", "poster": "assets/movie-the-godfather-1972-poster.jpg", "backdrop": "assets/movie-the-godfather-1972-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:24:12.0000000Z", "lastPlayedAt": "2026-09-18T19:24:44.9855867Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 238, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 97, "rtCriticsLabel": "Certified Fresh", "rtAudience": 98, "rtAudienceLabel": "Upright", "imdb": 9.2, "imdbVotes": 2262024, "tmdb": 8.688, "tmdbVotes": 23907}}, {"id": "movie-the-godfather-part-ii-1974", "title": "The Godfather Part II", "year": 1974, "type": "movie", "runtimeSeconds": 12031, "seasonCount": null, "communityRating": 8.573, "criticRating": 96, "genres": ["Drama", "Crime"], "overview": "In the continuing saga of the Corleone crime family, a young Vito Corleone grows up in Sicily and in 1910s New York. In the 1950s, Michael Corleone attempts to expand the family business into Las Vegas, Hollywood and\u2026", "poster": "assets/movie-the-godfather-part-ii-1974-poster.jpg", "backdrop": "assets/movie-the-godfather-part-ii-1974-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:24:18.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 240, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 96, "rtCriticsLabel": "Certified Fresh", "rtAudience": 97, "rtAudienceLabel": "Upright", "imdb": 9, "imdbVotes": 1521308, "tmdb": 8.571, "tmdbVotes": 14559}}, {"id": "movie-the-godfather-part-iii-1990", "title": "The Godfather Part III", "year": 1990, "type": "movie", "runtimeSeconds": 9476, "seasonCount": null, "communityRating": 7.416, "criticRating": 67, "genres": ["Crime", "Drama", "Thriller"], "overview": "In the midst of trying to legitimize his business dealings in 1979 New York and Italy, aging mafia don, Michael Corleone seeks forgiveness for his sins while taking a young protege under his wing.", "poster": "assets/movie-the-godfather-part-iii-1990-poster.jpg", "backdrop": "assets/movie-the-godfather-part-iii-1990-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:26:29.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 242, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 68, "rtCriticsLabel": "Fresh", "rtAudience": 68, "rtAudienceLabel": "Upright", "imdb": 7.5, "imdbVotes": 462448, "tmdb": 7.413, "tmdbVotes": 7159}}, {"id": "movie-gosford-park-2001", "title": "Gosford Park", "year": 2001, "type": "movie", "runtimeSeconds": 8247, "seasonCount": null, "communityRating": 6.815, "criticRating": 87, "genres": ["Drama", "Mystery", "Thriller"], "overview": "In 1930s England, a group of pretentious rich and famous gather together for a weekend of relaxation at a hunting resort. But when a murder occurs, each one of these interesting characters becomes a suspect.", "poster": "assets/movie-gosford-park-2001-poster.jpg", "backdrop": "assets/movie-gosford-park-2001-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:25:49.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 5279, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 87, "rtCriticsLabel": "Certified Fresh", "rtAudience": 78, "rtAudienceLabel": "Upright", "imdb": 7.2, "imdbVotes": 103883, "tmdb": 6.822, "tmdbVotes": 1317}}, {"id": "movie-grave-of-the-fireflies-1988", "title": "Grave of the Fireflies", "year": 1988, "type": "movie", "runtimeSeconds": 5313, "seasonCount": null, "communityRating": 8.45, "criticRating": 100, "genres": ["Animation", "Drama", "War"], "overview": "In the final months of World War II, 14-year-old Seita and his sister Setsuko are orphaned when their mother is killed during an air raid in Kobe, Japan. After a falling out with their aunt, they move into an\u2026", "poster": "assets/movie-grave-of-the-fireflies-1988-poster.jpg", "backdrop": "assets/movie-grave-of-the-fireflies-1988-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:24:14.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 12477, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Certified Fresh", "rtAudience": 95, "rtAudienceLabel": "Upright", "imdb": 8.5, "imdbVotes": 399802, "tmdb": 8.45, "tmdbVotes": 6938}}, {"id": "movie-the-great-gatsby-2013", "title": "The Great Gatsby", "year": 2013, "type": "movie", "runtimeSeconds": 8525, "seasonCount": null, "communityRating": 7.346, "criticRating": 49, "genres": ["Drama", "Romance"], "overview": "Nick Carraway arrives in New York in 1922, an era of loose morals, glittering jazz and bootleg kings. Chasing his own American Dream, Nick encounters the mysterious millionaire Gatsby and his bewitching cousin Daisy.\u2026", "poster": "assets/movie-the-great-gatsby-2013-poster.jpg", "backdrop": "assets/movie-the-great-gatsby-2013-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:26:50.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 64682, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 48, "rtCriticsLabel": "Rotten", "rtAudience": 67, "rtAudienceLabel": "Upright", "imdb": 7.2, "imdbVotes": 651882, "tmdb": 7.346, "tmdbVotes": 13290}}, {"id": "movie-the-hours-2002", "title": "The Hours", "year": 2002, "type": "movie", "runtimeSeconds": 6887, "seasonCount": null, "communityRating": 7.25, "criticRating": 79, "genres": ["Drama"], "overview": "The story of three women searching for more potent, meaningful lives. Each is alive at a different time and place, all are linked by their yearnings and their fears. Their stories intertwine, and finally come\u2026", "poster": "assets/movie-the-hours-2002-poster.jpg", "backdrop": "assets/movie-the-hours-2002-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:23:27.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 590, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 79, "rtCriticsLabel": "Certified Fresh", "rtAudience": 84, "rtAudienceLabel": "Upright", "imdb": 7.5, "imdbVotes": 148367, "tmdb": 7.249, "tmdbVotes": 1931}}, {"id": "movie-howl-s-moving-castle-2004", "title": "Howl's Moving Castle", "year": 2004, "type": "movie", "runtimeSeconds": 7149, "seasonCount": null, "communityRating": 8.4, "criticRating": 88, "genres": ["Fantasy", "Animation", "Adventure"], "overview": "Sophie, a young milliner, is turned into an elderly woman by a witch who enters her shop and curses her. She encounters a wizard named Howl and gets caught up in his resistance to fighting for the king.", "poster": "assets/movie-howl-s-moving-castle-2004-poster.jpg", "backdrop": "assets/movie-howl-s-moving-castle-2004-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:22:47.0000000Z", "lastPlayedAt": "2026-05-21T18:46:08.0000000Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 4935, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 87, "rtCriticsLabel": "Certified Fresh", "rtAudience": 93, "rtAudienceLabel": "Upright", "imdb": 8.2, "imdbVotes": 524520, "tmdb": 8.391, "tmdbVotes": 11642}}, {"id": "movie-in-the-heights-2021", "title": "In the Heights", "year": 2021, "type": "movie", "runtimeSeconds": 8579, "seasonCount": null, "communityRating": 7.111, "criticRating": null, "genres": ["Drama", "Romance"], "overview": "The story of Usnavi, a bodega owner who has mixed feelings about closing his store and retiring to the Dominican Republic or staying in Washington Heights.", "poster": "assets/movie-in-the-heights-2021-poster.jpg", "backdrop": "assets/movie-in-the-heights-2021-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:29:11.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 467909, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 94, "rtCriticsLabel": "Certified Fresh", "rtAudience": 94, "rtAudienceLabel": "Upright", "imdb": 7.2, "imdbVotes": 56513, "tmdb": 7.104, "tmdbVotes": 826}}, {"id": "movie-jennifer-s-body-2009", "title": "Jennifer's Body", "year": 2009, "type": "movie", "runtimeSeconds": 6450, "seasonCount": null, "communityRating": 6.012, "criticRating": 47, "genres": ["Horror", "Comedy"], "overview": "Jennifer, a gorgeous, seductive cheerleader takes evil to a whole new level after she's possessed by a sinister demon. Now it's up to her best friend to stop Jennifer's reign of terror before it's too late.", "poster": "assets/movie-jennifer-s-body-2009-poster.jpg", "backdrop": "assets/movie-jennifer-s-body-2009-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-30T03:37:04.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 19994, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 47, "rtCriticsLabel": "Rotten", "rtAudience": 36, "rtAudienceLabel": "Spilled", "imdb": 5.6, "imdbVotes": 179710, "tmdb": 6.013, "tmdbVotes": 4481}}, {"id": "movie-kiki-s-delivery-service-1989", "title": "Kiki's Delivery Service", "year": 1989, "type": "movie", "runtimeSeconds": 6183, "seasonCount": null, "communityRating": 7.839, "criticRating": null, "genres": ["Animation", "Family", "Fantasy"], "overview": "A young witch, on her mandatory year of independent life, finds fitting into a new community difficult while she supports herself by running an air courier service.", "poster": "assets/movie-kiki-s-delivery-service-1989-poster.jpg", "backdrop": "assets/movie-kiki-s-delivery-service-1989-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:24:20.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 16859, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 98, "rtCriticsLabel": "Certified Fresh", "rtAudience": 89, "rtAudienceLabel": "Upright", "imdb": 7.8, "imdbVotes": 192477, "tmdb": 7.839, "tmdbVotes": 4811}}, {"id": "movie-the-last-of-the-mohicans-1992", "title": "The Last of the Mohicans", "year": 1992, "type": "movie", "runtimeSeconds": 6874, "seasonCount": null, "communityRating": 7.388, "criticRating": 88, "genres": ["History", "War", "Drama"], "overview": "In war-torn colonial America, in the midst of a bloody battle between British, the French and Native American allies, the aristocratic daughter of a British Colonel and her party are captured by a group of Huron\u2026", "poster": "assets/movie-the-last-of-the-mohicans-1992-poster.jpg", "backdrop": "assets/movie-the-last-of-the-mohicans-1992-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-10T20:59:22.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 9361, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 88, "rtCriticsLabel": "Certified Fresh", "rtAudience": 88, "rtAudienceLabel": "Upright", "imdb": 7.6, "imdbVotes": 210791, "tmdb": 7.39, "tmdbVotes": 3593}}, {"id": "movie-the-last-samurai-2003", "title": "The Last Samurai", "year": 2003, "type": "movie", "runtimeSeconds": 9251, "seasonCount": null, "communityRating": 7.61, "criticRating": 65, "genres": ["Drama", "Action", "War"], "overview": "Nathan Algren is an American hired to instruct the Japanese army in the ways of modern warfare, which finds him learning to respect the samurai and the honorable principles that rule them. Pressed to destroy the\u2026", "poster": "assets/movie-the-last-samurai-2003-poster.jpg", "backdrop": "assets/movie-the-last-samurai-2003-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-26T05:25:10.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 616, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 66, "rtCriticsLabel": "Fresh", "rtAudience": 83, "rtAudienceLabel": "Upright", "imdb": 7.8, "imdbVotes": 512982, "tmdb": 7.614, "tmdbVotes": 7887}}, {"id": "movie-lemony-snicket-s-a-series-of-unfortunate-events-2004", "title": "Lemony Snicket's A Series of Unfortunate Events", "year": 2004, "type": "movie", "runtimeSeconds": 6479, "seasonCount": null, "communityRating": 6.8, "criticRating": 72, "genres": ["Adventure", "Comedy", "Family"], "overview": "Three wealthy children's parents are killed in a fire. When they are sent to a distant relative, they find out that he is plotting to kill them and seize their fortune.", "poster": "assets/movie-lemony-snicket-s-a-series-of-unfortunate-events-2004-poster.jpg", "backdrop": "assets/movie-lemony-snicket-s-a-series-of-unfortunate-events-2004-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:26:09.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 11774, "artwork": {"posterKind": "center_crop", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 72, "rtCriticsLabel": "Fresh", "rtAudience": 63, "rtAudienceLabel": "Upright", "imdb": 6.8, "imdbVotes": 233636, "tmdb": 6.82, "tmdbVotes": 5447}}, {"id": "movie-the-lighthouse-2019", "title": "The Lighthouse", "year": 2019, "type": "movie", "runtimeSeconds": 6549, "seasonCount": null, "communityRating": 7.463, "criticRating": 90, "genres": ["Drama", "Fantasy", "Thriller"], "overview": "Two lighthouse keepers try to maintain their sanity while living on a remote and mysterious New England island in the 1890s.", "poster": "assets/movie-the-lighthouse-2019-poster.jpg", "backdrop": "assets/movie-the-lighthouse-2019-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:29:01.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 503919, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 90, "rtCriticsLabel": "Certified Fresh", "rtAudience": 72, "rtAudienceLabel": "Upright", "imdb": 7.3, "imdbVotes": 312344, "tmdb": 7.46, "tmdbVotes": 5895}}, {"id": "movie-locke-2014", "title": "Locke", "year": 2014, "type": "movie", "runtimeSeconds": 5090, "seasonCount": null, "communityRating": 6.888, "criticRating": 91, "genres": ["Drama", "Thriller"], "overview": "Ivan Locke has worked hard to craft a good life for himself. Tonight, that life will collapse around him. On the eve of the biggest challenge of his career, Ivan receives a phone call that sets in motion a series of\u2026", "poster": "assets/movie-locke-2014-poster.jpg", "backdrop": "assets/movie-locke-2014-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T17:38:48.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 210479, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 90, "rtCriticsLabel": "Certified Fresh", "rtAudience": 72, "rtAudienceLabel": "Upright", "imdb": 7.1, "imdbVotes": 171265, "tmdb": 6.889, "tmdbVotes": 3088}}, {"id": "movie-the-matrix-1999", "title": "The Matrix", "year": 1999, "type": "movie", "runtimeSeconds": 8179, "seasonCount": null, "communityRating": 8.259, "criticRating": 83, "genres": ["Action", "Science Fiction"], "overview": "Set in the 22nd century, The Matrix tells the story of a computer hacker who joins a group of underground insurgents fighting the vast and powerful computers who now rule the earth.", "poster": "assets/movie-the-matrix-1999-poster.jpg", "backdrop": "assets/movie-the-matrix-1999-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:18:12.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 603, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 83, "rtCriticsLabel": "Certified Fresh", "rtAudience": 85, "rtAudienceLabel": "Upright", "imdb": 8.7, "imdbVotes": 2289808, "tmdb": 8.262, "tmdbVotes": 29153}}, {"id": "movie-the-matrix-reloaded-2003", "title": "The Matrix Reloaded", "year": 2003, "type": "movie", "runtimeSeconds": 8298, "seasonCount": null, "communityRating": 7.085, "criticRating": 74, "genres": ["Adventure", "Action", "Thriller"], "overview": "The Resistance builds in numbers as humans are freed from the Matrix and brought to the city of Zion. Neo discovers his superpowers, including the ability to see the code inside the Matrix. With machine sentinels\u2026", "poster": "assets/movie-the-matrix-reloaded-2003-poster.jpg", "backdrop": "assets/movie-the-matrix-reloaded-2003-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:24:03.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 604, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 73, "rtCriticsLabel": "Certified Fresh", "rtAudience": 72, "rtAudienceLabel": "Upright", "imdb": 7.2, "imdbVotes": 678013, "tmdb": 7.087, "tmdbVotes": 12504}}, {"id": "movie-the-matrix-resurrections-2021", "title": "The Matrix Resurrections", "year": 2021, "type": "movie", "runtimeSeconds": 8875, "seasonCount": null, "communityRating": 6.303, "criticRating": 63, "genres": ["Science Fiction", "Action", "Adventure"], "overview": "Plagued by strange memories, Neo's life takes an unexpected turn when he finds himself back inside the Matrix.", "poster": "assets/movie-the-matrix-resurrections-2021-poster.jpg", "backdrop": "assets/movie-the-matrix-resurrections-2021-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:31:22.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 624860, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 63, "rtCriticsLabel": "Fresh", "rtAudience": 63, "rtAudienceLabel": "Upright", "imdb": 5.6, "imdbVotes": 307780, "tmdb": 6.294, "tmdbVotes": 6842}}, {"id": "movie-the-matrix-revolutions-2003", "title": "The Matrix Revolutions", "year": 2003, "type": "movie", "runtimeSeconds": 7756, "seasonCount": null, "communityRating": 6.758, "criticRating": 33, "genres": ["Adventure", "Action", "Thriller"], "overview": "The human city of Zion defends itself against the massive invasion of the machines as Neo fights to end the war at another front while also opposing the rogue Agent Smith.", "poster": "assets/movie-the-matrix-revolutions-2003-poster.jpg", "backdrop": "assets/movie-the-matrix-revolutions-2003-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:25:47.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 605, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 34, "rtCriticsLabel": "Rotten", "rtAudience": 60, "rtAudienceLabel": "Upright", "imdb": 6.8, "imdbVotes": 581553, "tmdb": 6.762, "tmdbVotes": 11292}}, {"id": "movie-mercy-2026", "title": "Mercy", "year": 2026, "type": "movie", "runtimeSeconds": 5968, "seasonCount": null, "communityRating": 7.077, "criticRating": null, "genres": ["Science Fiction", "Thriller", "Crime"], "overview": "In the near future, a detective stands on trial accused of murdering his wife. He has ninety minutes to prove his innocence to the advanced AI Judge he once championed, before it determines his fate.", "poster": "assets/movie-mercy-2026-poster.jpg", "backdrop": "assets/movie-mercy-2026-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:31:40.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 1236153, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 24, "rtCriticsLabel": "Rotten", "rtAudience": 81, "rtAudienceLabel": "Upright", "imdb": 6.2, "imdbVotes": 83878, "tmdb": 7.054, "tmdbVotes": 1812}}, {"id": "movie-normal-2026", "title": "Normal", "year": 2026, "type": "movie", "runtimeSeconds": 5514, "seasonCount": null, "communityRating": 7.267, "criticRating": 77, "genres": ["Action", "Crime", "Thriller"], "overview": "An unassuming substitute sheriff with a troubled past responds to a bank robbery in a small, sleepy town and unknowingly uncovers something far more explosive.", "poster": "assets/movie-normal-2026-poster.jpg", "backdrop": "assets/movie-normal-2026-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-08-21T08:33:18.0000000Z", "lastPlayedAt": "2026-09-24T23:07:52.1161581Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 1242332, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 78, "rtCriticsLabel": "Certified Fresh", "rtAudience": 75, "rtAudienceLabel": "Upright", "imdb": 6.3, "imdbVotes": 37501, "tmdb": 7.241, "tmdbVotes": 786}}, {"id": "movie-outlaw-king-2018", "title": "Outlaw King", "year": 2018, "type": "movie", "runtimeSeconds": 7297, "seasonCount": null, "communityRating": 6.829, "criticRating": 63, "genres": ["Action", "History", "War"], "overview": "Forced into exile by the English after being crowned King of Scotland, legendary warrior Robert the Bruce fights to reclaim the throne.", "poster": "assets/movie-outlaw-king-2018-poster.jpg", "backdrop": "assets/movie-outlaw-king-2018-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:28:32.0000000Z", "lastPlayedAt": "2026-07-26T22:35:35.0000000Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 454227, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 63, "rtCriticsLabel": "Fresh", "rtAudience": 69, "rtAudienceLabel": "Upright", "imdb": 6.9, "imdbVotes": 89876, "tmdb": 6.844, "tmdbVotes": 1780}}, {"id": "movie-panic-room-2002", "title": "Panic Room", "year": 2002, "type": "movie", "runtimeSeconds": 6717, "seasonCount": null, "communityRating": 6.794, "criticRating": 76, "genres": ["Crime", "Drama", "Thriller"], "overview": "Trapped in their New York brownstone's panic room, a hidden chamber built as a sanctuary in the event of break-ins, newly divorced Meg Altman and her young daughter Sarah play a deadly game of cat-and-mouse with\u2026", "poster": "assets/movie-panic-room-2002-poster.jpg", "backdrop": "assets/movie-panic-room-2002-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-19T20:50:01.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 4547, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 76, "rtCriticsLabel": "Certified Fresh", "rtAudience": 64, "rtAudienceLabel": "Upright", "imdb": 6.8, "imdbVotes": 317468, "tmdb": 6.796, "tmdbVotes": 5653}}, {"id": "movie-the-patriot-2000", "title": "The Patriot", "year": 2000, "type": "movie", "runtimeSeconds": 9887, "seasonCount": null, "communityRating": 7.214, "criticRating": 61, "genres": ["Drama", "History", "War"], "overview": "After proving himself on the field of battle in the French and Indian War, Benjamin Martin wants nothing more to do with such things, preferring the simple life of a farmer. But when his son Gabriel enlists in the\u2026", "poster": "assets/movie-the-patriot-2000-poster.jpg", "backdrop": "assets/movie-the-patriot-2000-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-10T20:50:00.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 2024, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 61, "rtCriticsLabel": "Fresh", "rtAudience": 81, "rtAudienceLabel": "Upright", "imdb": 7.2, "imdbVotes": 317639, "tmdb": 7.215, "tmdbVotes": 4535}}, {"id": "movie-peaky-blinders-the-immortal-man-2026", "title": "Peaky Blinders: The Immortal Man", "year": 2026, "type": "movie", "runtimeSeconds": 6844, "seasonCount": null, "communityRating": 7.187, "criticRating": 90, "genres": ["Crime", "Drama"], "overview": "After his estranged son gets embroiled in a Nazi plot, self-exiled gangster Tommy Shelby must return to Birmingham to save his family \u2014 and his nation.", "poster": "assets/movie-peaky-blinders-the-immortal-man-2026-poster.jpg", "backdrop": "assets/movie-peaky-blinders-the-immortal-man-2026-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:30:01.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 875828, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 90, "rtCriticsLabel": "Certified Fresh", "rtAudience": 87, "rtAudienceLabel": "Upright", "imdb": 6.5, "imdbVotes": 92130, "tmdb": 7.189, "tmdbVotes": 1304}}, {"id": "movie-ponyo-2008", "title": "Ponyo", "year": 2008, "type": "movie", "runtimeSeconds": 6530, "seasonCount": null, "communityRating": 7.763, "criticRating": 91, "genres": ["Animation", "Fantasy", "Family"], "overview": "When Sosuke, a young boy who lives on a clifftop overlooking the sea, rescues a stranded goldfish named Ponyo, he discovers more than he bargained for. Ponyo is a curious, energetic young creature who yearns to be\u2026", "poster": "assets/movie-ponyo-2008-poster.jpg", "backdrop": "assets/movie-ponyo-2008-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:23:53.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 12429, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 91, "rtCriticsLabel": "Certified Fresh", "rtAudience": 84, "rtAudienceLabel": "Upright", "imdb": 7.6, "imdbVotes": 190294, "tmdb": 7.759, "tmdbVotes": 5050}}, {"id": "movie-porco-rosso-1992", "title": "Porco Rosso", "year": 1992, "type": "movie", "runtimeSeconds": 5600, "seasonCount": null, "communityRating": 7.794, "criticRating": 96, "genres": ["Animation", "Adventure", "Fantasy"], "overview": "In Italy in the 1930s, sky pirates in biplanes terrorize wealthy cruise ships as they sail the Adriatic Sea. The only pilot brave enough to stop the scourge is the mysterious Porco Rosso, a former World War I flying\u2026", "poster": "assets/movie-porco-rosso-1992-poster.jpg", "backdrop": "assets/movie-porco-rosso-1992-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:23:22.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 11621, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 96, "rtCriticsLabel": "Fresh", "rtAudience": 87, "rtAudienceLabel": "Upright", "imdb": 7.7, "imdbVotes": 119396, "tmdb": 7.789, "tmdbVotes": 3879}}, {"id": "movie-princess-mononoke-1997", "title": "Princess Mononoke", "year": 1997, "type": "movie", "runtimeSeconds": 8003, "seasonCount": null, "communityRating": 8.321, "criticRating": 93, "genres": ["Adventure", "Fantasy", "Animation"], "overview": "Ashitaka, a prince of the disappearing Emishi people, is cursed by a demonized boar god and must journey to the west to find a cure. Along the way, he encounters San, a young human woman fighting to protect the\u2026", "poster": "assets/movie-princess-mononoke-1997-poster.jpg", "backdrop": "assets/movie-princess-mononoke-1997-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:22:28.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 128, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 94, "rtCriticsLabel": "Certified Fresh", "rtAudience": 99, "rtAudienceLabel": "Upright", "imdb": 8.3, "imdbVotes": 493735, "tmdb": 8.321, "tmdbVotes": 9438}}, {"id": "movie-prisoners-2013", "title": "Prisoners", "year": 2013, "type": "movie", "runtimeSeconds": 9192, "seasonCount": null, "communityRating": 8.106, "criticRating": 81, "genres": ["Drama", "Thriller", "Crime"], "overview": "After his six-year-old daughter is abducted, a desperate father takes justice into his own hands when he loses faith in the detective leading the investigation, setting both men on a collision course as the search\u2026", "poster": "assets/movie-prisoners-2013-poster.jpg", "backdrop": "assets/movie-prisoners-2013-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:27:33.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 146233, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 82, "rtCriticsLabel": "Certified Fresh", "rtAudience": 87, "rtAudienceLabel": "Upright", "imdb": 8.2, "imdbVotes": 963062, "tmdb": 8.105, "tmdbVotes": 13722}}, {"id": "movie-racing-stripes-2005", "title": "Racing Stripes", "year": 2005, "type": "movie", "runtimeSeconds": 6117, "seasonCount": null, "communityRating": 5.945, "criticRating": 35, "genres": ["Comedy", "Family"], "overview": "Shattered illusions are hard to repair -- especially for a good-hearted zebra named Stripes who's spent his life on a Kentucky farm amidst the sorely mistaken notion that he's a debonair thoroughbred. Once he faces\u2026", "poster": "assets/movie-racing-stripes-2005-poster.jpg", "backdrop": "assets/movie-racing-stripes-2005-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:26:02.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 6439, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 35, "rtCriticsLabel": "Rotten", "rtAudience": 42, "rtAudienceLabel": "Spilled", "imdb": 5.3, "imdbVotes": 19569, "tmdb": 5.962, "tmdbVotes": 705}}, {"id": "movie-requiem-for-a-dream-2000", "title": "Requiem for a Dream", "year": 2000, "type": "movie", "runtimeSeconds": 6086, "seasonCount": null, "communityRating": 8.005, "criticRating": 80, "genres": ["Crime", "Drama"], "overview": "The drug-induced utopias of four Coney Island residents are shattered when their addictions run deep.", "poster": "assets/movie-requiem-for-a-dream-2000-poster.jpg", "backdrop": "assets/movie-requiem-for-a-dream-2000-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:24:15.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 641, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 81, "rtCriticsLabel": "Certified Fresh", "rtAudience": 93, "rtAudienceLabel": "Upright", "imdb": 8.3, "imdbVotes": 989090, "tmdb": 8, "tmdbVotes": 11454}}, {"id": "movie-rogue-one-a-star-wars-story-2016", "title": "Rogue One: A Star Wars Story", "year": 2016, "type": "movie", "runtimeSeconds": 8038, "seasonCount": null, "communityRating": 7.51, "criticRating": 84, "genres": ["Action", "Adventure", "Science Fiction"], "overview": "A rogue band of resistance fighters unite for a mission to steal the Death Star plans and bring a new hope to the galaxy.", "poster": "assets/movie-rogue-one-a-star-wars-story-2016-poster.jpg", "backdrop": "assets/movie-rogue-one-a-star-wars-story-2016-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:28:00.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 330459, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 84, "rtCriticsLabel": "Certified Fresh", "rtAudience": 87, "rtAudienceLabel": "Upright", "imdb": 7.8, "imdbVotes": 768365, "tmdb": 7.512, "tmdbVotes": 16937}}, {"id": "movie-secondhand-lions-2003", "title": "Secondhand Lions", "year": 2003, "type": "movie", "runtimeSeconds": 6547, "seasonCount": null, "communityRating": 7.237, "criticRating": 60, "genres": ["Family", "Comedy", "Drama"], "overview": "The comedic adventures of an introverted boy left on the doorstep of a pair of reluctant, eccentric great-uncles, whose exotic remembrances stir the boy's spirit and re-ignite the men's lives.", "poster": "assets/movie-secondhand-lions-2003-poster.jpg", "backdrop": "assets/movie-secondhand-lions-2003-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-12T07:16:13.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 13156, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 59, "rtCriticsLabel": "Rotten", "rtAudience": 84, "rtAudienceLabel": "Upright", "imdb": 7.5, "imdbVotes": 66911, "tmdb": 7.242, "tmdbVotes": 872}}, {"id": "movie-the-secret-life-of-pets-2-2019", "title": "The Secret Life of Pets 2", "year": 2019, "type": "movie", "runtimeSeconds": 5164, "seasonCount": null, "communityRating": 6.95, "criticRating": 60, "genres": ["Animation", "Comedy", "Family"], "overview": "Max the terrier must cope with some major life changes when his owner gets married and has a baby. When the family takes a trip to the countryside, nervous Max has numerous run-ins with canine-intolerant cows,\u2026", "poster": "assets/movie-the-secret-life-of-pets-2-2019-poster.jpg", "backdrop": "assets/movie-the-secret-life-of-pets-2-2019-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-05T07:00:53.0000000Z", "lastPlayedAt": "2026-09-17T18:07:00.7024231Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 412117, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 60, "rtCriticsLabel": "Fresh", "rtAudience": 90, "rtAudienceLabel": "Upright", "imdb": 6.4, "imdbVotes": 80464, "tmdb": 6.948, "tmdbVotes": 3450}}, {"id": "movie-self-reliance-2024", "title": "Self Reliance", "year": 2024, "type": "movie", "runtimeSeconds": 5461, "seasonCount": null, "communityRating": 5.888, "criticRating": 73, "genres": ["Comedy", "Thriller"], "overview": "When a man is offered a million dollars to play a game in which hunters try to kill him, he thinks he has found the perfect loophole: they can only attack when he\u2019s alone. His only problem is that none of his friends\u2026", "poster": "assets/movie-self-reliance-2024-poster.jpg", "backdrop": "assets/movie-self-reliance-2024-backdrop.jpg", "played": false, "playCount": 1, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:31:00.0000000Z", "lastPlayedAt": "2026-10-02T19:59:16.9286868Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 1072876, "artwork": {"posterKind": "center_crop", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 73, "rtCriticsLabel": "Certified Fresh", "rtAudience": 56, "rtAudienceLabel": "Spilled", "imdb": 6, "imdbVotes": 23771, "tmdb": 5.866, "tmdbVotes": 346}}, {"id": "movie-serpico-1973", "title": "Serpico", "year": 1973, "type": "movie", "runtimeSeconds": 7805, "seasonCount": null, "communityRating": 7.5, "criticRating": 93, "genres": ["Crime", "Drama", "History"], "overview": "New York cop Frank Serpico blows the whistle on the rampant corruption in the force only to have his comrades turn against him.", "poster": "assets/movie-serpico-1973-poster.jpg", "backdrop": "assets/movie-serpico-1973-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-10T21:45:02.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 9040, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 93, "rtCriticsLabel": "Certified Fresh", "rtAudience": 88, "rtAudienceLabel": "Upright", "imdb": 7.7, "imdbVotes": 149750, "tmdb": 7.525, "tmdbVotes": 2323}}, {"id": "movie-shaft-2000", "title": "Shaft", "year": 2000, "type": "movie", "runtimeSeconds": 5960, "seasonCount": null, "communityRating": 6.026, "criticRating": 67, "genres": ["Action", "Adventure", "Crime"], "overview": "New York police detective John Shaft arrests Walter Wade Jr. for a racially motivated slaying. But the only eyewitness disappears, and Wade jumps bail for Switzerland. Two years later Wade returns to face trial,\u2026", "poster": "assets/movie-shaft-2000-poster.jpg", "backdrop": "assets/movie-shaft-2000-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-10T21:30:40.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 479, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 68, "rtCriticsLabel": "Fresh", "rtAudience": 40, "rtAudienceLabel": "Spilled", "imdb": 6, "imdbVotes": 86257, "tmdb": 6.024, "tmdbVotes": 1531}}, {"id": "movie-a-silent-voice-the-movie-2016", "title": "A Silent Voice: The Movie", "year": 2016, "type": "movie", "runtimeSeconds": 7804, "seasonCount": null, "communityRating": 8.398, "criticRating": null, "genres": ["Animation", "Drama", "Romance"], "overview": "Shouya Ishida starts bullying the new girl in class, Shouko Nishimiya, because she is deaf. But as the teasing continues, the rest of the class starts to turn on Shouya for his lack of compassion. When they leave\u2026", "poster": "assets/movie-a-silent-voice-the-movie-2016-poster.jpg", "backdrop": "assets/movie-a-silent-voice-the-movie-2016-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T17:39:46.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 378064, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 95, "rtCriticsLabel": "Fresh", "rtAudience": 94, "rtAudienceLabel": "Upright", "imdb": 8.2, "imdbVotes": 151926, "tmdb": 8.396, "tmdbVotes": 4764}}, {"id": "movie-sinbad-legend-of-the-seven-seas-2003", "title": "Sinbad: Legend of the Seven Seas", "year": 2003, "type": "movie", "runtimeSeconds": 5143, "seasonCount": null, "communityRating": 7.067, "criticRating": 44, "genres": ["Action", "Adventure", "Animation"], "overview": "The sailor of legend is framed by the goddess Eris for the theft of the Book of Peace, and must travel to her realm at the end of the world to retrieve it and save the life of his childhood friend Prince Proteus.", "poster": "assets/movie-sinbad-legend-of-the-seven-seas-2003-poster.jpg", "backdrop": "assets/movie-sinbad-legend-of-the-seven-seas-2003-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:26:25.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 14411, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 46, "rtCriticsLabel": "Rotten", "rtAudience": 57, "rtAudienceLabel": "Spilled", "imdb": 6.8, "imdbVotes": 65298, "tmdb": 7.071, "tmdbVotes": 2323}}, {"id": "movie-sleeping-with-other-people-2015", "title": "Sleeping with Other People", "year": 2015, "type": "movie", "runtimeSeconds": 6076, "seasonCount": null, "communityRating": 6.274, "criticRating": 64, "genres": ["Comedy", "Romance"], "overview": "Can two serial cheaters get a second chance at love? After a one-night stand in college, New Yorkers Lainey and Jake meet by chance twelve years later and discover they each have the same problem: because of their\u2026", "poster": "assets/movie-sleeping-with-other-people-2015-poster.jpg", "backdrop": "assets/movie-sleeping-with-other-people-2015-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:27:42.0000000Z", "lastPlayedAt": "2026-07-17T17:50:44.0000000Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 288036, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 64, "rtCriticsLabel": "Fresh", "rtAudience": 56, "rtAudienceLabel": "Spilled", "imdb": 6.5, "imdbVotes": 52275, "tmdb": 6.275, "tmdbVotes": 1020}}, {"id": "movie-snowpiercer-2013", "title": "Snowpiercer", "year": 2013, "type": "movie", "runtimeSeconds": 7573, "seasonCount": null, "communityRating": 6.909, "criticRating": 94, "genres": ["Action", "Science Fiction", "Drama"], "overview": "In a future where a failed global-warming experiment kills off most life on the planet, a class system evolves aboard the Snowpiercer; a train that travels around the globe via a perpetual-motion engine.", "poster": "assets/movie-snowpiercer-2013-poster.jpg", "backdrop": "assets/movie-snowpiercer-2013-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:27:07.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 110415, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 94, "rtCriticsLabel": "Certified Fresh", "rtAudience": 72, "rtAudienceLabel": "Upright", "imdb": 7.1, "imdbVotes": 426976, "tmdb": 6.9, "tmdbVotes": 10807}}, {"id": "movie-spider-man-brand-new-day-2026", "title": "Spider-Man: Brand New Day", "year": 2026, "type": "movie", "runtimeSeconds": 8701, "seasonCount": null, "communityRating": 8.374, "criticRating": 90, "genres": ["Science Fiction", "Action", "Adventure"], "overview": "Fighting crime full-time as Spider-Man in a world that doesn't remember him\u2014and the pressure of seeing his old friends move on without him\u2014sparks a change in Peter Parker he may not have the power to control. But\u2026", "poster": "assets/movie-spider-man-brand-new-day-2026-poster.jpg", "backdrop": "assets/movie-spider-man-brand-new-day-2026-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-10T14:14:27.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 969681, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 90, "rtCriticsLabel": "Certified Fresh", "rtAudience": 97, "rtAudienceLabel": "Upright", "imdb": 7.9, "imdbVotes": 366886, "tmdb": 8.5, "tmdbVotes": 6537}}, {"id": "movie-spider-man-far-from-home-2019", "title": "Spider-Man: Far From Home", "year": 2019, "type": "movie", "runtimeSeconds": 7762, "seasonCount": null, "communityRating": 7.389, "criticRating": 91, "genres": ["Action", "Adventure", "Science Fiction"], "overview": "Peter Parker and his friends go on a summer trip to Europe. However, they will hardly be able to rest - Peter will have to agree to help Nick Fury uncover the mystery of creatures that cause natural disasters and\u2026", "poster": "assets/movie-spider-man-far-from-home-2019-poster.jpg", "backdrop": "assets/movie-spider-man-far-from-home-2019-backdrop.jpg", "played": true, "playCount": 1, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-10T14:25:47.0000000Z", "lastPlayedAt": "2026-10-10T17:50:35.6193119Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 429617, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 91, "rtCriticsLabel": "Certified Fresh", "rtAudience": 95, "rtAudienceLabel": "Upright", "imdb": 7.3, "imdbVotes": 682887, "tmdb": 7.387, "tmdbVotes": 17847}}, {"id": "movie-spider-man-homecoming-2017", "title": "Spider-Man: Homecoming", "year": 2017, "type": "movie", "runtimeSeconds": 8008, "seasonCount": null, "communityRating": 7.333, "criticRating": 92, "genres": ["Action", "Adventure", "Science Fiction"], "overview": "Following the events of Captain America: Civil War, Peter Parker, with the help of his mentor Tony Stark, tries to balance his life as an ordinary high school student in Queens, New York City, with fighting crime as\u2026", "poster": "assets/movie-spider-man-homecoming-2017-poster.jpg", "backdrop": "assets/movie-spider-man-homecoming-2017-backdrop.jpg", "played": true, "playCount": 1, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-10T14:33:13.0000000Z", "lastPlayedAt": "2026-10-10T20:31:23.4444213Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 315635, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 92, "rtCriticsLabel": "Certified Fresh", "rtAudience": 87, "rtAudienceLabel": "Upright", "imdb": 7.4, "imdbVotes": 840974, "tmdb": 7.332, "tmdbVotes": 24183}}, {"id": "movie-spider-man-no-way-home-2021", "title": "Spider-Man: No Way Home", "year": 2021, "type": "movie", "runtimeSeconds": 8890, "seasonCount": null, "communityRating": 7.947, "criticRating": 93, "genres": ["Action", "Adventure", "Science Fiction"], "overview": "Peter Parker is unmasked and no longer able to separate his normal life from the high-stakes of being a super-hero. When he asks for help from Doctor Strange the stakes become even more dangerous, forcing him to\u2026", "poster": "assets/movie-spider-man-no-way-home-2021-poster.jpg", "backdrop": "assets/movie-spider-man-no-way-home-2021-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-10T14:21:05.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 634649, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 93, "rtCriticsLabel": "Certified Fresh", "rtAudience": 97, "rtAudienceLabel": "Upright", "imdb": 8.1, "imdbVotes": 1082660, "tmdb": 7.947, "tmdbVotes": 23505}}, {"id": "movie-spirited-away-2001", "title": "Spirited Away", "year": 2001, "type": "movie", "runtimeSeconds": 7472, "seasonCount": null, "communityRating": 8.535, "criticRating": 96, "genres": ["Animation", "Family", "Fantasy"], "overview": "A young girl, Chihiro, becomes trapped in a strange new world of spirits. When her parents undergo a mysterious transformation, she must call upon the courage she never knew she had to free her family.", "poster": "assets/movie-spirited-away-2001-poster.jpg", "backdrop": "assets/movie-spirited-away-2001-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:22:42.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 129, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 96, "rtCriticsLabel": "Certified Fresh", "rtAudience": 99, "rtAudienceLabel": "Upright", "imdb": 8.6, "imdbVotes": 987658, "tmdb": 8.536, "tmdbVotes": 19176}}, {"id": "movie-the-substance-2024", "title": "The Substance", "year": 2024, "type": "movie", "runtimeSeconds": 8531, "seasonCount": null, "communityRating": 7.135, "criticRating": 89, "genres": ["Horror", "Science Fiction"], "overview": "A fading celebrity decides to use a black market drug, a cell-replicating substance that temporarily creates a younger, better version of herself.", "poster": "assets/movie-the-substance-2024-poster.jpg", "backdrop": "assets/movie-the-substance-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:30:22.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 933260, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 89, "rtCriticsLabel": "Certified Fresh", "rtAudience": 76, "rtAudienceLabel": "Upright", "imdb": 7.2, "imdbVotes": 466542, "tmdb": 7.13, "tmdbVotes": 6746}}, {"id": "movie-the-tale-of-the-princess-kaguya-2013", "title": "The Tale of The Princess Kaguya", "year": 2013, "type": "movie", "runtimeSeconds": 8218, "seasonCount": null, "communityRating": 8.1, "criticRating": 100, "genres": ["Animation", "Drama", "Fantasy"], "overview": "Found inside a shining stalk of bamboo by an old bamboo cutter and his wife, a tiny girl grows rapidly into an exquisite young lady. The mysterious young princess enthrals all who encounter her. But, ultimately, she\u2026", "poster": "assets/movie-the-tale-of-the-princess-kaguya-2013-poster.jpg", "backdrop": "assets/movie-the-tale-of-the-princess-kaguya-2013-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-07T13:40:15.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 149871, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Certified Fresh", "rtAudience": 90, "rtAudienceLabel": "Upright", "imdb": 8, "imdbVotes": 63574, "tmdb": 8.083, "tmdbVotes": 2079}}, {"id": "movie-taxi-driver-1976", "title": "Taxi Driver", "year": 1976, "type": "movie", "runtimeSeconds": 6831, "seasonCount": null, "communityRating": 8.121, "criticRating": 89, "genres": ["Crime", "Drama"], "overview": "Suffering from insomnia, disturbed loner Travis Bickle takes a job as a New York City cabbie, haunting the streets nightly, growing increasingly detached from reality as he dreams of cleaning up the filthy city.", "poster": "assets/movie-taxi-driver-1976-poster.jpg", "backdrop": "assets/movie-taxi-driver-1976-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:23:04.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 103, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 89, "rtCriticsLabel": "Certified Fresh", "rtAudience": 93, "rtAudienceLabel": "Upright", "imdb": 8.2, "imdbVotes": 1046700, "tmdb": 8.116, "tmdbVotes": 13973}}, {"id": "movie-triangle-of-sadness-2022", "title": "Triangle of Sadness", "year": 2022, "type": "movie", "runtimeSeconds": 8833, "seasonCount": null, "communityRating": 7.005, "criticRating": 71, "genres": ["Comedy", "Drama"], "overview": "A celebrity model couple are invited on a luxury cruise for the uber-rich, helmed by an unhinged, alcoholic captain. What first appears Instagrammable ends catastrophically, leaving the survivors stranded on a desert\u2026", "poster": "assets/movie-triangle-of-sadness-2022-poster.jpg", "backdrop": "assets/movie-triangle-of-sadness-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:29:00.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 497828, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 72, "rtCriticsLabel": "Fresh", "rtAudience": 80, "rtAudienceLabel": "Upright", "imdb": 7.2, "imdbVotes": 221069, "tmdb": 7, "tmdbVotes": 3104}}, {"id": "movie-tsotsi-2005", "title": "Tsotsi", "year": 2005, "type": "movie", "runtimeSeconds": 5663, "seasonCount": null, "communityRating": 6.937, "criticRating": 82, "genres": ["Crime", "Drama"], "overview": "A young South African boy from the Johannesburg ghetto named Tsotsi, meaning Gangster, leaves home as a child to get away from his helpless parents. Now a teenage thug, Tsotsi finds a baby in the back seat of a car\u2026", "poster": "assets/movie-tsotsi-2005-poster.jpg", "backdrop": "assets/movie-tsotsi-2005-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:24:58.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 868, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 82, "rtCriticsLabel": "Certified Fresh", "rtAudience": 86, "rtAudienceLabel": "Upright", "imdb": 7.2, "imdbVotes": 32526, "tmdb": 6.941, "tmdbVotes": 372}}, {"id": "movie-unknown-number-the-high-school-catfish-2025", "title": "Unknown Number: The High School Catfish", "year": 2025, "type": "movie", "runtimeSeconds": 5691, "seasonCount": null, "communityRating": 6.746, "criticRating": 85, "genres": ["Documentary"], "overview": "Vulgar, taunting texts blow up the phones of a teen and her boyfriend. Who's sending them \u2014 and why? This twisty documentary reveals the shocking answer.", "poster": "assets/movie-unknown-number-the-high-school-catfish-2025-poster.jpg", "backdrop": "assets/movie-unknown-number-the-high-school-catfish-2025-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:32:10.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 1517772, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 85, "rtCriticsLabel": "Fresh", "rtAudience": 69, "rtAudienceLabel": "Upright", "imdb": 6.7, "imdbVotes": 23039, "tmdb": 6.737, "tmdbVotes": 257}}, {"id": "movie-the-uprising-2026", "title": "The Uprising", "year": 2026, "type": "movie", "runtimeSeconds": 7684, "seasonCount": null, "communityRating": 7.846, "criticRating": 55, "genres": ["Action", "Drama", "History"], "overview": "As war and plague devastate 14th-century England, a humble peasant ignites a rebellion, uniting an army of commoners to defy the King\u2019s might in a desperate fight for justice and survival.", "poster": "assets/movie-the-uprising-2026-poster.jpg", "backdrop": "assets/movie-the-uprising-2026-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-11-02T16:37:03.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 977942, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 57, "rtCriticsLabel": "Rotten", "rtAudience": 65, "rtAudienceLabel": "Upright", "imdb": 6.2, "imdbVotes": 6622, "tmdb": 7.8, "tmdbVotes": 404}}, {"id": "movie-vice-2018", "title": "Vice", "year": 2018, "type": "movie", "runtimeSeconds": 7948, "seasonCount": null, "communityRating": 7.013, "criticRating": 64, "genres": ["Drama", "Comedy", "History"], "overview": "George W. Bush picks Dick Cheney, the CEO of Halliburton Co., to be his Republican running mate in the 2000 presidential election. No stranger to politics, Cheney's impressive r\u00e9sum\u00e9 includes stints as White House\u2026", "poster": "assets/movie-vice-2018-poster.jpg", "backdrop": "assets/movie-vice-2018-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:28:30.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 429197, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 64, "rtCriticsLabel": "Fresh", "rtAudience": 61, "rtAudienceLabel": "Upright", "imdb": 7.2, "imdbVotes": 177299, "tmdb": 7.014, "tmdbVotes": 3723}}, {"id": "movie-warriors-of-the-wind-1984", "title": "Warriors of the Wind", "year": 1984, "type": "movie", "runtimeSeconds": 7022, "seasonCount": null, "communityRating": 7.941, "criticRating": 87, "genres": ["Adventure", "Animation", "Fantasy"], "overview": "After a global war, the seaside kingdom known as the Valley of the Wind remains one of the last strongholds on Earth untouched by a poisonous jungle and the powerful insects that guard it. Led by the courageous\u2026", "poster": "assets/movie-warriors-of-the-wind-1984-poster.jpg", "backdrop": "assets/movie-warriors-of-the-wind-1984-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:22:20.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 81, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 87, "rtCriticsLabel": "Fresh", "rtAudience": 91, "rtAudienceLabel": "Upright", "imdb": 8, "imdbVotes": 200154, "tmdb": 7.94, "tmdbVotes": 4260}}, {"id": "movie-we-bury-the-dead-2026", "title": "We Bury the Dead", "year": 2026, "type": "movie", "runtimeSeconds": 5693, "seasonCount": null, "communityRating": 6.153, "criticRating": 87, "genres": ["Horror", "Thriller"], "overview": "After a catastrophic military disaster reanimates the dead, Ava searches for her missing husband amidst an increasingly hostile world.", "poster": "assets/movie-we-bury-the-dead-2026-poster.jpg", "backdrop": "assets/movie-we-bury-the-dead-2026-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-08-26T05:36:00.4272328Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 1198984, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 87, "rtCriticsLabel": "Certified Fresh", "rtAudience": 45, "rtAudienceLabel": "Spilled", "imdb": 5.6, "imdbVotes": 20693, "tmdb": 6.137, "tmdbVotes": 433}}, {"id": "movie-white-hot-the-rise-fall-of-abercrombie-fitch-2022", "title": "White Hot: The Rise & Fall of Abercrombie & Fitch", "year": 2022, "type": "movie", "runtimeSeconds": 5187, "seasonCount": null, "communityRating": 5.663, "criticRating": 66, "genres": ["Documentary"], "overview": "All the cool kids were wearing it. This documentary explores A&F's pop culture reign in the late '90s and early 2000s and how it thrived on exclusion.", "poster": "assets/movie-white-hot-the-rise-fall-of-abercrombie-fitch-2022-poster.jpg", "backdrop": "assets/movie-white-hot-the-rise-fall-of-abercrombie-fitch-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:30:34.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 953371, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 66, "rtCriticsLabel": "Fresh", "rtAudience": 29, "rtAudienceLabel": "Spilled", "imdb": 5.8, "imdbVotes": 5655, "tmdb": 5.7, "tmdbVotes": 102}}, {"id": "movie-the-wild-robot-2024", "title": "The Wild Robot", "year": 2024, "type": "movie", "runtimeSeconds": 6101, "seasonCount": null, "communityRating": 8.308, "criticRating": 97, "genres": ["Family", "Animation", "Science Fiction"], "overview": "After a shipwreck, an intelligent robot called Roz is stranded on an uninhabited island. To survive the harsh environment, Roz bonds with the island's animals and cares for an orphaned baby goose.", "poster": "assets/movie-the-wild-robot-2024-poster.jpg", "backdrop": "assets/movie-the-wild-robot-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:31:49.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 1184918, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 97, "rtCriticsLabel": "Certified Fresh", "rtAudience": 98, "rtAudienceLabel": "Upright", "imdb": 8.1, "imdbVotes": 241134, "tmdb": 8.309, "tmdbVotes": 6819}}, {"id": "movie-the-wind-rises-2013", "title": "The Wind Rises", "year": 2013, "type": "movie", "runtimeSeconds": 8031, "seasonCount": null, "communityRating": 7.784, "criticRating": 88, "genres": ["Animation", "Drama", "History"], "overview": "A lifelong love of flight inspires Japanese aviation engineer Jiro Horikoshi, whose storied career includes the creation of the A-6M World War II fighter plane.", "poster": "assets/movie-the-wind-rises-2013-poster.jpg", "backdrop": "assets/movie-the-wind-rises-2013-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:24:38.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 149870, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 89, "rtCriticsLabel": "Certified Fresh", "rtAudience": 85, "rtAudienceLabel": "Upright", "imdb": 7.8, "imdbVotes": 115756, "tmdb": 7.783, "tmdbVotes": 3536}}, {"id": "movie-the-witcher-nightmare-of-the-wolf-2021", "title": "The Witcher: Nightmare of the Wolf", "year": 2021, "type": "movie", "runtimeSeconds": 5015, "seasonCount": null, "communityRating": 7.422, "criticRating": 100, "genres": ["Animation", "Action", "Fantasy"], "overview": "Escaping from poverty to become a witcher, Vesemir slays monsters for coin and glory, but when a new menace rises, he must face the demons of his past.", "poster": "assets/movie-the-witcher-nightmare-of-the-wolf-2021-poster.jpg", "backdrop": "assets/movie-the-witcher-nightmare-of-the-wolf-2021-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-26T04:42:03.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 666243, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": 83, "rtAudienceLabel": "Upright", "imdb": 7.2, "imdbVotes": 55759, "tmdb": 7.407, "tmdbVotes": 1159}}, {"id": "movie-the-witcher-sirens-of-the-deep-2025", "title": "The Witcher: Sirens of the Deep", "year": 2025, "type": "movie", "runtimeSeconds": 5517, "seasonCount": null, "communityRating": 6.8, "criticRating": 60, "genres": ["Animation", "Fantasy", "Action"], "overview": "When human sailors are attacked by mysterious creatures of the deep, only one person can stop the war between land and sea: the Witcher, Geralt of Rivia.", "poster": "assets/movie-the-witcher-sirens-of-the-deep-2025-poster.jpg", "backdrop": "assets/movie-the-witcher-sirens-of-the-deep-2025-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-26T05:03:26.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 1203329, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 60, "rtCriticsLabel": "Fresh", "rtAudience": 37, "rtAudienceLabel": "Spilled", "imdb": 6.1, "imdbVotes": 11799, "tmdb": 6.741, "tmdbVotes": 303}}], "shows": [{"id": "tv-adults-2025", "title": "Adults", "year": 2025, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 6.495, "criticRating": null, "genres": ["Comedy"], "overview": "A group of twenty-somethings in New York trying to be good people, despite being neither \"good\" nor \"people\" yet.", "poster": "assets/tv-adults-2025-poster.jpg", "backdrop": "assets/tv-adults-2025-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-23T21:01:41.1814765Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 260458, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 88, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.5, "tmdbVotes": 102}}, {"id": "tv-american-crime-story-2016", "title": "American Crime Story", "year": 2016, "type": "tv", "runtimeSeconds": null, "seasonCount": 3, "communityRating": 7.573, "criticRating": null, "genres": ["Crime", "Drama"], "overview": "An anthology series centered around some of history's most famous criminal investigations.", "poster": "assets/tv-american-crime-story-2016-poster.jpg", "backdrop": "assets/tv-american-crime-story-2016-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0490488Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 64513, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 84, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.585, "tmdbVotes": 1094}}, {"id": "tv-american-primeval-2025", "title": "American Primeval", "year": 2025, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.691, "criticRating": null, "genres": ["Western", "Drama", "Action & Adventure"], "overview": "A mother and son fleeing from their past form a found family while confronting a harsh landscape of freedom and cruelty in the American West.", "poster": "assets/tv-american-primeval-2025-poster.jpg", "backdrop": "assets/tv-american-primeval-2025-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0573221Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 215866, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 71, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.741, "tmdbVotes": 538}}, {"id": "tv-arrested-development-2003", "title": "Arrested Development", "year": 2003, "type": "tv", "runtimeSeconds": null, "seasonCount": 5, "communityRating": 7.925, "criticRating": null, "genres": ["Comedy"], "overview": "The story of a wealthy family that lost everything, and the one son who had no choice but to keep them all together.", "poster": "assets/tv-arrested-development-2003-poster.jpg", "backdrop": "assets/tv-arrested-development-2003-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0513048Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 4589, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 75, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.906, "tmdbVotes": 1793}}, {"id": "tv-avatar-the-last-airbender-2005", "title": "Avatar: The Last Airbender", "year": 2005, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 3, "communityRating": 8.78, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "In a war-torn world of elemental magic, a young boy reawakens to undertake a dangerous mystic quest to fulfill his destiny as the Avatar, and bring peace to the world.", "poster": "assets/tv-avatar-the-last-airbender-2005-poster.jpg", "backdrop": "assets/tv-avatar-the-last-airbender-2005-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0504536Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 246, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.787, "tmdbVotes": 5215}}, {"id": "tv-b-b-vol-liefde-2021", "title": "B&B Vol Liefde", "year": 2021, "type": "tv", "runtimeSeconds": 3720, "seasonCount": 1, "communityRating": 7.2, "criticRating": null, "genres": ["Reality"], "overview": "B&B Vol Liefde is a heartwarming adventure, full of romance, excitement, and unforgettable moments. Adventurous Dutch B&B owners abroad throw their doors wide open and let their hearts speak, determined to find that\u2026", "poster": "assets/tv-b-b-vol-liefde-2021-poster.jpg", "backdrop": "assets/tv-b-b-vol-liefde-2021-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-31T22:30:11.129657Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 124428, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.2, "tmdbVotes": 10}}, {"id": "tv-the-bear-2022", "title": "The Bear", "year": 2022, "type": "tv", "runtimeSeconds": null, "seasonCount": 5, "communityRating": 8.139, "criticRating": null, "genres": ["Drama", "Comedy"], "overview": "Carmy, a young fine-dining chef, comes home to Chicago to run his family sandwich shop. As he fights to transform the shop and himself, he works alongside a rough-around-the-edges crew that ultimately reveal\u2026", "poster": "assets/tv-the-bear-2022-poster.jpg", "backdrop": "assets/tv-the-bear-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0467371Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 136315, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 93, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.158, "tmdbVotes": 2025}}, {"id": "tv-blue-planet-ii-2017", "title": "Blue Planet II", "year": 2017, "type": "tv", "runtimeSeconds": 3600, "seasonCount": 1, "communityRating": 8.64, "criticRating": null, "genres": ["Documentary", "Family"], "overview": "There is nowhere more powerful and unforgiving yet more beautiful and compelling than the ocean. Join us and explore the greatest yet least known parts of our planet.", "poster": "assets/tv-blue-planet-ii-2017-poster.jpg", "backdrop": "assets/tv-blue-planet-ii-2017-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0634849Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 74313, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 97, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.6, "tmdbVotes": 380}}, {"id": "tv-breeders-2020", "title": "Breeders", "year": 2020, "type": "tv", "runtimeSeconds": 1500, "seasonCount": 4, "communityRating": 6.8, "criticRating": null, "genres": ["Comedy"], "overview": "Exposing the parental-paradox that it is possible, in the very same moment, to love your child to the horizon of the universe, while being apoplectically angry enough to want to send them there.", "poster": "assets/tv-breeders-2020-poster.jpg", "backdrop": "assets/tv-breeders-2020-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-28T18:54:21.8885435Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 93290, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 92, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.8, "tmdbVotes": 104}}, {"id": "tv-chernobyl-2019", "title": "Chernobyl", "year": 2019, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 8.718, "criticRating": null, "genres": ["Drama"], "overview": "The true story of one of the worst man-made catastrophes in history: the catastrophic nuclear accident at Chernobyl. A tale of the brave men and women who sacrificed to save Europe from unimaginable disaster.", "poster": "assets/tv-chernobyl-2019-poster.jpg", "backdrop": "assets/tv-chernobyl-2019-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0469903Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 87108, "artwork": {"posterKind": "poster", "backdropKind": "center_crop"}, "ratings": {"rtCritics": 95, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.731, "tmdbVotes": 8496}}, {"id": "tv-community-2009", "title": "Community", "year": 2009, "type": "tv", "runtimeSeconds": null, "seasonCount": 6, "communityRating": 7.997, "criticRating": null, "genres": ["Comedy"], "overview": "Follow the lives of a group of students at what is possibly the world\u2019s worst community college in the fictional locale of Greendale, Colorado.", "poster": "assets/tv-community-2009-poster.jpg", "backdrop": "assets/tv-community-2009-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0531416Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 18347, "artwork": {"posterKind": "center_crop", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 88, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.994, "tmdbVotes": 2226}}, {"id": "tv-dead-end-job-2026", "title": "Dead-End Job", "year": 2026, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.7, "criticRating": null, "genres": ["Mystery"], "overview": "Drowning in debt, a young man takes lucrative part-time jobs from a mysterious agency \u2014 only to find his hustle for more money turning nightmarish.", "poster": "assets/tv-dead-end-job-2026-poster.jpg", "backdrop": "assets/tv-dead-end-job-2026-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-11-04T18:58:01.8352857Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 289517, "artwork": {"posterKind": "center_crop", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.7, "tmdbVotes": 38}}, {"id": "tv-dopesick-2021", "title": "Dopesick", "year": 2021, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 8.015, "criticRating": null, "genres": ["Crime", "Drama"], "overview": "The story of how one company triggered the worst drug epidemic in American history. Look into the epicenter of America's struggle with opioid addiction, from a distressed Virginia mining community, to the hallways of\u2026", "poster": "assets/tv-dopesick-2021-poster.jpg", "backdrop": "assets/tv-dopesick-2021-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0580168Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 110695, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 89, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.035, "tmdbVotes": 565}}, {"id": "tv-dune-prophecy-2024", "title": "Dune: Prophecy", "year": 2024, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.213, "criticRating": null, "genres": ["Sci-Fi & Fantasy", "Drama", "Action & Adventure"], "overview": "Ten thousand years before the ascension of Paul Atreides, sisters Valya and Tula Harkonnen establish the fabled sect and female order that would become known as the Bene Gesserit to control the future of humankind.", "poster": "assets/tv-dune-prophecy-2024-poster.jpg", "backdrop": "assets/tv-dune-prophecy-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-11-04T18:23:01.2908568Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 90228, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 64, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.213, "tmdbVotes": 677}}, {"id": "tv-east-of-eden-2026", "title": "East of Eden", "year": 2026, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.931, "criticRating": null, "genres": ["Drama", "Crime"], "overview": "In the early 1900s, a fiercely independent woman drives a rift between two brothers, igniting a saga that unfolds across two generations.", "poster": "assets/tv-east-of-eden-2026-poster.jpg", "backdrop": "assets/tv-east-of-eden-2026-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-23T18:07:11.3965096Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 258165, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 88, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.982, "tmdbVotes": 139}}, {"id": "tv-fallout-2024", "title": "Fallout", "year": 2024, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 8.124, "criticRating": null, "genres": ["Action & Adventure", "Sci-Fi & Fantasy", "Drama"], "overview": "The story of haves and have-nots in a world in which there's almost nothing left to have. 200 years after the apocalypse, the gentle denizens of luxury fallout shelters are forced to return to the irradiated\u2026", "poster": "assets/tv-fallout-2024-poster.jpg", "backdrop": "assets/tv-fallout-2024-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0669262Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 106379, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 94, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.106, "tmdbVotes": 3233}}, {"id": "tv-fawlty-towers-1975", "title": "Fawlty Towers", "year": 1975, "type": "tv", "runtimeSeconds": 1800, "seasonCount": 2, "communityRating": 8.295, "criticRating": null, "genres": ["Comedy"], "overview": "Owner Basil Fawlty, his wife Sybil, a chambermaid Polly, and Spanish waiter Manuel attempt to run their hotel amidst farcical situations and an array of demanding guests.", "poster": "assets/tv-fawlty-towers-1975-poster.jpg", "backdrop": "assets/tv-fawlty-towers-1975-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-19T23:26:39.7658344Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 2207, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.296, "tmdbVotes": 867}}, {"id": "tv-the-final-problem-2026", "title": "The Final Problem", "year": 2026, "type": "tv", "runtimeSeconds": 3420, "seasonCount": 1, "communityRating": 7.041, "criticRating": null, "genres": ["Crime", "Mystery"], "overview": "In the spring of 1959, a retired actor, who previously played Sherlock Holmes, must investigate a murder at a small hotel on a tiny island near Mallorca.", "poster": "assets/tv-the-final-problem-2026-poster.jpg", "backdrop": "assets/tv-the-final-problem-2026-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-11-04T19:16:12.4822791Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 290720, "artwork": {"posterKind": "center_crop", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.041, "tmdbVotes": 49}}, {"id": "tv-the-four-seasons-2025", "title": "The Four Seasons", "year": 2025, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 6.879, "criticRating": null, "genres": ["Comedy"], "overview": "The decades-long friendship between three married couples is tested when one divorces, complicating their tradition of quarterly weekend getaways.", "poster": "assets/tv-the-four-seasons-2025-poster.jpg", "backdrop": "assets/tv-the-four-seasons-2025-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-28T18:36:42.5326384Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 243316, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 81, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.892, "tmdbVotes": 204}}, {"id": "tv-the-get-down-2016", "title": "The Get Down", "year": 2016, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.3, "criticRating": null, "genres": ["Drama"], "overview": "In 1977 New York City, the talented and soulful youth of the South Bronx chase dreams and breakneck beats to transform music history.", "poster": "assets/tv-the-get-down-2016-poster.jpg", "backdrop": "assets/tv-the-get-down-2016-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0497737Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 65345, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 81, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.3, "tmdbVotes": 196}}, {"id": "tv-how-to-change-your-mind-2022", "title": "How to Change Your Mind", "year": 2022, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.76, "criticRating": null, "genres": ["Documentary", "Mystery"], "overview": "Author Michael Pollan leads the way in this docuseries exploring the history and uses of psychedelics, including LSD, psilocybin, MDMA and mescaline.", "poster": "assets/tv-how-to-change-your-mind-2022-poster.jpg", "backdrop": "assets/tv-how-to-change-your-mind-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0601399Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 204304, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.8, "tmdbVotes": 53}}, {"id": "tv-interrogation-2020", "title": "Interrogation", "year": 2020, "type": "tv", "runtimeSeconds": 2700, "seasonCount": 1, "communityRating": 6.788, "criticRating": null, "genres": ["Drama", "Crime"], "overview": "Based on a true story that spanned more than 30 years in which a young man was charged and convicted of brutally murdering his mother. Each episode is structured around an interrogation taken directly from the real\u2026", "poster": "assets/tv-interrogation-2020-poster.jpg", "backdrop": "assets/tv-interrogation-2020-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-26T05:52:06.1068207Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 96488, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 57, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.9, "tmdbVotes": 54}}, {"id": "tv-it-s-always-sunny-in-philadelphia-2005", "title": "It's Always Sunny in Philadelphia", "year": 2005, "type": "tv", "runtimeSeconds": null, "seasonCount": 18, "communityRating": 8.2, "criticRating": null, "genres": ["Comedy"], "overview": "Four egocentric friends run a neighborhood Irish pub in Philadelphia and try to find their way through the adult world of work and relationships. Unfortunately, their warped views and precarious judgments often lead\u2026", "poster": "assets/tv-it-s-always-sunny-in-philadelphia-2005-poster.jpg", "backdrop": "assets/tv-it-s-always-sunny-in-philadelphia-2005-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0485774Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 2710, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 94, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.253, "tmdbVotes": 1457}}, {"id": "tv-a-knight-of-the-seven-kingdoms-2026", "title": "A Knight of the Seven Kingdoms", "year": 2026, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 8.431, "criticRating": null, "genres": ["Drama", "Sci-Fi & Fantasy", "Action & Adventure"], "overview": "A century before the events of Game of Thrones, two unlikely heroes wandered Westeros: a young, naive but courageous knight, Ser Duncan the Tall, and his diminutive squire, Egg. Set in an age when the Targaryen line\u2026", "poster": "assets/tv-a-knight-of-the-seven-kingdoms-2026-poster.jpg", "backdrop": "assets/tv-a-knight-of-the-seven-kingdoms-2026-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0493344Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 224372, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 94, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.4, "tmdbVotes": 1266}}, {"id": "tv-landman-2024", "title": "Landman", "year": 2024, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 8.194, "criticRating": null, "genres": ["Drama"], "overview": "Set in the proverbial boomtowns of West-Texas and a modern-day tale of fortune-seeking in the world of oil rigs, the series is an upstairs/downstairs story of roughnecks and wildcat billionaires that are fueling a\u2026", "poster": "assets/tv-landman-2024-poster.jpg", "backdrop": "assets/tv-landman-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-22T21:38:18.0973875Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 157741, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 80, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.209, "tmdbVotes": 668}}, {"id": "tv-the-last-of-us-2023", "title": "The Last of Us", "year": 2023, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 8.419, "criticRating": null, "genres": ["Drama"], "overview": "Twenty years after modern civilization has been destroyed, Joel, a hardened survivor, is hired to smuggle Ellie, a 14-year-old girl, out of an oppressive quarantine zone. What starts as a small job soon becomes a\u2026", "poster": "assets/tv-the-last-of-us-2023-poster.jpg", "backdrop": "assets/tv-the-last-of-us-2023-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0640955Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 100088, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 94, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.4, "tmdbVotes": 7485}}, {"id": "tv-last-samurai-standing-2025", "title": "Last Samurai Standing", "year": 2025, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.805, "criticRating": null, "genres": ["Action & Adventure", "Drama"], "overview": "In the early Meiji era, Shujiro, once known as an undefeated samurai, decides to participate in a deadly survival game to save his family and villagers.", "poster": "assets/tv-last-samurai-standing-2025-poster.jpg", "backdrop": "assets/tv-last-samurai-standing-2025-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0622526Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 252193, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.812, "tmdbVotes": 271}}, {"id": "tv-lord-of-the-flies-2026", "title": "Lord of the Flies", "year": 2026, "type": "tv", "runtimeSeconds": 3480, "seasonCount": 1, "communityRating": 7.571, "criticRating": null, "genres": ["Drama", "Action & Adventure"], "overview": "After a plane crash, a group of schoolboys find themselves stranded on a tropical island without adults. Ralph is elected leader and, with the help of the intelligent Piggy, strives to maintain order and\u2026", "poster": "assets/tv-lord-of-the-flies-2026-poster.jpg", "backdrop": "assets/tv-lord-of-the-flies-2026-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0645648Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 270572, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 91, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.58, "tmdbVotes": 138}}, {"id": "tv-the-lord-of-the-rings-the-rings-of-power-2022", "title": "The Lord of the Rings: The Rings of Power", "year": 2022, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 7.233, "criticRating": null, "genres": ["Action & Adventure", "Sci-Fi & Fantasy", "Drama"], "overview": "Beginning in a time of relative peace, we follow an ensemble cast of characters as they confront the re-emergence of evil to Middle-earth. From the darkest depths of the Misty Mountains, to the majestic forests of\u2026", "poster": "assets/tv-the-lord-of-the-rings-the-rings-of-power-2022-poster.jpg", "backdrop": "assets/tv-the-lord-of-the-rings-the-rings-of-power-2022-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0618904Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 84773, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 84, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.211, "tmdbVotes": 4003}}, {"id": "tv-love-death-robots-2019", "title": "Love, Death & Robots", "year": 2019, "type": "tv", "runtimeSeconds": null, "seasonCount": 4, "communityRating": 8.21, "criticRating": null, "genres": ["Animation", "Sci-Fi & Fantasy"], "overview": "Terrifying creatures, wicked surprises and dark comedy converge in this NSFW anthology of animated stories presented by Tim Miller and David Fincher.", "poster": "assets/tv-love-death-robots-2019-poster.jpg", "backdrop": "assets/tv-love-death-robots-2019-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0460964Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 86831, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 89, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.203, "tmdbVotes": 4238}}, {"id": "tv-maniac-2018", "title": "Maniac", "year": 2018, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.338, "criticRating": null, "genres": ["Comedy", "Drama", "Sci-Fi & Fantasy"], "overview": "Two strangers are drawn to a mysterious pharmaceutical trial that will, they're assured, with no complications or side-effects whatsoever, solve all of their problems, permanently. Things do not go as planned.", "poster": "assets/tv-maniac-2018-poster.jpg", "backdrop": "assets/tv-maniac-2018-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-20T19:42:30.955548Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 73411, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 84, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.323, "tmdbVotes": 1073}}, {"id": "tv-mussolini-son-of-the-century-2024", "title": "Mussolini: Son of the Century", "year": 2024, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 8.135, "criticRating": null, "genres": ["Drama", "War & Politics"], "overview": "The rise of Benito Mussolini and the birth of fascism in Italy, chronicling a country\u2019s surrender to dictatorship and the relentless ascent of a man who rose from his ashes time and again.", "poster": "assets/tv-mussolini-son-of-the-century-2024-poster.jpg", "backdrop": "assets/tv-mussolini-son-of-the-century-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0586588Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 212333, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 96, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.1, "tmdbVotes": 179}}, {"id": "tv-the-night-manager-2016", "title": "The Night Manager", "year": 2016, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 7.74, "criticRating": null, "genres": ["Drama", "Mystery", "Crime"], "overview": "Former British soldier Jonathan Pine navigates the shadowy recesses of Whitehall and Washington where an unholy alliance operates between the intelligence community and the secret arms trade. To infiltrate the inner\u2026", "poster": "assets/tv-the-night-manager-2016-poster.jpg", "backdrop": "assets/tv-the-night-manager-2016-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0663672Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 61859, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 88, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.7, "tmdbVotes": 1422}}, {"id": "tv-one-piece-2023", "title": "ONE PIECE", "year": 2023, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 8.118, "criticRating": null, "genres": ["Action & Adventure", "Sci-Fi & Fantasy"], "overview": "With his straw hat and ragtag crew, young pirate Monkey D. Luffy goes on an epic voyage for treasure.", "poster": "assets/tv-one-piece-2023-poster.jpg", "backdrop": "assets/tv-one-piece-2023-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0439378Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 111110, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 93, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.104, "tmdbVotes": 1988}}, {"id": "tv-only-murders-in-the-building-2021", "title": "Only Murders in the Building", "year": 2021, "type": "tv", "runtimeSeconds": null, "seasonCount": 5, "communityRating": 8.438, "criticRating": null, "genres": ["Comedy", "Mystery", "Crime"], "overview": "Three strangers share an obsession with true crime and suddenly find themselves wrapped up in one.", "poster": "assets/tv-only-murders-in-the-building-2021-poster.jpg", "backdrop": "assets/tv-only-murders-in-the-building-2021-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0594614Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 107113, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 95, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.398, "tmdbVotes": 2333}}, {"id": "tv-over-the-garden-wall-2014", "title": "Over the Garden Wall", "year": 2014, "type": "tv", "runtimeSeconds": 720, "seasonCount": 1, "communityRating": 8.6, "criticRating": null, "genres": ["Mystery", "Sci-Fi & Fantasy", "Animation"], "overview": "Two brothers, Wirt and Greg, find themselves lost in the Unknown; a strange forest adrift in time. With the help of a wise old Woodsman and a foul-tempered bluebird named Beatrice, Wirt and Greg must travel across\u2026", "poster": "assets/tv-over-the-garden-wall-2014-poster.jpg", "backdrop": "assets/tv-over-the-garden-wall-2014-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0455757Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 61617, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.6, "tmdbVotes": 1854}}, {"id": "tv-peaky-blinders-2013", "title": "Peaky Blinders", "year": 2013, "type": "tv", "runtimeSeconds": null, "seasonCount": 6, "communityRating": 8.5, "criticRating": null, "genres": ["Drama", "Crime"], "overview": "A gangster family epic set in 1919 Birmingham, England and centered on a gang who sew razor blades in the peaks of their caps, and their fierce boss Tommy Shelby, who means to move up in the world.", "poster": "assets/tv-peaky-blinders-2013-poster.jpg", "backdrop": "assets/tv-peaky-blinders-2013-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0539185Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 60574, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 93, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.5, "tmdbVotes": 11506}}, {"id": "tv-the-pitt-2025", "title": "The Pitt", "year": 2025, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 8.729, "criticRating": null, "genres": ["Drama"], "overview": "The staff of Pittsburgh's Trauma Medical Center work around the clock to save lives in an overcrowded and underfunded emergency department.", "poster": "assets/tv-the-pitt-2025-poster.jpg", "backdrop": "assets/tv-the-pitt-2025-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0582942Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 250307, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 96, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.731, "tmdbVotes": 1046}}, {"id": "tv-pluribus-2025", "title": "Pluribus", "year": 2025, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.936, "criticRating": null, "genres": ["Drama", "Sci-Fi & Fantasy"], "overview": "The most miserable person on Earth must save the world from happiness.", "poster": "assets/tv-pluribus-2025-poster.jpg", "backdrop": "assets/tv-pluribus-2025-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0674462Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 225171, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 99, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.921, "tmdbVotes": 1191}}, {"id": "tv-ripley-2024", "title": "RIPLEY", "year": 2024, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.911, "criticRating": null, "genres": ["Drama"], "overview": "A grifter in 1960s New York is hired to convince a wealthy man's son to return home from Italy and begins a life of deceit, fraud and murder.", "poster": "assets/tv-ripley-2024-poster.jpg", "backdrop": "assets/tv-ripley-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0658942Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 94028, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 86, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.909, "tmdbVotes": 453}}, {"id": "tv-say-nothing-2024", "title": "Say Nothing", "year": 2024, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.462, "criticRating": null, "genres": ["Drama", "Crime"], "overview": "Through the eyes of various Irish Republican Army (IRA) members, explore the extremes some people will go to in the name of their beliefs, the way a deeply divided society can suddenly tip over into armed conflict,\u2026", "poster": "assets/tv-say-nothing-2024-poster.jpg", "backdrop": "assets/tv-say-nothing-2024-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0499752Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 245256, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 94, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.475, "tmdbVotes": 122}}, {"id": "tv-severance-2022", "title": "Severance", "year": 2022, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 8.396, "criticRating": null, "genres": ["Drama", "Mystery", "Sci-Fi & Fantasy"], "overview": "Mark leads a team of office workers whose memories have been surgically divided between their work and personal lives. When a mysterious colleague appears outside of work, it begins a journey to discover the truth\u2026", "poster": "assets/tv-severance-2022-poster.jpg", "backdrop": "assets/tv-severance-2022-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0598478Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 95396, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 95, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.389, "tmdbVotes": 3095}}, {"id": "tv-six-feet-under-2001", "title": "Six Feet Under", "year": 2001, "type": "tv", "runtimeSeconds": null, "seasonCount": 5, "communityRating": 8.115, "criticRating": null, "genres": ["Drama"], "overview": "When death is your business, what is your life? For the Fisher family, the world outside of their family-owned funeral home continues to be at least as challenging as\u2014and far less predictable than\u2014the one inside.", "poster": "assets/tv-six-feet-under-2001-poster.jpg", "backdrop": "assets/tv-six-feet-under-2001-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-26T04:04:05.108787Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 1274, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 81, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.1, "tmdbVotes": 1192}}, {"id": "tv-undercover-2019", "title": "Undercover", "year": 2019, "type": "tv", "runtimeSeconds": 3000, "seasonCount": 3, "communityRating": 7.5, "criticRating": null, "genres": ["Crime", "Drama"], "overview": "Undercover agents infiltrate a drug kingpin's operation by posing as a couple at the campground where he spends his weekends. Inspired by real events.", "poster": "assets/tv-undercover-2019-poster.jpg", "backdrop": "assets/tv-undercover-2019-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0570153Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 86942, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.5, "tmdbVotes": 186}}, {"id": "tv-upload-2020", "title": "Upload", "year": 2020, "type": "tv", "runtimeSeconds": null, "seasonCount": 4, "communityRating": 7.781, "criticRating": null, "genres": ["Comedy", "Sci-Fi & Fantasy", "Drama"], "overview": "In 2033, people who are near death can be \u201cuploaded\u201d into virtual reality hotels run by 6 tech firms. Cash-strapped Nora lives in Brooklyn and works customer service for the luxurious \u201cLakeview\u201d digital afterlife.\u2026", "poster": "assets/tv-upload-2020-poster.jpg", "backdrop": "assets/tv-upload-2020-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0566967Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 86248, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 87, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.757, "tmdbVotes": 1511}}, {"id": "tv-the-walk-in-2022", "title": "The Walk-In", "year": 2022, "type": "tv", "runtimeSeconds": 2760, "seasonCount": 1, "communityRating": 6.9, "criticRating": null, "genres": ["Drama", "Crime"], "overview": "Forced to flee the UK and go into hiding in the 1990s, when he became a mole from within the BNP, Matthew Collins returned to Britain to make a new life for himself, working with young white men in the fight against\u2026", "poster": "assets/tv-the-walk-in-2022-poster.jpg", "backdrop": "assets/tv-the-walk-in-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0654569Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 210664, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 80, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7, "tmdbVotes": 50}}, {"id": "tv-the-white-lotus-2021", "title": "The White Lotus", "year": 2021, "type": "tv", "runtimeSeconds": null, "seasonCount": 3, "communityRating": 7.596, "criticRating": null, "genres": ["Comedy", "Drama", "Mystery"], "overview": "Follow the exploits of various guests and employees at an exclusive tropical resort over the span of a week as with each passing day, a darker complexity emerges in these picture-perfect travelers, the hotel\u2019s\u2026", "poster": "assets/tv-the-white-lotus-2021-poster.jpg", "backdrop": "assets/tv-the-white-lotus-2021-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0546438Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 111803, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 90, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.599, "tmdbVotes": 1604}}, {"id": "tv-widow-s-bay-2026", "title": "Widow's Bay", "year": 2026, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 8.172, "criticRating": null, "genres": ["Drama", "Comedy", "Mystery"], "overview": "The mayor of a New England town resolves to turn it into the next tourist hot spot, despite local warnings that it's cursed.", "poster": "assets/tv-widow-s-bay-2026-poster.jpg", "backdrop": "assets/tv-widow-s-bay-2026-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-20T10:27:12.7003979Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 270476, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 98, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8, "tmdbVotes": 585}}, {"id": "tv-the-witcher-2019", "title": "The Witcher", "year": 2019, "type": "tv", "runtimeSeconds": null, "seasonCount": 4, "communityRating": 7.905, "criticRating": null, "genres": ["Drama", "Action & Adventure"], "overview": "Geralt of Rivia, a mutated monster-hunter for hire, journeys toward his destiny in a turbulent world where people often prove more wicked than beasts.", "poster": "assets/tv-the-witcher-2019-poster.jpg", "backdrop": "assets/tv-the-witcher-2019-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0552369Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 71912, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 75, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.88, "tmdbVotes": 6997}}, {"id": "tv-yellowjackets-2021", "title": "Yellowjackets", "year": 2021, "type": "tv", "runtimeSeconds": null, "seasonCount": 3, "communityRating": 7.449, "criticRating": null, "genres": ["Drama", "Mystery"], "overview": "This equal parts survival epic, psychological horror story and coming-of-age drama tells the saga of a team of wildly talented high school girls soccer players who become the (un)lucky survivors of a plane crash deep\u2026", "poster": "assets/tv-yellowjackets-2021-poster.jpg", "backdrop": "assets/tv-yellowjackets-2021-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0577612Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 117488, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 92, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.431, "tmdbVotes": 1070}}], "anime": [], "resume": [{"id": "episode-episode-1-2025-s1e1", "title": "Episode 1", "year": 2025, "type": "episode", "runtimeSeconds": 3618, "seasonCount": null, "communityRating": 7.357, "criticRating": null, "genres": ["Biography", "Drama", "History"], "overview": "Mussolini had just founded the Fasci di Combattimento when D'Annunzio took Fiume. Mussolini hoped to capitalize on the poet's achievement in the electoral arena.", "poster": "assets/episode-episode-1-2025-s1e1-poster.jpg", "backdrop": "assets/episode-episode-1-2025-s1e1-backdrop.jpg", "played": false, "playCount": 1, "resumeSeconds": 489.55, "progress": 0.1353, "addedAt": "2026-07-17T13:52:48.0000000Z", "lastPlayedAt": "2026-11-04T19:01:49.9731435Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "Mussolini: Son of the Century", "seasonNumber": 1, "episodeNumber": 1, "artwork": {"posterKind": "center_crop", "backdropKind": "backdrop"}}], "nextUp": [{"id": "episode-no-honeymoon-2020-s1e8", "title": "No Honeymoon", "year": 2020, "type": "episode", "runtimeSeconds": 1499, "seasonCount": null, "communityRating": 6.7, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "Paul attempts to focus on his upcoming wedding but is constantly thwarted by his family; Ally is distracted by work, Luke is misbehaving at school and Leah (Stella Gonet) doesn\u2019t understand why they are even\u2026", "poster": "assets/episode-no-honeymoon-2020-s1e8-poster.jpg", "backdrop": "assets/episode-no-honeymoon-2020-s1e8-backdrop.jpg", "played": false, "playCount": 1, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-28T19:00:50.0000000Z", "lastPlayedAt": "2026-11-03T20:57:58.3314947Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "Breeders", "seasonNumber": 1, "episodeNumber": 8, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}, {"id": "episode-notapusy-2005-s3e4", "title": "Notapusy", "year": 2005, "type": "episode", "runtimeSeconds": 1334, "seasonCount": null, "communityRating": 8.125, "criticRating": null, "genres": ["Comedy"], "overview": "To prove he's a man to Rita, Michael and his nephew Steve Holt compete in the Church And State Fair's annual triathlon. George Sr. tries to scare some at-risk youth, and Tobias tries to help George-Michael's\u2026", "poster": "assets/episode-notapusy-2005-s3e4-poster.jpg", "backdrop": "assets/episode-notapusy-2005-s3e4-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T12:17:58.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "Arrested Development", "seasonNumber": 3, "episodeNumber": 4, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}, {"id": "episode-the-man-who-would-be-vogue-2018-s2e1", "title": "The Man Who Would Be Vogue", "year": 2018, "type": "episode", "runtimeSeconds": 3203, "seasonCount": null, "communityRating": 7.459, "criticRating": null, "genres": ["Biography", "Crime", "Drama"], "overview": "The murder of Gianni Versace turns the eyes of the world onto Miami Beach.", "poster": "assets/episode-the-man-who-would-be-vogue-2018-s2e1-poster.jpg", "backdrop": "assets/episode-the-man-who-would-be-vogue-2018-s2e1-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T13:29:35.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "American Crime Story", "seasonNumber": 2, "episodeNumber": 1, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}, {"id": "episode-tomorrow-2024-s3e1", "title": "Tomorrow", "year": 2024, "type": "episode", "runtimeSeconds": 2207, "seasonCount": null, "communityRating": 7.6, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "The next day and the days that led to it.", "poster": "assets/episode-tomorrow-2024-s3e1-poster.jpg", "backdrop": "assets/episode-tomorrow-2024-s3e1-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T13:46:39.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "The Bear", "seasonNumber": 3, "episodeNumber": 1, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}, {"id": "episode-episode-1-2025-s1e1", "title": "Episode 1", "year": 2025, "type": "episode", "runtimeSeconds": 3618, "seasonCount": null, "communityRating": 7.357, "criticRating": null, "genres": ["Biography", "Drama", "History"], "overview": "Mussolini had just founded the Fasci di Combattimento when D'Annunzio took Fiume. Mussolini hoped to capitalize on the poet's achievement in the electoral arena.", "poster": "assets/episode-episode-1-2025-s1e1-poster.jpg", "backdrop": "assets/episode-episode-1-2025-s1e1-backdrop.jpg", "played": false, "playCount": 1, "resumeSeconds": 489.55, "progress": 0.1353, "addedAt": "2026-07-17T13:52:48.0000000Z", "lastPlayedAt": "2026-11-04T19:01:49.9731435Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "Mussolini: Son of the Century", "seasonNumber": 1, "episodeNumber": 1, "artwork": {"posterKind": "center_crop", "backdropKind": "backdrop"}}], "recentlyAdded": [{"id": "tv-the-final-problem-2026", "title": "The Final Problem", "year": 2026, "type": "tv", "runtimeSeconds": 3420, "seasonCount": 1, "communityRating": 7.041, "criticRating": null, "genres": ["Crime", "Mystery"], "overview": "In the spring of 1959, a retired actor, who previously played Sherlock Holmes, must investigate a murder at a small hotel on a tiny island near Mallorca.", "poster": "assets/tv-the-final-problem-2026-poster.jpg", "backdrop": "assets/tv-the-final-problem-2026-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-11-04T19:16:12.4822791Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 290720, "artwork": {"posterKind": "center_crop", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.041, "tmdbVotes": 49}}, {"id": "tv-dead-end-job-2026", "title": "Dead-End Job", "year": 2026, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.7, "criticRating": null, "genres": ["Mystery"], "overview": "Drowning in debt, a young man takes lucrative part-time jobs from a mysterious agency \u2014 only to find his hustle for more money turning nightmarish.", "poster": "assets/tv-dead-end-job-2026-poster.jpg", "backdrop": "assets/tv-dead-end-job-2026-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-11-04T18:58:01.8352857Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 289517, "artwork": {"posterKind": "center_crop", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.7, "tmdbVotes": 38}}, {"id": "tv-dune-prophecy-2024", "title": "Dune: Prophecy", "year": 2024, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.213, "criticRating": null, "genres": ["Sci-Fi & Fantasy", "Drama", "Action & Adventure"], "overview": "Ten thousand years before the ascension of Paul Atreides, sisters Valya and Tula Harkonnen establish the fabled sect and female order that would become known as the Bene Gesserit to control the future of humankind.", "poster": "assets/tv-dune-prophecy-2024-poster.jpg", "backdrop": "assets/tv-dune-prophecy-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-11-04T18:23:01.2908568Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 90228, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 64, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.213, "tmdbVotes": 677}}, {"id": "movie-the-uprising-2026", "title": "The Uprising", "year": 2026, "type": "movie", "runtimeSeconds": 7684, "seasonCount": null, "communityRating": 7.846, "criticRating": 55, "genres": ["Action", "Drama", "History"], "overview": "As war and plague devastate 14th-century England, a humble peasant ignites a rebellion, uniting an army of commoners to defy the King\u2019s might in a desperate fight for justice and survival.", "poster": "assets/movie-the-uprising-2026-poster.jpg", "backdrop": "assets/movie-the-uprising-2026-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-11-02T16:37:03.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 977942, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 57, "rtCriticsLabel": "Rotten", "rtAudience": 65, "rtAudienceLabel": "Upright", "imdb": 6.2, "imdbVotes": 6622, "tmdb": 7.8, "tmdbVotes": 404}}, {"id": "movie-jennifer-s-body-2009", "title": "Jennifer's Body", "year": 2009, "type": "movie", "runtimeSeconds": 6450, "seasonCount": null, "communityRating": 6.012, "criticRating": 47, "genres": ["Horror", "Comedy"], "overview": "Jennifer, a gorgeous, seductive cheerleader takes evil to a whole new level after she's possessed by a sinister demon. Now it's up to her best friend to stop Jennifer's reign of terror before it's too late.", "poster": "assets/movie-jennifer-s-body-2009-poster.jpg", "backdrop": "assets/movie-jennifer-s-body-2009-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-30T03:37:04.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 19994, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 47, "rtCriticsLabel": "Rotten", "rtAudience": 36, "rtAudienceLabel": "Spilled", "imdb": 5.6, "imdbVotes": 179710, "tmdb": 6.013, "tmdbVotes": 4481}}, {"id": "tv-breeders-2020", "title": "Breeders", "year": 2020, "type": "tv", "runtimeSeconds": 1500, "seasonCount": 4, "communityRating": 6.8, "criticRating": null, "genres": ["Comedy"], "overview": "Exposing the parental-paradox that it is possible, in the very same moment, to love your child to the horizon of the universe, while being apoplectically angry enough to want to send them there.", "poster": "assets/tv-breeders-2020-poster.jpg", "backdrop": "assets/tv-breeders-2020-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-28T18:54:21.8885435Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 93290, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 92, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.8, "tmdbVotes": 104}}, {"id": "tv-the-four-seasons-2025", "title": "The Four Seasons", "year": 2025, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 6.879, "criticRating": null, "genres": ["Comedy"], "overview": "The decades-long friendship between three married couples is tested when one divorces, complicating their tradition of quarterly weekend getaways.", "poster": "assets/tv-the-four-seasons-2025-poster.jpg", "backdrop": "assets/tv-the-four-seasons-2025-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-28T18:36:42.5326384Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 243316, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 81, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.892, "tmdbVotes": 204}}, {"id": "tv-adults-2025", "title": "Adults", "year": 2025, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 6.495, "criticRating": null, "genres": ["Comedy"], "overview": "A group of twenty-somethings in New York trying to be good people, despite being neither \"good\" nor \"people\" yet.", "poster": "assets/tv-adults-2025-poster.jpg", "backdrop": "assets/tv-adults-2025-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-23T21:01:41.1814765Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 260458, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 88, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.5, "tmdbVotes": 102}}, {"id": "tv-east-of-eden-2026", "title": "East of Eden", "year": 2026, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.931, "criticRating": null, "genres": ["Drama", "Crime"], "overview": "In the early 1900s, a fiercely independent woman drives a rift between two brothers, igniting a saga that unfolds across two generations.", "poster": "assets/tv-east-of-eden-2026-poster.jpg", "backdrop": "assets/tv-east-of-eden-2026-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-23T18:07:11.3965096Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 258165, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 88, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.982, "tmdbVotes": 139}}, {"id": "movie-fighting-with-my-family-2019", "title": "Fighting with My Family", "year": 2019, "type": "movie", "runtimeSeconds": 6509, "seasonCount": null, "communityRating": 6.84, "criticRating": 93, "genres": ["Comedy"], "overview": "Born into a tight-knit wrestling family, Paige and her brother Zak are ecstatic when they get the once-in-a-lifetime opportunity to try out for the WWE. But when only Paige earns a spot in the competitive training\u2026", "poster": "assets/movie-fighting-with-my-family-2019-poster.jpg", "backdrop": "assets/movie-fighting-with-my-family-2019-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-22T22:10:59.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 445629, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 93, "rtCriticsLabel": "Certified Fresh", "rtAudience": 86, "rtAudienceLabel": "Upright", "imdb": 7, "imdbVotes": 94576, "tmdb": 6.837, "tmdbVotes": 1952}}, {"id": "tv-landman-2024", "title": "Landman", "year": 2024, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 8.194, "criticRating": null, "genres": ["Drama"], "overview": "Set in the proverbial boomtowns of West-Texas and a modern-day tale of fortune-seeking in the world of oil rigs, the series is an upstairs/downstairs story of roughnecks and wildcat billionaires that are fueling a\u2026", "poster": "assets/tv-landman-2024-poster.jpg", "backdrop": "assets/tv-landman-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-22T21:38:18.0973875Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 157741, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 80, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.209, "tmdbVotes": 668}}, {"id": "tv-fawlty-towers-1975", "title": "Fawlty Towers", "year": 1975, "type": "tv", "runtimeSeconds": 1800, "seasonCount": 2, "communityRating": 8.295, "criticRating": null, "genres": ["Comedy"], "overview": "Owner Basil Fawlty, his wife Sybil, a chambermaid Polly, and Spanish waiter Manuel attempt to run their hotel amidst farcical situations and an array of demanding guests.", "poster": "assets/tv-fawlty-towers-1975-poster.jpg", "backdrop": "assets/tv-fawlty-towers-1975-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-19T23:26:39.7658344Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 2207, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.296, "tmdbVotes": 867}}], "discover": [{"id": "movie-12-years-a-slave-2013", "title": "12 Years a Slave", "year": 2013, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "History"], "overview": "In the pre-Civil War United States, Solomon Northup, a free black man from upstate New York, is abducted and sold into slavery. Facing cruelty as well as unexpected kindnesses Solomon struggles not only to stay\u2026", "poster": "assets/movie-12-years-a-slave-2013-poster.jpg", "backdrop": "assets/movie-12-years-a-slave-2013-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 76203, "ratings": {"rtCritics": 95, "rtCriticsLabel": "Certified Fresh", "rtAudience": 90, "rtAudienceLabel": "Upright", "imdb": 8.1, "imdbVotes": 796313, "tmdb": 7.927, "tmdbVotes": 12335}}, {"id": "movie-101-dalmatians-ii-patch-s-london-adventure-2003", "title": "101 Dalmatians II: Patch's London Adventure", "year": 2003, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Family", "Animation", "Adventure"], "overview": "Being one of 101 takes its toll on Patch, who doesn't feel unique. When he's accidentally left behind on moving day, he meets his idol, Thunderbolt, who enlists him on a publicity campaign.", "poster": "assets/movie-101-dalmatians-ii-patch-s-london-adventure-2003-poster.jpg", "backdrop": "assets/movie-101-dalmatians-ii-patch-s-london-adventure-2003-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 13654, "ratings": {"rtCritics": 67, "rtCriticsLabel": "Fresh", "rtAudience": 41, "rtAudienceLabel": "Spilled", "imdb": 5.7, "imdbVotes": 12343, "tmdb": 6.025, "tmdbVotes": 1274}}, {"id": "movie-absolution-2024", "title": "Absolution", "year": 2024, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Crime", "Thriller"], "overview": "An aging ex-boxer gangster working as muscle for a Boston crime boss receives an upsetting diagnosis. Despite a faltering memory, he attempts to rectify the sins of his past and reconnect with his estranged\u2026", "poster": "assets/movie-absolution-2024-poster.jpg", "backdrop": "assets/movie-absolution-2024-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 974453, "ratings": {"rtCritics": 56, "rtCriticsLabel": "Rotten", "rtAudience": 32, "rtAudienceLabel": "Spilled", "imdb": 5.2, "imdbVotes": 11413, "tmdb": 5.906, "tmdbVotes": 564}}, {"id": "movie-addicted-2014", "title": "Addicted", "year": 2014, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Thriller"], "overview": "A gallerist risks her family and flourishing career when she enters into an affair with a talented painter and slowly loses control of her life.", "poster": "assets/movie-addicted-2014-poster.jpg", "backdrop": "assets/movie-addicted-2014-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 235271, "ratings": {"rtCritics": 7, "rtCriticsLabel": "Rotten", "rtAudience": 51, "rtAudienceLabel": "Spilled", "imdb": 5, "imdbVotes": 13131, "tmdb": 5.928, "tmdbVotes": 466}}, {"id": "movie-all-the-money-in-the-world-2017", "title": "All the Money in the World", "year": 2017, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Crime", "History", "Thriller"], "overview": "The story of the kidnapping of 16-year-old John Paul Getty III and the desperate attempt by his devoted mother to convince his billionaire grandfather Jean Paul Getty to pay the ransom.", "poster": "assets/movie-all-the-money-in-the-world-2017-poster.jpg", "backdrop": "assets/movie-all-the-money-in-the-world-2017-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 446791, "ratings": {"rtCritics": 79, "rtCriticsLabel": "Certified Fresh", "rtAudience": 66, "rtAudienceLabel": "Upright", "imdb": 6.8, "imdbVotes": 97448, "tmdb": 6.493, "tmdbVotes": 2493}}, {"id": "movie-the-alto-knights-2025", "title": "The Alto Knights", "year": 2025, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Crime", "Drama"], "overview": "Two of New York's most notorious organized crime bosses, Frank Costello and Vito Genovese, vie for control of the city's streets. Once the best of friends, petty jealousies and a series of betrayals place them on a\u2026", "poster": "assets/movie-the-alto-knights-2025-poster.jpg", "backdrop": "assets/movie-the-alto-knights-2025-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 1013601, "ratings": {"rtCritics": 39, "rtCriticsLabel": "Rotten", "rtAudience": 71, "rtAudienceLabel": "Upright", "imdb": 5.9, "imdbVotes": 23017, "tmdb": 6.223, "tmdbVotes": 485}}, {"id": "movie-the-amazing-spider-man-2-2014", "title": "The Amazing Spider-Man 2", "year": 2014, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Adventure", "Science Fiction"], "overview": "For Peter Parker, life is busy. Between taking out the bad guys as Spider-Man and spending time with the person he loves, Gwen Stacy, high school graduation cannot come quickly enough. Peter has not forgotten about\u2026", "poster": "assets/movie-the-amazing-spider-man-2-2014-poster.jpg", "backdrop": "assets/movie-the-amazing-spider-man-2-2014-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 102382, "ratings": {"rtCritics": 51, "rtCriticsLabel": "Rotten", "rtAudience": 64, "rtAudienceLabel": "Upright", "imdb": 6.6, "imdbVotes": 611981, "tmdb": 6.567, "tmdbVotes": 14919}}, {"id": "movie-ant-man-and-the-wasp-quantumania-2023", "title": "Ant-Man and the Wasp: Quantumania", "year": 2023, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Adventure", "Science Fiction"], "overview": "Super-Hero partners Scott Lang and Hope van Dyne, along with with Hope's parents Janet van Dyne and Hank Pym, and Scott's daughter Cassie Lang, find themselves exploring the Quantum Realm, interacting with strange\u2026", "poster": "assets/movie-ant-man-and-the-wasp-quantumania-2023-poster.jpg", "backdrop": "assets/movie-ant-man-and-the-wasp-quantumania-2023-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 640146, "ratings": {"rtCritics": 46, "rtCriticsLabel": "Rotten", "rtAudience": 81, "rtAudienceLabel": "Upright", "imdb": 6, "imdbVotes": 281738, "tmdb": 6.197, "tmdbVotes": 6236}}, {"id": "movie-arctic-dogs-2019", "title": "Arctic Dogs", "year": 2019, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Animation", "Adventure", "Comedy"], "overview": "Animals band together to save the day when the evil Otto Von Walrus hatches a sinister scheme to accelerate global warming and melt the Arctic Circle.", "poster": "assets/movie-arctic-dogs-2019-poster.jpg", "backdrop": "assets/movie-arctic-dogs-2019-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 416153, "ratings": {"rtCritics": 13, "rtCriticsLabel": "Rotten", "rtAudience": 60, "rtAudienceLabel": "Upright", "imdb": 4.9, "imdbVotes": 2580, "tmdb": 5.9, "tmdbVotes": 148}}, {"id": "movie-the-avengers-2012", "title": "The Avengers", "year": 2012, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Science Fiction", "Action", "Adventure"], "overview": "When an unexpected enemy emerges and threatens global safety and security, Nick Fury, director of the international peacekeeping agency known as S.H.I.E.L.D., finds himself in need of a team to pull the world back\u2026", "poster": "assets/movie-the-avengers-2012-poster.jpg", "backdrop": "assets/movie-the-avengers-2012-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 24428, "ratings": {"rtCritics": 91, "rtCriticsLabel": "Certified Fresh", "rtAudience": 91, "rtAudienceLabel": "Upright", "imdb": 8, "imdbVotes": 1590693, "tmdb": 8.084, "tmdbVotes": 40262}}, {"id": "movie-black-bear-2020", "title": "Black Bear", "year": 2020, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Thriller"], "overview": "At a remote lake house in the Adirondack Mountains, a couple entertains an out-of-town guest looking for inspiration in her filmmaking. The group quickly falls into a calculated game of desire, manipulation, and\u2026", "poster": "assets/movie-black-bear-2020-poster.jpg", "backdrop": "assets/movie-black-bear-2020-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 624788, "ratings": {"rtCritics": 89, "rtCriticsLabel": "Certified Fresh", "rtAudience": 64, "rtAudienceLabel": "Upright", "imdb": 6.5, "imdbVotes": 18408, "tmdb": 6.337, "tmdbVotes": 315}}, {"id": "movie-black-panther-2018", "title": "Black Panther", "year": 2018, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Adventure", "Science Fiction"], "overview": "King T'Challa returns home to the reclusive, technologically advanced African nation of Wakanda to serve as his country's new leader. However, T'Challa soon finds that he is challenged for the throne by factions\u2026", "poster": "assets/movie-black-panther-2018-poster.jpg", "backdrop": "assets/movie-black-panther-2018-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 284054, "ratings": {"rtCritics": 96, "rtCriticsLabel": "Certified Fresh", "rtAudience": null, "rtAudienceLabel": "Spilled", "imdb": 7.3, "imdbVotes": 931120, "tmdb": 7.36, "tmdbVotes": 24190}}, {"id": "movie-a-bronx-tale-1993", "title": "A Bronx Tale", "year": 1993, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Crime"], "overview": "Set in the Bronx during the tumultuous 1960s, an adolescent boy is torn between his honest, working-class father and a violent yet charismatic crime boss. Complicating matters is the youngster's growing attraction -\u2026", "poster": "assets/movie-a-bronx-tale-1993-poster.jpg", "backdrop": "assets/movie-a-bronx-tale-1993-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 1607, "ratings": {"rtCritics": 97, "rtCriticsLabel": "Fresh", "rtAudience": 92, "rtAudienceLabel": "Upright", "imdb": 7.8, "imdbVotes": 191930, "tmdb": 7.913, "tmdbVotes": 3075}}, {"id": "movie-by-any-means-2026", "title": "By Any Means", "year": 2026, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Thriller", "History", "Crime"], "overview": "In 1960s Mississippi, a young Black FBI agent is forced to partner with a notorious mafia hitman to investigate a wave of brutal civil rights murders.", "poster": "assets/movie-by-any-means-2026-poster.jpg", "backdrop": "assets/movie-by-any-means-2026-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 1380417, "ratings": {"rtCritics": 49, "rtCriticsLabel": "Rotten", "rtAudience": 88, "rtAudienceLabel": "Upright", "imdb": 6.8, "imdbVotes": 3534, "tmdb": 6.6, "tmdbVotes": 92}}, {"id": "movie-captain-america-civil-war-2016", "title": "Captain America: Civil War", "year": 2016, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Adventure", "Action", "Science Fiction"], "overview": "Following the events of Age of Ultron, the collective governments of the world pass an act designed to regulate all superhuman activity. This polarizes opinion amongst the Avengers, causing two factions to side with\u2026", "poster": "assets/movie-captain-america-civil-war-2016-poster.jpg", "backdrop": "assets/movie-captain-america-civil-war-2016-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 271110, "ratings": {"rtCritics": 90, "rtCriticsLabel": "Certified Fresh", "rtAudience": 89, "rtAudienceLabel": "Upright", "imdb": 7.8, "imdbVotes": 954943, "tmdb": 7.455, "tmdbVotes": 24808}}, {"id": "movie-captain-america-the-first-avenger-2011", "title": "Captain America: The First Avenger", "year": 2011, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Adventure", "Science Fiction"], "overview": "During World War II, Steve Rogers is a sickly man from Brooklyn who's transformed into super-soldier Captain America to aid in the war effort. Rogers must stop the Red Skull \u2013 Adolf Hitler's ruthless head of\u2026", "poster": "assets/movie-captain-america-the-first-avenger-2011-poster.jpg", "backdrop": "assets/movie-captain-america-the-first-avenger-2011-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 1771, "ratings": {"rtCritics": 80, "rtCriticsLabel": "Certified Fresh", "rtAudience": 75, "rtAudienceLabel": "Upright", "imdb": 6.9, "imdbVotes": 993453, "tmdb": 7.012, "tmdbVotes": 23473}}, {"id": "movie-captain-america-the-winter-soldier-2014", "title": "Captain America: The Winter Soldier", "year": 2014, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Adventure", "Science Fiction"], "overview": "After the cataclysmic events in New York with The Avengers, Steve Rogers, aka Captain America is living quietly in Washington, D.C. and trying to adjust to the modern world. But when a S.H.I.E.L.D. colleague comes\u2026", "poster": "assets/movie-captain-america-the-winter-soldier-2014-poster.jpg", "backdrop": "assets/movie-captain-america-the-winter-soldier-2014-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 100402, "ratings": {"rtCritics": 90, "rtCriticsLabel": "Certified Fresh", "rtAudience": 92, "rtAudienceLabel": "Upright", "imdb": 7.7, "imdbVotes": 995514, "tmdb": 7.654, "tmdbVotes": 20688}}, {"id": "movie-careful-what-you-wish-for-2015", "title": "Careful What You Wish For", "year": 2015, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Thriller"], "overview": "A guy gets more than he bargained for after entering into an affair with the wife of an investment banker. Soon, a suspicious death and substantial life insurance policy embroil him in a scandal.", "poster": "assets/movie-careful-what-you-wish-for-2015-poster.jpg", "backdrop": "assets/movie-careful-what-you-wish-for-2015-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 317744, "ratings": {"rtCritics": 17, "rtCriticsLabel": "Rotten", "rtAudience": 61, "rtAudienceLabel": "Upright", "imdb": 5.6, "imdbVotes": 9452, "tmdb": 6.129, "tmdbVotes": 446}}, {"id": "tv-12-monkeys-2015", "title": "12 Monkeys", "year": 2015, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Sci-Fi & Fantasy", "Drama"], "overview": "The provocative story of Cole, a time traveler from a decimated future in a high-stakes race against the clock. Utilizing a dangerous and untested method of time travel, he journeys from 2043 to the present day on a\u2026", "poster": "assets/tv-12-monkeys-2015-poster.jpg", "backdrop": "assets/tv-12-monkeys-2015-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 60948, "ratings": {"rtCritics": 88, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.441, "tmdbVotes": 1245}}, {"id": "tv-90-day-fiance-before-the-90-days-2017", "title": "90 Day Fianc\u00e9: Before the 90 Days", "year": 2017, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Reality"], "overview": "Follow couples who have an existing relationship online, but haven't met in person. We'll experience their journey as they travel to the other's foreign country for the first time in an attempt to establish an\u2026", "poster": "assets/tv-90-day-fiance-before-the-90-days-2017-poster.jpg", "backdrop": "assets/tv-90-day-fiance-before-the-90-days-2017-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 73319, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.5, "tmdbVotes": 45}}, {"id": "tv-90-day-fiance-the-other-way-2019", "title": "90 Day Fianc\u00e9: The Other Way", "year": 2019, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Reality"], "overview": "Americans move to a foreign country all in the name of love. Will uprooting their lives in the United States pay off, or will they be packing up and moving back to America brokenhearted?", "poster": "assets/tv-90-day-fiance-the-other-way-2019-poster.jpg", "backdrop": "assets/tv-90-day-fiance-the-other-way-2019-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 90046, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.8, "tmdbVotes": 34}}, {"id": "tv-the-100-2014", "title": "The 100", "year": 2014, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Sci-Fi & Fantasy", "Action & Adventure", "Drama"], "overview": "100 years in the future, when the Earth has been abandoned due to radioactivity, the last surviving humans live on an ark orbiting the planet \u2014 but the ark won't last forever. So the repressive regime picks 100\u2026", "poster": "assets/tv-the-100-2014-poster.jpg", "backdrop": "assets/tv-the-100-2014-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 48866, "ratings": {"rtCritics": 93, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.878, "tmdbVotes": 8855}}, {"id": "tv-accused-2023", "title": "Accused", "year": 2023, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Crime", "Drama"], "overview": "In this crime anthology series, viewers discover how an ordinary person got caught up in an extraordinary situation, ultimately revealing how one wrong turn leads to another, until it\u2019s too late to turn back. Told\u2026", "poster": "assets/tv-accused-2023-poster.jpg", "backdrop": "assets/tv-accused-2023-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 202097, "ratings": {"rtCritics": 61, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.851, "tmdbVotes": 87}}, {"id": "tv-after-life-2019", "title": "After Life", "year": 2019, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "Tony had a perfect life. But after his wife Lisa suddenly dies, Tony changes. After contemplating taking his own life, he decides instead to live long enough to punish the world by saying and doing whatever he likes\u2026", "poster": "assets/tv-after-life-2019-poster.jpg", "backdrop": "assets/tv-after-life-2019-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 79410, "ratings": {"rtCritics": 72, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.902, "tmdbVotes": 1169}}, {"id": "tv-all-creatures-great-and-small-1978", "title": "All Creatures Great and Small", "year": 1978, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "The trials and misadventures of the staff at a country veterinary office in Yorkshire. James Herriot, a young animal surgeon, moves to a small Yorkshire town to begin his first job.", "poster": "assets/tv-all-creatures-great-and-small-1978-poster.jpg", "backdrop": "assets/tv-all-creatures-great-and-small-1978-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "center_crop", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 7406, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.8, "tmdbVotes": 50}}, {"id": "tv-american-housewife-2016", "title": "American Housewife", "year": 2016, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Comedy"], "overview": "A family comedy narrated by Katie, a strong-willed mother, raising her flawed family in a wealthy town filled with perfect wives and their perfect offspring.", "poster": "assets/tv-american-housewife-2016-poster.jpg", "backdrop": "assets/tv-american-housewife-2016-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 67117, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.7, "tmdbVotes": 187}}, {"id": "tv-atlantis-2013", "title": "Atlantis", "year": 2013, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action & Adventure", "Sci-Fi & Fantasy"], "overview": "A fantasy drama set in a world of legendary heroes and mythical creatures. Far from home and desperate for answers, Jason washes up on the shores of an ancient land. A mysterious place; a world of bull leaping, of\u2026", "poster": "assets/tv-atlantis-2013-poster.jpg", "backdrop": "assets/tv-atlantis-2013-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 47054, "ratings": {"rtCritics": 62, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.4, "tmdbVotes": 155}}, {"id": "tv-avatar-the-last-airbender-2024", "title": "Avatar: The Last Airbender", "year": 2024, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action & Adventure", "Sci-Fi & Fantasy", "Drama"], "overview": "A young boy known as the Avatar must master the four elemental powers to save a world at war \u2014 and fight a ruthless enemy bent on stopping him.", "poster": "assets/tv-avatar-the-last-airbender-2024-poster.jpg", "backdrop": "assets/tv-avatar-the-last-airbender-2024-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 82452, "ratings": {"rtCritics": 66, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.8, "tmdbVotes": 1406}}, {"id": "tv-avatar-seven-havens-2026", "title": "Avatar: Seven Havens", "year": 2026, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "In a world shattered by a devastating cataclysm, a young Earthbender discovers she's the new Avatar after Korra. But in this dangerous era, that title marks her as humanity's destroyer, not its savior. Hunted by both\u2026", "poster": "assets/tv-avatar-seven-havens-2026-poster.jpg", "backdrop": "assets/tv-avatar-seven-havens-2026-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 284833, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.9, "tmdbVotes": 183}}, {"id": "tv-batman-the-brave-and-the-bold-2008", "title": "Batman: The Brave and the Bold", "year": 2008, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "The Caped Crusader is teamed up with Blue Beetle, Green Arrow, Aquaman and countless others in his quest to uphold justice.", "poster": "assets/tv-batman-the-brave-and-the-bold-2008-poster.jpg", "backdrop": "assets/tv-batman-the-brave-and-the-bold-2008-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 15804, "ratings": {"rtCritics": 96, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.6, "tmdbVotes": 295}}, {"id": "tv-beat-bobby-flay-2013", "title": "Beat Bobby Flay", "year": 2013, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Reality"], "overview": "Two talented chefs go head-to-head for the chance to Beat Bobby Flay. To get to Bobby the chefs must first face off against each other, creating a spectacular dish with a secret ingredient of Bobby's choice. Judges\u2026", "poster": "assets/tv-beat-bobby-flay-2013-poster.jpg", "backdrop": "assets/tv-beat-bobby-flay-2013-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 62481, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.5, "tmdbVotes": 31}}, {"id": "tv-benson-1979", "title": "Benson", "year": 1979, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Comedy"], "overview": "A butler deals with life at the governor's mansion.", "poster": "assets/tv-benson-1979-poster.jpg", "backdrop": "assets/tv-benson-1979-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 1849, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7, "tmdbVotes": 46}}, {"id": "tv-black-rabbit-2025", "title": "Black Rabbit", "year": 2025, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Crime"], "overview": "A rising-star restaurateur is forced into New York's criminal underworld when his chaotic brother returns to town with loan sharks on his trail.", "poster": "assets/tv-black-rabbit-2025-poster.jpg", "backdrop": "assets/tv-black-rabbit-2025-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 249039, "ratings": {"rtCritics": 66, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.983, "tmdbVotes": 240}}, {"id": "tv-black-ish-2014", "title": "black-ish", "year": 2014, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Comedy"], "overview": "A family man struggles to gain a sense of cultural identity while raising his kids in a predominantly white, upper-middle-class neighborhood.", "poster": "assets/tv-black-ish-2014-poster.jpg", "backdrop": "assets/tv-black-ish-2014-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 61381, "ratings": {"rtCritics": 92, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.1, "tmdbVotes": 352}}, {"id": "tv-breaking-bad-2008", "title": "Breaking Bad", "year": 2008, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Crime"], "overview": "Walter White, a New Mexico chemistry teacher, is diagnosed with Stage III cancer and given a prognosis of only two years left to live. He becomes filled with a sense of fearlessness and an unrelenting desire to\u2026", "poster": "assets/tv-breaking-bad-2008-poster.jpg", "backdrop": "assets/tv-breaking-bad-2008-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "center_crop", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 1396, "ratings": {"rtCritics": 96, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.959, "tmdbVotes": 19030}}, {"id": "tv-the-capture-2019", "title": "The Capture", "year": 2019, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Crime", "Drama", "Mystery"], "overview": "When soldier Shaun Emery's conviction for a murder in Afghanistan is overturned due to flawed video evidence, he returns to life as a free man with his young daughter. But when damning CCTV footage from a night out\u2026", "poster": "assets/tv-the-capture-2019-poster.jpg", "backdrop": "assets/tv-the-capture-2019-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 93166, "ratings": {"rtCritics": 97, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.634, "tmdbVotes": 361}}], "discoverySource": {"state": "materialized_snapshot", "rankKnown": false, "selection": "First 18 alphabetically indexed movie stubs and first 18 series stubs, not ranked feed order", "observedAt": "2026-11-04T20:42:12.030108+00:00"}}], "notes": {"real": ["Profile keys and library membership derive from deployed Conductarr configuration and viewer-scoped Jellyfin GET responses.", "Names, years, overviews, library ratings, genres, season-container counts and watch/resume data come from actual Jellyfin metadata.", "All local artwork is copied from Jellyfin; discovery posters are the real materialized Conductarr recommendation artwork.", "Discover contains 36 real materialized personal recommendations per profile, with profile-scoped availability from read-only retained membership/request state.", "Justin has no playable resume items. Mindel and Shared each have one. No progress or watched flags were invented."], "approximations": ["Default profile initials and display capitalization are frontend presentation choices; colours reuse the existing PWA palette, not live user appearance configuration.", "Conductarr catalogue and recommendation GET endpoints both returned 401. Authentication was not bypassed. SQLite contains no retained recommendation feed/candidate snapshot tables; the source keeps ranked feeds in memory. Discovery therefore reads existing per-profile Jellyfin recommendation stubs as metadata only.", "Discovery selection is 18 alphabetically indexed movie suggestions plus 18 series suggestions, not personal rank order; rank is null. Materialization age and original feed ordering are unknown.", "Discovery stub runtime is synthetic (two seconds), so runtime, season count, rating and watch state are null rather than presenting stub values as real media facts.", "93 selected identities lack original landscape art in the materialized metadata; their backdrop is a marked crop of the real poster, not an original film backdrop. Existing artwork is reused; new artwork is centre-cropped to the required dimensions.", "Jellyfin server-side resizing requests maxWidth/maxHeight/quality. When the result had the wrong aspect ratio, sequential single-thread ffmpeg center crops produce exact 300x450 and 1280x720 JPEGs.", "Series seasonCount is Jellyfin ChildCount, including season containers for specials when present.", "Discover availability is a retained profile-membership/request snapshot, not a fresh playback or acquisition guarantee. not_here means absent from that profile in retained state, not absent from the entire household."], "missing": ["Justin Movies has seven live viewer-scoped films, not 200. No titles are invented or borrowed across profile boundaries.", "Discovery community/critic ratings, reliable runtime/season counts, personal feed ranking/freshness and 93 original landscape backdrops are unavailable through the permitted read paths."], "unmetRequirements": ["At least 200 real profile-scoped Justin Movies"], "mustNotBeShown": ["Recs Movies", "Recs Shows", "Justin Movies", "Justin Shows", "Mindel Movies", "Mindel Shows", "Shared Movies", "Shared Shows", "DGiC", "Stored names padded with spaces", "Internal bucket and acquisition-instance names"], "privacy": ["Includes actual watched and resume history for Justin, Mindel and Shared independently; Shared is not a union.", "No Jellyfin item/user IDs, source paths, service URLs, credentials, request actor identities, or private hotlinks are included. tmdbId is a public external title identity.", "Only the seven configured playable libraries are selected. Extra observed DGiC views and all recommendation stubs are excluded from playable collections.", "No profile is assumed to be a child or age-restricted. Each playable list and episode shelf is limited to that profile\u2019s configured libraries."], "sourceReadPaths": ["GET /Users (allowlisted mapping only)", "GET /Users/{userId}/Views", "GET /Users/{userId}/Items (parent-scoped playable libraries and metadata-only recommendation libraries)", "GET /Users/{userId}/Items/Resume", "GET /Shows/NextUp?UserId=...", "GET /Items/{itemId}/Images/Primary", "GET /Items/{itemId}/Images/Backdrop/0", "Conductarr GET /catalogue/discover?page=1&media_type=movie (401)", "Conductarr GET /api/v1/recommendations/profiles/justin/feed (401)", "SQLite file URI mode=ro plus PRAGMA query_only=ON: catalogue_metadata, bucket_memberships, native_requests; sqlite_master for schema inspection"], "productionMutations": 0, "artwork": {"files": 632, "bytes": 35201423, "posterDimensions": [300, 450], "backdropDimensions": [1280, 720], "posterQualityRequested": 70, "backdropQualityRequested": 65, "posterDerivedBackdropCount": 93}, "consumerContract": ["profiles is an array in Justin/Mindel/Shared order. Every profile owns movies/shows/anime/resume/nextUp/recentlyAdded/discover item arrays; empty shelves are intentional.", "Item paths are relative to fixture.json. Resolve assets against the fixture directory, not against the outer prototype document.", "Use destinations for visible navigation. No raw library display/stored names belong in UI.", "Deduplicate library/resume/discover by stable local id or (type,tmdbId). Never treat a materialized recommendation as a playable library item.", "played=null and watchStateKnown=false mean unknown, not unwatched. Null ratings are missing, not zero; communityRating source is Jellyfin, not an asserted TMDB/IMDb score.", "availability values are in_library/requested/not_here. No requested examples were observed in the selected discover set; do not fabricate them.", "Render artwork.backdropKind=poster_crop as a poster-derived landscape; prefer the poster over pretending it is original landscape art.", "This static fixture preserves actual current collection sizes, viewer-scoped watch state and unknown discovery ranking; request and playback demonstrations must remain labelled simulations."]}} \ No newline at end of file diff --git a/app/src/debug/assets/wolfskin-library.json b/app/src/debug/assets/wolfskin-library.json new file mode 100644 index 00000000..76617014 --- /dev/null +++ b/app/src/debug/assets/wolfskin-library.json @@ -0,0 +1 @@ +{"profiles":[{"key":"justin","movies":[{"id":"movie-the-girl-who-leapt-through-time-2006","title":"The Girl Who Leapt Through Time","year":2006,"type":"movie","runtimeSeconds":5886,"seasonCount":null,"communityRating":7.772,"criticRating":87,"genres":["Fantasy","Animation","Drama"],"overview":"When 17-year-old Makoto Konno gains the ability to 'leap' backwards through time, she immediately sets about improving her grades and preventing personal mishaps. However, she soon realises that changing the past\u2026","poster":"assets/movie-the-girl-who-leapt-through-time-2006-poster.jpg","backdrop":"assets/movie-the-girl-who-leapt-through-time-2006-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T17:39:11.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":14069,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":87,"rtCriticsLabel":"Fresh","rtAudience":90,"rtAudienceLabel":"Upright","imdb":7.6,"imdbVotes":76917,"tmdb":7.775,"tmdbVotes":2345}},{"id":"movie-locke-2014","title":"Locke","year":2014,"type":"movie","runtimeSeconds":5090,"seasonCount":null,"communityRating":6.891,"criticRating":91,"genres":["Drama","Thriller"],"overview":"Ivan Locke has worked hard to craft a good life for himself. Tonight, that life will collapse around him. On the eve of the biggest challenge of his career, Ivan receives a phone call that sets in motion a series of\u2026","poster":"assets/movie-locke-2014-poster.jpg","backdrop":"assets/movie-locke-2014-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T17:38:48.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":210479,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":90,"rtCriticsLabel":"Certified Fresh","rtAudience":72,"rtAudienceLabel":"Upright","imdb":7.1,"imdbVotes":171265,"tmdb":6.889,"tmdbVotes":3088}},{"id":"movie-look-who-s-back-2015","title":"Look Who's Back","year":2015,"type":"movie","runtimeSeconds":6936,"seasonCount":null,"communityRating":6.793,"criticRating":null,"genres":["Comedy"],"overview":"When Adolf Hitler reawakens at the site of his former bunker in present-day Berlin, he is mistaken for a comedian and quickly becomes a media phenomenon.","poster":"assets/movie-look-who-s-back-2015-poster.jpg","backdrop":"assets/movie-look-who-s-back-2015-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:27:48.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":318917,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":81,"rtAudienceLabel":"Upright","imdb":7,"imdbVotes":54672,"tmdb":6.796,"tmdbVotes":1990}},{"id":"movie-memories-1995","title":"Memories","year":1995,"type":"movie","runtimeSeconds":6805,"seasonCount":null,"communityRating":7.465,"criticRating":null,"genres":["Fantasy","Animation","Science Fiction"],"overview":"Three back-to-back anime films by three different directors make up this sci-fi trilogy three years in the making.","poster":"assets/movie-memories-1995-poster.jpg","backdrop":"assets/movie-memories-1995-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-30T21:36:03.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":42994,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":82,"rtAudienceLabel":"Upright","imdb":7.5,"imdbVotes":26638,"tmdb":7.459,"tmdbVotes":622}},{"id":"movie-reincarnated-2012","title":"Reincarnated","year":2012,"type":"movie","runtimeSeconds":5759,"seasonCount":null,"communityRating":6.145,"criticRating":28,"genres":["Music","Documentary"],"overview":"Evolution as an artist is often times what separates legends from the more mundane. After being heavily influenced by his experience in Jamaica \u2013 and his subsequent name change from Snoop Dogg to Snoop Lion \u2013 the LBC\u2026","poster":"assets/movie-reincarnated-2012-poster.jpg","backdrop":"assets/movie-reincarnated-2012-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:27:26.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":123431,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":28,"rtCriticsLabel":"Rotten","rtAudience":41,"rtAudienceLabel":"Spilled","imdb":6.2,"imdbVotes":2563,"tmdb":6.1,"tmdbVotes":69}},{"id":"movie-shipwrecked-nightmare-at-sea-2026","title":"Shipwrecked: Nightmare at Sea","year":2026,"type":"movie","runtimeSeconds":5385,"seasonCount":null,"communityRating":7.181,"criticRating":null,"genres":["Documentary"],"overview":"Never-before-seen footage and survivor accounts trace the 2012 shipwreck of a luxury cruise and the disaster that ensued, in this immersive documentary.","poster":"assets/movie-shipwrecked-nightmare-at-sea-2026-poster.jpg","backdrop":"assets/movie-shipwrecked-nightmare-at-sea-2026-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-20T20:11:43.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":1715492,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":6.5,"imdbVotes":5080,"tmdb":7.172,"tmdbVotes":87}},{"id":"movie-steins-gate-the-movie-load-region-of-deja-vu-2013","title":"Steins;Gate: The Movie - Load Region of D\u00e9j\u00e0 Vu","year":2013,"type":"movie","runtimeSeconds":5403,"seasonCount":null,"communityRating":7.8,"criticRating":null,"genres":["Animation","Mystery","Sci-Fi"],"overview":"After the events of the anime, Rintarou begins to feel the repercussions of extensive time travel, and eventually completely fades from reality. Kurisu, being the only companion to remember him, now must find a way\u2026","poster":"assets/movie-steins-gate-the-movie-load-region-of-deja-vu-2013-poster.jpg","backdrop":"assets/movie-steins-gate-the-movie-load-region-of-deja-vu-2013-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-22T14:55:54.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","artwork":{"posterKind":"poster","backdropKind":"backdrop"}}],"shows":[{"id":"tv-andor-2022","title":"Andor","year":2022,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":8.293,"criticRating":null,"genres":["Sci-Fi & Fantasy","Action & Adventure","Drama"],"overview":"In an era filled with danger, deception and intrigue, Cassian Andor will discover the difference he can make in the struggle against the tyrannical Galactic Empire. He embarks on a path that is destined to turn him\u2026","poster":"assets/tv-andor-2022-poster.jpg","backdrop":"assets/tv-andor-2022-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0432996Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":83867,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":96,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.321,"tmdbVotes":2312}},{"id":"tv-the-boys-2019","title":"The Boys","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":5,"communityRating":8.443,"criticRating":null,"genres":["Sci-Fi & Fantasy","Action & Adventure"],"overview":"A group of vigilantes known informally as \u201cThe Boys\u201d set out to take down corrupt superheroes with no more than blue-collar grit and a willingness to fight dirty.","poster":"assets/tv-the-boys-2019-poster.jpg","backdrop":"assets/tv-the-boys-2019-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0410131Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":76479,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":92,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.4,"tmdbVotes":13628}},{"id":"tv-californication-2007","title":"Californication","year":2007,"type":"tv","runtimeSeconds":1680,"seasonCount":7,"communityRating":7.632,"criticRating":null,"genres":["Comedy","Drama"],"overview":"A self-loathing, alcoholic writer attempts to repair his damaged relationships with his daughter and her mother while combating sex addiction, a budding drug problem, and the seeming inability to avoid making bad\u2026","poster":"assets/tv-californication-2007-poster.jpg","backdrop":"assets/tv-californication-2007-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0428037Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":1215,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":57,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.62,"tmdbVotes":1406}},{"id":"tv-firefly-2002","title":"Firefly","year":2002,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":8.347,"criticRating":null,"genres":["Drama","Action & Adventure","Sci-Fi & Fantasy"],"overview":"In the year 2517, the universe is a vast frontier where lawlessness reigns and adventure awaits! Join Captain Malcolm Reynolds and his ragtag crew aboard the spaceship Serenity as they navigate the fringes of\u2026","poster":"assets/tv-firefly-2002-poster.jpg","backdrop":"assets/tv-firefly-2002-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-20T11:40:08.4202369Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":1437,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":77,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.351,"tmdbVotes":2550}},{"id":"tv-fleabag-2016","title":"Fleabag","year":2016,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":8.278,"criticRating":null,"genres":["Comedy","Drama"],"overview":"A portrait into the mind of a dry-witted, sexual, angry, porn-watching, grief-riddled woman, trying to make sense of the world. As she hurls herself headlong at modern living, Fleabag is thrown roughly up against the\u2026","poster":"assets/tv-fleabag-2016-poster.jpg","backdrop":"assets/tv-fleabag-2016-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T17:42:25.4848214Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":67070,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.3,"tmdbVotes":1993}},{"id":"tv-gravity-falls-2012","title":"Gravity Falls","year":2012,"type":"tv","runtimeSeconds":1320,"seasonCount":2,"communityRating":8.619,"criticRating":null,"genres":["Action & Adventure","Animation","Comedy"],"overview":"Twin brother and sister Dipper and Mabel Pines are in for an unexpected adventure when they spend the summer helping their great uncle Stan run a tourist trap in the mysterious town of Gravity Falls, Oregon.","poster":"assets/tv-gravity-falls-2012-poster.jpg","backdrop":"assets/tv-gravity-falls-2012-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0435827Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":40075,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.624,"tmdbVotes":3647}},{"id":"tv-invincible-2021","title":"INVINCIBLE","year":2021,"type":"tv","runtimeSeconds":null,"seasonCount":4,"communityRating":8.638,"criticRating":null,"genres":["Animation","Drama","Sci-Fi & Fantasy"],"overview":"Mark Grayson is a normal teenager except for the fact that his father is the most powerful superhero on the planet. Shortly after his seventeenth birthday, Mark begins to develop powers of his own and enters into his\u2026","poster":"assets/tv-invincible-2021-poster.jpg","backdrop":"assets/tv-invincible-2021-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.040159Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":95557,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":99,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.6,"tmdbVotes":6155}},{"id":"tv-the-legend-of-korra-2012","title":"The Legend of Korra","year":2012,"type":"tv","runtimeSeconds":1380,"seasonCount":4,"communityRating":8.2,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"Avatar Korra, a headstrong, rebellious, feisty young woman who continually challenges and breaks with tradition, is on her quest to become a fully realized Avatar. In this story, the Avatar struggles to find balance\u2026","poster":"assets/tv-the-legend-of-korra-2012-poster.jpg","backdrop":"assets/tv-the-legend-of-korra-2012-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-20T00:19:23.2494945Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":33880,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":88,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.221,"tmdbVotes":2482}},{"id":"tv-love-death-robots-2019","title":"Love, Death & Robots","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":4,"communityRating":8.205,"criticRating":null,"genres":["Animation","Sci-Fi & Fantasy"],"overview":"Terrifying creatures, wicked surprises and dark comedy converge in this NSFW anthology of animated stories presented by Tim Miller and David Fincher.","poster":"assets/tv-love-death-robots-2019-poster.jpg","backdrop":"assets/tv-love-death-robots-2019-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-20T11:31:20.8818757Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":86831,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":89,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.203,"tmdbVotes":4238}},{"id":"tv-norsemen-2016","title":"Norsemen","year":2016,"type":"tv","runtimeSeconds":1800,"seasonCount":3,"communityRating":7.177,"criticRating":null,"genres":["Comedy","Drama"],"overview":"Norsemen is an epic and humorous drama series set in the Viking Age. The residents of an 8th-century Viking village experience political rivalry, social change and innovations that upend their culture and way of life.","poster":"assets/tv-norsemen-2016-poster.jpg","backdrop":"assets/tv-norsemen-2016-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.034182Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":68126,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.2,"tmdbVotes":233}},{"id":"tv-rick-and-morty-2013","title":"Rick and Morty","year":2013,"type":"tv","runtimeSeconds":null,"seasonCount":9,"communityRating":8.679,"criticRating":null,"genres":["Animation","Comedy","Sci-Fi & Fantasy"],"overview":"Follows a sociopathic genius scientist who drags his inherently timid grandson on adventures across the universe.","poster":"assets/tv-rick-and-morty-2013-poster.jpg","backdrop":"assets/tv-rick-and-morty-2013-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0419581Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":60625,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":91,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.7,"tmdbVotes":11548}},{"id":"tv-secret-level-2024","title":"Secret Level","year":2024,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.411,"criticRating":null,"genres":["Animation","Sci-Fi & Fantasy","Action & Adventure"],"overview":"Adult animated series of original short stories which are set within the worlds of beloved video games. Each episode serves as a gateway to a new adventure, unlocking exciting worlds from beloved gaming classics and\u2026","poster":"assets/tv-secret-level-2024-poster.jpg","backdrop":"assets/tv-secret-level-2024-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0336417Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":261579,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":65,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.392,"tmdbVotes":612}},{"id":"tv-silo-2023","title":"Silo","year":2023,"type":"tv","runtimeSeconds":null,"seasonCount":3,"communityRating":8.178,"criticRating":null,"genres":["Sci-Fi & Fantasy","Drama"],"overview":"In a ruined and toxic future, thousands live in a giant silo deep underground. After its sheriff breaks a cardinal rule and residents die mysteriously, engineer Juliette starts to uncover shocking secrets and the\u2026","poster":"assets/tv-silo-2023-poster.jpg","backdrop":"assets/tv-silo-2023-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0346526Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":125988,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":92,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.232,"tmdbVotes":2881}},{"id":"tv-south-park-1997","title":"South Park","year":1997,"type":"tv","runtimeSeconds":null,"seasonCount":29,"communityRating":8.336,"criticRating":null,"genres":["Animation","Comedy"],"overview":"Follow the misadventures of four irreverent grade-schoolers in the quiet, dysfunctional town of South Park, Colorado.","poster":"assets/tv-south-park-1997-poster.jpg","backdrop":"assets/tv-south-park-1997-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0456124Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":2190,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":80,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.332,"tmdbVotes":5250}}],"anime":[{"id":"tv-bleach-2004","title":"Bleach","year":2004,"type":"tv","runtimeSeconds":1440,"seasonCount":17,"communityRating":8.363,"criticRating":null,"genres":["Action & Adventure","Animation","Sci-Fi & Fantasy"],"overview":"For as long as he can remember, Ichigo Kurosaki has been able to see ghosts. But when he meets Rukia, a Soul Reaper who battles evil spirits known as Hollows, he finds his life is changed forever. Now, with a\u2026","poster":"assets/tv-bleach-2004-poster.jpg","backdrop":"assets/tv-bleach-2004-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0359827Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":30984,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.4,"tmdbVotes":2331}},{"id":"tv-chainsaw-man-2022","title":"Chainsaw Man","year":2022,"type":"tv","runtimeSeconds":1440,"seasonCount":1,"communityRating":8.515,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"Denji has a simple dream\u2014to live a happy and peaceful life, spending time with a girl he likes. This is a far cry from reality, however, as Denji is forced by the yakuza into killing devils in order to pay off his\u2026","poster":"assets/tv-chainsaw-man-2022-poster.jpg","backdrop":"assets/tv-chainsaw-man-2022-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-20T22:28:13.2095501Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":114410,"artwork":{"posterKind":"center_crop","backdropKind":"backdrop"},"ratings":{"rtCritics":97,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.508,"tmdbVotes":2364}},{"id":"tv-cyberpunk-edgerunners-2022","title":"Cyberpunk: Edgerunners","year":2022,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":8.466,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"In a dystopia riddled with corruption and cybernetic implants, a talented but reckless street kid strives to become a mercenary outlaw \u2014 an edgerunner.","poster":"assets/tv-cyberpunk-edgerunners-2022-poster.jpg","backdrop":"assets/tv-cyberpunk-edgerunners-2022-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-20T22:28:13.2093107Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":105248,"artwork":{"posterKind":"center_crop","backdropKind":"backdrop"},"ratings":{"rtCritics":89,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.507,"tmdbVotes":2132}},{"id":"tv-dan-da-dan-2024","title":"Dan Da Dan","year":2024,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":8.538,"criticRating":null,"genres":["Animation","Action & Adventure","Comedy"],"overview":"In a bet to prove whether ghosts or aliens exist, two high schoolers face terrifying paranormal threats, gain superpowers and maybe even fall in love?!","poster":"assets/tv-dan-da-dan-2024-poster.jpg","backdrop":"assets/tv-dan-da-dan-2024-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-22T10:31:16.2652252Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":240411,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.517,"tmdbVotes":986}},{"id":"tv-dr-stone-2019","title":"Dr. STONE","year":2019,"type":"tv","runtimeSeconds":1440,"seasonCount":4,"communityRating":8.541,"criticRating":null,"genres":["Animation","Action & Adventure","Comedy"],"overview":"One fateful day, all of humanity was petrified by a blinding flash of light. After several millennia, high schooler Taiju awakens and finds himself lost in a world of statues. However, he\u2019s not alone! His\u2026","poster":"assets/tv-dr-stone-2019-poster.jpg","backdrop":"assets/tv-dr-stone-2019-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-20T22:28:13.2091579Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":86031,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.53,"tmdbVotes":1814}},{"id":"tv-frieren-beyond-journey-s-end-2023","title":"Frieren: Beyond Journey's End","year":2023,"type":"tv","runtimeSeconds":1500,"seasonCount":2,"communityRating":8.803,"criticRating":null,"genres":["Animation","Action & Adventure","Drama"],"overview":"After the party of heroes defeated the Demon King, they restored peace to the land and returned to lives of solitude. Generations pass, and the elven mage Frieren comes face to face with humanity\u2019s mortality. She\u2026","poster":"assets/tv-frieren-beyond-journey-s-end-2023-poster.jpg","backdrop":"assets/tv-frieren-beyond-journey-s-end-2023-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-20T22:28:13.2094435Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":209867,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.826,"tmdbVotes":1016}},{"id":"tv-fullmetal-alchemist-brotherhood-2009","title":"Fullmetal Alchemist: Brotherhood","year":2009,"type":"tv","runtimeSeconds":1500,"seasonCount":1,"communityRating":8.7,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"Disregard for alchemy\u2019s laws ripped half of Edward Elric\u2019s limbs from his body and left his brother Alphonse\u2019s soul clinging to a suit of armor. To restore what was lost, the brothers seek the Philosopher\u2019s Stone.\u2026","poster":"assets/tv-fullmetal-alchemist-brotherhood-2009-poster.jpg","backdrop":"assets/tv-fullmetal-alchemist-brotherhood-2009-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-20T10:27:12.3573939Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":31911,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.7,"tmdbVotes":2633}},{"id":"tv-jujutsu-kaisen-2020","title":"JUJUTSU KAISEN","year":2020,"type":"tv","runtimeSeconds":1440,"seasonCount":3,"communityRating":8.583,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"Yuji Itadori is a boy with tremendous physical strength, though he lives a completely ordinary high school life. One day, to save a classmate who has been attacked by curses, he eats the finger of Ryomen Sukuna,\u2026","poster":"assets/tv-jujutsu-kaisen-2020-poster.jpg","backdrop":"assets/tv-jujutsu-kaisen-2020-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-20T22:28:13.207573Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":95479,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.574,"tmdbVotes":4735}},{"id":"tv-kaiju-no-8-2024","title":"Kaiju No. 8","year":2024,"type":"tv","runtimeSeconds":1440,"seasonCount":2,"communityRating":8.436,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"In a world plagued by creatures known as Kaiju, Kafka Hibino aspired to enlist in The Defense Force. He makes a promise to enlist with his childhood friend, Mina Ashiro. Soon, life takes them in separate ways. While\u2026","poster":"assets/tv-kaiju-no-8-2024-poster.jpg","backdrop":"assets/tv-kaiju-no-8-2024-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-22T10:31:16.2646566Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":207468,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.4,"tmdbVotes":836}},{"id":"tv-my-hero-academia-2016","title":"My Hero Academia","year":2016,"type":"tv","runtimeSeconds":1440,"seasonCount":8,"communityRating":8.607,"criticRating":null,"genres":["Action & Adventure","Animation","Sci-Fi & Fantasy"],"overview":"After he saves a bully from a Villain, a normal student is granted a superpower that allows him to attend a high school training academy for Heroes.","poster":"assets/tv-my-hero-academia-2016-poster.jpg","backdrop":"assets/tv-my-hero-academia-2016-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-20T22:28:13.2089315Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":65930,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.6,"tmdbVotes":5426}},{"id":"tv-neon-genesis-evangelion-1995","title":"Neon Genesis Evangelion","year":1995,"type":"tv","runtimeSeconds":1500,"seasonCount":1,"communityRating":8.568,"criticRating":null,"genres":["Sci-Fi & Fantasy","Animation","Drama"],"overview":"At the turn of the century, the Angels returned to Earth, seeking to wipe out humanity in an apocalyptic fury. Devastated, mankind's last remnants moved underground to wait for the day when the Angels would come back\u2026","poster":"assets/tv-neon-genesis-evangelion-1995-poster.jpg","backdrop":"assets/tv-neon-genesis-evangelion-1995-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0340629Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":890,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.56,"tmdbVotes":2205}},{"id":"tv-record-of-ragnarok-2021","title":"Record of Ragnarok","year":2021,"type":"tv","runtimeSeconds":null,"seasonCount":3,"communityRating":8.402,"criticRating":null,"genres":["Action & Adventure","Animation","Sci-Fi & Fantasy"],"overview":"Before eradicating humankind from the world, the gods give them one last chance to prove themselves worthy of survival. Let the Ragnarok battles begin.","poster":"assets/tv-record-of-ragnarok-2021-poster.jpg","backdrop":"assets/tv-record-of-ragnarok-2021-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-20T22:28:13.2085766Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":114868,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.403,"tmdbVotes":1929}},{"id":"tv-sakamoto-days-2025","title":"SAKAMOTO DAYS","year":2025,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":8.071,"criticRating":null,"genres":["Animation","Comedy","Action & Adventure"],"overview":"Once the greatest hitman of all, Taro Sakamoto retired in the name of love. But when his past catches up, he must fight to protect his beloved family.","poster":"assets/tv-sakamoto-days-2025-poster.jpg","backdrop":"assets/tv-sakamoto-days-2025-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-22T10:31:16.266066Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":207332,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.057,"tmdbVotes":375}},{"id":"tv-sentenced-to-be-a-hero-2025","title":"Sentenced to Be a Hero","year":2025,"type":"tv","runtimeSeconds":1440,"seasonCount":1,"communityRating":8.338,"criticRating":null,"genres":["Animation","Sci-Fi & Fantasy","Action & Adventure"],"overview":"In a world where heroism is a punishment, Xylo Forbartz, a condemned goddess killer, battles endless hordes of monstrous abominations as part of Penal Hero Unit 9004. Death is no escape, only a cycle of resurrection\u2026","poster":"assets/tv-sentenced-to-be-a-hero-2025-poster.jpg","backdrop":"assets/tv-sentenced-to-be-a-hero-2025-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-22T10:31:16.2666791Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":249907,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.244,"tmdbVotes":129}},{"id":"tv-spy-x-family-2022","title":"SPY x FAMILY","year":2022,"type":"tv","runtimeSeconds":1440,"seasonCount":3,"communityRating":8.506,"criticRating":null,"genres":["Animation","Action & Adventure","Comedy"],"overview":"A spy, an assassin and a telepath come together to pose as a family, each for their own reasons, while hiding their true identities from each other.","poster":"assets/tv-spy-x-family-2022-poster.jpg","backdrop":"assets/tv-spy-x-family-2022-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-22T10:31:16.2670364Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":120089,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.5,"tmdbVotes":2382}},{"id":"tv-the-tatami-galaxy-2010","title":"The Tatami Galaxy","year":2010,"type":"tv","runtimeSeconds":1500,"seasonCount":2,"communityRating":8.271,"criticRating":null,"genres":["Animation","Comedy","Drama"],"overview":"When a college dropout stops for a late night bite at a mysterious ramen stand, he crosses paths with a self-proclaimed deity of matrimony. This bizarre meeting sends the young man hurtling through a horrifying\u2026","poster":"assets/tv-the-tatami-galaxy-2010-poster.jpg","backdrop":"assets/tv-the-tatami-galaxy-2010-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-18T05:52:51.0023256Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":36243,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.3,"tmdbVotes":152}},{"id":"tv-vinland-saga-2019","title":"Vinland Saga","year":2019,"type":"tv","runtimeSeconds":1500,"seasonCount":2,"communityRating":8.527,"criticRating":null,"genres":["Animation","War & Politics","Action & Adventure"],"overview":"For a thousand years, the Vikings have made quite a name and reputation for themselves as the strongest families with a thirst for violence. Thorfinn, the son of one of the Vikings' greatest warriors, spends his\u2026","poster":"assets/tv-vinland-saga-2019-poster.jpg","backdrop":"assets/tv-vinland-saga-2019-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-20T22:28:13.207389Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":88803,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.509,"tmdbVotes":1064}},{"id":"tv-yu-yu-hakusho-1992","title":"Yu Yu Hakusho","year":1992,"type":"tv","runtimeSeconds":1440,"seasonCount":4,"communityRating":8.5,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"After dying to save a boy, delinquent tough guy Yusuke Urameshi is granted another chance at life by redeeming himself as a \"Spirit Detective.\"","poster":"assets/tv-yu-yu-hakusho-1992-poster.jpg","backdrop":"assets/tv-yu-yu-hakusho-1992-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-19T21:46:01.5733049Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":30669,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.475,"tmdbVotes":519}}],"discover":[{"id":"movie-the-amazing-spider-man-2012","title":"The Amazing Spider-Man","year":2012,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"A teenage Peter Parker grapples with both high school and amazing super-human crises as his alter-ego Spider-Man.","poster":"assets/movie-the-amazing-spider-man-2012-poster.jpg","backdrop":"assets/movie-the-amazing-spider-man-2012-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1930,"ratings":{"rtCritics":71,"rtCriticsLabel":"Certified Fresh","rtAudience":77,"rtAudienceLabel":"Upright","imdb":7,"imdbVotes":791938,"tmdb":6.768,"tmdbVotes":19312}},{"id":"movie-the-amazing-spider-man-2-2014","title":"The Amazing Spider-Man 2","year":2014,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"For Peter Parker, life is busy. Between taking out the bad guys as Spider-Man and spending time with the person he loves, Gwen Stacy, high school graduation cannot come quickly enough. Peter has not forgotten about\u2026","poster":"assets/movie-the-amazing-spider-man-2-2014-poster.jpg","backdrop":"assets/movie-the-amazing-spider-man-2-2014-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":102382,"ratings":{"rtCritics":51,"rtCriticsLabel":"Rotten","rtAudience":64,"rtAudienceLabel":"Upright","imdb":6.6,"imdbVotes":611981,"tmdb":6.567,"tmdbVotes":14919}},{"id":"movie-the-animatrix-2003","title":"The Animatrix","year":2003,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Science Fiction"],"overview":"Straight from the creators of the groundbreaking Matrix trilogy, this collection of short animated films from the world's leading anime directors fuses computer graphics and Japanese anime to provide the background\u2026","poster":"assets/movie-the-animatrix-2003-poster.jpg","backdrop":"assets/movie-the-animatrix-2003-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":55931,"ratings":{"rtCritics":86,"rtCriticsLabel":"Fresh","rtAudience":78,"rtAudienceLabel":"Upright","imdb":7.3,"imdbVotes":91929,"tmdb":7.221,"tmdbVotes":1872}},{"id":"movie-ant-man-and-the-wasp-quantumania-2023","title":"Ant-Man and the Wasp: Quantumania","year":2023,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"Super-Hero partners Scott Lang and Hope van Dyne, along with with Hope's parents Janet van Dyne and Hank Pym, and Scott's daughter Cassie Lang, find themselves exploring the Quantum Realm, interacting with strange\u2026","poster":"assets/movie-ant-man-and-the-wasp-quantumania-2023-poster.jpg","backdrop":"assets/movie-ant-man-and-the-wasp-quantumania-2023-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":640146,"ratings":{"rtCritics":46,"rtCriticsLabel":"Rotten","rtAudience":81,"rtAudienceLabel":"Upright","imdb":6,"imdbVotes":281738,"tmdb":6.197,"tmdbVotes":6236}},{"id":"movie-appleseed-alpha-2014","title":"Appleseed Alpha","year":2014,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Science Fiction","Action"],"overview":"The Third World War (2099\u20132126) devastated part of the Earth. Former nations are reforming while new powers emerge, and groups of humans struggle to survive in ruined cities. The story follows Deunan Knute and her\u2026","poster":"assets/movie-appleseed-alpha-2014-poster.jpg","backdrop":"assets/movie-appleseed-alpha-2014-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"center_crop","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":269650,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":59,"rtAudienceLabel":"Spilled","imdb":6.5,"imdbVotes":9689,"tmdb":6.808,"tmdbVotes":436}},{"id":"movie-aquaman-and-the-lost-kingdom-2023","title":"Aquaman and the Lost Kingdom","year":2023,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Fantasy"],"overview":"Black Manta seeks revenge on Aquaman for his father's death. Wielding the Black Trident's power, he becomes a formidable foe. To defend Atlantis, Arthur (Aquaman) forges an alliance with his imprisoned brother. They\u2026","poster":"assets/movie-aquaman-and-the-lost-kingdom-2023-poster.jpg","backdrop":"assets/movie-aquaman-and-the-lost-kingdom-2023-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":572802,"ratings":{"rtCritics":33,"rtCriticsLabel":"Rotten","rtAudience":79,"rtAudienceLabel":"Upright","imdb":5.6,"imdbVotes":122422,"tmdb":6.48,"tmdbVotes":3748}},{"id":"movie-the-avengers-2012","title":"The Avengers","year":2012,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Science Fiction","Action","Adventure"],"overview":"When an unexpected enemy emerges and threatens global safety and security, Nick Fury, director of the international peacekeeping agency known as S.H.I.E.L.D., finds himself in need of a team to pull the world back\u2026","poster":"assets/movie-the-avengers-2012-poster.jpg","backdrop":"assets/movie-the-avengers-2012-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":24428,"ratings":{"rtCritics":91,"rtCriticsLabel":"Certified Fresh","rtAudience":91,"rtAudienceLabel":"Upright","imdb":8,"imdbVotes":1590693,"tmdb":8.084,"tmdbVotes":40262}},{"id":"movie-avengers-confidential-black-widow-punisher-2014","title":"Avengers Confidential: Black Widow & Punisher","year":2014,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Science Fiction","Action"],"overview":"When the Punisher takes out a black-market weapons dealer, he stumbles upon a far-reaching terrorist plot devised by a group known as Leviathan.","poster":"assets/movie-avengers-confidential-black-widow-punisher-2014-poster.jpg","backdrop":"assets/movie-avengers-confidential-black-widow-punisher-2014-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":257346,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":55,"rtAudienceLabel":"Spilled","imdb":5.7,"imdbVotes":5166,"tmdb":6.368,"tmdbVotes":303}},{"id":"movie-avengers-age-of-ultron-2015","title":"Avengers: Age of Ultron","year":2015,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"When Tony Stark tries to jumpstart a dormant peacekeeping program, things go awry and Earth\u2019s Mightiest Heroes are put to the ultimate test as the fate of the planet hangs in the balance. As the villainous Ultron\u2026","poster":"assets/movie-avengers-age-of-ultron-2015-poster.jpg","backdrop":"assets/movie-avengers-age-of-ultron-2015-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":99861,"ratings":{"rtCritics":75,"rtCriticsLabel":"Certified Fresh","rtAudience":82,"rtAudienceLabel":"Upright","imdb":7.3,"imdbVotes":1025876,"tmdb":7.278,"tmdbVotes":25289}},{"id":"movie-avengers-infinity-war-2018","title":"Avengers: Infinity War","year":2018,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Adventure","Action","Science Fiction"],"overview":"As the Avengers and their allies have continued to protect the world from threats too large for any one hero to handle, a new danger has emerged from the cosmic shadows: Thanos. A despot of intergalactic infamy, his\u2026","poster":"assets/movie-avengers-infinity-war-2018-poster.jpg","backdrop":"assets/movie-avengers-infinity-war-2018-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":299536,"ratings":{"rtCritics":85,"rtCriticsLabel":"Certified Fresh","rtAudience":92,"rtAudienceLabel":"Upright","imdb":8.4,"imdbVotes":1421954,"tmdb":8.246,"tmdbVotes":33331}},{"id":"movie-batman-ninja-2018","title":"Batman Ninja","year":2018,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Action","Science Fiction"],"overview":"Batman, along with many of his allies and adversaries, finds himself transported to feudal Japan by Gorilla Grodd's time displacement machine.","poster":"assets/movie-batman-ninja-2018-poster.jpg","backdrop":"assets/movie-batman-ninja-2018-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":485942,"ratings":{"rtCritics":84,"rtCriticsLabel":"Fresh","rtAudience":60,"rtAudienceLabel":"Upright","imdb":5.6,"imdbVotes":24579,"tmdb":5.9,"tmdbVotes":1106}},{"id":"movie-batman-v-superman-dawn-of-justice-2016","title":"Batman v Superman: Dawn of Justice","year":2016,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Fantasy"],"overview":"Fearing the actions of a god-like Super Hero left unchecked, Gotham City\u2019s own formidable, forceful vigilante takes on Metropolis\u2019s most revered, modern-day savior, while the world wrestles with what sort of hero it\u2026","poster":"assets/movie-batman-v-superman-dawn-of-justice-2016-poster.jpg","backdrop":"assets/movie-batman-v-superman-dawn-of-justice-2016-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":209112,"ratings":{"rtCritics":28,"rtCriticsLabel":"Rotten","rtAudience":63,"rtAudienceLabel":"Upright","imdb":6.4,"imdbVotes":801382,"tmdb":6.014,"tmdbVotes":19527}},{"id":"movie-batman-the-dark-knight-returns-part-1-2012","title":"Batman: The Dark Knight Returns, Part 1","year":2012,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Science Fiction","Action","Animation"],"overview":"Batman has not been seen for ten years. A new breed of criminal ravages Gotham City, forcing 55-year-old Bruce Wayne back into the cape and cowl. But, does he still have what it takes to fight crime in a new era?","poster":"assets/movie-batman-the-dark-knight-returns-part-1-2012-poster.jpg","backdrop":"assets/movie-batman-the-dark-knight-returns-part-1-2012-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":123025,"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":93,"rtAudienceLabel":"Upright","imdb":7.9,"imdbVotes":68943,"tmdb":7.742,"tmdbVotes":1766}},{"id":"movie-black-adam-2022","title":"Black Adam","year":2022,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"Nearly 5,000 years after he was bestowed with the almighty powers of the Egyptian gods\u2014and imprisoned just as quickly\u2014Black Adam is freed from his earthly tomb, ready to unleash his unique form of justice on the\u2026","poster":"assets/movie-black-adam-2022-poster.jpg","backdrop":"assets/movie-black-adam-2022-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":436270,"ratings":{"rtCritics":39,"rtCriticsLabel":"Rotten","rtAudience":87,"rtAudienceLabel":"Upright","imdb":6.1,"imdbVotes":309624,"tmdb":6.747,"tmdbVotes":7370}},{"id":"movie-black-panther-2018","title":"Black Panther","year":2018,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"King T'Challa returns home to the reclusive, technologically advanced African nation of Wakanda to serve as his country's new leader. However, T'Challa soon finds that he is challenged for the throne by factions\u2026","poster":"assets/movie-black-panther-2018-poster.jpg","backdrop":"assets/movie-black-panther-2018-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":284054,"ratings":{"rtCritics":96,"rtCriticsLabel":"Certified Fresh","rtAudience":null,"rtAudienceLabel":"Spilled","imdb":7.3,"imdbVotes":931120,"tmdb":7.36,"tmdbVotes":24190}},{"id":"movie-black-panther-wakanda-forever-2022","title":"Black Panther: Wakanda Forever","year":2022,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"Queen Ramonda, Shuri, M\u2019Baku, Okoye and the Dora Milaje fight to protect their nation from intervening world powers in the wake of King T\u2019Challa\u2019s death. As the Wakandans strive to embrace their next chapter, the\u2026","poster":"assets/movie-black-panther-wakanda-forever-2022-poster.jpg","backdrop":"assets/movie-black-panther-wakanda-forever-2022-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":505642,"ratings":{"rtCritics":84,"rtCriticsLabel":"Certified Fresh","rtAudience":93,"rtAudienceLabel":"Upright","imdb":6.6,"imdbVotes":405088,"tmdb":6.984,"tmdbVotes":7920}},{"id":"movie-blame-2017","title":"BLAME!","year":2017,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Drama","Science Fiction"],"overview":"In the distant technological future, civilization has reached its ultimate Net-based form. An \"infection\" in the past caused the automated systems to spiral out of order, resulting in a multi-leveled city structure\u2026","poster":"assets/movie-blame-2017-poster.jpg","backdrop":"assets/movie-blame-2017-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":409421,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":61,"rtAudienceLabel":"Upright","imdb":6.6,"imdbVotes":10258,"tmdb":6.523,"tmdbVotes":370}},{"id":"movie-bleach-the-movie-fade-to-black-2008","title":"Bleach the Movie: Fade to Black","year":2008,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Animation","Fantasy"],"overview":"A strange disturbance in the World of the Living sends Ichigo and Kon to the Soul Society, where the Seireitei has been devastated by an explosion of reishi and Rukia is missing. Stranger still, none of the Soul\u2026","poster":"assets/movie-bleach-the-movie-fade-to-black-2008-poster.jpg","backdrop":"assets/movie-bleach-the-movie-fade-to-black-2008-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":17165,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":67,"rtAudienceLabel":"Upright","imdb":7.1,"imdbVotes":3693,"tmdb":7.1,"tmdbVotes":206}},{"id":"tv-90-day-fiance-before-the-90-days-2017","title":"90 Day Fianc\u00e9: Before the 90 Days","year":2017,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Reality"],"overview":"Follow couples who have an existing relationship online, but haven't met in person. We'll experience their journey as they travel to the other's foreign country for the first time in an attempt to establish an\u2026","poster":"assets/tv-90-day-fiance-before-the-90-days-2017-poster.jpg","backdrop":"assets/tv-90-day-fiance-before-the-90-days-2017-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":73319,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.5,"tmdbVotes":45}},{"id":"tv-90-day-fiance-the-other-way-2019","title":"90 Day Fianc\u00e9: The Other Way","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Reality"],"overview":"Americans move to a foreign country all in the name of love. Will uprooting their lives in the United States pay off, or will they be packing up and moving back to America brokenhearted?","poster":"assets/tv-90-day-fiance-the-other-way-2019-poster.jpg","backdrop":"assets/tv-90-day-fiance-the-other-way-2019-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":90046,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.8,"tmdbVotes":34}},{"id":"tv-the-100-2014","title":"The 100","year":2014,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Sci-Fi & Fantasy","Action & Adventure","Drama"],"overview":"100 years in the future, when the Earth has been abandoned due to radioactivity, the last surviving humans live on an ark orbiting the planet \u2014 but the ark won't last forever. So the repressive regime picks 100\u2026","poster":"assets/tv-the-100-2014-poster.jpg","backdrop":"assets/tv-the-100-2014-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":48866,"ratings":{"rtCritics":93,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.878,"tmdbVotes":8855}},{"id":"tv-the-abbott-and-costello-show-1952","title":"The Abbott and Costello Show","year":1952,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy"],"overview":"Bud and Lou are unemployed actors living in Mr. Fields\u2019 boarding house. Lou\u2019s girlfriend Hillary lives across the hall. Many situations arise leading to slapstick and puns.","poster":"assets/tv-the-abbott-and-costello-show-1952-poster.jpg","backdrop":"assets/tv-the-abbott-and-costello-show-1952-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":11821,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.2,"tmdbVotes":22}},{"id":"tv-accel-world-2012","title":"Accel World","year":2012,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Action & Adventure","Drama"],"overview":"A short, overweight student uses the Neuro Link, an evolution of the Nerve Gear, to escape himself from the reality. But his skills in the virtual world brings him to the attention of Kuroyukihime, a popular student\u2026","poster":"assets/tv-accel-world-2012-poster.jpg","backdrop":"assets/tv-accel-world-2012-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"center_crop","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":43167,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.429,"tmdbVotes":163}},{"id":"tv-ahsoka-2023","title":"Ahsoka","year":2023,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Sci-Fi & Fantasy","Action & Adventure","Drama"],"overview":"Former Jedi Knight Ahsoka Tano investigates an emerging threat to a vulnerable galaxy.","poster":"assets/tv-ahsoka-2023-poster.jpg","backdrop":"assets/tv-ahsoka-2023-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":114461,"ratings":{"rtCritics":85,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.4,"tmdbVotes":1351}},{"id":"tv-american-dad-2005","title":"American Dad!","year":2005,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Comedy"],"overview":"The series focuses on an eccentric motley crew that is the Smith family and their three housemates: Father, husband, and breadwinner Stan Smith; his better half housewife, Francine Smith; their college-aged daughter,\u2026","poster":"assets/tv-american-dad-2005-poster.jpg","backdrop":"assets/tv-american-dad-2005-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1433,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.022,"tmdbVotes":2481}},{"id":"tv-american-gods-2017","title":"American Gods","year":2017,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Mystery","Sci-Fi & Fantasy"],"overview":"An ex-con becomes the traveling partner of a conman who turns out to be one of the older gods trying to recruit troops to battle the upstart deities. Based on Neil Gaiman's fantasy novel.","poster":"assets/tv-american-gods-2017-poster.jpg","backdrop":"assets/tv-american-gods-2017-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":46639,"ratings":{"rtCritics":75,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.1,"tmdbVotes":1852}},{"id":"tv-andromeda-2000","title":"Andromeda","year":2000,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action & Adventure","Drama","Sci-Fi & Fantasy"],"overview":"Captain Dylan Hunt and his crew quest to restore a government that once presided over an extended peace and prosperity.","poster":"assets/tv-andromeda-2000-poster.jpg","backdrop":"assets/tv-andromeda-2000-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":2985,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.068,"tmdbVotes":315}},{"id":"tv-aqua-teen-hunger-force-2000","title":"Aqua Teen Hunger Force","year":2000,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Comedy"],"overview":"The surreal adventures of three anthropomorphic fast food items: Master Shake, Frylock and Meatwad, and their human nextdoor neighbor, Carl Brutananadilewski.","poster":"assets/tv-aqua-teen-hunger-force-2000-poster.jpg","backdrop":"assets/tv-aqua-teen-hunger-force-2000-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":251,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.5,"tmdbVotes":272}},{"id":"tv-arifureta-from-commonplace-to-world-s-strongest-2019","title":"Arifureta: From Commonplace to World's Strongest","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"Hajime Nagumo and his high school class are suddenly summoned to a fantastical land as heroes. But while most of his classmates have powerful stats and abilities, Hajime does not. Underappreciated and unprepared, he\u2026","poster":"assets/tv-arifureta-from-commonplace-to-world-s-strongest-2019-poster.jpg","backdrop":"assets/tv-arifureta-from-commonplace-to-world-s-strongest-2019-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":86034,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.382,"tmdbVotes":662}},{"id":"tv-arrow-2012","title":"Arrow","year":2012,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Crime","Drama","Action & Adventure"],"overview":"Spoiled billionaire playboy Oliver Queen is missing and presumed dead when his yacht is lost at sea. He returns five years later a changed man, determined to clean up the city as a hooded vigilante armed with a bow.","poster":"assets/tv-arrow-2012-poster.jpg","backdrop":"assets/tv-arrow-2012-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1412,"ratings":{"rtCritics":86,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.834,"tmdbVotes":6537}},{"id":"tv-avatar-the-last-airbender-2024","title":"Avatar: The Last Airbender","year":2024,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action & Adventure","Sci-Fi & Fantasy","Drama"],"overview":"A young boy known as the Avatar must master the four elemental powers to save a world at war \u2014 and fight a ruthless enemy bent on stopping him.","poster":"assets/tv-avatar-the-last-airbender-2024-poster.jpg","backdrop":"assets/tv-avatar-the-last-airbender-2024-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":82452,"ratings":{"rtCritics":66,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.8,"tmdbVotes":1406}},{"id":"tv-avatar-seven-havens-2026","title":"Avatar: Seven Havens","year":2026,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"In a world shattered by a devastating cataclysm, a young Earthbender discovers she's the new Avatar after Korra. But in this dangerous era, that title marks her as humanity's destroyer, not its savior. Hunted by both\u2026","poster":"assets/tv-avatar-seven-havens-2026-poster.jpg","backdrop":"assets/tv-avatar-seven-havens-2026-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":284833,"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.9,"tmdbVotes":183}},{"id":"tv-batman-the-brave-and-the-bold-2008","title":"Batman: The Brave and the Bold","year":2008,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"The Caped Crusader is teamed up with Blue Beetle, Green Arrow, Aquaman and countless others in his quest to uphold justice.","poster":"assets/tv-batman-the-brave-and-the-bold-2008-poster.jpg","backdrop":"assets/tv-batman-the-brave-and-the-bold-2008-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":15804,"ratings":{"rtCritics":96,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.6,"tmdbVotes":295}},{"id":"tv-batwoman-2019","title":"Batwoman","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Sci-Fi & Fantasy","Action & Adventure"],"overview":"Kate Kane, armed with a passion for social justice and a flair for speaking her mind, soars onto the streets of Gotham as Batwoman, an out lesbian and highly trained street fighter primed to snuff out the failing\u2026","poster":"assets/tv-batwoman-2019-poster.jpg","backdrop":"assets/tv-batwoman-2019-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":89247,"ratings":{"rtCritics":83,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.8,"tmdbVotes":1619}},{"id":"tv-big-mouth-2017","title":"Big Mouth","year":2017,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy","Animation"],"overview":"Teenage friends find their lives upended by the wonders and horrors of puberty in this edgy comedy from real-life pals Nick Kroll and Andrew Goldberg.","poster":"assets/tv-big-mouth-2017-poster.jpg","backdrop":"assets/tv-big-mouth-2017-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":74204,"ratings":{"rtCritics":95,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.591,"tmdbVotes":1274}},{"id":"tv-black-lightning-2018","title":"Black Lightning","year":2018,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action & Adventure","Sci-Fi & Fantasy","Drama"],"overview":"Jefferson Pierce is a man wrestling with a secret. As the father of two daughters and principal of a charter high school that also serves as a safe haven for young people in a New Orleans neighborhood overrun by gang\u2026","poster":"assets/tv-black-lightning-2018-poster.jpg","backdrop":"assets/tv-black-lightning-2018-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":71663,"ratings":{"rtCritics":92,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.907,"tmdbVotes":991}}]},{"key":"mindel","movies":[{"id":"movie-chicago-2002","title":"Chicago","year":2002,"type":"movie","runtimeSeconds":6808,"seasonCount":null,"communityRating":7.131,"criticRating":87,"genres":["Comedy","Crime","Drama"],"overview":"Murderesses Velma Kelly and Roxie Hart find themselves on death row together and fight for the fame that will keep them from the gallows in 1920s Chicago.","poster":"assets/movie-chicago-2002-poster.jpg","backdrop":"assets/movie-chicago-2002-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:24:53.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":1574,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":87,"rtCriticsLabel":"Certified Fresh","rtAudience":83,"rtAudienceLabel":"Upright","imdb":7.2,"imdbVotes":259272,"tmdb":7.135,"tmdbVotes":3025}},{"id":"movie-coherence-2014","title":"Coherence","year":2014,"type":"movie","runtimeSeconds":5286,"seasonCount":null,"communityRating":7.21,"criticRating":89,"genres":["Thriller","Science Fiction"],"overview":"Four couples gather for dinner the night a mysterious and powerful comet passes overhead.","poster":"assets/movie-coherence-2014-poster.jpg","backdrop":"assets/movie-coherence-2014-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2017-08-12T23:49:00.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":220289,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":89,"rtCriticsLabel":"Certified Fresh","rtAudience":81,"rtAudienceLabel":"Upright","imdb":7.1,"imdbVotes":180354,"tmdb":7.208,"tmdbVotes":3702}},{"id":"movie-the-killing-of-a-sacred-deer-2017","title":"The Killing of a Sacred Deer","year":2017,"type":"movie","runtimeSeconds":7262,"seasonCount":null,"communityRating":7.003,"criticRating":79,"genres":["Drama","Thriller","Mystery"],"overview":"Dr. Steven Murphy is a renowned cardiovascular surgeon who presides over a spotless household with his wife and two children. Lurking at the margins of his idyllic suburban existence is Martin, a fatherless teen who\u2026","poster":"assets/movie-the-killing-of-a-sacred-deer-2017-poster.jpg","backdrop":"assets/movie-the-killing-of-a-sacred-deer-2017-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-22T21:49:26.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":399057,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":79,"rtCriticsLabel":"Certified Fresh","rtAudience":63,"rtAudienceLabel":"Upright","imdb":7,"imdbVotes":219488,"tmdb":7.001,"tmdbVotes":4640}},{"id":"movie-night-is-short-walk-on-girl-2017","title":"Night Is Short, Walk On Girl","year":2017,"type":"movie","runtimeSeconds":5548,"seasonCount":null,"communityRating":7.421,"criticRating":90,"genres":["Comedy","Romance","Animation"],"overview":"As a group of university students go out for a night on the town, a sophomore known only as \"The Girl with Black Hair\" experiences a series of surreal encounters with the local nightlife \u2013 all the while unaware of\u2026","poster":"assets/movie-night-is-short-walk-on-girl-2017-poster.jpg","backdrop":"assets/movie-night-is-short-walk-on-girl-2017-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T17:39:14.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":430214,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":90,"rtCriticsLabel":"Fresh","rtAudience":84,"rtAudienceLabel":"Upright","imdb":7.5,"imdbVotes":7640,"tmdb":7.4,"tmdbVotes":267}},{"id":"movie-thelma-louise-1991","title":"Thelma & Louise","year":1991,"type":"movie","runtimeSeconds":7786,"seasonCount":null,"communityRating":7.596,"criticRating":87,"genres":["Drama","Crime","Adventure"],"overview":"Taking a break from their dreary lives, close friends Thelma and Louise embark on a short weekend trip that ends in unforeseen incriminating circumstances. As fugitives, both women rediscover the strength of their\u2026","poster":"assets/movie-thelma-louise-1991-poster.jpg","backdrop":"assets/movie-thelma-louise-1991-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:24:40.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":1541,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":87,"rtCriticsLabel":"Certified Fresh","rtAudience":82,"rtAudienceLabel":"Upright","imdb":7.6,"imdbVotes":197947,"tmdb":7.594,"tmdbVotes":4063}},{"id":"movie-vicky-cristina-barcelona-2008","title":"Vicky Cristina Barcelona","year":2008,"type":"movie","runtimeSeconds":5804,"seasonCount":null,"communityRating":6.798,"criticRating":80,"genres":["Drama","Romance"],"overview":"Two girlfriends on a summer holiday in Spain become enamored with the same painter, unaware that his ex-wife, with whom he has a tempestuous relationship, is about to re-enter the picture.","poster":"assets/movie-vicky-cristina-barcelona-2008-poster.jpg","backdrop":"assets/movie-vicky-cristina-barcelona-2008-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:25:34.0000000Z","lastPlayedAt":"2026-08-11T20:12:57.3064089Z","availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":5038,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":80,"rtCriticsLabel":"Certified Fresh","rtAudience":74,"rtAudienceLabel":"Upright","imdb":7.1,"imdbVotes":283286,"tmdb":6.797,"tmdbVotes":4164}}],"shows":[{"id":"tv-all-creatures-great-small-2020","title":"All Creatures Great & Small","year":2020,"type":"tv","runtimeSeconds":3000,"seasonCount":7,"communityRating":8.2,"criticRating":null,"genres":["Drama","Comedy"],"overview":"The heartwarming and humorous adventures of a young country vet in the Yorkshire Dales in the 1930s. A remake of the 1978 series.","poster":"assets/tv-all-creatures-great-small-2020-poster.jpg","backdrop":"assets/tv-all-creatures-great-small-2020-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-11-04T20:13:03.4266156Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":108255,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":99,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.2,"tmdbVotes":168}},{"id":"tv-back-to-life-2019","title":"Back to Life","year":2019,"type":"tv","runtimeSeconds":1500,"seasonCount":2,"communityRating":7.367,"criticRating":null,"genres":["Comedy","Drama"],"overview":"When Miri Matteson returns home after eighteen years, can she integrate back into her old life? With a terrible event from her past hanging over her, it won\u2019t be easy.","poster":"assets/tv-back-to-life-2019-poster.jpg","backdrop":"assets/tv-back-to-life-2019-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0688758Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":88510,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.4,"tmdbVotes":65}},{"id":"tv-big-boys-2022","title":"Big Boys","year":2022,"type":"tv","runtimeSeconds":1440,"seasonCount":3,"communityRating":7.6,"criticRating":null,"genres":["Comedy","Drama"],"overview":"Two boys from very different ends of the 'spectrum of masculinity' become best friends at Brent University freshers' week; in their first year of uni, they explore, experiment and try to discover themselves, helping\u2026","poster":"assets/tv-big-boys-2022-poster.jpg","backdrop":"assets/tv-big-boys-2022-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-11-04T20:25:58.2156837Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":202851,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.6,"tmdbVotes":49}},{"id":"tv-call-the-midwife-2012","title":"Call the Midwife","year":2012,"type":"tv","runtimeSeconds":3120,"seasonCount":15,"communityRating":7.5,"criticRating":null,"genres":["Drama"],"overview":"Drama following the lives of a group of midwives working in the poverty-stricken East End of London during the 1950s, based on the best-selling memoirs of Jennifer Worth.","poster":"assets/tv-call-the-midwife-2012-poster.jpg","backdrop":"assets/tv-call-the-midwife-2012-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0377216Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":39793,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.577,"tmdbVotes":267}},{"id":"tv-derry-girls-2018","title":"Derry Girls","year":2018,"type":"tv","runtimeSeconds":1380,"seasonCount":3,"communityRating":8.144,"criticRating":null,"genres":["Comedy","Drama"],"overview":"Amidst the political conflict of Northern Ireland in the 1990s, five secondary school students square off with the universal challenges of being a teenager.","poster":"assets/tv-derry-girls-2018-poster.jpg","backdrop":"assets/tv-derry-girls-2018-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-02T20:01:55.8955537Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":76148,"artwork":{"posterKind":"center_crop","backdropKind":"backdrop"},"ratings":{"rtCritics":99,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.138,"tmdbVotes":454}},{"id":"tv-desperate-housewives-2004","title":"Desperate Housewives","year":2004,"type":"tv","runtimeSeconds":2520,"seasonCount":8,"communityRating":7.896,"criticRating":null,"genres":["Mystery","Drama","Comedy"],"overview":"Looking down on her friends and family isn't a way of life for Mary Alice Young... it's a way of death. One day, in her perfect house, in the loveliest of suburbs, Mary Alice ended it all. Now she's taking us into\u2026","poster":"assets/tv-desperate-housewives-2004-poster.jpg","backdrop":"assets/tv-desperate-housewives-2004-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-29T20:56:40.3505596Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":693,"artwork":{"posterKind":"poster","backdropKind":"center_crop"},"ratings":{"rtCritics":72,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.896,"tmdbVotes":2171}},{"id":"tv-the-diplomat-2023","title":"The Diplomat","year":2023,"type":"tv","runtimeSeconds":null,"seasonCount":4,"communityRating":7.569,"criticRating":null,"genres":["Drama","War & Politics"],"overview":"Amid an international crisis, a US diplomat contends with her high-profile job as ambassador to the UK and her strained marriage to a political star.","poster":"assets/tv-the-diplomat-2023-poster.jpg","backdrop":"assets/tv-the-diplomat-2023-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0640348Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":203857,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":93,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.652,"tmdbVotes":521}},{"id":"tv-the-durrells-2016","title":"The Durrells","year":2016,"type":"tv","runtimeSeconds":2760,"seasonCount":4,"communityRating":7.402,"criticRating":null,"genres":["Drama","Comedy","Family"],"overview":"In 1935, financially strapped widow Louisa Durrell, whose life has fallen apart, decides to move from England, with her four children (three sons, one daughter), to the island of Corfu, Greece. Once there, the family\u2026","poster":"assets/tv-the-durrells-2016-poster.jpg","backdrop":"assets/tv-the-durrells-2016-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0619436Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":66134,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":94,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.4,"tmdbVotes":127}},{"id":"tv-etoile-2025","title":"\u00c9toile","year":2025,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.197,"criticRating":null,"genres":["Comedy","Drama"],"overview":"In an ambitious gambit to save their storied institutions, two world-renowned ballet companies in New York City and Paris swap their most talented stars.","poster":"assets/tv-etoile-2025-poster.jpg","backdrop":"assets/tv-etoile-2025-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0601383Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":225383,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":86,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.3,"tmdbVotes":40}},{"id":"tv-gilmore-girls-2000","title":"Gilmore Girls","year":2000,"type":"tv","runtimeSeconds":2640,"seasonCount":7,"communityRating":7.893,"criticRating":null,"genres":["Comedy","Drama"],"overview":"Set in the charming town of Stars Hollow, Connecticut, the series follows the captivating lives of Lorelai and Rory Gilmore, a mother/daughter pair who have a relationship most people only dream of.","poster":"assets/tv-gilmore-girls-2000-poster.jpg","backdrop":"assets/tv-gilmore-girls-2000-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0631035Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":4586,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.9,"tmdbVotes":1339}},{"id":"tv-ginny-georgia-2021","title":"Ginny & Georgia","year":2021,"type":"tv","runtimeSeconds":null,"seasonCount":3,"communityRating":8.026,"criticRating":null,"genres":["Comedy","Drama"],"overview":"Free-spirited Georgia and her two kids, Ginny and Austin, move north in search of a fresh start but find that the road to new beginnings can be bumpy.","poster":"assets/tv-ginny-georgia-2021-poster.jpg","backdrop":"assets/tv-ginny-georgia-2021-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0694828Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":117581,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":68,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8,"tmdbVotes":1840}},{"id":"tv-hellcats-2010","title":"Hellcats","year":2010,"type":"tv","runtimeSeconds":2460,"seasonCount":1,"communityRating":7.5,"criticRating":null,"genres":["Drama"],"overview":"Pre-law student Marti Perkins' world is flipped upside down when she loses her scholarship, and realizes the only way she can stay in school is by reigniting her dormant teen gymnastic skills to win a place on Lancer\u2026","poster":"assets/tv-hellcats-2010-poster.jpg","backdrop":"assets/tv-hellcats-2010-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-22T20:39:02.7719318Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":32504,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":65,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.5,"tmdbVotes":116}},{"id":"tv-high-potential-2024","title":"High Potential","year":2024,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":8.201,"criticRating":null,"genres":["Drama","Crime","Mystery"],"overview":"Morgan is a single mom with an exceptional mind, whose unconventional knack for solving crimes leads to an unusual and unstoppable partnership with a by-the-book seasoned detective.","poster":"assets/tv-high-potential-2024-poster.jpg","backdrop":"assets/tv-high-potential-2024-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0686091Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":226637,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":98,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.3,"tmdbVotes":707}},{"id":"tv-how-to-get-to-heaven-from-belfast-2026","title":"How to Get to Heaven from Belfast","year":2026,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.027,"criticRating":null,"genres":["Mystery","Comedy","Drama"],"overview":"Three lifelong pals embark on a chaotic quest to solve the mystery of their old friend's suspicious death and keep their own dark secret under wraps.","poster":"assets/tv-how-to-get-to-heaven-from-belfast-2026-poster.jpg","backdrop":"assets/tv-how-to-get-to-heaven-from-belfast-2026-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0683192Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":233079,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":95,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.063,"tmdbVotes":95}},{"id":"tv-the-hunting-wives-2025","title":"The Hunting Wives","year":2025,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":6.932,"criticRating":null,"genres":["Drama","Mystery"],"overview":"Sophie trades New England for East Texas and falls into a wealthy socialite's magnetic orbit \u2014 where a clique of housewives hide deadly secrets.","poster":"assets/tv-the-hunting-wives-2025-poster.jpg","backdrop":"assets/tv-the-hunting-wives-2025-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-20T19:42:46.3227892Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":244447,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":81,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.964,"tmdbVotes":112}},{"id":"tv-the-lost-flowers-of-alice-hart-2023","title":"The Lost Flowers of Alice Hart","year":2023,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.634,"criticRating":null,"genres":["Drama","Mystery"],"overview":"After losing her parents in a mysterious fire, nine-year-old Alice Hart is raised by her grandmother June on a flower farm where she learns there are secrets within secrets. But years on, an unearthed betrayal sees\u2026","poster":"assets/tv-the-lost-flowers-of-alice-hart-2023-poster.jpg","backdrop":"assets/tv-the-lost-flowers-of-alice-hart-2023-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0607236Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":125816,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":82,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.64,"tmdbVotes":161}},{"id":"tv-love-is-blind-2020","title":"Love Is Blind","year":2020,"type":"tv","runtimeSeconds":null,"seasonCount":11,"communityRating":6.261,"criticRating":null,"genres":["Reality"],"overview":"Nick and Vanessa Lachey host this social experiment where single men and women look for love and get engaged, all before meeting in person.","poster":"assets/tv-love-is-blind-2020-poster.jpg","backdrop":"assets/tv-love-is-blind-2020-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-22T17:11:08.2447226Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":99353,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":66,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.233,"tmdbVotes":118}},{"id":"tv-maximum-pleasure-guaranteed-2026","title":"Maximum Pleasure Guaranteed","year":2026,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":6.676,"criticRating":null,"genres":["Comedy"],"overview":"A newly divorced mom falls down a dangerous rabbit hole of blackmail, murder, and youth soccer.","poster":"assets/tv-maximum-pleasure-guaranteed-2026-poster.jpg","backdrop":"assets/tv-maximum-pleasure-guaranteed-2026-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-07T21:04:02.8277793Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":285404,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":93,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.799,"tmdbVotes":92}},{"id":"tv-minx-2022","title":"Minx","year":2022,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":6.833,"criticRating":null,"genres":["Comedy","Drama"],"overview":"An earnest young feminist joins forces with a low-rent publisher to make the world's first erotic magazine for women.","poster":"assets/tv-minx-2022-poster.jpg","backdrop":"assets/tv-minx-2022-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0653613Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":118303,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":93,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.9,"tmdbVotes":123}},{"id":"tv-offspring-2010","title":"Offspring","year":2010,"type":"tv","runtimeSeconds":3600,"seasonCount":7,"communityRating":6.9,"criticRating":null,"genres":["Drama"],"overview":"An exuberant drama set in Melbourne's Fitzroy, centering on Nina Proudman and her struggle to deal with her fabulously messy family, her hunt for a decent love life and her tendency to overthink and fly off into fantasy.","poster":"assets/tv-offspring-2010-poster.jpg","backdrop":"assets/tv-offspring-2010-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0664581Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":33155,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.9,"tmdbVotes":27}},{"id":"tv-only-murders-in-the-building-2021","title":"Only Murders in the Building","year":2021,"type":"tv","runtimeSeconds":null,"seasonCount":5,"communityRating":8.398,"criticRating":null,"genres":["Comedy","Mystery","Crime"],"overview":"Three strangers share an obsession with true crime and suddenly find themselves wrapped up in one.","poster":"assets/tv-only-murders-in-the-building-2021-poster.jpg","backdrop":"assets/tv-only-murders-in-the-building-2021-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-11-04T20:25:58.2163301Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":107113,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":95,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.398,"tmdbVotes":2333}},{"id":"tv-outlander-blood-of-my-blood-2025","title":"Outlander: Blood of My Blood","year":2025,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":8.542,"criticRating":null,"genres":["Drama"],"overview":"From the battlefields of World War I to the rugged Highlands of 18th century Scotland, two fated couples must defy the forces that seek to tear them apart, unfolding in surprising and unforeseen ways in this romantic\u2026","poster":"assets/tv-outlander-blood-of-my-blood-2025-poster.jpg","backdrop":"assets/tv-outlander-blood-of-my-blood-2025-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0691373Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":207484,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":94,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.738,"tmdbVotes":124}},{"id":"tv-rivals-2024","title":"Rivals","year":2024,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":7.44,"criticRating":null,"genres":["Drama"],"overview":"It is 1986 and Britain is booming. \"Rivals\" dives headfirst into the cutthroat world of TV where hair-dos are big and ambitions are even bigger. Deals are brokered in boardrooms, as well as bedrooms. Nobody can be\u2026","poster":"assets/tv-rivals-2024-poster.jpg","backdrop":"assets/tv-rivals-2024-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0645815Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":208921,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":96,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.4,"tmdbVotes":93}},{"id":"tv-rooster-2026","title":"Rooster","year":2026,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.431,"criticRating":null,"genres":["Comedy"],"overview":"On a college campus, an author navigates a complicated relationship with his daughter.","poster":"assets/tv-rooster-2026-poster.jpg","backdrop":"assets/tv-rooster-2026-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.063389Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":254953,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":88,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.423,"tmdbVotes":144}},{"id":"tv-sex-and-the-city-1998","title":"Sex and the City","year":1998,"type":"tv","runtimeSeconds":null,"seasonCount":6,"communityRating":7.365,"criticRating":null,"genres":["Drama","Comedy"],"overview":"Based on the bestselling book by Candace Bushnell, Sex and the City tells the story of four best friends, all single and in their late thirties, as they pursue their careers and talk about their sex lives, all while\u2026","poster":"assets/tv-sex-and-the-city-1998-poster.jpg","backdrop":"assets/tv-sex-and-the-city-1998-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0679324Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":105,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":70,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.351,"tmdbVotes":2220}},{"id":"tv-skymed-2022","title":"SkyMed","year":2022,"type":"tv","runtimeSeconds":2640,"seasonCount":4,"communityRating":6.665,"criticRating":null,"genres":["Drama"],"overview":"Life, death and drama at 20,000 feet. The series weaves together intense character journeys and high-stakes medical rescues, as we follow the triumphs, heartbreaks and tribulations of budding nurses and pilots flying\u2026","poster":"assets/tv-skymed-2022-poster.jpg","backdrop":"assets/tv-skymed-2022-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-22T19:44:29.2431354Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":133908,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.699,"tmdbVotes":98}},{"id":"tv-so-you-think-you-can-dance-2008","title":"So You Think You Can Dance","year":2008,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":9.8,"criticRating":null,"genres":["Family","Reality"],"overview":"So You Think You Can Dance is a joint Belgian-Dutch dance competition show broadcast on RTL 5 and vtm television stations. The first season was broadcast only in the Netherlands, whereas starting with season 2, the\u2026","poster":"assets/tv-so-you-think-you-can-dance-2008-poster.jpg","backdrop":"assets/tv-so-you-think-you-can-dance-2008-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-23T11:11:35.4124418Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":40231,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":9.8,"tmdbVotes":4}},{"id":"tv-the-white-lotus-2021","title":"The White Lotus","year":2021,"type":"tv","runtimeSeconds":null,"seasonCount":3,"communityRating":7.597,"criticRating":null,"genres":["Comedy","Drama","Mystery"],"overview":"Follow the exploits of various guests and employees at an exclusive tropical resort over the span of a week as with each passing day, a darker complexity emerges in these picture-perfect travelers, the hotel\u2019s\u2026","poster":"assets/tv-the-white-lotus-2021-poster.jpg","backdrop":"assets/tv-the-white-lotus-2021-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-20T02:45:52.8742674Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":111803,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":90,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.599,"tmdbVotes":1604}},{"id":"tv-yellowjackets-2021","title":"Yellowjackets","year":2021,"type":"tv","runtimeSeconds":null,"seasonCount":3,"communityRating":7.431,"criticRating":null,"genres":["Drama","Mystery"],"overview":"This equal parts survival epic, psychological horror story and coming-of-age drama tells the saga of a team of wildly talented high school girls soccer players who become the (un)lucky survivors of a plane crash deep\u2026","poster":"assets/tv-yellowjackets-2021-poster.jpg","backdrop":"assets/tv-yellowjackets-2021-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-11-04T20:31:47.4592756Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":117488,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":92,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.431,"tmdbVotes":1070}}],"anime":[],"discover":[{"id":"movie-across-the-hall-2009","title":"Across the Hall","year":2009,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Thriller","Mystery"],"overview":"Convinced that his fiancee is cheating on him, a man follows her to a hotel and calls his best friend to help him avoid a calamity.","poster":"assets/movie-across-the-hall-2009-poster.jpg","backdrop":"assets/movie-across-the-hall-2009-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":21469,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":39,"rtAudienceLabel":"Spilled","imdb":5.8,"imdbVotes":5130,"tmdb":6,"tmdbVotes":129}},{"id":"movie-addicted-2014","title":"Addicted","year":2014,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Thriller"],"overview":"A gallerist risks her family and flourishing career when she enters into an affair with a talented painter and slowly loses control of her life.","poster":"assets/movie-addicted-2014-poster.jpg","backdrop":"assets/movie-addicted-2014-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":235271,"ratings":{"rtCritics":7,"rtCriticsLabel":"Rotten","rtAudience":51,"rtAudienceLabel":"Spilled","imdb":5,"imdbVotes":13131,"tmdb":5.928,"tmdbVotes":466}},{"id":"movie-adulterers-2016","title":"Adulterers","year":2016,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Thriller","Drama"],"overview":"On a steamy New Orleans afternoon, Sam Deuprey decides to surprise his beautiful wife, Ashley, for their anniversary and comes home early. But Ashley is not alone in the bedroom. She and her lover, Damien are about\u2026","poster":"assets/movie-adulterers-2016-poster.jpg","backdrop":"assets/movie-adulterers-2016-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":376004,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":37,"rtAudienceLabel":"Spilled","imdb":5.1,"imdbVotes":2144,"tmdb":6.2,"tmdbVotes":116}},{"id":"movie-alone-with-her-2006","title":"Alone With Her","year":2006,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Crime","Drama","Thriller"],"overview":"The harrowing story of a disturbed young man's attempts to win the affections of an unsuspecting young woman. When Doug first sees Amy, he instantly falls for her and begins to watch her every move, going so far as\u2026","poster":"assets/movie-alone-with-her-2006-poster.jpg","backdrop":"assets/movie-alone-with-her-2006-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":18206,"ratings":{"rtCritics":70,"rtCriticsLabel":"Fresh","rtAudience":46,"rtAudienceLabel":"Spilled","imdb":6,"imdbVotes":4359,"tmdb":5.9,"tmdbVotes":94}},{"id":"movie-awake-2007","title":"Awake","year":2007,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Thriller","Crime","Mystery"],"overview":"While undergoing heart surgery, a man experiences a phenomenon called \u2018anesthetic awareness\u2019, which leaves him awake but paralyzed throughout the operation. As various obstacles present themselves, his wife must make\u2026","poster":"assets/movie-awake-2007-poster.jpg","backdrop":"assets/movie-awake-2007-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":13483,"ratings":{"rtCritics":23,"rtCriticsLabel":"Rotten","rtAudience":55,"rtAudienceLabel":"Spilled","imdb":6.5,"imdbVotes":89389,"tmdb":6.586,"tmdbVotes":1649}},{"id":"movie-black-bear-2020","title":"Black Bear","year":2020,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Thriller"],"overview":"At a remote lake house in the Adirondack Mountains, a couple entertains an out-of-town guest looking for inspiration in her filmmaking. The group quickly falls into a calculated game of desire, manipulation, and\u2026","poster":"assets/movie-black-bear-2020-poster.jpg","backdrop":"assets/movie-black-bear-2020-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":624788,"ratings":{"rtCritics":89,"rtCriticsLabel":"Certified Fresh","rtAudience":64,"rtAudienceLabel":"Upright","imdb":6.5,"imdbVotes":18408,"tmdb":6.337,"tmdbVotes":315}},{"id":"movie-butterfly-on-a-wheel-2007","title":"Butterfly on a Wheel","year":2007,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Crime","Drama","Thriller"],"overview":"A sociopathic kidnapper methodically pushes a desperate pair of parents to their absolute breaking point.","poster":"assets/movie-butterfly-on-a-wheel-2007-poster.jpg","backdrop":"assets/movie-butterfly-on-a-wheel-2007-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":13250,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":61,"rtAudienceLabel":"Upright","imdb":6.6,"imdbVotes":35409,"tmdb":6.457,"tmdbVotes":595}},{"id":"movie-careful-what-you-wish-for-2015","title":"Careful What You Wish For","year":2015,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Thriller"],"overview":"A guy gets more than he bargained for after entering into an affair with the wife of an investment banker. Soon, a suspicious death and substantial life insurance policy embroil him in a scandal.","poster":"assets/movie-careful-what-you-wish-for-2015-poster.jpg","backdrop":"assets/movie-careful-what-you-wish-for-2015-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":317744,"ratings":{"rtCritics":17,"rtCriticsLabel":"Rotten","rtAudience":61,"rtAudienceLabel":"Upright","imdb":5.6,"imdbVotes":9452,"tmdb":6.129,"tmdbVotes":446}},{"id":"movie-crime-of-passion-1956","title":"Crime of Passion","year":1956,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Crime","Thriller"],"overview":"Kathy leaves the newspaper business to marry homicide detective Bill, but is frustrated by his lack of ambition and the banality of life in the suburbs. Her drive to advance Bill's career soon takes her down a\u2026","poster":"assets/movie-crime-of-passion-1956-poster.jpg","backdrop":"assets/movie-crime-of-passion-1956-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"center_crop","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1920,"ratings":{"rtCritics":80,"rtCriticsLabel":"Fresh","rtAudience":34,"rtAudienceLabel":"Spilled","imdb":6.4,"imdbVotes":3750,"tmdb":6.1,"tmdbVotes":57}},{"id":"movie-deep-water-2022","title":"Deep Water","year":2022,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Mystery","Thriller"],"overview":"A well-to-do husband who allows his wife to have affairs in order to avoid a divorce becomes a prime suspect in the disappearance of her lovers.","poster":"assets/movie-deep-water-2022-poster.jpg","backdrop":"assets/movie-deep-water-2022-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":619979,"ratings":{"rtCritics":35,"rtCriticsLabel":"Rotten","rtAudience":25,"rtAudienceLabel":"Spilled","imdb":5.5,"imdbVotes":74098,"tmdb":5.834,"tmdbVotes":1755}},{"id":"movie-derailed-2005","title":"Derailed","year":2005,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Thriller"],"overview":"When two married business executives having an affair are blackmailed by a violent criminal, they are forced to turn the tables on him to save their families.","poster":"assets/movie-derailed-2005-poster.jpg","backdrop":"assets/movie-derailed-2005-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":8999,"ratings":{"rtCritics":20,"rtCriticsLabel":"Rotten","rtAudience":58,"rtAudienceLabel":"Spilled","imdb":6.6,"imdbVotes":79563,"tmdb":6.405,"tmdbVotes":1193}},{"id":"movie-die-my-love-2025","title":"Die My Love","year":2025,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama"],"overview":"After inheriting a remote Montana house, Jackson moves there from New York with his partner Grace, and the couple soon welcome a child. As Jackson becomes increasingly absent and rural isolation sets in, Grace\u2026","poster":"assets/movie-die-my-love-2025-poster.jpg","backdrop":"assets/movie-die-my-love-2025-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1033148,"ratings":{"rtCritics":74,"rtCriticsLabel":"Certified Fresh","rtAudience":45,"rtAudienceLabel":"Spilled","imdb":6,"imdbVotes":50664,"tmdb":6.1,"tmdbVotes":683}},{"id":"movie-dream-house-2011","title":"Dream House","year":2011,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Mystery","Drama","Thriller"],"overview":"Publisher Will Atenton quits a lucrative job in New York to relocate his wife, Libby, and their daughters to a quaint town in New England. However, as they settle into their home the Atentons discover that a woman\u2026","poster":"assets/movie-dream-house-2011-poster.jpg","backdrop":"assets/movie-dream-house-2011-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":69668,"ratings":{"rtCritics":7,"rtCriticsLabel":"Rotten","rtAudience":36,"rtAudienceLabel":"Spilled","imdb":6,"imdbVotes":71386,"tmdb":6.212,"tmdbVotes":1480}},{"id":"movie-dream-lover-1994","title":"Dream Lover","year":1994,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Mystery","Thriller"],"overview":"Not long after they cross paths at an art gallery, architect Ray Reardon and hypnotically sensual Lena are married with children. But as strange incidents occur, Ray begins to realize he may not really know the woman\u2026","poster":"assets/movie-dream-lover-1994-poster.jpg","backdrop":"assets/movie-dream-lover-1994-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":18190,"ratings":{"rtCritics":57,"rtCriticsLabel":"Rotten","rtAudience":38,"rtAudienceLabel":"Spilled","imdb":6.2,"imdbVotes":7865,"tmdb":6.844,"tmdbVotes":167}},{"id":"movie-drop-2025","title":"Drop","year":2025,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Mystery","Thriller"],"overview":"Violet, a widowed mother on her first date in years, arrives at an upscale restaurant where she is relieved that her date, Henry, is more charming and handsome than she expected. But their chemistry begins to curdle\u2026","poster":"assets/movie-drop-2025-poster.jpg","backdrop":"assets/movie-drop-2025-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1249213,"ratings":{"rtCritics":83,"rtCriticsLabel":"Certified Fresh","rtAudience":79,"rtAudienceLabel":"Upright","imdb":6.1,"imdbVotes":61018,"tmdb":6.275,"tmdbVotes":1023}},{"id":"movie-eastern-promises-2007","title":"Eastern Promises","year":2007,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Thriller","Crime","Mystery"],"overview":"A Russian teenager living in London dies during childbirth but leaves clues in her diary that could tie her child to a rape involving a violent Russian mob family.","poster":"assets/movie-eastern-promises-2007-poster.jpg","backdrop":"assets/movie-eastern-promises-2007-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":2252,"ratings":{"rtCritics":89,"rtCriticsLabel":"Certified Fresh","rtAudience":83,"rtAudienceLabel":"Upright","imdb":7.6,"imdbVotes":281015,"tmdb":7.349,"tmdbVotes":3900}},{"id":"movie-faces-1968","title":"Faces","year":1968,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama"],"overview":"Middle-aged suburban husband Richard abruptly tells his wife, Maria, that he wants a divorce. As Richard takes up with a younger woman, Maria enjoys a night on the town with her friends and meets a younger man. As\u2026","poster":"assets/movie-faces-1968-poster.jpg","backdrop":"assets/movie-faces-1968-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"center_crop","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":753,"ratings":{"rtCritics":83,"rtCriticsLabel":"Fresh","rtAudience":86,"rtAudienceLabel":"Upright","imdb":7.4,"imdbVotes":12950,"tmdb":7.093,"tmdbVotes":227}},{"id":"movie-fatale-2020","title":"Fatale","year":2020,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Thriller"],"overview":"After a wild one-night stand, successful sports agent Derrick Tyler watches his perfect life slowly disappear when he discovers the sexy and mysterious woman he risked everything for is determined police detective\u2026","poster":"assets/movie-fatale-2020-poster.jpg","backdrop":"assets/movie-fatale-2020-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":549514,"ratings":{"rtCritics":46,"rtCriticsLabel":"Rotten","rtAudience":87,"rtAudienceLabel":"Upright","imdb":5.5,"imdbVotes":16392,"tmdb":6,"tmdbVotes":401}},{"id":"tv-56-days-2026","title":"56 Days","year":2026,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Crime","Mystery"],"overview":"When an unidentified body is found in a luxury apartment linked to Oliver Kennedy and his girlfriend Ciara Wyse, Detectives Lee Reardon and Karl Connolly reconstruct the couple's deadly romance across the past 56 days.","poster":"assets/tv-56-days-2026-poster.jpg","backdrop":"assets/tv-56-days-2026-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":241372,"ratings":{"rtCritics":60,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.388,"tmdbVotes":245}},{"id":"tv-90-day-fiance-before-the-90-days-2017","title":"90 Day Fianc\u00e9: Before the 90 Days","year":2017,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Reality"],"overview":"Follow couples who have an existing relationship online, but haven't met in person. We'll experience their journey as they travel to the other's foreign country for the first time in an attempt to establish an\u2026","poster":"assets/tv-90-day-fiance-before-the-90-days-2017-poster.jpg","backdrop":"assets/tv-90-day-fiance-before-the-90-days-2017-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":73319,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.5,"tmdbVotes":45}},{"id":"tv-90-day-fiance-the-other-way-2019","title":"90 Day Fianc\u00e9: The Other Way","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Reality"],"overview":"Americans move to a foreign country all in the name of love. Will uprooting their lives in the United States pay off, or will they be packing up and moving back to America brokenhearted?","poster":"assets/tv-90-day-fiance-the-other-way-2019-poster.jpg","backdrop":"assets/tv-90-day-fiance-the-other-way-2019-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":90046,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.8,"tmdbVotes":34}},{"id":"tv-absolutely-fabulous-1992","title":"Absolutely Fabulous","year":1992,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy"],"overview":"Set in the world of fashion and PR, immature fun-loving mother Edina Monsoon and her best friend Patsy drive Eddie's sensible daughter, Saffron, up the wall with their constant drug abuse and outrageous selfishness.\u2026","poster":"assets/tv-absolutely-fabulous-1992-poster.jpg","backdrop":"assets/tv-absolutely-fabulous-1992-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":4493,"ratings":{"rtCritics":96,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.6,"tmdbVotes":198}},{"id":"tv-the-afterparty-2022","title":"The Afterparty","year":2022,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Mystery","Comedy","Crime"],"overview":"Nothing is as it seems in this hilarious murder mystery where each suspect's story is told through a different film genre.","poster":"assets/tv-the-afterparty-2022-poster.jpg","backdrop":"assets/tv-the-afterparty-2022-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":106454,"ratings":{"rtCritics":92,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.071,"tmdbVotes":245}},{"id":"tv-agatha-christie-s-marple-2004","title":"Agatha Christie's Marple","year":2004,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Crime","Mystery"],"overview":"The adventures of Miss Jane Marple, an elderly spinster living in the quiet little village of St Mary Mead. During her many visits to friends and relatives in other villages, Miss Marple often stumbles upon\u2026","poster":"assets/tv-agatha-christie-s-marple-2004-poster.jpg","backdrop":"assets/tv-agatha-christie-s-marple-2004-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":6127,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.9,"tmdbVotes":152}},{"id":"tv-all-american-homecoming-2022","title":"All American: Homecoming","year":2022,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama"],"overview":"Follow the journeys of Simone Hicks, a tennis hopeful from Beverly Hills, and elite baseball player Damon Sims from Chicago, as they navigate life at the prestigious HBCU Bringston University.","poster":"assets/tv-all-american-homecoming-2022-poster.jpg","backdrop":"assets/tv-all-american-homecoming-2022-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":153723,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.3,"tmdbVotes":70}},{"id":"tv-and-just-like-that-2021","title":"And Just Like That\u2026","year":2021,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy","Drama"],"overview":"This new chapter of \u201cSex and the City\u201d follows Carrie, Miranda and Charlotte as they navigate the journey from the complicated reality of life and friendship in their 30s to the even more complicated reality of life\u2026","poster":"assets/tv-and-just-like-that-2021-poster.jpg","backdrop":"assets/tv-and-just-like-that-2021-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":116450,"ratings":{"rtCritics":52,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.211,"tmdbVotes":556}},{"id":"tv-anne-with-an-e-2017","title":"Anne with an E","year":2017,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Family"],"overview":"A coming-of-age story about an outsider who, against all odds and numerous challenges, fights for love and acceptance and for her place in the world. The series centers on a young orphaned girl in the late 1890s,\u2026","poster":"assets/tv-anne-with-an-e-2017-poster.jpg","backdrop":"assets/tv-anne-with-an-e-2017-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":70785,"ratings":{"rtCritics":63,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.7,"tmdbVotes":5019}},{"id":"tv-awkward-2011","title":"Awkward.","year":2011,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy","Drama"],"overview":"An irreverent look at the conflict, chaos and humor that defines teenage life through the eyes of 15-year-old Jenna Hamilton whose life begins to change when a simple accident becomes an epic misunderstanding and is\u2026","poster":"assets/tv-awkward-2011-poster.jpg","backdrop":"assets/tv-awkward-2011-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":39859,"ratings":{"rtCritics":78,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.4,"tmdbVotes":427}},{"id":"tv-the-baby-sitters-club-2020","title":"The Baby-Sitters Club","year":2020,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Kids","Drama"],"overview":"In this contemporary take on the beloved book series, five best friends launch a baby-sitting business that's big on fun and adventure.","poster":"assets/tv-the-baby-sitters-club-2020-poster.jpg","backdrop":"assets/tv-the-baby-sitters-club-2020-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":100412,"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.9,"tmdbVotes":506}},{"id":"tv-ballers-2015","title":"Ballers","year":2015,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Comedy"],"overview":"Looking at the lives of former and current football players, the show follows former superstar Spencer Strasmore as he gets his life on track in retirement while mentoring other current and former players through the\u2026","poster":"assets/tv-ballers-2015-poster.jpg","backdrop":"assets/tv-ballers-2015-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":62704,"ratings":{"rtCritics":72,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.809,"tmdbVotes":820}},{"id":"tv-baretta-1975","title":"Baretta","year":1975,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Crime","Drama","Mystery"],"overview":"Baretta is an American detective television series which ran on ABC from 1975 to 1978. The show was a milder version of a successful 1973\u201374 ABC series, Toma, starring Tony Musante as chameleon-like, real-life New\u2026","poster":"assets/tv-baretta-1975-poster.jpg","backdrop":"assets/tv-baretta-1975-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":3216,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.5,"tmdbVotes":43}},{"id":"tv-based-on-a-true-story-2023","title":"Based on a True Story","year":2023,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy","Drama"],"overview":"The lives of a realtor, a plumber and a former tennis star unexpectedly collide, exposing America\u2019s obsession with true crime, murder and the slow-close toilet seat.","poster":"assets/tv-based-on-a-true-story-2023-poster.jpg","backdrop":"assets/tv-based-on-a-true-story-2023-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":197373,"ratings":{"rtCritics":79,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7,"tmdbVotes":160}},{"id":"tv-the-beast-in-me-2025","title":"The Beast in Me","year":2025,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Mystery"],"overview":"A famous author is pulled into a twisted mind game with her rich, powerful new neighbor \u2014 who might be a murderer.","poster":"assets/tv-the-beast-in-me-2025-poster.jpg","backdrop":"assets/tv-the-beast-in-me-2025-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":250504,"ratings":{"rtCritics":85,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.072,"tmdbVotes":334}},{"id":"tv-big-city-greens-2018","title":"Big City Greens","year":2018,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Family","Animation","Comedy"],"overview":"The offbeat adventures of 10-year-old Cricket Green, a mischievous and optimistic country boy who moves to the big city with his wildly out of place family \u2013 older sister Tilly, father Bill and Gramma Alice.","poster":"assets/tv-big-city-greens-2018-poster.jpg","backdrop":"assets/tv-big-city-greens-2018-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":80587,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.555,"tmdbVotes":82}},{"id":"tv-the-bold-type-2017","title":"The Bold Type","year":2017,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Comedy"],"overview":"A glimpse into the outrageous lives of Jane, Kat and Sutton, who are working at the nation's top women's magazine, Scarlet, while navigating their careers, identities and individual voices.","poster":"assets/tv-the-bold-type-2017-poster.jpg","backdrop":"assets/tv-the-bold-type-2017-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":69630,"ratings":{"rtCritics":89,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.7,"tmdbVotes":204}},{"id":"tv-brassic-2019","title":"Brassic","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy","Drama","Crime"],"overview":"A group of working-class friends finding unconventional ways to win at life in suburban northern England. These lads have dealt, scammed, bribed and conned their way through adolescence, but now, their dealing and\u2026","poster":"assets/tv-brassic-2019-poster.jpg","backdrop":"assets/tv-brassic-2019-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":92621,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.336,"tmdbVotes":263}}]},{"key":"shared","movies":[{"id":"movie-american-gangster-2007","title":"American Gangster","year":2007,"type":"movie","runtimeSeconds":10546,"seasonCount":null,"communityRating":7.595,"criticRating":81,"genres":["Drama","Crime"],"overview":"Loosely based on the criminal career of Frank Lucas, a gangster from La Grange, North Carolina, who smuggled heroin into the United States on American service planes returning from the Vietnam War, before being\u2026","poster":"assets/movie-american-gangster-2007-poster.jpg","backdrop":"assets/movie-american-gangster-2007-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-08-23T10:22:36.0000000Z","lastPlayedAt":"2026-08-30T21:05:39.7952451Z","availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":4982,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":81,"rtCriticsLabel":"Certified Fresh","rtAudience":90,"rtAudienceLabel":"Upright","imdb":7.8,"imdbVotes":495656,"tmdb":7.599,"tmdbVotes":6350}},{"id":"movie-annie-hall-1977","title":"Annie Hall","year":1977,"type":"movie","runtimeSeconds":5604,"seasonCount":null,"communityRating":7.712,"criticRating":97,"genres":["Comedy","Drama","Romance"],"overview":"New York comedian Alvy Singer falls in love with the ditsy Annie Hall.","poster":"assets/movie-annie-hall-1977-poster.jpg","backdrop":"assets/movie-annie-hall-1977-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:24:23.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":703,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":97,"rtCriticsLabel":"Certified Fresh","rtAudience":92,"rtAudienceLabel":"Upright","imdb":7.9,"imdbVotes":293349,"tmdb":7.71,"tmdbVotes":4391}},{"id":"movie-apocalypse-now-1979","title":"Apocalypse Now","year":1979,"type":"movie","runtimeSeconds":8835,"seasonCount":null,"communityRating":8.261,"criticRating":91,"genres":["Drama","War"],"overview":"At the height of the Vietnam war, Captain Benjamin Willard is sent on a dangerous mission that, officially, \"does not exist, nor will it ever exist.\" His goal is to locate - and eliminate - a mysterious Green Beret\u2026","poster":"assets/movie-apocalypse-now-1979-poster.jpg","backdrop":"assets/movie-apocalypse-now-1979-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:22:25.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":28,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":91,"rtCriticsLabel":"Certified Fresh","rtAudience":94,"rtAudienceLabel":"Upright","imdb":8.4,"imdbVotes":781424,"tmdb":8.3,"tmdbVotes":9526}},{"id":"movie-arrival-2016","title":"Arrival","year":2016,"type":"movie","runtimeSeconds":6967,"seasonCount":null,"communityRating":7.636,"criticRating":94,"genres":["Drama","Science Fiction","Mystery"],"overview":"Taking place after alien crafts land around the world, an expert linguist is recruited by the military to determine whether they come in peace or are a threat.","poster":"assets/movie-arrival-2016-poster.jpg","backdrop":"assets/movie-arrival-2016-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-14T19:54:12.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":329865,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":94,"rtCriticsLabel":"Certified Fresh","rtAudience":83,"rtAudienceLabel":"Upright","imdb":7.9,"imdbVotes":883621,"tmdb":7.638,"tmdbVotes":20125}},{"id":"movie-avatar-aang-the-last-airbender-2026","title":"Avatar Aang: The Last Airbender","year":2026,"type":"movie","runtimeSeconds":5930,"seasonCount":null,"communityRating":9.147,"criticRating":90,"genres":["Animation","Adventure","Fantasy"],"overview":"Avatar Aang, the world's last Airbender, learns of an ancient power that could save his culture from extinction. With the help of his friends, he embarks on a global quest to find it before it falls into the wrong\u2026","poster":"assets/movie-avatar-aang-the-last-airbender-2026-poster.jpg","backdrop":"assets/movie-avatar-aang-the-last-airbender-2026-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-25T09:53:09.0000000Z","lastPlayedAt":"2026-08-02T19:22:33.6155383Z","availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":980431,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":90,"rtCriticsLabel":"Certified Fresh","rtAudience":99,"rtAudienceLabel":"Upright","imdb":7.7,"imdbVotes":30792,"tmdb":9,"tmdbVotes":1417}},{"id":"movie-bohemian-rhapsody-2018","title":"Bohemian Rhapsody","year":2018,"type":"movie","runtimeSeconds":8077,"seasonCount":null,"communityRating":7.95,"criticRating":60,"genres":["Music","Drama"],"overview":"Singer Freddie Mercury, guitarist Brian May, drummer Roger Taylor and bass guitarist John Deacon take the music world by storm when they form the rock 'n' roll band Queen in 1970. Hit songs become instant classics.\u2026","poster":"assets/movie-bohemian-rhapsody-2018-poster.jpg","backdrop":"assets/movie-bohemian-rhapsody-2018-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:28:20.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":424694,"artwork":{"posterKind":"center_crop","backdropKind":"backdrop"},"ratings":{"rtCritics":60,"rtCriticsLabel":"Fresh","rtAudience":85,"rtAudienceLabel":"Upright","imdb":7.9,"imdbVotes":655894,"tmdb":7.947,"tmdbVotes":18196}},{"id":"movie-the-boy-and-the-heron-2023","title":"The Boy and the Heron","year":2023,"type":"movie","runtimeSeconds":7437,"seasonCount":null,"communityRating":7.4,"criticRating":96,"genres":["Animation","Fantasy","Drama"],"overview":"While the Second World War rages, the teenage Mahito, haunted by his mother's tragic death, is relocated from Tokyo to the serene rural home of his new stepmother Natsuko, a woman who bears a striking resemblance to\u2026","poster":"assets/movie-the-boy-and-the-heron-2023-poster.jpg","backdrop":"assets/movie-the-boy-and-the-heron-2023-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:25:15.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":508883,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":96,"rtCriticsLabel":"Certified Fresh","rtAudience":88,"rtAudienceLabel":"Upright","imdb":7.3,"imdbVotes":152169,"tmdb":7.387,"tmdbVotes":2893}},{"id":"movie-braveheart-1995","title":"Braveheart","year":1995,"type":"movie","runtimeSeconds":10665,"seasonCount":null,"communityRating":7.945,"criticRating":74,"genres":["Drama","History","War"],"overview":"Enraged at the slaughter of Murron, his new bride and childhood love, Scottish warrior William Wallace slays a platoon of the local English lord's soldiers. This leads the village to revolt and, eventually, the\u2026","poster":"assets/movie-braveheart-1995-poster.jpg","backdrop":"assets/movie-braveheart-1995-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-10T21:24:28.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":197,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":74,"rtCriticsLabel":"Certified Fresh","rtAudience":85,"rtAudienceLabel":"Upright","imdb":8.3,"imdbVotes":1174990,"tmdb":7.947,"tmdbVotes":11584}},{"id":"movie-the-breakfast-club-1985","title":"The Breakfast Club","year":1985,"type":"movie","runtimeSeconds":5833,"seasonCount":null,"communityRating":7.7,"criticRating":87,"genres":["Comedy","Drama"],"overview":"Five high school students from different walks of life endure a Saturday detention under a power-hungry principal. The disparate group includes rebel John, princess Claire, outcast Allison, brainy Brian and Andrew,\u2026","poster":"assets/movie-the-breakfast-club-1985-poster.jpg","backdrop":"assets/movie-the-breakfast-club-1985-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:25:03.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":2108,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":87,"rtCriticsLabel":"Certified Fresh","rtAudience":92,"rtAudienceLabel":"Upright","imdb":7.8,"imdbVotes":478743,"tmdb":7.713,"tmdbVotes":8815}},{"id":"movie-castle-in-the-sky-1986","title":"Castle in the Sky","year":1986,"type":"movie","runtimeSeconds":7474,"seasonCount":null,"communityRating":8,"criticRating":97,"genres":["Adventure","Fantasy","Animation"],"overview":"A young boy and a girl with a magic crystal must race against pirates and foreign agents in a search for a legendary floating castle.","poster":"assets/movie-castle-in-the-sky-1986-poster.jpg","backdrop":"assets/movie-castle-in-the-sky-1986-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:23:09.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":10515,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":96,"rtCriticsLabel":"Fresh","rtAudience":91,"rtAudienceLabel":"Upright","imdb":8,"imdbVotes":201545,"tmdb":7.972,"tmdbVotes":4868}},{"id":"movie-citizen-kane-1941","title":"Citizen Kane","year":1941,"type":"movie","runtimeSeconds":7173,"seasonCount":null,"communityRating":7.966,"criticRating":99,"genres":["Mystery","Drama"],"overview":"Newspaper magnate Charles Foster Kane is taken from his mother as a boy and made the ward of a rich industrialist. As a result, every well-meaning, tyrannical or self-destructive move he makes for the rest of his\u2026","poster":"assets/movie-citizen-kane-1941-poster.jpg","backdrop":"assets/movie-citizen-kane-1941-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:22:16.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":15,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":99,"rtCriticsLabel":"Certified Fresh","rtAudience":98,"rtAudienceLabel":"Upright","imdb":8.2,"imdbVotes":498321,"tmdb":7.962,"tmdbVotes":6324}},{"id":"movie-conclave-2024","title":"Conclave","year":2024,"type":"movie","runtimeSeconds":7230,"seasonCount":null,"communityRating":7.19,"criticRating":93,"genres":["Drama","Thriller"],"overview":"After the unexpected death of the Pope, Cardinal Lawrence is tasked with managing the covert and ancient ritual of electing a new one. Sequestered in the Vatican with the Catholic Church\u2019s most powerful leaders until\u2026","poster":"assets/movie-conclave-2024-poster.jpg","backdrop":"assets/movie-conclave-2024-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:30:41.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":974576,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":93,"rtCriticsLabel":"Certified Fresh","rtAudience":86,"rtAudienceLabel":"Upright","imdb":7.4,"imdbVotes":290498,"tmdb":7.19,"tmdbVotes":3744}},{"id":"movie-the-dry-2021","title":"The Dry","year":2021,"type":"movie","runtimeSeconds":7037,"seasonCount":null,"communityRating":6.779,"criticRating":null,"genres":["Crime","Thriller","Mystery"],"overview":"Aaron Falk returns to his drought-stricken hometown to attend a tragic funeral. But his return opens a decades-old wound - the unsolved death of a teenage girl.","poster":"assets/movie-the-dry-2021-poster.jpg","backdrop":"assets/movie-the-dry-2021-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:29:20.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":567797,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":90,"rtCriticsLabel":"Certified Fresh","rtAudience":89,"rtAudienceLabel":"Upright","imdb":6.8,"imdbVotes":41935,"tmdb":6.8,"tmdbVotes":892}},{"id":"movie-easy-a-2010","title":"Easy A","year":2010,"type":"movie","runtimeSeconds":5545,"seasonCount":null,"communityRating":6.8,"criticRating":85,"genres":["Comedy"],"overview":"Olive, an average high school student, sees her below-the-radar existence turn around overnight once she decides to use the school's gossip grapevine to advance her social standing. Now her classmates are turning\u2026","poster":"assets/movie-easy-a-2010-poster.jpg","backdrop":"assets/movie-easy-a-2010-backdrop.jpg","played":true,"playCount":1,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-17T19:59:04.0000000Z","lastPlayedAt":"2026-10-17T20:02:51.1083337Z","availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":37735,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":85,"rtCriticsLabel":"Certified Fresh","rtAudience":77,"rtAudienceLabel":"Upright","imdb":7,"imdbVotes":448780,"tmdb":6.8,"tmdbVotes":7619}},{"id":"movie-el-camino-a-breaking-bad-movie-2019","title":"El Camino: A Breaking Bad Movie","year":2019,"type":"movie","runtimeSeconds":7362,"seasonCount":null,"communityRating":7.055,"criticRating":92,"genres":["Crime","Drama","Thriller"],"overview":"In the wake of his dramatic escape from captivity, Jesse Pinkman must come to terms with his past in order to forge some kind of future.","poster":"assets/movie-el-camino-a-breaking-bad-movie-2019-poster.jpg","backdrop":"assets/movie-el-camino-a-breaking-bad-movie-2019-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-21T23:31:11.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":559969,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":92,"rtCriticsLabel":"Certified Fresh","rtAudience":81,"rtAudienceLabel":"Upright","imdb":7.3,"imdbVotes":354488,"tmdb":7.056,"tmdbVotes":5950}},{"id":"movie-emancipation-2022","title":"Emancipation","year":2022,"type":"movie","runtimeSeconds":7949,"seasonCount":null,"communityRating":7.7,"criticRating":45,"genres":["Drama","War","History"],"overview":"Inspired by the gripping true story of a man who would do anything for his family\u2014and for freedom. When Peter, an enslaved man, risks his life to escape and return to his family, he embarks on a perilous journey of\u2026","poster":"assets/movie-emancipation-2022-poster.jpg","backdrop":"assets/movie-emancipation-2022-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-10T20:37:34.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":715931,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":45,"rtCriticsLabel":"Rotten","rtAudience":58,"rtAudienceLabel":"Spilled","imdb":6.3,"imdbVotes":32079,"tmdb":7.744,"tmdbVotes":1319}},{"id":"movie-fighting-with-my-family-2019","title":"Fighting with My Family","year":2019,"type":"movie","runtimeSeconds":6509,"seasonCount":null,"communityRating":6.84,"criticRating":93,"genres":["Comedy"],"overview":"Born into a tight-knit wrestling family, Paige and her brother Zak are ecstatic when they get the once-in-a-lifetime opportunity to try out for the WWE. But when only Paige earns a spot in the competitive training\u2026","poster":"assets/movie-fighting-with-my-family-2019-poster.jpg","backdrop":"assets/movie-fighting-with-my-family-2019-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-22T22:10:59.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":445629,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":93,"rtCriticsLabel":"Certified Fresh","rtAudience":86,"rtAudienceLabel":"Upright","imdb":7,"imdbVotes":94576,"tmdb":6.837,"tmdbVotes":1952}},{"id":"movie-force-of-nature-the-dry-2-2024","title":"Force of Nature: The Dry 2","year":2024,"type":"movie","runtimeSeconds":6753,"seasonCount":null,"communityRating":6.3,"criticRating":66,"genres":["Mystery","Thriller"],"overview":"When five women take part in a corporate hiking retreat and only four come out on the other side, Federal Agents Aaron Falk and Carmen Cooper head deep into the Victorian mountain ranges to investigate in the hopes\u2026","poster":"assets/movie-force-of-nature-the-dry-2-2024-poster.jpg","backdrop":"assets/movie-force-of-nature-the-dry-2-2024-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:29:49.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":832262,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":66,"rtCriticsLabel":"Fresh","rtAudience":43,"rtAudienceLabel":"Spilled","imdb":5.9,"imdbVotes":10343,"tmdb":6.269,"tmdbVotes":305}},{"id":"movie-free-state-of-jones-2016","title":"Free State of Jones","year":2016,"type":"movie","runtimeSeconds":8376,"seasonCount":null,"communityRating":6.79,"criticRating":48,"genres":["Action","Drama","History"],"overview":"In 1863, Mississippi farmer Newt Knight serves as a medic for the Confederate Army. Opposed to slavery, Knight would rather help the wounded than fight the Union. After his nephew dies in battle, Newt returns home to\u2026","poster":"assets/movie-free-state-of-jones-2016-poster.jpg","backdrop":"assets/movie-free-state-of-jones-2016-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-11T17:56:53.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":316152,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":48,"rtCriticsLabel":"Rotten","rtAudience":64,"rtAudienceLabel":"Upright","imdb":6.9,"imdbVotes":68931,"tmdb":6.788,"tmdbVotes":1730}},{"id":"movie-from-up-on-poppy-hill-2011","title":"From Up on Poppy Hill","year":2011,"type":"movie","runtimeSeconds":5482,"seasonCount":null,"communityRating":7.5,"criticRating":87,"genres":["Animation","Drama"],"overview":"Yokohama, 1963. Japan is picking itself up from the devastation of World War II and preparing to host the 1964 Olympics\u2014and the mood is one of both optimism and conflict as the young generation struggles to throw off\u2026","poster":"assets/movie-from-up-on-poppy-hill-2011-poster.jpg","backdrop":"assets/movie-from-up-on-poppy-hill-2011-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-07T14:41:03.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":83389,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":87,"rtCriticsLabel":"Certified Fresh","rtAudience":80,"rtAudienceLabel":"Upright","imdb":7.4,"imdbVotes":60769,"tmdb":7.509,"tmdbVotes":2026}},{"id":"movie-ghost-in-the-shell-1995","title":"Ghost in the Shell","year":1995,"type":"movie","runtimeSeconds":4959,"seasonCount":null,"communityRating":7.929,"criticRating":95,"genres":["Action","Animation","Science Fiction"],"overview":"In the year 2029, the barriers of our world have been broken down by the net and by cybernetics, but this brings new vulnerability to humans in the form of brain-hacking. When a highly-wanted hacker known as 'The\u2026","poster":"assets/movie-ghost-in-the-shell-1995-poster.jpg","backdrop":"assets/movie-ghost-in-the-shell-1995-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:22:57.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":9323,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":97,"rtCriticsLabel":"Certified Fresh","rtAudience":90,"rtAudienceLabel":"Upright","imdb":7.9,"imdbVotes":178061,"tmdb":7.928,"tmdbVotes":4130}},{"id":"movie-ghost-in-the-shell-2-innocence-2004","title":"Ghost in the Shell 2: Innocence","year":2004,"type":"movie","runtimeSeconds":5950,"seasonCount":null,"communityRating":7.352,"criticRating":65,"genres":["Animation","Drama","Science Fiction"],"overview":"Cyborg detective Batou is assigned to investigate a series of murders committed by gynoids\u2014doll-like cyborgs, which all malfunctioned, killed, then self-destructed afterwards. The brains of the gynoids initialize in\u2026","poster":"assets/movie-ghost-in-the-shell-2-innocence-2004-poster.jpg","backdrop":"assets/movie-ghost-in-the-shell-2-innocence-2004-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:23:38.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":12140,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":65,"rtCriticsLabel":"Fresh","rtAudience":80,"rtAudienceLabel":"Upright","imdb":7.4,"imdbVotes":43280,"tmdb":7.356,"tmdbVotes":1049}},{"id":"movie-the-godfather-1972","title":"The Godfather","year":1972,"type":"movie","runtimeSeconds":10603,"seasonCount":null,"communityRating":8.687,"criticRating":97,"genres":["Drama","Crime"],"overview":"Spanning the years 1945 to 1955, a chronicle of the fictional Italian-American Corleone crime family. When organized crime family patriarch, Vito Corleone barely survives an attempt on his life, his youngest son,\u2026","poster":"assets/movie-the-godfather-1972-poster.jpg","backdrop":"assets/movie-the-godfather-1972-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:24:12.0000000Z","lastPlayedAt":"2026-09-18T19:24:44.9855867Z","availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":238,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":97,"rtCriticsLabel":"Certified Fresh","rtAudience":98,"rtAudienceLabel":"Upright","imdb":9.2,"imdbVotes":2262024,"tmdb":8.688,"tmdbVotes":23907}},{"id":"movie-the-godfather-part-ii-1974","title":"The Godfather Part II","year":1974,"type":"movie","runtimeSeconds":12031,"seasonCount":null,"communityRating":8.573,"criticRating":96,"genres":["Drama","Crime"],"overview":"In the continuing saga of the Corleone crime family, a young Vito Corleone grows up in Sicily and in 1910s New York. In the 1950s, Michael Corleone attempts to expand the family business into Las Vegas, Hollywood and\u2026","poster":"assets/movie-the-godfather-part-ii-1974-poster.jpg","backdrop":"assets/movie-the-godfather-part-ii-1974-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:24:18.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":240,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":96,"rtCriticsLabel":"Certified Fresh","rtAudience":97,"rtAudienceLabel":"Upright","imdb":9,"imdbVotes":1521308,"tmdb":8.571,"tmdbVotes":14559}},{"id":"movie-the-godfather-part-iii-1990","title":"The Godfather Part III","year":1990,"type":"movie","runtimeSeconds":9476,"seasonCount":null,"communityRating":7.416,"criticRating":67,"genres":["Crime","Drama","Thriller"],"overview":"In the midst of trying to legitimize his business dealings in 1979 New York and Italy, aging mafia don, Michael Corleone seeks forgiveness for his sins while taking a young protege under his wing.","poster":"assets/movie-the-godfather-part-iii-1990-poster.jpg","backdrop":"assets/movie-the-godfather-part-iii-1990-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:26:29.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":242,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":68,"rtCriticsLabel":"Fresh","rtAudience":68,"rtAudienceLabel":"Upright","imdb":7.5,"imdbVotes":462448,"tmdb":7.413,"tmdbVotes":7159}},{"id":"movie-gosford-park-2001","title":"Gosford Park","year":2001,"type":"movie","runtimeSeconds":8247,"seasonCount":null,"communityRating":6.815,"criticRating":87,"genres":["Drama","Mystery","Thriller"],"overview":"In 1930s England, a group of pretentious rich and famous gather together for a weekend of relaxation at a hunting resort. But when a murder occurs, each one of these interesting characters becomes a suspect.","poster":"assets/movie-gosford-park-2001-poster.jpg","backdrop":"assets/movie-gosford-park-2001-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:25:49.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":5279,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":87,"rtCriticsLabel":"Certified Fresh","rtAudience":78,"rtAudienceLabel":"Upright","imdb":7.2,"imdbVotes":103883,"tmdb":6.822,"tmdbVotes":1317}},{"id":"movie-grave-of-the-fireflies-1988","title":"Grave of the Fireflies","year":1988,"type":"movie","runtimeSeconds":5313,"seasonCount":null,"communityRating":8.45,"criticRating":100,"genres":["Animation","Drama","War"],"overview":"In the final months of World War II, 14-year-old Seita and his sister Setsuko are orphaned when their mother is killed during an air raid in Kobe, Japan. After a falling out with their aunt, they move into an\u2026","poster":"assets/movie-grave-of-the-fireflies-1988-poster.jpg","backdrop":"assets/movie-grave-of-the-fireflies-1988-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:24:14.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":12477,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Certified Fresh","rtAudience":95,"rtAudienceLabel":"Upright","imdb":8.5,"imdbVotes":399802,"tmdb":8.45,"tmdbVotes":6938}},{"id":"movie-the-great-gatsby-2013","title":"The Great Gatsby","year":2013,"type":"movie","runtimeSeconds":8525,"seasonCount":null,"communityRating":7.346,"criticRating":49,"genres":["Drama","Romance"],"overview":"Nick Carraway arrives in New York in 1922, an era of loose morals, glittering jazz and bootleg kings. Chasing his own American Dream, Nick encounters the mysterious millionaire Gatsby and his bewitching cousin Daisy.\u2026","poster":"assets/movie-the-great-gatsby-2013-poster.jpg","backdrop":"assets/movie-the-great-gatsby-2013-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:26:50.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":64682,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":48,"rtCriticsLabel":"Rotten","rtAudience":67,"rtAudienceLabel":"Upright","imdb":7.2,"imdbVotes":651882,"tmdb":7.346,"tmdbVotes":13290}},{"id":"movie-the-hours-2002","title":"The Hours","year":2002,"type":"movie","runtimeSeconds":6887,"seasonCount":null,"communityRating":7.25,"criticRating":79,"genres":["Drama"],"overview":"The story of three women searching for more potent, meaningful lives. Each is alive at a different time and place, all are linked by their yearnings and their fears. Their stories intertwine, and finally come\u2026","poster":"assets/movie-the-hours-2002-poster.jpg","backdrop":"assets/movie-the-hours-2002-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:23:27.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":590,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":79,"rtCriticsLabel":"Certified Fresh","rtAudience":84,"rtAudienceLabel":"Upright","imdb":7.5,"imdbVotes":148367,"tmdb":7.249,"tmdbVotes":1931}},{"id":"movie-howl-s-moving-castle-2004","title":"Howl's Moving Castle","year":2004,"type":"movie","runtimeSeconds":7149,"seasonCount":null,"communityRating":8.4,"criticRating":88,"genres":["Fantasy","Animation","Adventure"],"overview":"Sophie, a young milliner, is turned into an elderly woman by a witch who enters her shop and curses her. She encounters a wizard named Howl and gets caught up in his resistance to fighting for the king.","poster":"assets/movie-howl-s-moving-castle-2004-poster.jpg","backdrop":"assets/movie-howl-s-moving-castle-2004-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:22:47.0000000Z","lastPlayedAt":"2026-05-21T18:46:08.0000000Z","availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":4935,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":87,"rtCriticsLabel":"Certified Fresh","rtAudience":93,"rtAudienceLabel":"Upright","imdb":8.2,"imdbVotes":524520,"tmdb":8.391,"tmdbVotes":11642}},{"id":"movie-in-the-heights-2021","title":"In the Heights","year":2021,"type":"movie","runtimeSeconds":8579,"seasonCount":null,"communityRating":7.111,"criticRating":null,"genres":["Drama","Romance"],"overview":"The story of Usnavi, a bodega owner who has mixed feelings about closing his store and retiring to the Dominican Republic or staying in Washington Heights.","poster":"assets/movie-in-the-heights-2021-poster.jpg","backdrop":"assets/movie-in-the-heights-2021-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:29:11.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":467909,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":94,"rtCriticsLabel":"Certified Fresh","rtAudience":94,"rtAudienceLabel":"Upright","imdb":7.2,"imdbVotes":56513,"tmdb":7.104,"tmdbVotes":826}},{"id":"movie-jennifer-s-body-2009","title":"Jennifer's Body","year":2009,"type":"movie","runtimeSeconds":6450,"seasonCount":null,"communityRating":6.012,"criticRating":47,"genres":["Horror","Comedy"],"overview":"Jennifer, a gorgeous, seductive cheerleader takes evil to a whole new level after she's possessed by a sinister demon. Now it's up to her best friend to stop Jennifer's reign of terror before it's too late.","poster":"assets/movie-jennifer-s-body-2009-poster.jpg","backdrop":"assets/movie-jennifer-s-body-2009-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-30T03:37:04.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":19994,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":47,"rtCriticsLabel":"Rotten","rtAudience":36,"rtAudienceLabel":"Spilled","imdb":5.6,"imdbVotes":179710,"tmdb":6.013,"tmdbVotes":4481}},{"id":"movie-kiki-s-delivery-service-1989","title":"Kiki's Delivery Service","year":1989,"type":"movie","runtimeSeconds":6183,"seasonCount":null,"communityRating":7.839,"criticRating":null,"genres":["Animation","Family","Fantasy"],"overview":"A young witch, on her mandatory year of independent life, finds fitting into a new community difficult while she supports herself by running an air courier service.","poster":"assets/movie-kiki-s-delivery-service-1989-poster.jpg","backdrop":"assets/movie-kiki-s-delivery-service-1989-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:24:20.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":16859,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":98,"rtCriticsLabel":"Certified Fresh","rtAudience":89,"rtAudienceLabel":"Upright","imdb":7.8,"imdbVotes":192477,"tmdb":7.839,"tmdbVotes":4811}},{"id":"movie-the-last-of-the-mohicans-1992","title":"The Last of the Mohicans","year":1992,"type":"movie","runtimeSeconds":6874,"seasonCount":null,"communityRating":7.388,"criticRating":88,"genres":["History","War","Drama"],"overview":"In war-torn colonial America, in the midst of a bloody battle between British, the French and Native American allies, the aristocratic daughter of a British Colonel and her party are captured by a group of Huron\u2026","poster":"assets/movie-the-last-of-the-mohicans-1992-poster.jpg","backdrop":"assets/movie-the-last-of-the-mohicans-1992-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-10T20:59:22.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":9361,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":88,"rtCriticsLabel":"Certified Fresh","rtAudience":88,"rtAudienceLabel":"Upright","imdb":7.6,"imdbVotes":210791,"tmdb":7.39,"tmdbVotes":3593}},{"id":"movie-the-last-samurai-2003","title":"The Last Samurai","year":2003,"type":"movie","runtimeSeconds":9251,"seasonCount":null,"communityRating":7.61,"criticRating":65,"genres":["Drama","Action","War"],"overview":"Nathan Algren is an American hired to instruct the Japanese army in the ways of modern warfare, which finds him learning to respect the samurai and the honorable principles that rule them. Pressed to destroy the\u2026","poster":"assets/movie-the-last-samurai-2003-poster.jpg","backdrop":"assets/movie-the-last-samurai-2003-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-26T05:25:10.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":616,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":66,"rtCriticsLabel":"Fresh","rtAudience":83,"rtAudienceLabel":"Upright","imdb":7.8,"imdbVotes":512982,"tmdb":7.614,"tmdbVotes":7887}},{"id":"movie-lemony-snicket-s-a-series-of-unfortunate-events-2004","title":"Lemony Snicket's A Series of Unfortunate Events","year":2004,"type":"movie","runtimeSeconds":6479,"seasonCount":null,"communityRating":6.8,"criticRating":72,"genres":["Adventure","Comedy","Family"],"overview":"Three wealthy children's parents are killed in a fire. When they are sent to a distant relative, they find out that he is plotting to kill them and seize their fortune.","poster":"assets/movie-lemony-snicket-s-a-series-of-unfortunate-events-2004-poster.jpg","backdrop":"assets/movie-lemony-snicket-s-a-series-of-unfortunate-events-2004-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:26:09.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":11774,"artwork":{"posterKind":"center_crop","backdropKind":"backdrop"},"ratings":{"rtCritics":72,"rtCriticsLabel":"Fresh","rtAudience":63,"rtAudienceLabel":"Upright","imdb":6.8,"imdbVotes":233636,"tmdb":6.82,"tmdbVotes":5447}},{"id":"movie-the-lighthouse-2019","title":"The Lighthouse","year":2019,"type":"movie","runtimeSeconds":6549,"seasonCount":null,"communityRating":7.463,"criticRating":90,"genres":["Drama","Fantasy","Thriller"],"overview":"Two lighthouse keepers try to maintain their sanity while living on a remote and mysterious New England island in the 1890s.","poster":"assets/movie-the-lighthouse-2019-poster.jpg","backdrop":"assets/movie-the-lighthouse-2019-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:29:01.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":503919,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":90,"rtCriticsLabel":"Certified Fresh","rtAudience":72,"rtAudienceLabel":"Upright","imdb":7.3,"imdbVotes":312344,"tmdb":7.46,"tmdbVotes":5895}},{"id":"movie-locke-2014","title":"Locke","year":2014,"type":"movie","runtimeSeconds":5090,"seasonCount":null,"communityRating":6.888,"criticRating":91,"genres":["Drama","Thriller"],"overview":"Ivan Locke has worked hard to craft a good life for himself. Tonight, that life will collapse around him. On the eve of the biggest challenge of his career, Ivan receives a phone call that sets in motion a series of\u2026","poster":"assets/movie-locke-2014-poster.jpg","backdrop":"assets/movie-locke-2014-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T17:38:48.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":210479,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":90,"rtCriticsLabel":"Certified Fresh","rtAudience":72,"rtAudienceLabel":"Upright","imdb":7.1,"imdbVotes":171265,"tmdb":6.889,"tmdbVotes":3088}},{"id":"movie-the-matrix-1999","title":"The Matrix","year":1999,"type":"movie","runtimeSeconds":8179,"seasonCount":null,"communityRating":8.259,"criticRating":83,"genres":["Action","Science Fiction"],"overview":"Set in the 22nd century, The Matrix tells the story of a computer hacker who joins a group of underground insurgents fighting the vast and powerful computers who now rule the earth.","poster":"assets/movie-the-matrix-1999-poster.jpg","backdrop":"assets/movie-the-matrix-1999-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:18:12.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":603,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":83,"rtCriticsLabel":"Certified Fresh","rtAudience":85,"rtAudienceLabel":"Upright","imdb":8.7,"imdbVotes":2289808,"tmdb":8.262,"tmdbVotes":29153}},{"id":"movie-the-matrix-reloaded-2003","title":"The Matrix Reloaded","year":2003,"type":"movie","runtimeSeconds":8298,"seasonCount":null,"communityRating":7.085,"criticRating":74,"genres":["Adventure","Action","Thriller"],"overview":"The Resistance builds in numbers as humans are freed from the Matrix and brought to the city of Zion. Neo discovers his superpowers, including the ability to see the code inside the Matrix. With machine sentinels\u2026","poster":"assets/movie-the-matrix-reloaded-2003-poster.jpg","backdrop":"assets/movie-the-matrix-reloaded-2003-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:24:03.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":604,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":73,"rtCriticsLabel":"Certified Fresh","rtAudience":72,"rtAudienceLabel":"Upright","imdb":7.2,"imdbVotes":678013,"tmdb":7.087,"tmdbVotes":12504}},{"id":"movie-the-matrix-resurrections-2021","title":"The Matrix Resurrections","year":2021,"type":"movie","runtimeSeconds":8875,"seasonCount":null,"communityRating":6.303,"criticRating":63,"genres":["Science Fiction","Action","Adventure"],"overview":"Plagued by strange memories, Neo's life takes an unexpected turn when he finds himself back inside the Matrix.","poster":"assets/movie-the-matrix-resurrections-2021-poster.jpg","backdrop":"assets/movie-the-matrix-resurrections-2021-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:31:22.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":624860,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":63,"rtCriticsLabel":"Fresh","rtAudience":63,"rtAudienceLabel":"Upright","imdb":5.6,"imdbVotes":307780,"tmdb":6.294,"tmdbVotes":6842}},{"id":"movie-the-matrix-revolutions-2003","title":"The Matrix Revolutions","year":2003,"type":"movie","runtimeSeconds":7756,"seasonCount":null,"communityRating":6.758,"criticRating":33,"genres":["Adventure","Action","Thriller"],"overview":"The human city of Zion defends itself against the massive invasion of the machines as Neo fights to end the war at another front while also opposing the rogue Agent Smith.","poster":"assets/movie-the-matrix-revolutions-2003-poster.jpg","backdrop":"assets/movie-the-matrix-revolutions-2003-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:25:47.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":605,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":34,"rtCriticsLabel":"Rotten","rtAudience":60,"rtAudienceLabel":"Upright","imdb":6.8,"imdbVotes":581553,"tmdb":6.762,"tmdbVotes":11292}},{"id":"movie-mercy-2026","title":"Mercy","year":2026,"type":"movie","runtimeSeconds":5968,"seasonCount":null,"communityRating":7.077,"criticRating":null,"genres":["Science Fiction","Thriller","Crime"],"overview":"In the near future, a detective stands on trial accused of murdering his wife. He has ninety minutes to prove his innocence to the advanced AI Judge he once championed, before it determines his fate.","poster":"assets/movie-mercy-2026-poster.jpg","backdrop":"assets/movie-mercy-2026-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:31:40.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":1236153,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":24,"rtCriticsLabel":"Rotten","rtAudience":81,"rtAudienceLabel":"Upright","imdb":6.2,"imdbVotes":83878,"tmdb":7.054,"tmdbVotes":1812}},{"id":"movie-normal-2026","title":"Normal","year":2026,"type":"movie","runtimeSeconds":5514,"seasonCount":null,"communityRating":7.267,"criticRating":77,"genres":["Action","Crime","Thriller"],"overview":"An unassuming substitute sheriff with a troubled past responds to a bank robbery in a small, sleepy town and unknowingly uncovers something far more explosive.","poster":"assets/movie-normal-2026-poster.jpg","backdrop":"assets/movie-normal-2026-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-08-21T08:33:18.0000000Z","lastPlayedAt":"2026-09-24T23:07:52.1161581Z","availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":1242332,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":78,"rtCriticsLabel":"Certified Fresh","rtAudience":75,"rtAudienceLabel":"Upright","imdb":6.3,"imdbVotes":37501,"tmdb":7.241,"tmdbVotes":786}},{"id":"movie-outlaw-king-2018","title":"Outlaw King","year":2018,"type":"movie","runtimeSeconds":7297,"seasonCount":null,"communityRating":6.829,"criticRating":63,"genres":["Action","History","War"],"overview":"Forced into exile by the English after being crowned King of Scotland, legendary warrior Robert the Bruce fights to reclaim the throne.","poster":"assets/movie-outlaw-king-2018-poster.jpg","backdrop":"assets/movie-outlaw-king-2018-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:28:32.0000000Z","lastPlayedAt":"2026-07-26T22:35:35.0000000Z","availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":454227,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":63,"rtCriticsLabel":"Fresh","rtAudience":69,"rtAudienceLabel":"Upright","imdb":6.9,"imdbVotes":89876,"tmdb":6.844,"tmdbVotes":1780}},{"id":"movie-panic-room-2002","title":"Panic Room","year":2002,"type":"movie","runtimeSeconds":6717,"seasonCount":null,"communityRating":6.794,"criticRating":76,"genres":["Crime","Drama","Thriller"],"overview":"Trapped in their New York brownstone's panic room, a hidden chamber built as a sanctuary in the event of break-ins, newly divorced Meg Altman and her young daughter Sarah play a deadly game of cat-and-mouse with\u2026","poster":"assets/movie-panic-room-2002-poster.jpg","backdrop":"assets/movie-panic-room-2002-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-19T20:50:01.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":4547,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":76,"rtCriticsLabel":"Certified Fresh","rtAudience":64,"rtAudienceLabel":"Upright","imdb":6.8,"imdbVotes":317468,"tmdb":6.796,"tmdbVotes":5653}},{"id":"movie-the-patriot-2000","title":"The Patriot","year":2000,"type":"movie","runtimeSeconds":9887,"seasonCount":null,"communityRating":7.214,"criticRating":61,"genres":["Drama","History","War"],"overview":"After proving himself on the field of battle in the French and Indian War, Benjamin Martin wants nothing more to do with such things, preferring the simple life of a farmer. But when his son Gabriel enlists in the\u2026","poster":"assets/movie-the-patriot-2000-poster.jpg","backdrop":"assets/movie-the-patriot-2000-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-10T20:50:00.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":2024,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":61,"rtCriticsLabel":"Fresh","rtAudience":81,"rtAudienceLabel":"Upright","imdb":7.2,"imdbVotes":317639,"tmdb":7.215,"tmdbVotes":4535}},{"id":"movie-peaky-blinders-the-immortal-man-2026","title":"Peaky Blinders: The Immortal Man","year":2026,"type":"movie","runtimeSeconds":6844,"seasonCount":null,"communityRating":7.187,"criticRating":90,"genres":["Crime","Drama"],"overview":"After his estranged son gets embroiled in a Nazi plot, self-exiled gangster Tommy Shelby must return to Birmingham to save his family \u2014 and his nation.","poster":"assets/movie-peaky-blinders-the-immortal-man-2026-poster.jpg","backdrop":"assets/movie-peaky-blinders-the-immortal-man-2026-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:30:01.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":875828,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":90,"rtCriticsLabel":"Certified Fresh","rtAudience":87,"rtAudienceLabel":"Upright","imdb":6.5,"imdbVotes":92130,"tmdb":7.189,"tmdbVotes":1304}},{"id":"movie-ponyo-2008","title":"Ponyo","year":2008,"type":"movie","runtimeSeconds":6530,"seasonCount":null,"communityRating":7.763,"criticRating":91,"genres":["Animation","Fantasy","Family"],"overview":"When Sosuke, a young boy who lives on a clifftop overlooking the sea, rescues a stranded goldfish named Ponyo, he discovers more than he bargained for. Ponyo is a curious, energetic young creature who yearns to be\u2026","poster":"assets/movie-ponyo-2008-poster.jpg","backdrop":"assets/movie-ponyo-2008-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:23:53.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":12429,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":91,"rtCriticsLabel":"Certified Fresh","rtAudience":84,"rtAudienceLabel":"Upright","imdb":7.6,"imdbVotes":190294,"tmdb":7.759,"tmdbVotes":5050}},{"id":"movie-porco-rosso-1992","title":"Porco Rosso","year":1992,"type":"movie","runtimeSeconds":5600,"seasonCount":null,"communityRating":7.794,"criticRating":96,"genres":["Animation","Adventure","Fantasy"],"overview":"In Italy in the 1930s, sky pirates in biplanes terrorize wealthy cruise ships as they sail the Adriatic Sea. The only pilot brave enough to stop the scourge is the mysterious Porco Rosso, a former World War I flying\u2026","poster":"assets/movie-porco-rosso-1992-poster.jpg","backdrop":"assets/movie-porco-rosso-1992-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:23:22.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":11621,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":96,"rtCriticsLabel":"Fresh","rtAudience":87,"rtAudienceLabel":"Upright","imdb":7.7,"imdbVotes":119396,"tmdb":7.789,"tmdbVotes":3879}},{"id":"movie-princess-mononoke-1997","title":"Princess Mononoke","year":1997,"type":"movie","runtimeSeconds":8003,"seasonCount":null,"communityRating":8.321,"criticRating":93,"genres":["Adventure","Fantasy","Animation"],"overview":"Ashitaka, a prince of the disappearing Emishi people, is cursed by a demonized boar god and must journey to the west to find a cure. Along the way, he encounters San, a young human woman fighting to protect the\u2026","poster":"assets/movie-princess-mononoke-1997-poster.jpg","backdrop":"assets/movie-princess-mononoke-1997-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:22:28.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":128,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":94,"rtCriticsLabel":"Certified Fresh","rtAudience":99,"rtAudienceLabel":"Upright","imdb":8.3,"imdbVotes":493735,"tmdb":8.321,"tmdbVotes":9438}},{"id":"movie-prisoners-2013","title":"Prisoners","year":2013,"type":"movie","runtimeSeconds":9192,"seasonCount":null,"communityRating":8.106,"criticRating":81,"genres":["Drama","Thriller","Crime"],"overview":"After his six-year-old daughter is abducted, a desperate father takes justice into his own hands when he loses faith in the detective leading the investigation, setting both men on a collision course as the search\u2026","poster":"assets/movie-prisoners-2013-poster.jpg","backdrop":"assets/movie-prisoners-2013-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:27:33.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":146233,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":82,"rtCriticsLabel":"Certified Fresh","rtAudience":87,"rtAudienceLabel":"Upright","imdb":8.2,"imdbVotes":963062,"tmdb":8.105,"tmdbVotes":13722}},{"id":"movie-racing-stripes-2005","title":"Racing Stripes","year":2005,"type":"movie","runtimeSeconds":6117,"seasonCount":null,"communityRating":5.945,"criticRating":35,"genres":["Comedy","Family"],"overview":"Shattered illusions are hard to repair -- especially for a good-hearted zebra named Stripes who's spent his life on a Kentucky farm amidst the sorely mistaken notion that he's a debonair thoroughbred. Once he faces\u2026","poster":"assets/movie-racing-stripes-2005-poster.jpg","backdrop":"assets/movie-racing-stripes-2005-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:26:02.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":6439,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":35,"rtCriticsLabel":"Rotten","rtAudience":42,"rtAudienceLabel":"Spilled","imdb":5.3,"imdbVotes":19569,"tmdb":5.962,"tmdbVotes":705}},{"id":"movie-requiem-for-a-dream-2000","title":"Requiem for a Dream","year":2000,"type":"movie","runtimeSeconds":6086,"seasonCount":null,"communityRating":8.005,"criticRating":80,"genres":["Crime","Drama"],"overview":"The drug-induced utopias of four Coney Island residents are shattered when their addictions run deep.","poster":"assets/movie-requiem-for-a-dream-2000-poster.jpg","backdrop":"assets/movie-requiem-for-a-dream-2000-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:24:15.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":641,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":81,"rtCriticsLabel":"Certified Fresh","rtAudience":93,"rtAudienceLabel":"Upright","imdb":8.3,"imdbVotes":989090,"tmdb":8,"tmdbVotes":11454}},{"id":"movie-rogue-one-a-star-wars-story-2016","title":"Rogue One: A Star Wars Story","year":2016,"type":"movie","runtimeSeconds":8038,"seasonCount":null,"communityRating":7.51,"criticRating":84,"genres":["Action","Adventure","Science Fiction"],"overview":"A rogue band of resistance fighters unite for a mission to steal the Death Star plans and bring a new hope to the galaxy.","poster":"assets/movie-rogue-one-a-star-wars-story-2016-poster.jpg","backdrop":"assets/movie-rogue-one-a-star-wars-story-2016-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:28:00.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":330459,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":84,"rtCriticsLabel":"Certified Fresh","rtAudience":87,"rtAudienceLabel":"Upright","imdb":7.8,"imdbVotes":768365,"tmdb":7.512,"tmdbVotes":16937}},{"id":"movie-secondhand-lions-2003","title":"Secondhand Lions","year":2003,"type":"movie","runtimeSeconds":6547,"seasonCount":null,"communityRating":7.237,"criticRating":60,"genres":["Family","Comedy","Drama"],"overview":"The comedic adventures of an introverted boy left on the doorstep of a pair of reluctant, eccentric great-uncles, whose exotic remembrances stir the boy's spirit and re-ignite the men's lives.","poster":"assets/movie-secondhand-lions-2003-poster.jpg","backdrop":"assets/movie-secondhand-lions-2003-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-12T07:16:13.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":13156,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":59,"rtCriticsLabel":"Rotten","rtAudience":84,"rtAudienceLabel":"Upright","imdb":7.5,"imdbVotes":66911,"tmdb":7.242,"tmdbVotes":872}},{"id":"movie-the-secret-life-of-pets-2-2019","title":"The Secret Life of Pets 2","year":2019,"type":"movie","runtimeSeconds":5164,"seasonCount":null,"communityRating":6.95,"criticRating":60,"genres":["Animation","Comedy","Family"],"overview":"Max the terrier must cope with some major life changes when his owner gets married and has a baby. When the family takes a trip to the countryside, nervous Max has numerous run-ins with canine-intolerant cows,\u2026","poster":"assets/movie-the-secret-life-of-pets-2-2019-poster.jpg","backdrop":"assets/movie-the-secret-life-of-pets-2-2019-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-05T07:00:53.0000000Z","lastPlayedAt":"2026-09-17T18:07:00.7024231Z","availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":412117,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":60,"rtCriticsLabel":"Fresh","rtAudience":90,"rtAudienceLabel":"Upright","imdb":6.4,"imdbVotes":80464,"tmdb":6.948,"tmdbVotes":3450}},{"id":"movie-self-reliance-2024","title":"Self Reliance","year":2024,"type":"movie","runtimeSeconds":5461,"seasonCount":null,"communityRating":5.888,"criticRating":73,"genres":["Comedy","Thriller"],"overview":"When a man is offered a million dollars to play a game in which hunters try to kill him, he thinks he has found the perfect loophole: they can only attack when he\u2019s alone. His only problem is that none of his friends\u2026","poster":"assets/movie-self-reliance-2024-poster.jpg","backdrop":"assets/movie-self-reliance-2024-backdrop.jpg","played":false,"playCount":1,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:31:00.0000000Z","lastPlayedAt":"2026-10-02T19:59:16.9286868Z","availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":1072876,"artwork":{"posterKind":"center_crop","backdropKind":"backdrop"},"ratings":{"rtCritics":73,"rtCriticsLabel":"Certified Fresh","rtAudience":56,"rtAudienceLabel":"Spilled","imdb":6,"imdbVotes":23771,"tmdb":5.866,"tmdbVotes":346}},{"id":"movie-serpico-1973","title":"Serpico","year":1973,"type":"movie","runtimeSeconds":7805,"seasonCount":null,"communityRating":7.5,"criticRating":93,"genres":["Crime","Drama","History"],"overview":"New York cop Frank Serpico blows the whistle on the rampant corruption in the force only to have his comrades turn against him.","poster":"assets/movie-serpico-1973-poster.jpg","backdrop":"assets/movie-serpico-1973-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-10T21:45:02.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":9040,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":93,"rtCriticsLabel":"Certified Fresh","rtAudience":88,"rtAudienceLabel":"Upright","imdb":7.7,"imdbVotes":149750,"tmdb":7.525,"tmdbVotes":2323}},{"id":"movie-shaft-2000","title":"Shaft","year":2000,"type":"movie","runtimeSeconds":5960,"seasonCount":null,"communityRating":6.026,"criticRating":67,"genres":["Action","Adventure","Crime"],"overview":"New York police detective John Shaft arrests Walter Wade Jr. for a racially motivated slaying. But the only eyewitness disappears, and Wade jumps bail for Switzerland. Two years later Wade returns to face trial,\u2026","poster":"assets/movie-shaft-2000-poster.jpg","backdrop":"assets/movie-shaft-2000-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-10T21:30:40.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":479,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":68,"rtCriticsLabel":"Fresh","rtAudience":40,"rtAudienceLabel":"Spilled","imdb":6,"imdbVotes":86257,"tmdb":6.024,"tmdbVotes":1531}},{"id":"movie-a-silent-voice-the-movie-2016","title":"A Silent Voice: The Movie","year":2016,"type":"movie","runtimeSeconds":7804,"seasonCount":null,"communityRating":8.398,"criticRating":null,"genres":["Animation","Drama","Romance"],"overview":"Shouya Ishida starts bullying the new girl in class, Shouko Nishimiya, because she is deaf. But as the teasing continues, the rest of the class starts to turn on Shouya for his lack of compassion. When they leave\u2026","poster":"assets/movie-a-silent-voice-the-movie-2016-poster.jpg","backdrop":"assets/movie-a-silent-voice-the-movie-2016-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T17:39:46.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":378064,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":95,"rtCriticsLabel":"Fresh","rtAudience":94,"rtAudienceLabel":"Upright","imdb":8.2,"imdbVotes":151926,"tmdb":8.396,"tmdbVotes":4764}},{"id":"movie-sinbad-legend-of-the-seven-seas-2003","title":"Sinbad: Legend of the Seven Seas","year":2003,"type":"movie","runtimeSeconds":5143,"seasonCount":null,"communityRating":7.067,"criticRating":44,"genres":["Action","Adventure","Animation"],"overview":"The sailor of legend is framed by the goddess Eris for the theft of the Book of Peace, and must travel to her realm at the end of the world to retrieve it and save the life of his childhood friend Prince Proteus.","poster":"assets/movie-sinbad-legend-of-the-seven-seas-2003-poster.jpg","backdrop":"assets/movie-sinbad-legend-of-the-seven-seas-2003-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:26:25.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":14411,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":46,"rtCriticsLabel":"Rotten","rtAudience":57,"rtAudienceLabel":"Spilled","imdb":6.8,"imdbVotes":65298,"tmdb":7.071,"tmdbVotes":2323}},{"id":"movie-sleeping-with-other-people-2015","title":"Sleeping with Other People","year":2015,"type":"movie","runtimeSeconds":6076,"seasonCount":null,"communityRating":6.274,"criticRating":64,"genres":["Comedy","Romance"],"overview":"Can two serial cheaters get a second chance at love? After a one-night stand in college, New Yorkers Lainey and Jake meet by chance twelve years later and discover they each have the same problem: because of their\u2026","poster":"assets/movie-sleeping-with-other-people-2015-poster.jpg","backdrop":"assets/movie-sleeping-with-other-people-2015-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:27:42.0000000Z","lastPlayedAt":"2026-07-17T17:50:44.0000000Z","availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":288036,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":64,"rtCriticsLabel":"Fresh","rtAudience":56,"rtAudienceLabel":"Spilled","imdb":6.5,"imdbVotes":52275,"tmdb":6.275,"tmdbVotes":1020}},{"id":"movie-snowpiercer-2013","title":"Snowpiercer","year":2013,"type":"movie","runtimeSeconds":7573,"seasonCount":null,"communityRating":6.909,"criticRating":94,"genres":["Action","Science Fiction","Drama"],"overview":"In a future where a failed global-warming experiment kills off most life on the planet, a class system evolves aboard the Snowpiercer; a train that travels around the globe via a perpetual-motion engine.","poster":"assets/movie-snowpiercer-2013-poster.jpg","backdrop":"assets/movie-snowpiercer-2013-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:27:07.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":110415,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":94,"rtCriticsLabel":"Certified Fresh","rtAudience":72,"rtAudienceLabel":"Upright","imdb":7.1,"imdbVotes":426976,"tmdb":6.9,"tmdbVotes":10807}},{"id":"movie-spider-man-brand-new-day-2026","title":"Spider-Man: Brand New Day","year":2026,"type":"movie","runtimeSeconds":8701,"seasonCount":null,"communityRating":8.374,"criticRating":90,"genres":["Science Fiction","Action","Adventure"],"overview":"Fighting crime full-time as Spider-Man in a world that doesn't remember him\u2014and the pressure of seeing his old friends move on without him\u2014sparks a change in Peter Parker he may not have the power to control. But\u2026","poster":"assets/movie-spider-man-brand-new-day-2026-poster.jpg","backdrop":"assets/movie-spider-man-brand-new-day-2026-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-10T14:14:27.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":969681,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":90,"rtCriticsLabel":"Certified Fresh","rtAudience":97,"rtAudienceLabel":"Upright","imdb":7.9,"imdbVotes":366886,"tmdb":8.5,"tmdbVotes":6537}},{"id":"movie-spider-man-far-from-home-2019","title":"Spider-Man: Far From Home","year":2019,"type":"movie","runtimeSeconds":7762,"seasonCount":null,"communityRating":7.389,"criticRating":91,"genres":["Action","Adventure","Science Fiction"],"overview":"Peter Parker and his friends go on a summer trip to Europe. However, they will hardly be able to rest - Peter will have to agree to help Nick Fury uncover the mystery of creatures that cause natural disasters and\u2026","poster":"assets/movie-spider-man-far-from-home-2019-poster.jpg","backdrop":"assets/movie-spider-man-far-from-home-2019-backdrop.jpg","played":true,"playCount":1,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-10T14:25:47.0000000Z","lastPlayedAt":"2026-10-10T17:50:35.6193119Z","availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":429617,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":91,"rtCriticsLabel":"Certified Fresh","rtAudience":95,"rtAudienceLabel":"Upright","imdb":7.3,"imdbVotes":682887,"tmdb":7.387,"tmdbVotes":17847}},{"id":"movie-spider-man-homecoming-2017","title":"Spider-Man: Homecoming","year":2017,"type":"movie","runtimeSeconds":8008,"seasonCount":null,"communityRating":7.333,"criticRating":92,"genres":["Action","Adventure","Science Fiction"],"overview":"Following the events of Captain America: Civil War, Peter Parker, with the help of his mentor Tony Stark, tries to balance his life as an ordinary high school student in Queens, New York City, with fighting crime as\u2026","poster":"assets/movie-spider-man-homecoming-2017-poster.jpg","backdrop":"assets/movie-spider-man-homecoming-2017-backdrop.jpg","played":true,"playCount":1,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-10T14:33:13.0000000Z","lastPlayedAt":"2026-10-10T20:31:23.4444213Z","availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":315635,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":92,"rtCriticsLabel":"Certified Fresh","rtAudience":87,"rtAudienceLabel":"Upright","imdb":7.4,"imdbVotes":840974,"tmdb":7.332,"tmdbVotes":24183}},{"id":"movie-spider-man-no-way-home-2021","title":"Spider-Man: No Way Home","year":2021,"type":"movie","runtimeSeconds":8890,"seasonCount":null,"communityRating":7.947,"criticRating":93,"genres":["Action","Adventure","Science Fiction"],"overview":"Peter Parker is unmasked and no longer able to separate his normal life from the high-stakes of being a super-hero. When he asks for help from Doctor Strange the stakes become even more dangerous, forcing him to\u2026","poster":"assets/movie-spider-man-no-way-home-2021-poster.jpg","backdrop":"assets/movie-spider-man-no-way-home-2021-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-10T14:21:05.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":634649,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":93,"rtCriticsLabel":"Certified Fresh","rtAudience":97,"rtAudienceLabel":"Upright","imdb":8.1,"imdbVotes":1082660,"tmdb":7.947,"tmdbVotes":23505}},{"id":"movie-spirited-away-2001","title":"Spirited Away","year":2001,"type":"movie","runtimeSeconds":7472,"seasonCount":null,"communityRating":8.535,"criticRating":96,"genres":["Animation","Family","Fantasy"],"overview":"A young girl, Chihiro, becomes trapped in a strange new world of spirits. When her parents undergo a mysterious transformation, she must call upon the courage she never knew she had to free her family.","poster":"assets/movie-spirited-away-2001-poster.jpg","backdrop":"assets/movie-spirited-away-2001-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:22:42.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":129,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":96,"rtCriticsLabel":"Certified Fresh","rtAudience":99,"rtAudienceLabel":"Upright","imdb":8.6,"imdbVotes":987658,"tmdb":8.536,"tmdbVotes":19176}},{"id":"movie-the-substance-2024","title":"The Substance","year":2024,"type":"movie","runtimeSeconds":8531,"seasonCount":null,"communityRating":7.135,"criticRating":89,"genres":["Horror","Science Fiction"],"overview":"A fading celebrity decides to use a black market drug, a cell-replicating substance that temporarily creates a younger, better version of herself.","poster":"assets/movie-the-substance-2024-poster.jpg","backdrop":"assets/movie-the-substance-2024-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:30:22.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":933260,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":89,"rtCriticsLabel":"Certified Fresh","rtAudience":76,"rtAudienceLabel":"Upright","imdb":7.2,"imdbVotes":466542,"tmdb":7.13,"tmdbVotes":6746}},{"id":"movie-the-tale-of-the-princess-kaguya-2013","title":"The Tale of The Princess Kaguya","year":2013,"type":"movie","runtimeSeconds":8218,"seasonCount":null,"communityRating":8.1,"criticRating":100,"genres":["Animation","Drama","Fantasy"],"overview":"Found inside a shining stalk of bamboo by an old bamboo cutter and his wife, a tiny girl grows rapidly into an exquisite young lady. The mysterious young princess enthrals all who encounter her. But, ultimately, she\u2026","poster":"assets/movie-the-tale-of-the-princess-kaguya-2013-poster.jpg","backdrop":"assets/movie-the-tale-of-the-princess-kaguya-2013-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-07T13:40:15.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":149871,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Certified Fresh","rtAudience":90,"rtAudienceLabel":"Upright","imdb":8,"imdbVotes":63574,"tmdb":8.083,"tmdbVotes":2079}},{"id":"movie-taxi-driver-1976","title":"Taxi Driver","year":1976,"type":"movie","runtimeSeconds":6831,"seasonCount":null,"communityRating":8.121,"criticRating":89,"genres":["Crime","Drama"],"overview":"Suffering from insomnia, disturbed loner Travis Bickle takes a job as a New York City cabbie, haunting the streets nightly, growing increasingly detached from reality as he dreams of cleaning up the filthy city.","poster":"assets/movie-taxi-driver-1976-poster.jpg","backdrop":"assets/movie-taxi-driver-1976-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:23:04.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":103,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":89,"rtCriticsLabel":"Certified Fresh","rtAudience":93,"rtAudienceLabel":"Upright","imdb":8.2,"imdbVotes":1046700,"tmdb":8.116,"tmdbVotes":13973}},{"id":"movie-triangle-of-sadness-2022","title":"Triangle of Sadness","year":2022,"type":"movie","runtimeSeconds":8833,"seasonCount":null,"communityRating":7.005,"criticRating":71,"genres":["Comedy","Drama"],"overview":"A celebrity model couple are invited on a luxury cruise for the uber-rich, helmed by an unhinged, alcoholic captain. What first appears Instagrammable ends catastrophically, leaving the survivors stranded on a desert\u2026","poster":"assets/movie-triangle-of-sadness-2022-poster.jpg","backdrop":"assets/movie-triangle-of-sadness-2022-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:29:00.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":497828,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":72,"rtCriticsLabel":"Fresh","rtAudience":80,"rtAudienceLabel":"Upright","imdb":7.2,"imdbVotes":221069,"tmdb":7,"tmdbVotes":3104}},{"id":"movie-tsotsi-2005","title":"Tsotsi","year":2005,"type":"movie","runtimeSeconds":5663,"seasonCount":null,"communityRating":6.937,"criticRating":82,"genres":["Crime","Drama"],"overview":"A young South African boy from the Johannesburg ghetto named Tsotsi, meaning Gangster, leaves home as a child to get away from his helpless parents. Now a teenage thug, Tsotsi finds a baby in the back seat of a car\u2026","poster":"assets/movie-tsotsi-2005-poster.jpg","backdrop":"assets/movie-tsotsi-2005-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:24:58.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":868,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":82,"rtCriticsLabel":"Certified Fresh","rtAudience":86,"rtAudienceLabel":"Upright","imdb":7.2,"imdbVotes":32526,"tmdb":6.941,"tmdbVotes":372}},{"id":"movie-unknown-number-the-high-school-catfish-2025","title":"Unknown Number: The High School Catfish","year":2025,"type":"movie","runtimeSeconds":5691,"seasonCount":null,"communityRating":6.746,"criticRating":85,"genres":["Documentary"],"overview":"Vulgar, taunting texts blow up the phones of a teen and her boyfriend. Who's sending them \u2014 and why? This twisty documentary reveals the shocking answer.","poster":"assets/movie-unknown-number-the-high-school-catfish-2025-poster.jpg","backdrop":"assets/movie-unknown-number-the-high-school-catfish-2025-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:32:10.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":1517772,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":85,"rtCriticsLabel":"Fresh","rtAudience":69,"rtAudienceLabel":"Upright","imdb":6.7,"imdbVotes":23039,"tmdb":6.737,"tmdbVotes":257}},{"id":"movie-the-uprising-2026","title":"The Uprising","year":2026,"type":"movie","runtimeSeconds":7684,"seasonCount":null,"communityRating":7.846,"criticRating":55,"genres":["Action","Drama","History"],"overview":"As war and plague devastate 14th-century England, a humble peasant ignites a rebellion, uniting an army of commoners to defy the King\u2019s might in a desperate fight for justice and survival.","poster":"assets/movie-the-uprising-2026-poster.jpg","backdrop":"assets/movie-the-uprising-2026-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-11-02T16:37:03.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":977942,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":57,"rtCriticsLabel":"Rotten","rtAudience":65,"rtAudienceLabel":"Upright","imdb":6.2,"imdbVotes":6622,"tmdb":7.8,"tmdbVotes":404}},{"id":"movie-vice-2018","title":"Vice","year":2018,"type":"movie","runtimeSeconds":7948,"seasonCount":null,"communityRating":7.013,"criticRating":64,"genres":["Drama","Comedy","History"],"overview":"George W. Bush picks Dick Cheney, the CEO of Halliburton Co., to be his Republican running mate in the 2000 presidential election. No stranger to politics, Cheney's impressive r\u00e9sum\u00e9 includes stints as White House\u2026","poster":"assets/movie-vice-2018-poster.jpg","backdrop":"assets/movie-vice-2018-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:28:30.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":429197,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":64,"rtCriticsLabel":"Fresh","rtAudience":61,"rtAudienceLabel":"Upright","imdb":7.2,"imdbVotes":177299,"tmdb":7.014,"tmdbVotes":3723}},{"id":"movie-warriors-of-the-wind-1984","title":"Warriors of the Wind","year":1984,"type":"movie","runtimeSeconds":7022,"seasonCount":null,"communityRating":7.941,"criticRating":87,"genres":["Adventure","Animation","Fantasy"],"overview":"After a global war, the seaside kingdom known as the Valley of the Wind remains one of the last strongholds on Earth untouched by a poisonous jungle and the powerful insects that guard it. Led by the courageous\u2026","poster":"assets/movie-warriors-of-the-wind-1984-poster.jpg","backdrop":"assets/movie-warriors-of-the-wind-1984-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:22:20.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":81,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":87,"rtCriticsLabel":"Fresh","rtAudience":91,"rtAudienceLabel":"Upright","imdb":8,"imdbVotes":200154,"tmdb":7.94,"tmdbVotes":4260}},{"id":"movie-we-bury-the-dead-2026","title":"We Bury the Dead","year":2026,"type":"movie","runtimeSeconds":5693,"seasonCount":null,"communityRating":6.153,"criticRating":87,"genres":["Horror","Thriller"],"overview":"After a catastrophic military disaster reanimates the dead, Ava searches for her missing husband amidst an increasingly hostile world.","poster":"assets/movie-we-bury-the-dead-2026-poster.jpg","backdrop":"assets/movie-we-bury-the-dead-2026-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-08-26T05:36:00.4272328Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":1198984,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":87,"rtCriticsLabel":"Certified Fresh","rtAudience":45,"rtAudienceLabel":"Spilled","imdb":5.6,"imdbVotes":20693,"tmdb":6.137,"tmdbVotes":433}},{"id":"movie-white-hot-the-rise-fall-of-abercrombie-fitch-2022","title":"White Hot: The Rise & Fall of Abercrombie & Fitch","year":2022,"type":"movie","runtimeSeconds":5187,"seasonCount":null,"communityRating":5.663,"criticRating":66,"genres":["Documentary"],"overview":"All the cool kids were wearing it. This documentary explores A&F's pop culture reign in the late '90s and early 2000s and how it thrived on exclusion.","poster":"assets/movie-white-hot-the-rise-fall-of-abercrombie-fitch-2022-poster.jpg","backdrop":"assets/movie-white-hot-the-rise-fall-of-abercrombie-fitch-2022-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:30:34.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":953371,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":66,"rtCriticsLabel":"Fresh","rtAudience":29,"rtAudienceLabel":"Spilled","imdb":5.8,"imdbVotes":5655,"tmdb":5.7,"tmdbVotes":102}},{"id":"movie-the-wild-robot-2024","title":"The Wild Robot","year":2024,"type":"movie","runtimeSeconds":6101,"seasonCount":null,"communityRating":8.308,"criticRating":97,"genres":["Family","Animation","Science Fiction"],"overview":"After a shipwreck, an intelligent robot called Roz is stranded on an uninhabited island. To survive the harsh environment, Roz bonds with the island's animals and cares for an orphaned baby goose.","poster":"assets/movie-the-wild-robot-2024-poster.jpg","backdrop":"assets/movie-the-wild-robot-2024-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:31:49.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":1184918,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":97,"rtCriticsLabel":"Certified Fresh","rtAudience":98,"rtAudienceLabel":"Upright","imdb":8.1,"imdbVotes":241134,"tmdb":8.309,"tmdbVotes":6819}},{"id":"movie-the-wind-rises-2013","title":"The Wind Rises","year":2013,"type":"movie","runtimeSeconds":8031,"seasonCount":null,"communityRating":7.784,"criticRating":88,"genres":["Animation","Drama","History"],"overview":"A lifelong love of flight inspires Japanese aviation engineer Jiro Horikoshi, whose storied career includes the creation of the A-6M World War II fighter plane.","poster":"assets/movie-the-wind-rises-2013-poster.jpg","backdrop":"assets/movie-the-wind-rises-2013-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:24:38.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":149870,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":89,"rtCriticsLabel":"Certified Fresh","rtAudience":85,"rtAudienceLabel":"Upright","imdb":7.8,"imdbVotes":115756,"tmdb":7.783,"tmdbVotes":3536}},{"id":"movie-the-witcher-nightmare-of-the-wolf-2021","title":"The Witcher: Nightmare of the Wolf","year":2021,"type":"movie","runtimeSeconds":5015,"seasonCount":null,"communityRating":7.422,"criticRating":100,"genres":["Animation","Action","Fantasy"],"overview":"Escaping from poverty to become a witcher, Vesemir slays monsters for coin and glory, but when a new menace rises, he must face the demons of his past.","poster":"assets/movie-the-witcher-nightmare-of-the-wolf-2021-poster.jpg","backdrop":"assets/movie-the-witcher-nightmare-of-the-wolf-2021-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-26T04:42:03.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":666243,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":83,"rtAudienceLabel":"Upright","imdb":7.2,"imdbVotes":55759,"tmdb":7.407,"tmdbVotes":1159}},{"id":"movie-the-witcher-sirens-of-the-deep-2025","title":"The Witcher: Sirens of the Deep","year":2025,"type":"movie","runtimeSeconds":5517,"seasonCount":null,"communityRating":6.8,"criticRating":60,"genres":["Animation","Fantasy","Action"],"overview":"When human sailors are attacked by mysterious creatures of the deep, only one person can stop the war between land and sea: the Witcher, Geralt of Rivia.","poster":"assets/movie-the-witcher-sirens-of-the-deep-2025-poster.jpg","backdrop":"assets/movie-the-witcher-sirens-of-the-deep-2025-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-26T05:03:26.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":1203329,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":60,"rtCriticsLabel":"Fresh","rtAudience":37,"rtAudienceLabel":"Spilled","imdb":6.1,"imdbVotes":11799,"tmdb":6.741,"tmdbVotes":303}}],"shows":[{"id":"tv-adults-2025","title":"Adults","year":2025,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":6.495,"criticRating":null,"genres":["Comedy"],"overview":"A group of twenty-somethings in New York trying to be good people, despite being neither \"good\" nor \"people\" yet.","poster":"assets/tv-adults-2025-poster.jpg","backdrop":"assets/tv-adults-2025-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-23T21:01:41.1814765Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":260458,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":88,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.5,"tmdbVotes":102}},{"id":"tv-american-crime-story-2016","title":"American Crime Story","year":2016,"type":"tv","runtimeSeconds":null,"seasonCount":3,"communityRating":7.573,"criticRating":null,"genres":["Crime","Drama"],"overview":"An anthology series centered around some of history's most famous criminal investigations.","poster":"assets/tv-american-crime-story-2016-poster.jpg","backdrop":"assets/tv-american-crime-story-2016-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0490488Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":64513,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":84,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.585,"tmdbVotes":1094}},{"id":"tv-american-primeval-2025","title":"American Primeval","year":2025,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.691,"criticRating":null,"genres":["Western","Drama","Action & Adventure"],"overview":"A mother and son fleeing from their past form a found family while confronting a harsh landscape of freedom and cruelty in the American West.","poster":"assets/tv-american-primeval-2025-poster.jpg","backdrop":"assets/tv-american-primeval-2025-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0573221Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":215866,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":71,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.741,"tmdbVotes":538}},{"id":"tv-arrested-development-2003","title":"Arrested Development","year":2003,"type":"tv","runtimeSeconds":null,"seasonCount":5,"communityRating":7.925,"criticRating":null,"genres":["Comedy"],"overview":"The story of a wealthy family that lost everything, and the one son who had no choice but to keep them all together.","poster":"assets/tv-arrested-development-2003-poster.jpg","backdrop":"assets/tv-arrested-development-2003-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0513048Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":4589,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":75,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.906,"tmdbVotes":1793}},{"id":"tv-avatar-the-last-airbender-2005","title":"Avatar: The Last Airbender","year":2005,"type":"tv","runtimeSeconds":1440,"seasonCount":3,"communityRating":8.78,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"In a war-torn world of elemental magic, a young boy reawakens to undertake a dangerous mystic quest to fulfill his destiny as the Avatar, and bring peace to the world.","poster":"assets/tv-avatar-the-last-airbender-2005-poster.jpg","backdrop":"assets/tv-avatar-the-last-airbender-2005-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0504536Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":246,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.787,"tmdbVotes":5215}},{"id":"tv-b-b-vol-liefde-2021","title":"B&B Vol Liefde","year":2021,"type":"tv","runtimeSeconds":3720,"seasonCount":1,"communityRating":7.2,"criticRating":null,"genres":["Reality"],"overview":"B&B Vol Liefde is a heartwarming adventure, full of romance, excitement, and unforgettable moments. Adventurous Dutch B&B owners abroad throw their doors wide open and let their hearts speak, determined to find that\u2026","poster":"assets/tv-b-b-vol-liefde-2021-poster.jpg","backdrop":"assets/tv-b-b-vol-liefde-2021-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-31T22:30:11.129657Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":124428,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.2,"tmdbVotes":10}},{"id":"tv-the-bear-2022","title":"The Bear","year":2022,"type":"tv","runtimeSeconds":null,"seasonCount":5,"communityRating":8.139,"criticRating":null,"genres":["Drama","Comedy"],"overview":"Carmy, a young fine-dining chef, comes home to Chicago to run his family sandwich shop. As he fights to transform the shop and himself, he works alongside a rough-around-the-edges crew that ultimately reveal\u2026","poster":"assets/tv-the-bear-2022-poster.jpg","backdrop":"assets/tv-the-bear-2022-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0467371Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":136315,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":93,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.158,"tmdbVotes":2025}},{"id":"tv-blue-planet-ii-2017","title":"Blue Planet II","year":2017,"type":"tv","runtimeSeconds":3600,"seasonCount":1,"communityRating":8.64,"criticRating":null,"genres":["Documentary","Family"],"overview":"There is nowhere more powerful and unforgiving yet more beautiful and compelling than the ocean. Join us and explore the greatest yet least known parts of our planet.","poster":"assets/tv-blue-planet-ii-2017-poster.jpg","backdrop":"assets/tv-blue-planet-ii-2017-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0634849Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":74313,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":97,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.6,"tmdbVotes":380}},{"id":"tv-breeders-2020","title":"Breeders","year":2020,"type":"tv","runtimeSeconds":1500,"seasonCount":4,"communityRating":6.8,"criticRating":null,"genres":["Comedy"],"overview":"Exposing the parental-paradox that it is possible, in the very same moment, to love your child to the horizon of the universe, while being apoplectically angry enough to want to send them there.","poster":"assets/tv-breeders-2020-poster.jpg","backdrop":"assets/tv-breeders-2020-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-28T18:54:21.8885435Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":93290,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":92,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.8,"tmdbVotes":104}},{"id":"tv-chernobyl-2019","title":"Chernobyl","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":8.718,"criticRating":null,"genres":["Drama"],"overview":"The true story of one of the worst man-made catastrophes in history: the catastrophic nuclear accident at Chernobyl. A tale of the brave men and women who sacrificed to save Europe from unimaginable disaster.","poster":"assets/tv-chernobyl-2019-poster.jpg","backdrop":"assets/tv-chernobyl-2019-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0469903Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":87108,"artwork":{"posterKind":"poster","backdropKind":"center_crop"},"ratings":{"rtCritics":95,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.731,"tmdbVotes":8496}},{"id":"tv-community-2009","title":"Community","year":2009,"type":"tv","runtimeSeconds":null,"seasonCount":6,"communityRating":7.997,"criticRating":null,"genres":["Comedy"],"overview":"Follow the lives of a group of students at what is possibly the world\u2019s worst community college in the fictional locale of Greendale, Colorado.","poster":"assets/tv-community-2009-poster.jpg","backdrop":"assets/tv-community-2009-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0531416Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":18347,"artwork":{"posterKind":"center_crop","backdropKind":"backdrop"},"ratings":{"rtCritics":88,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.994,"tmdbVotes":2226}},{"id":"tv-dead-end-job-2026","title":"Dead-End Job","year":2026,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.7,"criticRating":null,"genres":["Mystery"],"overview":"Drowning in debt, a young man takes lucrative part-time jobs from a mysterious agency \u2014 only to find his hustle for more money turning nightmarish.","poster":"assets/tv-dead-end-job-2026-poster.jpg","backdrop":"assets/tv-dead-end-job-2026-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-11-04T18:58:01.8352857Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":289517,"artwork":{"posterKind":"center_crop","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.7,"tmdbVotes":38}},{"id":"tv-dopesick-2021","title":"Dopesick","year":2021,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":8.015,"criticRating":null,"genres":["Crime","Drama"],"overview":"The story of how one company triggered the worst drug epidemic in American history. Look into the epicenter of America's struggle with opioid addiction, from a distressed Virginia mining community, to the hallways of\u2026","poster":"assets/tv-dopesick-2021-poster.jpg","backdrop":"assets/tv-dopesick-2021-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0580168Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":110695,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":89,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.035,"tmdbVotes":565}},{"id":"tv-dune-prophecy-2024","title":"Dune: Prophecy","year":2024,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.213,"criticRating":null,"genres":["Sci-Fi & Fantasy","Drama","Action & Adventure"],"overview":"Ten thousand years before the ascension of Paul Atreides, sisters Valya and Tula Harkonnen establish the fabled sect and female order that would become known as the Bene Gesserit to control the future of humankind.","poster":"assets/tv-dune-prophecy-2024-poster.jpg","backdrop":"assets/tv-dune-prophecy-2024-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-11-04T18:23:01.2908568Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":90228,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":64,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.213,"tmdbVotes":677}},{"id":"tv-east-of-eden-2026","title":"East of Eden","year":2026,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.931,"criticRating":null,"genres":["Drama","Crime"],"overview":"In the early 1900s, a fiercely independent woman drives a rift between two brothers, igniting a saga that unfolds across two generations.","poster":"assets/tv-east-of-eden-2026-poster.jpg","backdrop":"assets/tv-east-of-eden-2026-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-23T18:07:11.3965096Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":258165,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":88,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.982,"tmdbVotes":139}},{"id":"tv-fallout-2024","title":"Fallout","year":2024,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":8.124,"criticRating":null,"genres":["Action & Adventure","Sci-Fi & Fantasy","Drama"],"overview":"The story of haves and have-nots in a world in which there's almost nothing left to have. 200 years after the apocalypse, the gentle denizens of luxury fallout shelters are forced to return to the irradiated\u2026","poster":"assets/tv-fallout-2024-poster.jpg","backdrop":"assets/tv-fallout-2024-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0669262Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":106379,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":94,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.106,"tmdbVotes":3233}},{"id":"tv-fawlty-towers-1975","title":"Fawlty Towers","year":1975,"type":"tv","runtimeSeconds":1800,"seasonCount":2,"communityRating":8.295,"criticRating":null,"genres":["Comedy"],"overview":"Owner Basil Fawlty, his wife Sybil, a chambermaid Polly, and Spanish waiter Manuel attempt to run their hotel amidst farcical situations and an array of demanding guests.","poster":"assets/tv-fawlty-towers-1975-poster.jpg","backdrop":"assets/tv-fawlty-towers-1975-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-19T23:26:39.7658344Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":2207,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.296,"tmdbVotes":867}},{"id":"tv-the-final-problem-2026","title":"The Final Problem","year":2026,"type":"tv","runtimeSeconds":3420,"seasonCount":1,"communityRating":7.041,"criticRating":null,"genres":["Crime","Mystery"],"overview":"In the spring of 1959, a retired actor, who previously played Sherlock Holmes, must investigate a murder at a small hotel on a tiny island near Mallorca.","poster":"assets/tv-the-final-problem-2026-poster.jpg","backdrop":"assets/tv-the-final-problem-2026-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-11-04T19:16:12.4822791Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":290720,"artwork":{"posterKind":"center_crop","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.041,"tmdbVotes":49}},{"id":"tv-the-four-seasons-2025","title":"The Four Seasons","year":2025,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":6.879,"criticRating":null,"genres":["Comedy"],"overview":"The decades-long friendship between three married couples is tested when one divorces, complicating their tradition of quarterly weekend getaways.","poster":"assets/tv-the-four-seasons-2025-poster.jpg","backdrop":"assets/tv-the-four-seasons-2025-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-28T18:36:42.5326384Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":243316,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":81,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.892,"tmdbVotes":204}},{"id":"tv-the-get-down-2016","title":"The Get Down","year":2016,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.3,"criticRating":null,"genres":["Drama"],"overview":"In 1977 New York City, the talented and soulful youth of the South Bronx chase dreams and breakneck beats to transform music history.","poster":"assets/tv-the-get-down-2016-poster.jpg","backdrop":"assets/tv-the-get-down-2016-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0497737Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":65345,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":81,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.3,"tmdbVotes":196}},{"id":"tv-how-to-change-your-mind-2022","title":"How to Change Your Mind","year":2022,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.76,"criticRating":null,"genres":["Documentary","Mystery"],"overview":"Author Michael Pollan leads the way in this docuseries exploring the history and uses of psychedelics, including LSD, psilocybin, MDMA and mescaline.","poster":"assets/tv-how-to-change-your-mind-2022-poster.jpg","backdrop":"assets/tv-how-to-change-your-mind-2022-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0601399Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":204304,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.8,"tmdbVotes":53}},{"id":"tv-interrogation-2020","title":"Interrogation","year":2020,"type":"tv","runtimeSeconds":2700,"seasonCount":1,"communityRating":6.788,"criticRating":null,"genres":["Drama","Crime"],"overview":"Based on a true story that spanned more than 30 years in which a young man was charged and convicted of brutally murdering his mother. Each episode is structured around an interrogation taken directly from the real\u2026","poster":"assets/tv-interrogation-2020-poster.jpg","backdrop":"assets/tv-interrogation-2020-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-26T05:52:06.1068207Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":96488,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":57,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.9,"tmdbVotes":54}},{"id":"tv-it-s-always-sunny-in-philadelphia-2005","title":"It's Always Sunny in Philadelphia","year":2005,"type":"tv","runtimeSeconds":null,"seasonCount":18,"communityRating":8.2,"criticRating":null,"genres":["Comedy"],"overview":"Four egocentric friends run a neighborhood Irish pub in Philadelphia and try to find their way through the adult world of work and relationships. Unfortunately, their warped views and precarious judgments often lead\u2026","poster":"assets/tv-it-s-always-sunny-in-philadelphia-2005-poster.jpg","backdrop":"assets/tv-it-s-always-sunny-in-philadelphia-2005-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0485774Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":2710,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":94,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.253,"tmdbVotes":1457}},{"id":"tv-a-knight-of-the-seven-kingdoms-2026","title":"A Knight of the Seven Kingdoms","year":2026,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":8.431,"criticRating":null,"genres":["Drama","Sci-Fi & Fantasy","Action & Adventure"],"overview":"A century before the events of Game of Thrones, two unlikely heroes wandered Westeros: a young, naive but courageous knight, Ser Duncan the Tall, and his diminutive squire, Egg. Set in an age when the Targaryen line\u2026","poster":"assets/tv-a-knight-of-the-seven-kingdoms-2026-poster.jpg","backdrop":"assets/tv-a-knight-of-the-seven-kingdoms-2026-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0493344Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":224372,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":94,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.4,"tmdbVotes":1266}},{"id":"tv-landman-2024","title":"Landman","year":2024,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":8.194,"criticRating":null,"genres":["Drama"],"overview":"Set in the proverbial boomtowns of West-Texas and a modern-day tale of fortune-seeking in the world of oil rigs, the series is an upstairs/downstairs story of roughnecks and wildcat billionaires that are fueling a\u2026","poster":"assets/tv-landman-2024-poster.jpg","backdrop":"assets/tv-landman-2024-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-22T21:38:18.0973875Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":157741,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":80,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.209,"tmdbVotes":668}},{"id":"tv-the-last-of-us-2023","title":"The Last of Us","year":2023,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":8.419,"criticRating":null,"genres":["Drama"],"overview":"Twenty years after modern civilization has been destroyed, Joel, a hardened survivor, is hired to smuggle Ellie, a 14-year-old girl, out of an oppressive quarantine zone. What starts as a small job soon becomes a\u2026","poster":"assets/tv-the-last-of-us-2023-poster.jpg","backdrop":"assets/tv-the-last-of-us-2023-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0640955Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":100088,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":94,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.4,"tmdbVotes":7485}},{"id":"tv-last-samurai-standing-2025","title":"Last Samurai Standing","year":2025,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.805,"criticRating":null,"genres":["Action & Adventure","Drama"],"overview":"In the early Meiji era, Shujiro, once known as an undefeated samurai, decides to participate in a deadly survival game to save his family and villagers.","poster":"assets/tv-last-samurai-standing-2025-poster.jpg","backdrop":"assets/tv-last-samurai-standing-2025-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0622526Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":252193,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.812,"tmdbVotes":271}},{"id":"tv-lord-of-the-flies-2026","title":"Lord of the Flies","year":2026,"type":"tv","runtimeSeconds":3480,"seasonCount":1,"communityRating":7.571,"criticRating":null,"genres":["Drama","Action & Adventure"],"overview":"After a plane crash, a group of schoolboys find themselves stranded on a tropical island without adults. Ralph is elected leader and, with the help of the intelligent Piggy, strives to maintain order and\u2026","poster":"assets/tv-lord-of-the-flies-2026-poster.jpg","backdrop":"assets/tv-lord-of-the-flies-2026-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0645648Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":270572,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":91,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.58,"tmdbVotes":138}},{"id":"tv-the-lord-of-the-rings-the-rings-of-power-2022","title":"The Lord of the Rings: The Rings of Power","year":2022,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":7.233,"criticRating":null,"genres":["Action & Adventure","Sci-Fi & Fantasy","Drama"],"overview":"Beginning in a time of relative peace, we follow an ensemble cast of characters as they confront the re-emergence of evil to Middle-earth. From the darkest depths of the Misty Mountains, to the majestic forests of\u2026","poster":"assets/tv-the-lord-of-the-rings-the-rings-of-power-2022-poster.jpg","backdrop":"assets/tv-the-lord-of-the-rings-the-rings-of-power-2022-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0618904Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":84773,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":84,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.211,"tmdbVotes":4003}},{"id":"tv-love-death-robots-2019","title":"Love, Death & Robots","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":4,"communityRating":8.21,"criticRating":null,"genres":["Animation","Sci-Fi & Fantasy"],"overview":"Terrifying creatures, wicked surprises and dark comedy converge in this NSFW anthology of animated stories presented by Tim Miller and David Fincher.","poster":"assets/tv-love-death-robots-2019-poster.jpg","backdrop":"assets/tv-love-death-robots-2019-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0460964Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":86831,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":89,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.203,"tmdbVotes":4238}},{"id":"tv-maniac-2018","title":"Maniac","year":2018,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.338,"criticRating":null,"genres":["Comedy","Drama","Sci-Fi & Fantasy"],"overview":"Two strangers are drawn to a mysterious pharmaceutical trial that will, they're assured, with no complications or side-effects whatsoever, solve all of their problems, permanently. Things do not go as planned.","poster":"assets/tv-maniac-2018-poster.jpg","backdrop":"assets/tv-maniac-2018-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-20T19:42:30.955548Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":73411,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":84,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.323,"tmdbVotes":1073}},{"id":"tv-mussolini-son-of-the-century-2024","title":"Mussolini: Son of the Century","year":2024,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":8.135,"criticRating":null,"genres":["Drama","War & Politics"],"overview":"The rise of Benito Mussolini and the birth of fascism in Italy, chronicling a country\u2019s surrender to dictatorship and the relentless ascent of a man who rose from his ashes time and again.","poster":"assets/tv-mussolini-son-of-the-century-2024-poster.jpg","backdrop":"assets/tv-mussolini-son-of-the-century-2024-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0586588Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":212333,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":96,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.1,"tmdbVotes":179}},{"id":"tv-the-night-manager-2016","title":"The Night Manager","year":2016,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":7.74,"criticRating":null,"genres":["Drama","Mystery","Crime"],"overview":"Former British soldier Jonathan Pine navigates the shadowy recesses of Whitehall and Washington where an unholy alliance operates between the intelligence community and the secret arms trade. To infiltrate the inner\u2026","poster":"assets/tv-the-night-manager-2016-poster.jpg","backdrop":"assets/tv-the-night-manager-2016-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0663672Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":61859,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":88,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.7,"tmdbVotes":1422}},{"id":"tv-one-piece-2023","title":"ONE PIECE","year":2023,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":8.118,"criticRating":null,"genres":["Action & Adventure","Sci-Fi & Fantasy"],"overview":"With his straw hat and ragtag crew, young pirate Monkey D. Luffy goes on an epic voyage for treasure.","poster":"assets/tv-one-piece-2023-poster.jpg","backdrop":"assets/tv-one-piece-2023-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0439378Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":111110,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":93,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.104,"tmdbVotes":1988}},{"id":"tv-only-murders-in-the-building-2021","title":"Only Murders in the Building","year":2021,"type":"tv","runtimeSeconds":null,"seasonCount":5,"communityRating":8.438,"criticRating":null,"genres":["Comedy","Mystery","Crime"],"overview":"Three strangers share an obsession with true crime and suddenly find themselves wrapped up in one.","poster":"assets/tv-only-murders-in-the-building-2021-poster.jpg","backdrop":"assets/tv-only-murders-in-the-building-2021-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0594614Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":107113,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":95,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.398,"tmdbVotes":2333}},{"id":"tv-over-the-garden-wall-2014","title":"Over the Garden Wall","year":2014,"type":"tv","runtimeSeconds":720,"seasonCount":1,"communityRating":8.6,"criticRating":null,"genres":["Mystery","Sci-Fi & Fantasy","Animation"],"overview":"Two brothers, Wirt and Greg, find themselves lost in the Unknown; a strange forest adrift in time. With the help of a wise old Woodsman and a foul-tempered bluebird named Beatrice, Wirt and Greg must travel across\u2026","poster":"assets/tv-over-the-garden-wall-2014-poster.jpg","backdrop":"assets/tv-over-the-garden-wall-2014-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0455757Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":61617,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.6,"tmdbVotes":1854}},{"id":"tv-peaky-blinders-2013","title":"Peaky Blinders","year":2013,"type":"tv","runtimeSeconds":null,"seasonCount":6,"communityRating":8.5,"criticRating":null,"genres":["Drama","Crime"],"overview":"A gangster family epic set in 1919 Birmingham, England and centered on a gang who sew razor blades in the peaks of their caps, and their fierce boss Tommy Shelby, who means to move up in the world.","poster":"assets/tv-peaky-blinders-2013-poster.jpg","backdrop":"assets/tv-peaky-blinders-2013-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0539185Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":60574,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":93,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.5,"tmdbVotes":11506}},{"id":"tv-the-pitt-2025","title":"The Pitt","year":2025,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":8.729,"criticRating":null,"genres":["Drama"],"overview":"The staff of Pittsburgh's Trauma Medical Center work around the clock to save lives in an overcrowded and underfunded emergency department.","poster":"assets/tv-the-pitt-2025-poster.jpg","backdrop":"assets/tv-the-pitt-2025-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0582942Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":250307,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":96,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.731,"tmdbVotes":1046}},{"id":"tv-pluribus-2025","title":"Pluribus","year":2025,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.936,"criticRating":null,"genres":["Drama","Sci-Fi & Fantasy"],"overview":"The most miserable person on Earth must save the world from happiness.","poster":"assets/tv-pluribus-2025-poster.jpg","backdrop":"assets/tv-pluribus-2025-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0674462Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":225171,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":99,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.921,"tmdbVotes":1191}},{"id":"tv-ripley-2024","title":"RIPLEY","year":2024,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.911,"criticRating":null,"genres":["Drama"],"overview":"A grifter in 1960s New York is hired to convince a wealthy man's son to return home from Italy and begins a life of deceit, fraud and murder.","poster":"assets/tv-ripley-2024-poster.jpg","backdrop":"assets/tv-ripley-2024-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0658942Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":94028,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":86,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.909,"tmdbVotes":453}},{"id":"tv-say-nothing-2024","title":"Say Nothing","year":2024,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.462,"criticRating":null,"genres":["Drama","Crime"],"overview":"Through the eyes of various Irish Republican Army (IRA) members, explore the extremes some people will go to in the name of their beliefs, the way a deeply divided society can suddenly tip over into armed conflict,\u2026","poster":"assets/tv-say-nothing-2024-poster.jpg","backdrop":"assets/tv-say-nothing-2024-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0499752Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":245256,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":94,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.475,"tmdbVotes":122}},{"id":"tv-severance-2022","title":"Severance","year":2022,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":8.396,"criticRating":null,"genres":["Drama","Mystery","Sci-Fi & Fantasy"],"overview":"Mark leads a team of office workers whose memories have been surgically divided between their work and personal lives. When a mysterious colleague appears outside of work, it begins a journey to discover the truth\u2026","poster":"assets/tv-severance-2022-poster.jpg","backdrop":"assets/tv-severance-2022-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0598478Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":95396,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":95,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.389,"tmdbVotes":3095}},{"id":"tv-six-feet-under-2001","title":"Six Feet Under","year":2001,"type":"tv","runtimeSeconds":null,"seasonCount":5,"communityRating":8.115,"criticRating":null,"genres":["Drama"],"overview":"When death is your business, what is your life? For the Fisher family, the world outside of their family-owned funeral home continues to be at least as challenging as\u2014and far less predictable than\u2014the one inside.","poster":"assets/tv-six-feet-under-2001-poster.jpg","backdrop":"assets/tv-six-feet-under-2001-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-26T04:04:05.108787Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":1274,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":81,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.1,"tmdbVotes":1192}},{"id":"tv-undercover-2019","title":"Undercover","year":2019,"type":"tv","runtimeSeconds":3000,"seasonCount":3,"communityRating":7.5,"criticRating":null,"genres":["Crime","Drama"],"overview":"Undercover agents infiltrate a drug kingpin's operation by posing as a couple at the campground where he spends his weekends. Inspired by real events.","poster":"assets/tv-undercover-2019-poster.jpg","backdrop":"assets/tv-undercover-2019-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0570153Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":86942,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.5,"tmdbVotes":186}},{"id":"tv-upload-2020","title":"Upload","year":2020,"type":"tv","runtimeSeconds":null,"seasonCount":4,"communityRating":7.781,"criticRating":null,"genres":["Comedy","Sci-Fi & Fantasy","Drama"],"overview":"In 2033, people who are near death can be \u201cuploaded\u201d into virtual reality hotels run by 6 tech firms. Cash-strapped Nora lives in Brooklyn and works customer service for the luxurious \u201cLakeview\u201d digital afterlife.\u2026","poster":"assets/tv-upload-2020-poster.jpg","backdrop":"assets/tv-upload-2020-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0566967Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":86248,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":87,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.757,"tmdbVotes":1511}},{"id":"tv-the-walk-in-2022","title":"The Walk-In","year":2022,"type":"tv","runtimeSeconds":2760,"seasonCount":1,"communityRating":6.9,"criticRating":null,"genres":["Drama","Crime"],"overview":"Forced to flee the UK and go into hiding in the 1990s, when he became a mole from within the BNP, Matthew Collins returned to Britain to make a new life for himself, working with young white men in the fight against\u2026","poster":"assets/tv-the-walk-in-2022-poster.jpg","backdrop":"assets/tv-the-walk-in-2022-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0654569Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":210664,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":80,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7,"tmdbVotes":50}},{"id":"tv-the-white-lotus-2021","title":"The White Lotus","year":2021,"type":"tv","runtimeSeconds":null,"seasonCount":3,"communityRating":7.596,"criticRating":null,"genres":["Comedy","Drama","Mystery"],"overview":"Follow the exploits of various guests and employees at an exclusive tropical resort over the span of a week as with each passing day, a darker complexity emerges in these picture-perfect travelers, the hotel\u2019s\u2026","poster":"assets/tv-the-white-lotus-2021-poster.jpg","backdrop":"assets/tv-the-white-lotus-2021-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0546438Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":111803,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":90,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.599,"tmdbVotes":1604}},{"id":"tv-widow-s-bay-2026","title":"Widow's Bay","year":2026,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":8.172,"criticRating":null,"genres":["Drama","Comedy","Mystery"],"overview":"The mayor of a New England town resolves to turn it into the next tourist hot spot, despite local warnings that it's cursed.","poster":"assets/tv-widow-s-bay-2026-poster.jpg","backdrop":"assets/tv-widow-s-bay-2026-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-20T10:27:12.7003979Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":270476,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":98,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8,"tmdbVotes":585}},{"id":"tv-the-witcher-2019","title":"The Witcher","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":4,"communityRating":7.905,"criticRating":null,"genres":["Drama","Action & Adventure"],"overview":"Geralt of Rivia, a mutated monster-hunter for hire, journeys toward his destiny in a turbulent world where people often prove more wicked than beasts.","poster":"assets/tv-the-witcher-2019-poster.jpg","backdrop":"assets/tv-the-witcher-2019-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0552369Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":71912,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":75,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.88,"tmdbVotes":6997}},{"id":"tv-yellowjackets-2021","title":"Yellowjackets","year":2021,"type":"tv","runtimeSeconds":null,"seasonCount":3,"communityRating":7.449,"criticRating":null,"genres":["Drama","Mystery"],"overview":"This equal parts survival epic, psychological horror story and coming-of-age drama tells the saga of a team of wildly talented high school girls soccer players who become the (un)lucky survivors of a plane crash deep\u2026","poster":"assets/tv-yellowjackets-2021-poster.jpg","backdrop":"assets/tv-yellowjackets-2021-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0577612Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":117488,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":92,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.431,"tmdbVotes":1070}}],"anime":[],"discover":[{"id":"movie-12-years-a-slave-2013","title":"12 Years a Slave","year":2013,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","History"],"overview":"In the pre-Civil War United States, Solomon Northup, a free black man from upstate New York, is abducted and sold into slavery. Facing cruelty as well as unexpected kindnesses Solomon struggles not only to stay\u2026","poster":"assets/movie-12-years-a-slave-2013-poster.jpg","backdrop":"assets/movie-12-years-a-slave-2013-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":76203,"ratings":{"rtCritics":95,"rtCriticsLabel":"Certified Fresh","rtAudience":90,"rtAudienceLabel":"Upright","imdb":8.1,"imdbVotes":796313,"tmdb":7.927,"tmdbVotes":12335}},{"id":"movie-101-dalmatians-ii-patch-s-london-adventure-2003","title":"101 Dalmatians II: Patch's London Adventure","year":2003,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Family","Animation","Adventure"],"overview":"Being one of 101 takes its toll on Patch, who doesn't feel unique. When he's accidentally left behind on moving day, he meets his idol, Thunderbolt, who enlists him on a publicity campaign.","poster":"assets/movie-101-dalmatians-ii-patch-s-london-adventure-2003-poster.jpg","backdrop":"assets/movie-101-dalmatians-ii-patch-s-london-adventure-2003-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":13654,"ratings":{"rtCritics":67,"rtCriticsLabel":"Fresh","rtAudience":41,"rtAudienceLabel":"Spilled","imdb":5.7,"imdbVotes":12343,"tmdb":6.025,"tmdbVotes":1274}},{"id":"movie-absolution-2024","title":"Absolution","year":2024,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Crime","Thriller"],"overview":"An aging ex-boxer gangster working as muscle for a Boston crime boss receives an upsetting diagnosis. Despite a faltering memory, he attempts to rectify the sins of his past and reconnect with his estranged\u2026","poster":"assets/movie-absolution-2024-poster.jpg","backdrop":"assets/movie-absolution-2024-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":974453,"ratings":{"rtCritics":56,"rtCriticsLabel":"Rotten","rtAudience":32,"rtAudienceLabel":"Spilled","imdb":5.2,"imdbVotes":11413,"tmdb":5.906,"tmdbVotes":564}},{"id":"movie-addicted-2014","title":"Addicted","year":2014,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Thriller"],"overview":"A gallerist risks her family and flourishing career when she enters into an affair with a talented painter and slowly loses control of her life.","poster":"assets/movie-addicted-2014-poster.jpg","backdrop":"assets/movie-addicted-2014-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":235271,"ratings":{"rtCritics":7,"rtCriticsLabel":"Rotten","rtAudience":51,"rtAudienceLabel":"Spilled","imdb":5,"imdbVotes":13131,"tmdb":5.928,"tmdbVotes":466}},{"id":"movie-all-the-money-in-the-world-2017","title":"All the Money in the World","year":2017,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Crime","History","Thriller"],"overview":"The story of the kidnapping of 16-year-old John Paul Getty III and the desperate attempt by his devoted mother to convince his billionaire grandfather Jean Paul Getty to pay the ransom.","poster":"assets/movie-all-the-money-in-the-world-2017-poster.jpg","backdrop":"assets/movie-all-the-money-in-the-world-2017-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":446791,"ratings":{"rtCritics":79,"rtCriticsLabel":"Certified Fresh","rtAudience":66,"rtAudienceLabel":"Upright","imdb":6.8,"imdbVotes":97448,"tmdb":6.493,"tmdbVotes":2493}},{"id":"movie-the-alto-knights-2025","title":"The Alto Knights","year":2025,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Crime","Drama"],"overview":"Two of New York's most notorious organized crime bosses, Frank Costello and Vito Genovese, vie for control of the city's streets. Once the best of friends, petty jealousies and a series of betrayals place them on a\u2026","poster":"assets/movie-the-alto-knights-2025-poster.jpg","backdrop":"assets/movie-the-alto-knights-2025-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1013601,"ratings":{"rtCritics":39,"rtCriticsLabel":"Rotten","rtAudience":71,"rtAudienceLabel":"Upright","imdb":5.9,"imdbVotes":23017,"tmdb":6.223,"tmdbVotes":485}},{"id":"movie-the-amazing-spider-man-2-2014","title":"The Amazing Spider-Man 2","year":2014,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"For Peter Parker, life is busy. Between taking out the bad guys as Spider-Man and spending time with the person he loves, Gwen Stacy, high school graduation cannot come quickly enough. Peter has not forgotten about\u2026","poster":"assets/movie-the-amazing-spider-man-2-2014-poster.jpg","backdrop":"assets/movie-the-amazing-spider-man-2-2014-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":102382,"ratings":{"rtCritics":51,"rtCriticsLabel":"Rotten","rtAudience":64,"rtAudienceLabel":"Upright","imdb":6.6,"imdbVotes":611981,"tmdb":6.567,"tmdbVotes":14919}},{"id":"movie-ant-man-and-the-wasp-quantumania-2023","title":"Ant-Man and the Wasp: Quantumania","year":2023,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"Super-Hero partners Scott Lang and Hope van Dyne, along with with Hope's parents Janet van Dyne and Hank Pym, and Scott's daughter Cassie Lang, find themselves exploring the Quantum Realm, interacting with strange\u2026","poster":"assets/movie-ant-man-and-the-wasp-quantumania-2023-poster.jpg","backdrop":"assets/movie-ant-man-and-the-wasp-quantumania-2023-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":640146,"ratings":{"rtCritics":46,"rtCriticsLabel":"Rotten","rtAudience":81,"rtAudienceLabel":"Upright","imdb":6,"imdbVotes":281738,"tmdb":6.197,"tmdbVotes":6236}},{"id":"movie-arctic-dogs-2019","title":"Arctic Dogs","year":2019,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Adventure","Comedy"],"overview":"Animals band together to save the day when the evil Otto Von Walrus hatches a sinister scheme to accelerate global warming and melt the Arctic Circle.","poster":"assets/movie-arctic-dogs-2019-poster.jpg","backdrop":"assets/movie-arctic-dogs-2019-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":416153,"ratings":{"rtCritics":13,"rtCriticsLabel":"Rotten","rtAudience":60,"rtAudienceLabel":"Upright","imdb":4.9,"imdbVotes":2580,"tmdb":5.9,"tmdbVotes":148}},{"id":"movie-the-avengers-2012","title":"The Avengers","year":2012,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Science Fiction","Action","Adventure"],"overview":"When an unexpected enemy emerges and threatens global safety and security, Nick Fury, director of the international peacekeeping agency known as S.H.I.E.L.D., finds himself in need of a team to pull the world back\u2026","poster":"assets/movie-the-avengers-2012-poster.jpg","backdrop":"assets/movie-the-avengers-2012-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":24428,"ratings":{"rtCritics":91,"rtCriticsLabel":"Certified Fresh","rtAudience":91,"rtAudienceLabel":"Upright","imdb":8,"imdbVotes":1590693,"tmdb":8.084,"tmdbVotes":40262}},{"id":"movie-black-bear-2020","title":"Black Bear","year":2020,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Thriller"],"overview":"At a remote lake house in the Adirondack Mountains, a couple entertains an out-of-town guest looking for inspiration in her filmmaking. The group quickly falls into a calculated game of desire, manipulation, and\u2026","poster":"assets/movie-black-bear-2020-poster.jpg","backdrop":"assets/movie-black-bear-2020-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":624788,"ratings":{"rtCritics":89,"rtCriticsLabel":"Certified Fresh","rtAudience":64,"rtAudienceLabel":"Upright","imdb":6.5,"imdbVotes":18408,"tmdb":6.337,"tmdbVotes":315}},{"id":"movie-black-panther-2018","title":"Black Panther","year":2018,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"King T'Challa returns home to the reclusive, technologically advanced African nation of Wakanda to serve as his country's new leader. However, T'Challa soon finds that he is challenged for the throne by factions\u2026","poster":"assets/movie-black-panther-2018-poster.jpg","backdrop":"assets/movie-black-panther-2018-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":284054,"ratings":{"rtCritics":96,"rtCriticsLabel":"Certified Fresh","rtAudience":null,"rtAudienceLabel":"Spilled","imdb":7.3,"imdbVotes":931120,"tmdb":7.36,"tmdbVotes":24190}},{"id":"movie-a-bronx-tale-1993","title":"A Bronx Tale","year":1993,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Crime"],"overview":"Set in the Bronx during the tumultuous 1960s, an adolescent boy is torn between his honest, working-class father and a violent yet charismatic crime boss. Complicating matters is the youngster's growing attraction -\u2026","poster":"assets/movie-a-bronx-tale-1993-poster.jpg","backdrop":"assets/movie-a-bronx-tale-1993-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1607,"ratings":{"rtCritics":97,"rtCriticsLabel":"Fresh","rtAudience":92,"rtAudienceLabel":"Upright","imdb":7.8,"imdbVotes":191930,"tmdb":7.913,"tmdbVotes":3075}},{"id":"movie-by-any-means-2026","title":"By Any Means","year":2026,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Thriller","History","Crime"],"overview":"In 1960s Mississippi, a young Black FBI agent is forced to partner with a notorious mafia hitman to investigate a wave of brutal civil rights murders.","poster":"assets/movie-by-any-means-2026-poster.jpg","backdrop":"assets/movie-by-any-means-2026-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1380417,"ratings":{"rtCritics":49,"rtCriticsLabel":"Rotten","rtAudience":88,"rtAudienceLabel":"Upright","imdb":6.8,"imdbVotes":3534,"tmdb":6.6,"tmdbVotes":92}},{"id":"movie-captain-america-civil-war-2016","title":"Captain America: Civil War","year":2016,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Adventure","Action","Science Fiction"],"overview":"Following the events of Age of Ultron, the collective governments of the world pass an act designed to regulate all superhuman activity. This polarizes opinion amongst the Avengers, causing two factions to side with\u2026","poster":"assets/movie-captain-america-civil-war-2016-poster.jpg","backdrop":"assets/movie-captain-america-civil-war-2016-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":271110,"ratings":{"rtCritics":90,"rtCriticsLabel":"Certified Fresh","rtAudience":89,"rtAudienceLabel":"Upright","imdb":7.8,"imdbVotes":954943,"tmdb":7.455,"tmdbVotes":24808}},{"id":"movie-captain-america-the-first-avenger-2011","title":"Captain America: The First Avenger","year":2011,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"During World War II, Steve Rogers is a sickly man from Brooklyn who's transformed into super-soldier Captain America to aid in the war effort. Rogers must stop the Red Skull \u2013 Adolf Hitler's ruthless head of\u2026","poster":"assets/movie-captain-america-the-first-avenger-2011-poster.jpg","backdrop":"assets/movie-captain-america-the-first-avenger-2011-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1771,"ratings":{"rtCritics":80,"rtCriticsLabel":"Certified Fresh","rtAudience":75,"rtAudienceLabel":"Upright","imdb":6.9,"imdbVotes":993453,"tmdb":7.012,"tmdbVotes":23473}},{"id":"movie-captain-america-the-winter-soldier-2014","title":"Captain America: The Winter Soldier","year":2014,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"After the cataclysmic events in New York with The Avengers, Steve Rogers, aka Captain America is living quietly in Washington, D.C. and trying to adjust to the modern world. But when a S.H.I.E.L.D. colleague comes\u2026","poster":"assets/movie-captain-america-the-winter-soldier-2014-poster.jpg","backdrop":"assets/movie-captain-america-the-winter-soldier-2014-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":100402,"ratings":{"rtCritics":90,"rtCriticsLabel":"Certified Fresh","rtAudience":92,"rtAudienceLabel":"Upright","imdb":7.7,"imdbVotes":995514,"tmdb":7.654,"tmdbVotes":20688}},{"id":"movie-careful-what-you-wish-for-2015","title":"Careful What You Wish For","year":2015,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Thriller"],"overview":"A guy gets more than he bargained for after entering into an affair with the wife of an investment banker. Soon, a suspicious death and substantial life insurance policy embroil him in a scandal.","poster":"assets/movie-careful-what-you-wish-for-2015-poster.jpg","backdrop":"assets/movie-careful-what-you-wish-for-2015-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":317744,"ratings":{"rtCritics":17,"rtCriticsLabel":"Rotten","rtAudience":61,"rtAudienceLabel":"Upright","imdb":5.6,"imdbVotes":9452,"tmdb":6.129,"tmdbVotes":446}},{"id":"tv-12-monkeys-2015","title":"12 Monkeys","year":2015,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Sci-Fi & Fantasy","Drama"],"overview":"The provocative story of Cole, a time traveler from a decimated future in a high-stakes race against the clock. Utilizing a dangerous and untested method of time travel, he journeys from 2043 to the present day on a\u2026","poster":"assets/tv-12-monkeys-2015-poster.jpg","backdrop":"assets/tv-12-monkeys-2015-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":60948,"ratings":{"rtCritics":88,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.441,"tmdbVotes":1245}},{"id":"tv-90-day-fiance-before-the-90-days-2017","title":"90 Day Fianc\u00e9: Before the 90 Days","year":2017,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Reality"],"overview":"Follow couples who have an existing relationship online, but haven't met in person. We'll experience their journey as they travel to the other's foreign country for the first time in an attempt to establish an\u2026","poster":"assets/tv-90-day-fiance-before-the-90-days-2017-poster.jpg","backdrop":"assets/tv-90-day-fiance-before-the-90-days-2017-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":73319,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.5,"tmdbVotes":45}},{"id":"tv-90-day-fiance-the-other-way-2019","title":"90 Day Fianc\u00e9: The Other Way","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Reality"],"overview":"Americans move to a foreign country all in the name of love. Will uprooting their lives in the United States pay off, or will they be packing up and moving back to America brokenhearted?","poster":"assets/tv-90-day-fiance-the-other-way-2019-poster.jpg","backdrop":"assets/tv-90-day-fiance-the-other-way-2019-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":90046,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.8,"tmdbVotes":34}},{"id":"tv-the-100-2014","title":"The 100","year":2014,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Sci-Fi & Fantasy","Action & Adventure","Drama"],"overview":"100 years in the future, when the Earth has been abandoned due to radioactivity, the last surviving humans live on an ark orbiting the planet \u2014 but the ark won't last forever. So the repressive regime picks 100\u2026","poster":"assets/tv-the-100-2014-poster.jpg","backdrop":"assets/tv-the-100-2014-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":48866,"ratings":{"rtCritics":93,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.878,"tmdbVotes":8855}},{"id":"tv-accused-2023","title":"Accused","year":2023,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Crime","Drama"],"overview":"In this crime anthology series, viewers discover how an ordinary person got caught up in an extraordinary situation, ultimately revealing how one wrong turn leads to another, until it\u2019s too late to turn back. Told\u2026","poster":"assets/tv-accused-2023-poster.jpg","backdrop":"assets/tv-accused-2023-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":202097,"ratings":{"rtCritics":61,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.851,"tmdbVotes":87}},{"id":"tv-after-life-2019","title":"After Life","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy","Drama"],"overview":"Tony had a perfect life. But after his wife Lisa suddenly dies, Tony changes. After contemplating taking his own life, he decides instead to live long enough to punish the world by saying and doing whatever he likes\u2026","poster":"assets/tv-after-life-2019-poster.jpg","backdrop":"assets/tv-after-life-2019-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":79410,"ratings":{"rtCritics":72,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.902,"tmdbVotes":1169}},{"id":"tv-all-creatures-great-and-small-1978","title":"All Creatures Great and Small","year":1978,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy","Drama"],"overview":"The trials and misadventures of the staff at a country veterinary office in Yorkshire. James Herriot, a young animal surgeon, moves to a small Yorkshire town to begin his first job.","poster":"assets/tv-all-creatures-great-and-small-1978-poster.jpg","backdrop":"assets/tv-all-creatures-great-and-small-1978-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"center_crop","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":7406,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.8,"tmdbVotes":50}},{"id":"tv-american-housewife-2016","title":"American Housewife","year":2016,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy"],"overview":"A family comedy narrated by Katie, a strong-willed mother, raising her flawed family in a wealthy town filled with perfect wives and their perfect offspring.","poster":"assets/tv-american-housewife-2016-poster.jpg","backdrop":"assets/tv-american-housewife-2016-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":67117,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.7,"tmdbVotes":187}},{"id":"tv-atlantis-2013","title":"Atlantis","year":2013,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action & Adventure","Sci-Fi & Fantasy"],"overview":"A fantasy drama set in a world of legendary heroes and mythical creatures. Far from home and desperate for answers, Jason washes up on the shores of an ancient land. A mysterious place; a world of bull leaping, of\u2026","poster":"assets/tv-atlantis-2013-poster.jpg","backdrop":"assets/tv-atlantis-2013-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":47054,"ratings":{"rtCritics":62,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.4,"tmdbVotes":155}},{"id":"tv-avatar-the-last-airbender-2024","title":"Avatar: The Last Airbender","year":2024,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action & Adventure","Sci-Fi & Fantasy","Drama"],"overview":"A young boy known as the Avatar must master the four elemental powers to save a world at war \u2014 and fight a ruthless enemy bent on stopping him.","poster":"assets/tv-avatar-the-last-airbender-2024-poster.jpg","backdrop":"assets/tv-avatar-the-last-airbender-2024-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":82452,"ratings":{"rtCritics":66,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.8,"tmdbVotes":1406}},{"id":"tv-avatar-seven-havens-2026","title":"Avatar: Seven Havens","year":2026,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"In a world shattered by a devastating cataclysm, a young Earthbender discovers she's the new Avatar after Korra. But in this dangerous era, that title marks her as humanity's destroyer, not its savior. Hunted by both\u2026","poster":"assets/tv-avatar-seven-havens-2026-poster.jpg","backdrop":"assets/tv-avatar-seven-havens-2026-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":284833,"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.9,"tmdbVotes":183}},{"id":"tv-batman-the-brave-and-the-bold-2008","title":"Batman: The Brave and the Bold","year":2008,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"The Caped Crusader is teamed up with Blue Beetle, Green Arrow, Aquaman and countless others in his quest to uphold justice.","poster":"assets/tv-batman-the-brave-and-the-bold-2008-poster.jpg","backdrop":"assets/tv-batman-the-brave-and-the-bold-2008-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":15804,"ratings":{"rtCritics":96,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.6,"tmdbVotes":295}},{"id":"tv-beat-bobby-flay-2013","title":"Beat Bobby Flay","year":2013,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Reality"],"overview":"Two talented chefs go head-to-head for the chance to Beat Bobby Flay. To get to Bobby the chefs must first face off against each other, creating a spectacular dish with a secret ingredient of Bobby's choice. Judges\u2026","poster":"assets/tv-beat-bobby-flay-2013-poster.jpg","backdrop":"assets/tv-beat-bobby-flay-2013-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":62481,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.5,"tmdbVotes":31}},{"id":"tv-benson-1979","title":"Benson","year":1979,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy"],"overview":"A butler deals with life at the governor's mansion.","poster":"assets/tv-benson-1979-poster.jpg","backdrop":"assets/tv-benson-1979-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1849,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7,"tmdbVotes":46}},{"id":"tv-black-rabbit-2025","title":"Black Rabbit","year":2025,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Crime"],"overview":"A rising-star restaurateur is forced into New York's criminal underworld when his chaotic brother returns to town with loan sharks on his trail.","poster":"assets/tv-black-rabbit-2025-poster.jpg","backdrop":"assets/tv-black-rabbit-2025-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":249039,"ratings":{"rtCritics":66,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.983,"tmdbVotes":240}},{"id":"tv-black-ish-2014","title":"black-ish","year":2014,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy"],"overview":"A family man struggles to gain a sense of cultural identity while raising his kids in a predominantly white, upper-middle-class neighborhood.","poster":"assets/tv-black-ish-2014-poster.jpg","backdrop":"assets/tv-black-ish-2014-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":61381,"ratings":{"rtCritics":92,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.1,"tmdbVotes":352}},{"id":"tv-breaking-bad-2008","title":"Breaking Bad","year":2008,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Crime"],"overview":"Walter White, a New Mexico chemistry teacher, is diagnosed with Stage III cancer and given a prognosis of only two years left to live. He becomes filled with a sense of fearlessness and an unrelenting desire to\u2026","poster":"assets/tv-breaking-bad-2008-poster.jpg","backdrop":"assets/tv-breaking-bad-2008-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"center_crop","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1396,"ratings":{"rtCritics":96,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.959,"tmdbVotes":19030}},{"id":"tv-the-capture-2019","title":"The Capture","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Crime","Drama","Mystery"],"overview":"When soldier Shaun Emery's conviction for a murder in Afghanistan is overturned due to flawed video evidence, he returns to life as a free man with his young daughter. But when damning CCTV footage from a night out\u2026","poster":"assets/tv-the-capture-2019-poster.jpg","backdrop":"assets/tv-the-capture-2019-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":93166,"ratings":{"rtCritics":97,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.634,"tmdbVotes":361}}]}],"seeAll":{"trending":[{"id":"tv-american-gods-2017","title":"American Gods","year":2017,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Mystery","Sci-Fi & Fantasy"],"overview":"An ex-con becomes the traveling partner of a conman who turns out to be one of the older gods trying to recruit troops to battle the upstart deities. Based on Neil Gaiman's fantasy novel.","poster":"assets/tv-american-gods-2017-poster.jpg","backdrop":"assets/tv-american-gods-2017-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":46639,"ratings":{"rtCritics":75,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.1,"tmdbVotes":1852}},{"id":"tv-avatar-seven-havens-2026","title":"Avatar: Seven Havens","year":2026,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"In a world shattered by a devastating cataclysm, a young Earthbender discovers she's the new Avatar after Korra. But in this dangerous era, that title marks her as humanity's destroyer, not its savior. Hunted by both\u2026","poster":"assets/tv-avatar-seven-havens-2026-poster.jpg","backdrop":"assets/tv-avatar-seven-havens-2026-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":284833,"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.9,"tmdbVotes":183}},{"id":"tv-batman-the-brave-and-the-bold-2008","title":"Batman: The Brave and the Bold","year":2008,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"The Caped Crusader is teamed up with Blue Beetle, Green Arrow, Aquaman and countless others in his quest to uphold justice.","poster":"assets/tv-batman-the-brave-and-the-bold-2008-poster.jpg","backdrop":"assets/tv-batman-the-brave-and-the-bold-2008-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":15804,"ratings":{"rtCritics":96,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.6,"tmdbVotes":295}},{"id":"tv-batwoman-2019","title":"Batwoman","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Sci-Fi & Fantasy","Action & Adventure"],"overview":"Kate Kane, armed with a passion for social justice and a flair for speaking her mind, soars onto the streets of Gotham as Batwoman, an out lesbian and highly trained street fighter primed to snuff out the failing\u2026","poster":"assets/tv-batwoman-2019-poster.jpg","backdrop":"assets/tv-batwoman-2019-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":89247,"ratings":{"rtCritics":83,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.8,"tmdbVotes":1619}},{"id":"tv-big-mouth-2017","title":"Big Mouth","year":2017,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy","Animation"],"overview":"Teenage friends find their lives upended by the wonders and horrors of puberty in this edgy comedy from real-life pals Nick Kroll and Andrew Goldberg.","poster":"assets/tv-big-mouth-2017-poster.jpg","backdrop":"assets/tv-big-mouth-2017-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":74204,"ratings":{"rtCritics":95,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.591,"tmdbVotes":1274}},{"id":"tv-black-lightning-2018","title":"Black Lightning","year":2018,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action & Adventure","Sci-Fi & Fantasy","Drama"],"overview":"Jefferson Pierce is a man wrestling with a secret. As the father of two daughters and principal of a charter high school that also serves as a safe haven for young people in a New Orleans neighborhood overrun by gang\u2026","poster":"assets/tv-black-lightning-2018-poster.jpg","backdrop":"assets/tv-black-lightning-2018-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":71663,"ratings":{"rtCritics":92,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.907,"tmdbVotes":991}},{"id":"movie-across-the-hall-2009","title":"Across the Hall","year":2009,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Thriller","Mystery"],"overview":"Convinced that his fiancee is cheating on him, a man follows her to a hotel and calls his best friend to help him avoid a calamity.","poster":"assets/movie-across-the-hall-2009-poster.jpg","backdrop":"assets/movie-across-the-hall-2009-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":21469,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":39,"rtAudienceLabel":"Spilled","imdb":5.8,"imdbVotes":5130,"tmdb":6,"tmdbVotes":129}},{"id":"movie-addicted-2014","title":"Addicted","year":2014,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Thriller"],"overview":"A gallerist risks her family and flourishing career when she enters into an affair with a talented painter and slowly loses control of her life.","poster":"assets/movie-addicted-2014-poster.jpg","backdrop":"assets/movie-addicted-2014-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":235271,"ratings":{"rtCritics":7,"rtCriticsLabel":"Rotten","rtAudience":51,"rtAudienceLabel":"Spilled","imdb":5,"imdbVotes":13131,"tmdb":5.928,"tmdbVotes":466}},{"id":"movie-adulterers-2016","title":"Adulterers","year":2016,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Thriller","Drama"],"overview":"On a steamy New Orleans afternoon, Sam Deuprey decides to surprise his beautiful wife, Ashley, for their anniversary and comes home early. But Ashley is not alone in the bedroom. She and her lover, Damien are about\u2026","poster":"assets/movie-adulterers-2016-poster.jpg","backdrop":"assets/movie-adulterers-2016-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":376004,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":37,"rtAudienceLabel":"Spilled","imdb":5.1,"imdbVotes":2144,"tmdb":6.2,"tmdbVotes":116}},{"id":"movie-alone-with-her-2006","title":"Alone With Her","year":2006,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Crime","Drama","Thriller"],"overview":"The harrowing story of a disturbed young man's attempts to win the affections of an unsuspecting young woman. When Doug first sees Amy, he instantly falls for her and begins to watch her every move, going so far as\u2026","poster":"assets/movie-alone-with-her-2006-poster.jpg","backdrop":"assets/movie-alone-with-her-2006-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":18206,"ratings":{"rtCritics":70,"rtCriticsLabel":"Fresh","rtAudience":46,"rtAudienceLabel":"Spilled","imdb":6,"imdbVotes":4359,"tmdb":5.9,"tmdbVotes":94}},{"id":"movie-awake-2007","title":"Awake","year":2007,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Thriller","Crime","Mystery"],"overview":"While undergoing heart surgery, a man experiences a phenomenon called \u2018anesthetic awareness\u2019, which leaves him awake but paralyzed throughout the operation. As various obstacles present themselves, his wife must make\u2026","poster":"assets/movie-awake-2007-poster.jpg","backdrop":"assets/movie-awake-2007-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":13483,"ratings":{"rtCritics":23,"rtCriticsLabel":"Rotten","rtAudience":55,"rtAudienceLabel":"Spilled","imdb":6.5,"imdbVotes":89389,"tmdb":6.586,"tmdbVotes":1649}},{"id":"movie-black-bear-2020","title":"Black Bear","year":2020,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Thriller"],"overview":"At a remote lake house in the Adirondack Mountains, a couple entertains an out-of-town guest looking for inspiration in her filmmaking. The group quickly falls into a calculated game of desire, manipulation, and\u2026","poster":"assets/movie-black-bear-2020-poster.jpg","backdrop":"assets/movie-black-bear-2020-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":624788,"ratings":{"rtCritics":89,"rtCriticsLabel":"Certified Fresh","rtAudience":64,"rtAudienceLabel":"Upright","imdb":6.5,"imdbVotes":18408,"tmdb":6.337,"tmdbVotes":315}},{"id":"movie-butterfly-on-a-wheel-2007","title":"Butterfly on a Wheel","year":2007,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Crime","Drama","Thriller"],"overview":"A sociopathic kidnapper methodically pushes a desperate pair of parents to their absolute breaking point.","poster":"assets/movie-butterfly-on-a-wheel-2007-poster.jpg","backdrop":"assets/movie-butterfly-on-a-wheel-2007-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":13250,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":61,"rtAudienceLabel":"Upright","imdb":6.6,"imdbVotes":35409,"tmdb":6.457,"tmdbVotes":595}},{"id":"movie-careful-what-you-wish-for-2015","title":"Careful What You Wish For","year":2015,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Thriller"],"overview":"A guy gets more than he bargained for after entering into an affair with the wife of an investment banker. Soon, a suspicious death and substantial life insurance policy embroil him in a scandal.","poster":"assets/movie-careful-what-you-wish-for-2015-poster.jpg","backdrop":"assets/movie-careful-what-you-wish-for-2015-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":317744,"ratings":{"rtCritics":17,"rtCriticsLabel":"Rotten","rtAudience":61,"rtAudienceLabel":"Upright","imdb":5.6,"imdbVotes":9452,"tmdb":6.129,"tmdbVotes":446}},{"id":"movie-crime-of-passion-1956","title":"Crime of Passion","year":1956,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Crime","Thriller"],"overview":"Kathy leaves the newspaper business to marry homicide detective Bill, but is frustrated by his lack of ambition and the banality of life in the suburbs. Her drive to advance Bill's career soon takes her down a\u2026","poster":"assets/movie-crime-of-passion-1956-poster.jpg","backdrop":"assets/movie-crime-of-passion-1956-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"center_crop","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1920,"ratings":{"rtCritics":80,"rtCriticsLabel":"Fresh","rtAudience":34,"rtAudienceLabel":"Spilled","imdb":6.4,"imdbVotes":3750,"tmdb":6.1,"tmdbVotes":57}},{"id":"movie-deep-water-2022","title":"Deep Water","year":2022,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Mystery","Thriller"],"overview":"A well-to-do husband who allows his wife to have affairs in order to avoid a divorce becomes a prime suspect in the disappearance of her lovers.","poster":"assets/movie-deep-water-2022-poster.jpg","backdrop":"assets/movie-deep-water-2022-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":619979,"ratings":{"rtCritics":35,"rtCriticsLabel":"Rotten","rtAudience":25,"rtAudienceLabel":"Spilled","imdb":5.5,"imdbVotes":74098,"tmdb":5.834,"tmdbVotes":1755}},{"id":"movie-derailed-2005","title":"Derailed","year":2005,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Thriller"],"overview":"When two married business executives having an affair are blackmailed by a violent criminal, they are forced to turn the tables on him to save their families.","poster":"assets/movie-derailed-2005-poster.jpg","backdrop":"assets/movie-derailed-2005-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":8999,"ratings":{"rtCritics":20,"rtCriticsLabel":"Rotten","rtAudience":58,"rtAudienceLabel":"Spilled","imdb":6.6,"imdbVotes":79563,"tmdb":6.405,"tmdbVotes":1193}},{"id":"movie-die-my-love-2025","title":"Die My Love","year":2025,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama"],"overview":"After inheriting a remote Montana house, Jackson moves there from New York with his partner Grace, and the couple soon welcome a child. As Jackson becomes increasingly absent and rural isolation sets in, Grace\u2026","poster":"assets/movie-die-my-love-2025-poster.jpg","backdrop":"assets/movie-die-my-love-2025-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1033148,"ratings":{"rtCritics":74,"rtCriticsLabel":"Certified Fresh","rtAudience":45,"rtAudienceLabel":"Spilled","imdb":6,"imdbVotes":50664,"tmdb":6.1,"tmdbVotes":683}},{"id":"movie-dream-house-2011","title":"Dream House","year":2011,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Mystery","Drama","Thriller"],"overview":"Publisher Will Atenton quits a lucrative job in New York to relocate his wife, Libby, and their daughters to a quaint town in New England. However, as they settle into their home the Atentons discover that a woman\u2026","poster":"assets/movie-dream-house-2011-poster.jpg","backdrop":"assets/movie-dream-house-2011-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":69668,"ratings":{"rtCritics":7,"rtCriticsLabel":"Rotten","rtAudience":36,"rtAudienceLabel":"Spilled","imdb":6,"imdbVotes":71386,"tmdb":6.212,"tmdbVotes":1480}},{"id":"movie-dream-lover-1994","title":"Dream Lover","year":1994,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Mystery","Thriller"],"overview":"Not long after they cross paths at an art gallery, architect Ray Reardon and hypnotically sensual Lena are married with children. But as strange incidents occur, Ray begins to realize he may not really know the woman\u2026","poster":"assets/movie-dream-lover-1994-poster.jpg","backdrop":"assets/movie-dream-lover-1994-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":18190,"ratings":{"rtCritics":57,"rtCriticsLabel":"Rotten","rtAudience":38,"rtAudienceLabel":"Spilled","imdb":6.2,"imdbVotes":7865,"tmdb":6.844,"tmdbVotes":167}},{"id":"movie-drop-2025","title":"Drop","year":2025,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Mystery","Thriller"],"overview":"Violet, a widowed mother on her first date in years, arrives at an upscale restaurant where she is relieved that her date, Henry, is more charming and handsome than she expected. But their chemistry begins to curdle\u2026","poster":"assets/movie-drop-2025-poster.jpg","backdrop":"assets/movie-drop-2025-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1249213,"ratings":{"rtCritics":83,"rtCriticsLabel":"Certified Fresh","rtAudience":79,"rtAudienceLabel":"Upright","imdb":6.1,"imdbVotes":61018,"tmdb":6.275,"tmdbVotes":1023}},{"id":"movie-eastern-promises-2007","title":"Eastern Promises","year":2007,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Thriller","Crime","Mystery"],"overview":"A Russian teenager living in London dies during childbirth but leaves clues in her diary that could tie her child to a rape involving a violent Russian mob family.","poster":"assets/movie-eastern-promises-2007-poster.jpg","backdrop":"assets/movie-eastern-promises-2007-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":2252,"ratings":{"rtCritics":89,"rtCriticsLabel":"Certified Fresh","rtAudience":83,"rtAudienceLabel":"Upright","imdb":7.6,"imdbVotes":281015,"tmdb":7.349,"tmdbVotes":3900}},{"id":"movie-faces-1968","title":"Faces","year":1968,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama"],"overview":"Middle-aged suburban husband Richard abruptly tells his wife, Maria, that he wants a divorce. As Richard takes up with a younger woman, Maria enjoys a night on the town with her friends and meets a younger man. As\u2026","poster":"assets/movie-faces-1968-poster.jpg","backdrop":"assets/movie-faces-1968-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"center_crop","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":753,"ratings":{"rtCritics":83,"rtCriticsLabel":"Fresh","rtAudience":86,"rtAudienceLabel":"Upright","imdb":7.4,"imdbVotes":12950,"tmdb":7.093,"tmdbVotes":227}},{"id":"movie-fatale-2020","title":"Fatale","year":2020,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Thriller"],"overview":"After a wild one-night stand, successful sports agent Derrick Tyler watches his perfect life slowly disappear when he discovers the sexy and mysterious woman he risked everything for is determined police detective\u2026","poster":"assets/movie-fatale-2020-poster.jpg","backdrop":"assets/movie-fatale-2020-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":549514,"ratings":{"rtCritics":46,"rtCriticsLabel":"Rotten","rtAudience":87,"rtAudienceLabel":"Upright","imdb":5.5,"imdbVotes":16392,"tmdb":6,"tmdbVotes":401}},{"id":"tv-56-days-2026","title":"56 Days","year":2026,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Crime","Mystery"],"overview":"When an unidentified body is found in a luxury apartment linked to Oliver Kennedy and his girlfriend Ciara Wyse, Detectives Lee Reardon and Karl Connolly reconstruct the couple's deadly romance across the past 56 days.","poster":"assets/tv-56-days-2026-poster.jpg","backdrop":"assets/tv-56-days-2026-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":241372,"ratings":{"rtCritics":60,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.388,"tmdbVotes":245}},{"id":"tv-absolutely-fabulous-1992","title":"Absolutely Fabulous","year":1992,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy"],"overview":"Set in the world of fashion and PR, immature fun-loving mother Edina Monsoon and her best friend Patsy drive Eddie's sensible daughter, Saffron, up the wall with their constant drug abuse and outrageous selfishness.\u2026","poster":"assets/tv-absolutely-fabulous-1992-poster.jpg","backdrop":"assets/tv-absolutely-fabulous-1992-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":4493,"ratings":{"rtCritics":96,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.6,"tmdbVotes":198}},{"id":"tv-the-afterparty-2022","title":"The Afterparty","year":2022,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Mystery","Comedy","Crime"],"overview":"Nothing is as it seems in this hilarious murder mystery where each suspect's story is told through a different film genre.","poster":"assets/tv-the-afterparty-2022-poster.jpg","backdrop":"assets/tv-the-afterparty-2022-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":106454,"ratings":{"rtCritics":92,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.071,"tmdbVotes":245}},{"id":"tv-agatha-christie-s-marple-2004","title":"Agatha Christie's Marple","year":2004,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Crime","Mystery"],"overview":"The adventures of Miss Jane Marple, an elderly spinster living in the quiet little village of St Mary Mead. During her many visits to friends and relatives in other villages, Miss Marple often stumbles upon\u2026","poster":"assets/tv-agatha-christie-s-marple-2004-poster.jpg","backdrop":"assets/tv-agatha-christie-s-marple-2004-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":6127,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.9,"tmdbVotes":152}},{"id":"tv-all-american-homecoming-2022","title":"All American: Homecoming","year":2022,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama"],"overview":"Follow the journeys of Simone Hicks, a tennis hopeful from Beverly Hills, and elite baseball player Damon Sims from Chicago, as they navigate life at the prestigious HBCU Bringston University.","poster":"assets/tv-all-american-homecoming-2022-poster.jpg","backdrop":"assets/tv-all-american-homecoming-2022-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":153723,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.3,"tmdbVotes":70}},{"id":"tv-and-just-like-that-2021","title":"And Just Like That\u2026","year":2021,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy","Drama"],"overview":"This new chapter of \u201cSex and the City\u201d follows Carrie, Miranda and Charlotte as they navigate the journey from the complicated reality of life and friendship in their 30s to the even more complicated reality of life\u2026","poster":"assets/tv-and-just-like-that-2021-poster.jpg","backdrop":"assets/tv-and-just-like-that-2021-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":116450,"ratings":{"rtCritics":52,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.211,"tmdbVotes":556}},{"id":"tv-anne-with-an-e-2017","title":"Anne with an E","year":2017,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Family"],"overview":"A coming-of-age story about an outsider who, against all odds and numerous challenges, fights for love and acceptance and for her place in the world. The series centers on a young orphaned girl in the late 1890s,\u2026","poster":"assets/tv-anne-with-an-e-2017-poster.jpg","backdrop":"assets/tv-anne-with-an-e-2017-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":70785,"ratings":{"rtCritics":63,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.7,"tmdbVotes":5019}},{"id":"tv-awkward-2011","title":"Awkward.","year":2011,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy","Drama"],"overview":"An irreverent look at the conflict, chaos and humor that defines teenage life through the eyes of 15-year-old Jenna Hamilton whose life begins to change when a simple accident becomes an epic misunderstanding and is\u2026","poster":"assets/tv-awkward-2011-poster.jpg","backdrop":"assets/tv-awkward-2011-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":39859,"ratings":{"rtCritics":78,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.4,"tmdbVotes":427}},{"id":"tv-the-baby-sitters-club-2020","title":"The Baby-Sitters Club","year":2020,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Kids","Drama"],"overview":"In this contemporary take on the beloved book series, five best friends launch a baby-sitting business that's big on fun and adventure.","poster":"assets/tv-the-baby-sitters-club-2020-poster.jpg","backdrop":"assets/tv-the-baby-sitters-club-2020-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":100412,"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.9,"tmdbVotes":506}},{"id":"tv-ballers-2015","title":"Ballers","year":2015,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Comedy"],"overview":"Looking at the lives of former and current football players, the show follows former superstar Spencer Strasmore as he gets his life on track in retirement while mentoring other current and former players through the\u2026","poster":"assets/tv-ballers-2015-poster.jpg","backdrop":"assets/tv-ballers-2015-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":62704,"ratings":{"rtCritics":72,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.809,"tmdbVotes":820}},{"id":"tv-baretta-1975","title":"Baretta","year":1975,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Crime","Drama","Mystery"],"overview":"Baretta is an American detective television series which ran on ABC from 1975 to 1978. The show was a milder version of a successful 1973\u201374 ABC series, Toma, starring Tony Musante as chameleon-like, real-life New\u2026","poster":"assets/tv-baretta-1975-poster.jpg","backdrop":"assets/tv-baretta-1975-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":3216,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.5,"tmdbVotes":43}},{"id":"tv-based-on-a-true-story-2023","title":"Based on a True Story","year":2023,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy","Drama"],"overview":"The lives of a realtor, a plumber and a former tennis star unexpectedly collide, exposing America\u2019s obsession with true crime, murder and the slow-close toilet seat.","poster":"assets/tv-based-on-a-true-story-2023-poster.jpg","backdrop":"assets/tv-based-on-a-true-story-2023-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":197373,"ratings":{"rtCritics":79,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7,"tmdbVotes":160}},{"id":"tv-the-beast-in-me-2025","title":"The Beast in Me","year":2025,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Mystery"],"overview":"A famous author is pulled into a twisted mind game with her rich, powerful new neighbor \u2014 who might be a murderer.","poster":"assets/tv-the-beast-in-me-2025-poster.jpg","backdrop":"assets/tv-the-beast-in-me-2025-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":250504,"ratings":{"rtCritics":85,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.072,"tmdbVotes":334}},{"id":"tv-big-city-greens-2018","title":"Big City Greens","year":2018,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Family","Animation","Comedy"],"overview":"The offbeat adventures of 10-year-old Cricket Green, a mischievous and optimistic country boy who moves to the big city with his wildly out of place family \u2013 older sister Tilly, father Bill and Gramma Alice.","poster":"assets/tv-big-city-greens-2018-poster.jpg","backdrop":"assets/tv-big-city-greens-2018-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":80587,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.555,"tmdbVotes":82}},{"id":"tv-the-bold-type-2017","title":"The Bold Type","year":2017,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Comedy"],"overview":"A glimpse into the outrageous lives of Jane, Kat and Sutton, who are working at the nation's top women's magazine, Scarlet, while navigating their careers, identities and individual voices.","poster":"assets/tv-the-bold-type-2017-poster.jpg","backdrop":"assets/tv-the-bold-type-2017-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":69630,"ratings":{"rtCritics":89,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.7,"tmdbVotes":204}},{"id":"tv-brassic-2019","title":"Brassic","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy","Drama","Crime"],"overview":"A group of working-class friends finding unconventional ways to win at life in suburban northern England. These lads have dealt, scammed, bribed and conned their way through adolescence, but now, their dealing and\u2026","poster":"assets/tv-brassic-2019-poster.jpg","backdrop":"assets/tv-brassic-2019-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":92621,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.336,"tmdbVotes":263}},{"id":"movie-12-years-a-slave-2013","title":"12 Years a Slave","year":2013,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","History"],"overview":"In the pre-Civil War United States, Solomon Northup, a free black man from upstate New York, is abducted and sold into slavery. Facing cruelty as well as unexpected kindnesses Solomon struggles not only to stay\u2026","poster":"assets/movie-12-years-a-slave-2013-poster.jpg","backdrop":"assets/movie-12-years-a-slave-2013-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":76203,"ratings":{"rtCritics":95,"rtCriticsLabel":"Certified Fresh","rtAudience":90,"rtAudienceLabel":"Upright","imdb":8.1,"imdbVotes":796313,"tmdb":7.927,"tmdbVotes":12335}},{"id":"movie-101-dalmatians-ii-patch-s-london-adventure-2003","title":"101 Dalmatians II: Patch's London Adventure","year":2003,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Family","Animation","Adventure"],"overview":"Being one of 101 takes its toll on Patch, who doesn't feel unique. When he's accidentally left behind on moving day, he meets his idol, Thunderbolt, who enlists him on a publicity campaign.","poster":"assets/movie-101-dalmatians-ii-patch-s-london-adventure-2003-poster.jpg","backdrop":"assets/movie-101-dalmatians-ii-patch-s-london-adventure-2003-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":13654,"ratings":{"rtCritics":67,"rtCriticsLabel":"Fresh","rtAudience":41,"rtAudienceLabel":"Spilled","imdb":5.7,"imdbVotes":12343,"tmdb":6.025,"tmdbVotes":1274}},{"id":"movie-absolution-2024","title":"Absolution","year":2024,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Crime","Thriller"],"overview":"An aging ex-boxer gangster working as muscle for a Boston crime boss receives an upsetting diagnosis. Despite a faltering memory, he attempts to rectify the sins of his past and reconnect with his estranged\u2026","poster":"assets/movie-absolution-2024-poster.jpg","backdrop":"assets/movie-absolution-2024-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":974453,"ratings":{"rtCritics":56,"rtCriticsLabel":"Rotten","rtAudience":32,"rtAudienceLabel":"Spilled","imdb":5.2,"imdbVotes":11413,"tmdb":5.906,"tmdbVotes":564}},{"id":"movie-all-the-money-in-the-world-2017","title":"All the Money in the World","year":2017,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Crime","History","Thriller"],"overview":"The story of the kidnapping of 16-year-old John Paul Getty III and the desperate attempt by his devoted mother to convince his billionaire grandfather Jean Paul Getty to pay the ransom.","poster":"assets/movie-all-the-money-in-the-world-2017-poster.jpg","backdrop":"assets/movie-all-the-money-in-the-world-2017-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":446791,"ratings":{"rtCritics":79,"rtCriticsLabel":"Certified Fresh","rtAudience":66,"rtAudienceLabel":"Upright","imdb":6.8,"imdbVotes":97448,"tmdb":6.493,"tmdbVotes":2493}},{"id":"movie-the-alto-knights-2025","title":"The Alto Knights","year":2025,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Crime","Drama"],"overview":"Two of New York's most notorious organized crime bosses, Frank Costello and Vito Genovese, vie for control of the city's streets. Once the best of friends, petty jealousies and a series of betrayals place them on a\u2026","poster":"assets/movie-the-alto-knights-2025-poster.jpg","backdrop":"assets/movie-the-alto-knights-2025-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1013601,"ratings":{"rtCritics":39,"rtCriticsLabel":"Rotten","rtAudience":71,"rtAudienceLabel":"Upright","imdb":5.9,"imdbVotes":23017,"tmdb":6.223,"tmdbVotes":485}},{"id":"movie-arctic-dogs-2019","title":"Arctic Dogs","year":2019,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Adventure","Comedy"],"overview":"Animals band together to save the day when the evil Otto Von Walrus hatches a sinister scheme to accelerate global warming and melt the Arctic Circle.","poster":"assets/movie-arctic-dogs-2019-poster.jpg","backdrop":"assets/movie-arctic-dogs-2019-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":416153,"ratings":{"rtCritics":13,"rtCriticsLabel":"Rotten","rtAudience":60,"rtAudienceLabel":"Upright","imdb":4.9,"imdbVotes":2580,"tmdb":5.9,"tmdbVotes":148}},{"id":"movie-a-bronx-tale-1993","title":"A Bronx Tale","year":1993,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Crime"],"overview":"Set in the Bronx during the tumultuous 1960s, an adolescent boy is torn between his honest, working-class father and a violent yet charismatic crime boss. Complicating matters is the youngster's growing attraction -\u2026","poster":"assets/movie-a-bronx-tale-1993-poster.jpg","backdrop":"assets/movie-a-bronx-tale-1993-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1607,"ratings":{"rtCritics":97,"rtCriticsLabel":"Fresh","rtAudience":92,"rtAudienceLabel":"Upright","imdb":7.8,"imdbVotes":191930,"tmdb":7.913,"tmdbVotes":3075}},{"id":"movie-by-any-means-2026","title":"By Any Means","year":2026,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Thriller","History","Crime"],"overview":"In 1960s Mississippi, a young Black FBI agent is forced to partner with a notorious mafia hitman to investigate a wave of brutal civil rights murders.","poster":"assets/movie-by-any-means-2026-poster.jpg","backdrop":"assets/movie-by-any-means-2026-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1380417,"ratings":{"rtCritics":49,"rtCriticsLabel":"Rotten","rtAudience":88,"rtAudienceLabel":"Upright","imdb":6.8,"imdbVotes":3534,"tmdb":6.6,"tmdbVotes":92}},{"id":"movie-captain-america-civil-war-2016","title":"Captain America: Civil War","year":2016,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Adventure","Action","Science Fiction"],"overview":"Following the events of Age of Ultron, the collective governments of the world pass an act designed to regulate all superhuman activity. This polarizes opinion amongst the Avengers, causing two factions to side with\u2026","poster":"assets/movie-captain-america-civil-war-2016-poster.jpg","backdrop":"assets/movie-captain-america-civil-war-2016-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":271110,"ratings":{"rtCritics":90,"rtCriticsLabel":"Certified Fresh","rtAudience":89,"rtAudienceLabel":"Upright","imdb":7.8,"imdbVotes":954943,"tmdb":7.455,"tmdbVotes":24808}},{"id":"movie-captain-america-the-first-avenger-2011","title":"Captain America: The First Avenger","year":2011,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"During World War II, Steve Rogers is a sickly man from Brooklyn who's transformed into super-soldier Captain America to aid in the war effort. Rogers must stop the Red Skull \u2013 Adolf Hitler's ruthless head of\u2026","poster":"assets/movie-captain-america-the-first-avenger-2011-poster.jpg","backdrop":"assets/movie-captain-america-the-first-avenger-2011-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1771,"ratings":{"rtCritics":80,"rtCriticsLabel":"Certified Fresh","rtAudience":75,"rtAudienceLabel":"Upright","imdb":6.9,"imdbVotes":993453,"tmdb":7.012,"tmdbVotes":23473}},{"id":"movie-captain-america-the-winter-soldier-2014","title":"Captain America: The Winter Soldier","year":2014,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"After the cataclysmic events in New York with The Avengers, Steve Rogers, aka Captain America is living quietly in Washington, D.C. and trying to adjust to the modern world. But when a S.H.I.E.L.D. colleague comes\u2026","poster":"assets/movie-captain-america-the-winter-soldier-2014-poster.jpg","backdrop":"assets/movie-captain-america-the-winter-soldier-2014-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":100402,"ratings":{"rtCritics":90,"rtCriticsLabel":"Certified Fresh","rtAudience":92,"rtAudienceLabel":"Upright","imdb":7.7,"imdbVotes":995514,"tmdb":7.654,"tmdbVotes":20688}},{"id":"tv-12-monkeys-2015","title":"12 Monkeys","year":2015,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Sci-Fi & Fantasy","Drama"],"overview":"The provocative story of Cole, a time traveler from a decimated future in a high-stakes race against the clock. Utilizing a dangerous and untested method of time travel, he journeys from 2043 to the present day on a\u2026","poster":"assets/tv-12-monkeys-2015-poster.jpg","backdrop":"assets/tv-12-monkeys-2015-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":60948,"ratings":{"rtCritics":88,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.441,"tmdbVotes":1245}},{"id":"tv-accused-2023","title":"Accused","year":2023,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Crime","Drama"],"overview":"In this crime anthology series, viewers discover how an ordinary person got caught up in an extraordinary situation, ultimately revealing how one wrong turn leads to another, until it\u2019s too late to turn back. Told\u2026","poster":"assets/tv-accused-2023-poster.jpg","backdrop":"assets/tv-accused-2023-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":202097,"ratings":{"rtCritics":61,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.851,"tmdbVotes":87}},{"id":"tv-after-life-2019","title":"After Life","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy","Drama"],"overview":"Tony had a perfect life. But after his wife Lisa suddenly dies, Tony changes. After contemplating taking his own life, he decides instead to live long enough to punish the world by saying and doing whatever he likes\u2026","poster":"assets/tv-after-life-2019-poster.jpg","backdrop":"assets/tv-after-life-2019-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":79410,"ratings":{"rtCritics":72,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.902,"tmdbVotes":1169}},{"id":"tv-all-creatures-great-and-small-1978","title":"All Creatures Great and Small","year":1978,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy","Drama"],"overview":"The trials and misadventures of the staff at a country veterinary office in Yorkshire. James Herriot, a young animal surgeon, moves to a small Yorkshire town to begin his first job.","poster":"assets/tv-all-creatures-great-and-small-1978-poster.jpg","backdrop":"assets/tv-all-creatures-great-and-small-1978-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"center_crop","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":7406,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.8,"tmdbVotes":50}},{"id":"tv-american-housewife-2016","title":"American Housewife","year":2016,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy"],"overview":"A family comedy narrated by Katie, a strong-willed mother, raising her flawed family in a wealthy town filled with perfect wives and their perfect offspring.","poster":"assets/tv-american-housewife-2016-poster.jpg","backdrop":"assets/tv-american-housewife-2016-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":67117,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.7,"tmdbVotes":187}},{"id":"tv-atlantis-2013","title":"Atlantis","year":2013,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action & Adventure","Sci-Fi & Fantasy"],"overview":"A fantasy drama set in a world of legendary heroes and mythical creatures. Far from home and desperate for answers, Jason washes up on the shores of an ancient land. A mysterious place; a world of bull leaping, of\u2026","poster":"assets/tv-atlantis-2013-poster.jpg","backdrop":"assets/tv-atlantis-2013-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":47054,"ratings":{"rtCritics":62,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.4,"tmdbVotes":155}},{"id":"tv-beat-bobby-flay-2013","title":"Beat Bobby Flay","year":2013,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Reality"],"overview":"Two talented chefs go head-to-head for the chance to Beat Bobby Flay. To get to Bobby the chefs must first face off against each other, creating a spectacular dish with a secret ingredient of Bobby's choice. Judges\u2026","poster":"assets/tv-beat-bobby-flay-2013-poster.jpg","backdrop":"assets/tv-beat-bobby-flay-2013-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":62481,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.5,"tmdbVotes":31}},{"id":"tv-benson-1979","title":"Benson","year":1979,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy"],"overview":"A butler deals with life at the governor's mansion.","poster":"assets/tv-benson-1979-poster.jpg","backdrop":"assets/tv-benson-1979-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1849,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7,"tmdbVotes":46}},{"id":"tv-black-rabbit-2025","title":"Black Rabbit","year":2025,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Crime"],"overview":"A rising-star restaurateur is forced into New York's criminal underworld when his chaotic brother returns to town with loan sharks on his trail.","poster":"assets/tv-black-rabbit-2025-poster.jpg","backdrop":"assets/tv-black-rabbit-2025-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":249039,"ratings":{"rtCritics":66,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.983,"tmdbVotes":240}},{"id":"tv-black-ish-2014","title":"black-ish","year":2014,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy"],"overview":"A family man struggles to gain a sense of cultural identity while raising his kids in a predominantly white, upper-middle-class neighborhood.","poster":"assets/tv-black-ish-2014-poster.jpg","backdrop":"assets/tv-black-ish-2014-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":61381,"ratings":{"rtCritics":92,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.1,"tmdbVotes":352}},{"id":"tv-breaking-bad-2008","title":"Breaking Bad","year":2008,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Crime"],"overview":"Walter White, a New Mexico chemistry teacher, is diagnosed with Stage III cancer and given a prognosis of only two years left to live. He becomes filled with a sense of fearlessness and an unrelenting desire to\u2026","poster":"assets/tv-breaking-bad-2008-poster.jpg","backdrop":"assets/tv-breaking-bad-2008-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"center_crop","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1396,"ratings":{"rtCritics":96,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.959,"tmdbVotes":19030}},{"id":"tv-the-capture-2019","title":"The Capture","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Crime","Drama","Mystery"],"overview":"When soldier Shaun Emery's conviction for a murder in Afghanistan is overturned due to flawed video evidence, he returns to life as a free man with his young daughter. But when damning CCTV footage from a night out\u2026","poster":"assets/tv-the-capture-2019-poster.jpg","backdrop":"assets/tv-the-capture-2019-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":93166,"ratings":{"rtCritics":97,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.634,"tmdbVotes":361}}],"duplicate":[{"id":"movie-the-amazing-spider-man-2012","title":"The Amazing Spider-Man","year":2012,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"A teenage Peter Parker grapples with both high school and amazing super-human crises as his alter-ego Spider-Man.","poster":"assets/movie-the-amazing-spider-man-2012-poster.jpg","backdrop":"assets/movie-the-amazing-spider-man-2012-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1930,"ratings":{"rtCritics":71,"rtCriticsLabel":"Certified Fresh","rtAudience":77,"rtAudienceLabel":"Upright","imdb":7,"imdbVotes":791938,"tmdb":6.768,"tmdbVotes":19312}},{"id":"movie-the-amazing-spider-man-2-2014","title":"The Amazing Spider-Man 2","year":2014,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"For Peter Parker, life is busy. Between taking out the bad guys as Spider-Man and spending time with the person he loves, Gwen Stacy, high school graduation cannot come quickly enough. Peter has not forgotten about\u2026","poster":"assets/movie-the-amazing-spider-man-2-2014-poster.jpg","backdrop":"assets/movie-the-amazing-spider-man-2-2014-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":102382,"ratings":{"rtCritics":51,"rtCriticsLabel":"Rotten","rtAudience":64,"rtAudienceLabel":"Upright","imdb":6.6,"imdbVotes":611981,"tmdb":6.567,"tmdbVotes":14919}},{"id":"movie-the-animatrix-2003","title":"The Animatrix","year":2003,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Science Fiction"],"overview":"Straight from the creators of the groundbreaking Matrix trilogy, this collection of short animated films from the world's leading anime directors fuses computer graphics and Japanese anime to provide the background\u2026","poster":"assets/movie-the-animatrix-2003-poster.jpg","backdrop":"assets/movie-the-animatrix-2003-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":55931,"ratings":{"rtCritics":86,"rtCriticsLabel":"Fresh","rtAudience":78,"rtAudienceLabel":"Upright","imdb":7.3,"imdbVotes":91929,"tmdb":7.221,"tmdbVotes":1872}},{"id":"movie-ant-man-and-the-wasp-quantumania-2023","title":"Ant-Man and the Wasp: Quantumania","year":2023,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"Super-Hero partners Scott Lang and Hope van Dyne, along with with Hope's parents Janet van Dyne and Hank Pym, and Scott's daughter Cassie Lang, find themselves exploring the Quantum Realm, interacting with strange\u2026","poster":"assets/movie-ant-man-and-the-wasp-quantumania-2023-poster.jpg","backdrop":"assets/movie-ant-man-and-the-wasp-quantumania-2023-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":640146,"ratings":{"rtCritics":46,"rtCriticsLabel":"Rotten","rtAudience":81,"rtAudienceLabel":"Upright","imdb":6,"imdbVotes":281738,"tmdb":6.197,"tmdbVotes":6236}},{"id":"movie-appleseed-alpha-2014","title":"Appleseed Alpha","year":2014,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Science Fiction","Action"],"overview":"The Third World War (2099\u20132126) devastated part of the Earth. Former nations are reforming while new powers emerge, and groups of humans struggle to survive in ruined cities. The story follows Deunan Knute and her\u2026","poster":"assets/movie-appleseed-alpha-2014-poster.jpg","backdrop":"assets/movie-appleseed-alpha-2014-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"center_crop","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":269650,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":59,"rtAudienceLabel":"Spilled","imdb":6.5,"imdbVotes":9689,"tmdb":6.808,"tmdbVotes":436}},{"id":"movie-aquaman-and-the-lost-kingdom-2023","title":"Aquaman and the Lost Kingdom","year":2023,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Fantasy"],"overview":"Black Manta seeks revenge on Aquaman for his father's death. Wielding the Black Trident's power, he becomes a formidable foe. To defend Atlantis, Arthur (Aquaman) forges an alliance with his imprisoned brother. They\u2026","poster":"assets/movie-aquaman-and-the-lost-kingdom-2023-poster.jpg","backdrop":"assets/movie-aquaman-and-the-lost-kingdom-2023-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":572802,"ratings":{"rtCritics":33,"rtCriticsLabel":"Rotten","rtAudience":79,"rtAudienceLabel":"Upright","imdb":5.6,"imdbVotes":122422,"tmdb":6.48,"tmdbVotes":3748}},{"id":"movie-the-avengers-2012","title":"The Avengers","year":2012,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Science Fiction","Action","Adventure"],"overview":"When an unexpected enemy emerges and threatens global safety and security, Nick Fury, director of the international peacekeeping agency known as S.H.I.E.L.D., finds himself in need of a team to pull the world back\u2026","poster":"assets/movie-the-avengers-2012-poster.jpg","backdrop":"assets/movie-the-avengers-2012-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":24428,"ratings":{"rtCritics":91,"rtCriticsLabel":"Certified Fresh","rtAudience":91,"rtAudienceLabel":"Upright","imdb":8,"imdbVotes":1590693,"tmdb":8.084,"tmdbVotes":40262}},{"id":"movie-avengers-confidential-black-widow-punisher-2014","title":"Avengers Confidential: Black Widow & Punisher","year":2014,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Science Fiction","Action"],"overview":"When the Punisher takes out a black-market weapons dealer, he stumbles upon a far-reaching terrorist plot devised by a group known as Leviathan.","poster":"assets/movie-avengers-confidential-black-widow-punisher-2014-poster.jpg","backdrop":"assets/movie-avengers-confidential-black-widow-punisher-2014-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":257346,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":55,"rtAudienceLabel":"Spilled","imdb":5.7,"imdbVotes":5166,"tmdb":6.368,"tmdbVotes":303}},{"id":"movie-avengers-age-of-ultron-2015","title":"Avengers: Age of Ultron","year":2015,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"When Tony Stark tries to jumpstart a dormant peacekeeping program, things go awry and Earth\u2019s Mightiest Heroes are put to the ultimate test as the fate of the planet hangs in the balance. As the villainous Ultron\u2026","poster":"assets/movie-avengers-age-of-ultron-2015-poster.jpg","backdrop":"assets/movie-avengers-age-of-ultron-2015-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":99861,"ratings":{"rtCritics":75,"rtCriticsLabel":"Certified Fresh","rtAudience":82,"rtAudienceLabel":"Upright","imdb":7.3,"imdbVotes":1025876,"tmdb":7.278,"tmdbVotes":25289}},{"id":"movie-avengers-infinity-war-2018","title":"Avengers: Infinity War","year":2018,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Adventure","Action","Science Fiction"],"overview":"As the Avengers and their allies have continued to protect the world from threats too large for any one hero to handle, a new danger has emerged from the cosmic shadows: Thanos. A despot of intergalactic infamy, his\u2026","poster":"assets/movie-avengers-infinity-war-2018-poster.jpg","backdrop":"assets/movie-avengers-infinity-war-2018-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":299536,"ratings":{"rtCritics":85,"rtCriticsLabel":"Certified Fresh","rtAudience":92,"rtAudienceLabel":"Upright","imdb":8.4,"imdbVotes":1421954,"tmdb":8.246,"tmdbVotes":33331}},{"id":"movie-batman-ninja-2018","title":"Batman Ninja","year":2018,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Action","Science Fiction"],"overview":"Batman, along with many of his allies and adversaries, finds himself transported to feudal Japan by Gorilla Grodd's time displacement machine.","poster":"assets/movie-batman-ninja-2018-poster.jpg","backdrop":"assets/movie-batman-ninja-2018-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":485942,"ratings":{"rtCritics":84,"rtCriticsLabel":"Fresh","rtAudience":60,"rtAudienceLabel":"Upright","imdb":5.6,"imdbVotes":24579,"tmdb":5.9,"tmdbVotes":1106}},{"id":"movie-batman-v-superman-dawn-of-justice-2016","title":"Batman v Superman: Dawn of Justice","year":2016,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Fantasy"],"overview":"Fearing the actions of a god-like Super Hero left unchecked, Gotham City\u2019s own formidable, forceful vigilante takes on Metropolis\u2019s most revered, modern-day savior, while the world wrestles with what sort of hero it\u2026","poster":"assets/movie-batman-v-superman-dawn-of-justice-2016-poster.jpg","backdrop":"assets/movie-batman-v-superman-dawn-of-justice-2016-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":209112,"ratings":{"rtCritics":28,"rtCriticsLabel":"Rotten","rtAudience":63,"rtAudienceLabel":"Upright","imdb":6.4,"imdbVotes":801382,"tmdb":6.014,"tmdbVotes":19527}},{"id":"movie-batman-the-dark-knight-returns-part-1-2012","title":"Batman: The Dark Knight Returns, Part 1","year":2012,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Science Fiction","Action","Animation"],"overview":"Batman has not been seen for ten years. A new breed of criminal ravages Gotham City, forcing 55-year-old Bruce Wayne back into the cape and cowl. But, does he still have what it takes to fight crime in a new era?","poster":"assets/movie-batman-the-dark-knight-returns-part-1-2012-poster.jpg","backdrop":"assets/movie-batman-the-dark-knight-returns-part-1-2012-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":123025,"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":93,"rtAudienceLabel":"Upright","imdb":7.9,"imdbVotes":68943,"tmdb":7.742,"tmdbVotes":1766}},{"id":"movie-black-adam-2022","title":"Black Adam","year":2022,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"Nearly 5,000 years after he was bestowed with the almighty powers of the Egyptian gods\u2014and imprisoned just as quickly\u2014Black Adam is freed from his earthly tomb, ready to unleash his unique form of justice on the\u2026","poster":"assets/movie-black-adam-2022-poster.jpg","backdrop":"assets/movie-black-adam-2022-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":436270,"ratings":{"rtCritics":39,"rtCriticsLabel":"Rotten","rtAudience":87,"rtAudienceLabel":"Upright","imdb":6.1,"imdbVotes":309624,"tmdb":6.747,"tmdbVotes":7370}},{"id":"movie-black-panther-2018","title":"Black Panther","year":2018,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"King T'Challa returns home to the reclusive, technologically advanced African nation of Wakanda to serve as his country's new leader. However, T'Challa soon finds that he is challenged for the throne by factions\u2026","poster":"assets/movie-black-panther-2018-poster.jpg","backdrop":"assets/movie-black-panther-2018-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":284054,"ratings":{"rtCritics":96,"rtCriticsLabel":"Certified Fresh","rtAudience":null,"rtAudienceLabel":"Spilled","imdb":7.3,"imdbVotes":931120,"tmdb":7.36,"tmdbVotes":24190}},{"id":"movie-black-panther-wakanda-forever-2022","title":"Black Panther: Wakanda Forever","year":2022,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"Queen Ramonda, Shuri, M\u2019Baku, Okoye and the Dora Milaje fight to protect their nation from intervening world powers in the wake of King T\u2019Challa\u2019s death. As the Wakandans strive to embrace their next chapter, the\u2026","poster":"assets/movie-black-panther-wakanda-forever-2022-poster.jpg","backdrop":"assets/movie-black-panther-wakanda-forever-2022-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":505642,"ratings":{"rtCritics":84,"rtCriticsLabel":"Certified Fresh","rtAudience":93,"rtAudienceLabel":"Upright","imdb":6.6,"imdbVotes":405088,"tmdb":6.984,"tmdbVotes":7920}},{"id":"movie-blame-2017","title":"BLAME!","year":2017,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Drama","Science Fiction"],"overview":"In the distant technological future, civilization has reached its ultimate Net-based form. An \"infection\" in the past caused the automated systems to spiral out of order, resulting in a multi-leveled city structure\u2026","poster":"assets/movie-blame-2017-poster.jpg","backdrop":"assets/movie-blame-2017-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":409421,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":61,"rtAudienceLabel":"Upright","imdb":6.6,"imdbVotes":10258,"tmdb":6.523,"tmdbVotes":370}},{"id":"movie-bleach-the-movie-fade-to-black-2008","title":"Bleach the Movie: Fade to Black","year":2008,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Animation","Fantasy"],"overview":"A strange disturbance in the World of the Living sends Ichigo and Kon to the Soul Society, where the Seireitei has been devastated by an explosion of reishi and Rukia is missing. Stranger still, none of the Soul\u2026","poster":"assets/movie-bleach-the-movie-fade-to-black-2008-poster.jpg","backdrop":"assets/movie-bleach-the-movie-fade-to-black-2008-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":17165,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":67,"rtAudienceLabel":"Upright","imdb":7.1,"imdbVotes":3693,"tmdb":7.1,"tmdbVotes":206}},{"id":"tv-90-day-fiance-before-the-90-days-2017","title":"90 Day Fianc\u00e9: Before the 90 Days","year":2017,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Reality"],"overview":"Follow couples who have an existing relationship online, but haven't met in person. We'll experience their journey as they travel to the other's foreign country for the first time in an attempt to establish an\u2026","poster":"assets/tv-90-day-fiance-before-the-90-days-2017-poster.jpg","backdrop":"assets/tv-90-day-fiance-before-the-90-days-2017-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":73319,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.5,"tmdbVotes":45}},{"id":"tv-90-day-fiance-the-other-way-2019","title":"90 Day Fianc\u00e9: The Other Way","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Reality"],"overview":"Americans move to a foreign country all in the name of love. Will uprooting their lives in the United States pay off, or will they be packing up and moving back to America brokenhearted?","poster":"assets/tv-90-day-fiance-the-other-way-2019-poster.jpg","backdrop":"assets/tv-90-day-fiance-the-other-way-2019-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":90046,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.8,"tmdbVotes":34}},{"id":"tv-the-100-2014","title":"The 100","year":2014,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Sci-Fi & Fantasy","Action & Adventure","Drama"],"overview":"100 years in the future, when the Earth has been abandoned due to radioactivity, the last surviving humans live on an ark orbiting the planet \u2014 but the ark won't last forever. So the repressive regime picks 100\u2026","poster":"assets/tv-the-100-2014-poster.jpg","backdrop":"assets/tv-the-100-2014-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":48866,"ratings":{"rtCritics":93,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.878,"tmdbVotes":8855}},{"id":"tv-the-abbott-and-costello-show-1952","title":"The Abbott and Costello Show","year":1952,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy"],"overview":"Bud and Lou are unemployed actors living in Mr. Fields\u2019 boarding house. Lou\u2019s girlfriend Hillary lives across the hall. Many situations arise leading to slapstick and puns.","poster":"assets/tv-the-abbott-and-costello-show-1952-poster.jpg","backdrop":"assets/tv-the-abbott-and-costello-show-1952-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":11821,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.2,"tmdbVotes":22}},{"id":"tv-accel-world-2012","title":"Accel World","year":2012,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Action & Adventure","Drama"],"overview":"A short, overweight student uses the Neuro Link, an evolution of the Nerve Gear, to escape himself from the reality. But his skills in the virtual world brings him to the attention of Kuroyukihime, a popular student\u2026","poster":"assets/tv-accel-world-2012-poster.jpg","backdrop":"assets/tv-accel-world-2012-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"center_crop","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":43167,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.429,"tmdbVotes":163}},{"id":"tv-ahsoka-2023","title":"Ahsoka","year":2023,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Sci-Fi & Fantasy","Action & Adventure","Drama"],"overview":"Former Jedi Knight Ahsoka Tano investigates an emerging threat to a vulnerable galaxy.","poster":"assets/tv-ahsoka-2023-poster.jpg","backdrop":"assets/tv-ahsoka-2023-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":114461,"ratings":{"rtCritics":85,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.4,"tmdbVotes":1351}},{"id":"tv-american-dad-2005","title":"American Dad!","year":2005,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Comedy"],"overview":"The series focuses on an eccentric motley crew that is the Smith family and their three housemates: Father, husband, and breadwinner Stan Smith; his better half housewife, Francine Smith; their college-aged daughter,\u2026","poster":"assets/tv-american-dad-2005-poster.jpg","backdrop":"assets/tv-american-dad-2005-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1433,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.022,"tmdbVotes":2481}},{"id":"tv-american-gods-2017","title":"American Gods","year":2017,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Mystery","Sci-Fi & Fantasy"],"overview":"An ex-con becomes the traveling partner of a conman who turns out to be one of the older gods trying to recruit troops to battle the upstart deities. Based on Neil Gaiman's fantasy novel.","poster":"assets/tv-american-gods-2017-poster.jpg","backdrop":"assets/tv-american-gods-2017-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":46639,"ratings":{"rtCritics":75,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.1,"tmdbVotes":1852}},{"id":"tv-andromeda-2000","title":"Andromeda","year":2000,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action & Adventure","Drama","Sci-Fi & Fantasy"],"overview":"Captain Dylan Hunt and his crew quest to restore a government that once presided over an extended peace and prosperity.","poster":"assets/tv-andromeda-2000-poster.jpg","backdrop":"assets/tv-andromeda-2000-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":2985,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.068,"tmdbVotes":315}},{"id":"tv-aqua-teen-hunger-force-2000","title":"Aqua Teen Hunger Force","year":2000,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Comedy"],"overview":"The surreal adventures of three anthropomorphic fast food items: Master Shake, Frylock and Meatwad, and their human nextdoor neighbor, Carl Brutananadilewski.","poster":"assets/tv-aqua-teen-hunger-force-2000-poster.jpg","backdrop":"assets/tv-aqua-teen-hunger-force-2000-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":251,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.5,"tmdbVotes":272}},{"id":"tv-arifureta-from-commonplace-to-world-s-strongest-2019","title":"Arifureta: From Commonplace to World's Strongest","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"Hajime Nagumo and his high school class are suddenly summoned to a fantastical land as heroes. But while most of his classmates have powerful stats and abilities, Hajime does not. Underappreciated and unprepared, he\u2026","poster":"assets/tv-arifureta-from-commonplace-to-world-s-strongest-2019-poster.jpg","backdrop":"assets/tv-arifureta-from-commonplace-to-world-s-strongest-2019-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":86034,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.382,"tmdbVotes":662}},{"id":"tv-arrow-2012","title":"Arrow","year":2012,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Crime","Drama","Action & Adventure"],"overview":"Spoiled billionaire playboy Oliver Queen is missing and presumed dead when his yacht is lost at sea. He returns five years later a changed man, determined to clean up the city as a hooded vigilante armed with a bow.","poster":"assets/tv-arrow-2012-poster.jpg","backdrop":"assets/tv-arrow-2012-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1412,"ratings":{"rtCritics":86,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.834,"tmdbVotes":6537}},{"id":"tv-avatar-the-last-airbender-2024","title":"Avatar: The Last Airbender","year":2024,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action & Adventure","Sci-Fi & Fantasy","Drama"],"overview":"A young boy known as the Avatar must master the four elemental powers to save a world at war \u2014 and fight a ruthless enemy bent on stopping him.","poster":"assets/tv-avatar-the-last-airbender-2024-poster.jpg","backdrop":"assets/tv-avatar-the-last-airbender-2024-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":82452,"ratings":{"rtCritics":66,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.8,"tmdbVotes":1406}},{"id":"tv-avatar-seven-havens-2026","title":"Avatar: Seven Havens","year":2026,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"In a world shattered by a devastating cataclysm, a young Earthbender discovers she's the new Avatar after Korra. But in this dangerous era, that title marks her as humanity's destroyer, not its savior. Hunted by both\u2026","poster":"assets/tv-avatar-seven-havens-2026-poster.jpg","backdrop":"assets/tv-avatar-seven-havens-2026-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":284833,"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.9,"tmdbVotes":183}},{"id":"tv-batman-the-brave-and-the-bold-2008","title":"Batman: The Brave and the Bold","year":2008,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"The Caped Crusader is teamed up with Blue Beetle, Green Arrow, Aquaman and countless others in his quest to uphold justice.","poster":"assets/tv-batman-the-brave-and-the-bold-2008-poster.jpg","backdrop":"assets/tv-batman-the-brave-and-the-bold-2008-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":15804,"ratings":{"rtCritics":96,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.6,"tmdbVotes":295}},{"id":"tv-batwoman-2019","title":"Batwoman","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Sci-Fi & Fantasy","Action & Adventure"],"overview":"Kate Kane, armed with a passion for social justice and a flair for speaking her mind, soars onto the streets of Gotham as Batwoman, an out lesbian and highly trained street fighter primed to snuff out the failing\u2026","poster":"assets/tv-batwoman-2019-poster.jpg","backdrop":"assets/tv-batwoman-2019-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":89247,"ratings":{"rtCritics":83,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.8,"tmdbVotes":1619}},{"id":"tv-big-mouth-2017","title":"Big Mouth","year":2017,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy","Animation"],"overview":"Teenage friends find their lives upended by the wonders and horrors of puberty in this edgy comedy from real-life pals Nick Kroll and Andrew Goldberg.","poster":"assets/tv-big-mouth-2017-poster.jpg","backdrop":"assets/tv-big-mouth-2017-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":74204,"ratings":{"rtCritics":95,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.591,"tmdbVotes":1274}},{"id":"tv-black-lightning-2018","title":"Black Lightning","year":2018,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action & Adventure","Sci-Fi & Fantasy","Drama"],"overview":"Jefferson Pierce is a man wrestling with a secret. As the father of two daughters and principal of a charter high school that also serves as a safe haven for young people in a New Orleans neighborhood overrun by gang\u2026","poster":"assets/tv-black-lightning-2018-poster.jpg","backdrop":"assets/tv-black-lightning-2018-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":71663,"ratings":{"rtCritics":92,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.907,"tmdbVotes":991}}]}} diff --git a/app/src/debug/assets/wolfskin-requests.json b/app/src/debug/assets/wolfskin-requests.json new file mode 100644 index 00000000..507ebd65 --- /dev/null +++ b/app/src/debug/assets/wolfskin-requests.json @@ -0,0 +1,217 @@ +{ + "source": "Locked Opus fixture/fixture.json; index.html EXAMPLES() pool order", + "items": [ + { + "id": "x1", + "mediaType": "tv", + "tmdbId": 11821, + "title": "The Abbott and Costello Show", + "requesterKey": "justin", + "requesterName": "Justin", + "actorKey": "justin", + "actorName": "Justin", + "stage": "Downloading", + "poster": "tv-the-abbott-and-costello-show-1952-poster.jpg", + "ratings": [ + { + "mark": "tmdb", + "score": 7.2, + "percentage": false + } + ] + }, + { + "id": "x2", + "mediaType": "movie", + "tmdbId": 13483, + "title": "Awake", + "requesterKey": "mindel", + "requesterName": "Mindel", + "actorKey": "justin", + "actorName": "Justin", + "stage": "AlmostReady", + "poster": "movie-awake-2007-poster.jpg", + "ratings": [ + { + "mark": "rotten", + "score": 23, + "percentage": true + }, + { + "mark": "spilled", + "score": 55, + "percentage": true + }, + { + "mark": "imdb", + "score": 6.5, + "percentage": false + } + ] + }, + { + "id": "x3", + "mediaType": "tv", + "tmdbId": 70785, + "title": "Anne with an E", + "requesterKey": "shared", + "requesterName": "Shared", + "actorKey": "mindel", + "actorName": "Mindel", + "stage": "Searching", + "poster": "tv-anne-with-an-e-2017-poster.jpg", + "ratings": [ + { + "mark": "fresh", + "score": 63, + "percentage": true + } + ] + }, + { + "id": "x4", + "mediaType": "movie", + "tmdbId": 99861, + "title": "Avengers: Age of Ultron", + "requesterKey": "justin", + "requesterName": "Justin", + "actorKey": "justin", + "actorName": "Justin", + "stage": "Waiting", + "poster": "movie-avengers-age-of-ultron-2015-poster.jpg", + "ratings": [ + { + "mark": "fresh", + "score": 75, + "percentage": true + }, + { + "mark": "upright", + "score": 82, + "percentage": true + }, + { + "mark": "imdb", + "score": 7.3, + "percentage": false + } + ] + }, + { + "id": "x5", + "mediaType": "movie", + "tmdbId": 1033148, + "title": "Die My Love", + "requesterKey": "mindel", + "requesterName": "Mindel", + "actorKey": "mindel", + "actorName": "Mindel", + "stage": "Failed", + "poster": "movie-die-my-love-2025-poster.jpg", + "ratings": [ + { + "mark": "fresh", + "score": 74, + "percentage": true + }, + { + "mark": "spilled", + "score": 45, + "percentage": true + }, + { + "mark": "imdb", + "score": 6, + "percentage": false + } + ] + }, + { + "id": "x6", + "mediaType": "movie", + "tmdbId": 416153, + "title": "Arctic Dogs", + "requesterKey": "shared", + "requesterName": "Shared", + "actorKey": "justin", + "actorName": "Justin", + "stage": "Ready", + "poster": "movie-arctic-dogs-2019-poster.jpg", + "ratings": [ + { + "mark": "rotten", + "score": 13, + "percentage": true + }, + { + "mark": "upright", + "score": 60, + "percentage": true + }, + { + "mark": "imdb", + "score": 4.9, + "percentage": false + } + ] + }, + { + "id": "x7", + "mediaType": "movie", + "tmdbId": 76203, + "title": "12 Years a Slave", + "requesterKey": "justin", + "requesterName": "Justin", + "actorKey": "shared", + "actorName": "Shared", + "stage": "Waiting", + "poster": "movie-12-years-a-slave-2013-poster.jpg", + "ratings": [ + { + "mark": "fresh", + "score": 95, + "percentage": true + }, + { + "mark": "upright", + "score": 90, + "percentage": true + }, + { + "mark": "imdb", + "score": 8.1, + "percentage": false + } + ] + }, + { + "id": "x8", + "mediaType": "movie", + "tmdbId": 14069, + "title": "The Girl Who Leapt Through Time", + "requesterKey": "mindel", + "requesterName": "Mindel", + "actorKey": "justin", + "actorName": "Justin", + "stage": "Waiting", + "poster": "movie-the-girl-who-leapt-through-time-2006-poster.jpg", + "ratings": [ + { + "mark": "fresh", + "score": 87, + "percentage": true + }, + { + "mark": "upright", + "score": 90, + "percentage": true + }, + { + "mark": "imdb", + "score": 7.6, + "percentage": false + } + ] + } + ] +} diff --git a/app/src/debug/assets/wolfskin-requests/movie-12-years-a-slave-2013-poster.jpg b/app/src/debug/assets/wolfskin-requests/movie-12-years-a-slave-2013-poster.jpg new file mode 100644 index 00000000..ff1f55e6 Binary files /dev/null and b/app/src/debug/assets/wolfskin-requests/movie-12-years-a-slave-2013-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-requests/movie-arctic-dogs-2019-poster.jpg b/app/src/debug/assets/wolfskin-requests/movie-arctic-dogs-2019-poster.jpg new file mode 100644 index 00000000..07552443 Binary files /dev/null and b/app/src/debug/assets/wolfskin-requests/movie-arctic-dogs-2019-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-requests/movie-avengers-age-of-ultron-2015-poster.jpg b/app/src/debug/assets/wolfskin-requests/movie-avengers-age-of-ultron-2015-poster.jpg new file mode 100644 index 00000000..300840ff Binary files /dev/null and b/app/src/debug/assets/wolfskin-requests/movie-avengers-age-of-ultron-2015-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-requests/movie-awake-2007-poster.jpg b/app/src/debug/assets/wolfskin-requests/movie-awake-2007-poster.jpg new file mode 100644 index 00000000..32595897 Binary files /dev/null and b/app/src/debug/assets/wolfskin-requests/movie-awake-2007-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-requests/movie-die-my-love-2025-poster.jpg b/app/src/debug/assets/wolfskin-requests/movie-die-my-love-2025-poster.jpg new file mode 100644 index 00000000..f6eca818 Binary files /dev/null and b/app/src/debug/assets/wolfskin-requests/movie-die-my-love-2025-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-requests/movie-the-girl-who-leapt-through-time-2006-poster.jpg b/app/src/debug/assets/wolfskin-requests/movie-the-girl-who-leapt-through-time-2006-poster.jpg new file mode 100644 index 00000000..ded6cd2a Binary files /dev/null and b/app/src/debug/assets/wolfskin-requests/movie-the-girl-who-leapt-through-time-2006-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-requests/tv-anne-with-an-e-2017-poster.jpg b/app/src/debug/assets/wolfskin-requests/tv-anne-with-an-e-2017-poster.jpg new file mode 100644 index 00000000..55d443b4 Binary files /dev/null and b/app/src/debug/assets/wolfskin-requests/tv-anne-with-an-e-2017-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-requests/tv-the-abbott-and-costello-show-1952-poster.jpg b/app/src/debug/assets/wolfskin-requests/tv-the-abbott-and-costello-show-1952-poster.jpg new file mode 100644 index 00000000..7d6768ea Binary files /dev/null and b/app/src/debug/assets/wolfskin-requests/tv-the-abbott-and-costello-show-1952-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-shell.json b/app/src/debug/assets/wolfskin-shell.json new file mode 100644 index 00000000..d99323a9 --- /dev/null +++ b/app/src/debug/assets/wolfskin-shell.json @@ -0,0 +1,78 @@ +{ + "source": "Locked Opus fixture/fixture.json; shell-only projection; debug/test only", + "profiles": [ + { + "key": "justin", + "name": "Justin", + "initial": "J", + "color": "#62c4ff", + "destinations": { + "Home": { + "kind": "home" + }, + "Movies": { + "kind": "library", + "collection": "movies" + }, + "Shows": { + "kind": "library", + "collection": "shows" + }, + "Anime": { + "kind": "library", + "collection": "anime" + }, + "Discover": { + "kind": "discover", + "collection": "discover" + } + } + }, + { + "key": "mindel", + "name": "Mindel", + "initial": "M", + "color": "#ff9d72", + "destinations": { + "Home": { + "kind": "home" + }, + "Movies": { + "kind": "library", + "collection": "movies" + }, + "Shows": { + "kind": "library", + "collection": "shows" + }, + "Discover": { + "kind": "discover", + "collection": "discover" + } + } + }, + { + "key": "shared", + "name": "Shared", + "initial": "S", + "color": "#9be38f", + "destinations": { + "Home": { + "kind": "home" + }, + "Movies": { + "kind": "library", + "collection": "movies" + }, + "Shows": { + "kind": "library", + "collection": "shows" + }, + "Discover": { + "kind": "discover", + "collection": "discover" + } + } + } + ] +} diff --git a/app/src/debug/assets/wolfskin-titles.json b/app/src/debug/assets/wolfskin-titles.json new file mode 100644 index 00000000..c7559aaa --- /dev/null +++ b/app/src/debug/assets/wolfskin-titles.json @@ -0,0 +1 @@ +{"profiles":[{"key":"justin","name":"Justin","initial":"J","color":"#62c4ff","destinations":{"Home":{"kind":"home"},"Movies":{"kind":"library","collection":"movies"},"Shows":{"kind":"library","collection":"shows"},"Anime":{"kind":"library","collection":"anime"},"Discover":{"kind":"discover","collection":"discover"}},"movies":[{"id":"movie-the-girl-who-leapt-through-time-2006","title":"The Girl Who Leapt Through Time","year":2006,"type":"movie","runtimeSeconds":5886,"seasonCount":null,"communityRating":7.772,"criticRating":87,"genres":["Fantasy","Animation","Drama"],"overview":"When 17-year-old Makoto Konno gains the ability to 'leap' backwards through time, she immediately sets about improving her grades and preventing personal mishaps. However, she soon realises that changing the past\u2026","poster":"assets/movie-the-girl-who-leapt-through-time-2006-poster.jpg","backdrop":"assets/movie-the-girl-who-leapt-through-time-2006-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T17:39:11.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":14069,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":87,"rtCriticsLabel":"Fresh","rtAudience":90,"rtAudienceLabel":"Upright","imdb":7.6,"imdbVotes":76917,"tmdb":7.775,"tmdbVotes":2345}},{"id":"movie-locke-2014","title":"Locke","year":2014,"type":"movie","runtimeSeconds":5090,"seasonCount":null,"communityRating":6.891,"criticRating":91,"genres":["Drama","Thriller"],"overview":"Ivan Locke has worked hard to craft a good life for himself. Tonight, that life will collapse around him. On the eve of the biggest challenge of his career, Ivan receives a phone call that sets in motion a series of\u2026","poster":"assets/movie-locke-2014-poster.jpg","backdrop":"assets/movie-locke-2014-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T17:38:48.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":210479,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":90,"rtCriticsLabel":"Certified Fresh","rtAudience":72,"rtAudienceLabel":"Upright","imdb":7.1,"imdbVotes":171265,"tmdb":6.889,"tmdbVotes":3088}},{"id":"movie-look-who-s-back-2015","title":"Look Who's Back","year":2015,"type":"movie","runtimeSeconds":6936,"seasonCount":null,"communityRating":6.793,"criticRating":null,"genres":["Comedy"],"overview":"When Adolf Hitler reawakens at the site of his former bunker in present-day Berlin, he is mistaken for a comedian and quickly becomes a media phenomenon.","poster":"assets/movie-look-who-s-back-2015-poster.jpg","backdrop":"assets/movie-look-who-s-back-2015-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:27:48.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":318917,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":81,"rtAudienceLabel":"Upright","imdb":7,"imdbVotes":54672,"tmdb":6.796,"tmdbVotes":1990}},{"id":"movie-memories-1995","title":"Memories","year":1995,"type":"movie","runtimeSeconds":6805,"seasonCount":null,"communityRating":7.465,"criticRating":null,"genres":["Fantasy","Animation","Science Fiction"],"overview":"Three back-to-back anime films by three different directors make up this sci-fi trilogy three years in the making.","poster":"assets/movie-memories-1995-poster.jpg","backdrop":"assets/movie-memories-1995-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-30T21:36:03.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":42994,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":82,"rtAudienceLabel":"Upright","imdb":7.5,"imdbVotes":26638,"tmdb":7.459,"tmdbVotes":622}},{"id":"movie-reincarnated-2012","title":"Reincarnated","year":2012,"type":"movie","runtimeSeconds":5759,"seasonCount":null,"communityRating":6.145,"criticRating":28,"genres":["Music","Documentary"],"overview":"Evolution as an artist is often times what separates legends from the more mundane. After being heavily influenced by his experience in Jamaica \u2013 and his subsequent name change from Snoop Dogg to Snoop Lion \u2013 the LBC\u2026","poster":"assets/movie-reincarnated-2012-poster.jpg","backdrop":"assets/movie-reincarnated-2012-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:27:26.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":123431,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":28,"rtCriticsLabel":"Rotten","rtAudience":41,"rtAudienceLabel":"Spilled","imdb":6.2,"imdbVotes":2563,"tmdb":6.1,"tmdbVotes":69}},{"id":"movie-shipwrecked-nightmare-at-sea-2026","title":"Shipwrecked: Nightmare at Sea","year":2026,"type":"movie","runtimeSeconds":5385,"seasonCount":null,"communityRating":7.181,"criticRating":null,"genres":["Documentary"],"overview":"Never-before-seen footage and survivor accounts trace the 2012 shipwreck of a luxury cruise and the disaster that ensued, in this immersive documentary.","poster":"assets/movie-shipwrecked-nightmare-at-sea-2026-poster.jpg","backdrop":"assets/movie-shipwrecked-nightmare-at-sea-2026-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-20T20:11:43.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":1715492,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":6.5,"imdbVotes":5080,"tmdb":7.172,"tmdbVotes":87}},{"id":"movie-steins-gate-the-movie-load-region-of-deja-vu-2013","title":"Steins;Gate: The Movie - Load Region of D\u00e9j\u00e0 Vu","year":2013,"type":"movie","runtimeSeconds":5403,"seasonCount":null,"communityRating":7.8,"criticRating":null,"genres":["Animation","Mystery","Sci-Fi"],"overview":"After the events of the anime, Rintarou begins to feel the repercussions of extensive time travel, and eventually completely fades from reality. Kurisu, being the only companion to remember him, now must find a way\u2026","poster":"assets/movie-steins-gate-the-movie-load-region-of-deja-vu-2013-poster.jpg","backdrop":"assets/movie-steins-gate-the-movie-load-region-of-deja-vu-2013-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-22T14:55:54.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","artwork":{"posterKind":"poster","backdropKind":"backdrop"}}],"shows":[{"id":"tv-andor-2022","title":"Andor","year":2022,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":8.293,"criticRating":null,"genres":["Sci-Fi & Fantasy","Action & Adventure","Drama"],"overview":"In an era filled with danger, deception and intrigue, Cassian Andor will discover the difference he can make in the struggle against the tyrannical Galactic Empire. He embarks on a path that is destined to turn him\u2026","poster":"assets/tv-andor-2022-poster.jpg","backdrop":"assets/tv-andor-2022-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0432996Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":83867,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":96,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.321,"tmdbVotes":2312}},{"id":"tv-the-boys-2019","title":"The Boys","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":5,"communityRating":8.443,"criticRating":null,"genres":["Sci-Fi & Fantasy","Action & Adventure"],"overview":"A group of vigilantes known informally as \u201cThe Boys\u201d set out to take down corrupt superheroes with no more than blue-collar grit and a willingness to fight dirty.","poster":"assets/tv-the-boys-2019-poster.jpg","backdrop":"assets/tv-the-boys-2019-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0410131Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":76479,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":92,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.4,"tmdbVotes":13628}},{"id":"tv-californication-2007","title":"Californication","year":2007,"type":"tv","runtimeSeconds":1680,"seasonCount":7,"communityRating":7.632,"criticRating":null,"genres":["Comedy","Drama"],"overview":"A self-loathing, alcoholic writer attempts to repair his damaged relationships with his daughter and her mother while combating sex addiction, a budding drug problem, and the seeming inability to avoid making bad\u2026","poster":"assets/tv-californication-2007-poster.jpg","backdrop":"assets/tv-californication-2007-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0428037Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":1215,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":57,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.62,"tmdbVotes":1406}},{"id":"tv-firefly-2002","title":"Firefly","year":2002,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":8.347,"criticRating":null,"genres":["Drama","Action & Adventure","Sci-Fi & Fantasy"],"overview":"In the year 2517, the universe is a vast frontier where lawlessness reigns and adventure awaits! Join Captain Malcolm Reynolds and his ragtag crew aboard the spaceship Serenity as they navigate the fringes of\u2026","poster":"assets/tv-firefly-2002-poster.jpg","backdrop":"assets/tv-firefly-2002-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-20T11:40:08.4202369Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":1437,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":77,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.351,"tmdbVotes":2550}},{"id":"tv-fleabag-2016","title":"Fleabag","year":2016,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":8.278,"criticRating":null,"genres":["Comedy","Drama"],"overview":"A portrait into the mind of a dry-witted, sexual, angry, porn-watching, grief-riddled woman, trying to make sense of the world. As she hurls herself headlong at modern living, Fleabag is thrown roughly up against the\u2026","poster":"assets/tv-fleabag-2016-poster.jpg","backdrop":"assets/tv-fleabag-2016-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T17:42:25.4848214Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":67070,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.3,"tmdbVotes":1993}},{"id":"tv-gravity-falls-2012","title":"Gravity Falls","year":2012,"type":"tv","runtimeSeconds":1320,"seasonCount":2,"communityRating":8.619,"criticRating":null,"genres":["Action & Adventure","Animation","Comedy"],"overview":"Twin brother and sister Dipper and Mabel Pines are in for an unexpected adventure when they spend the summer helping their great uncle Stan run a tourist trap in the mysterious town of Gravity Falls, Oregon.","poster":"assets/tv-gravity-falls-2012-poster.jpg","backdrop":"assets/tv-gravity-falls-2012-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0435827Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":40075,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.624,"tmdbVotes":3647}},{"id":"tv-invincible-2021","title":"INVINCIBLE","year":2021,"type":"tv","runtimeSeconds":null,"seasonCount":4,"communityRating":8.638,"criticRating":null,"genres":["Animation","Drama","Sci-Fi & Fantasy"],"overview":"Mark Grayson is a normal teenager except for the fact that his father is the most powerful superhero on the planet. Shortly after his seventeenth birthday, Mark begins to develop powers of his own and enters into his\u2026","poster":"assets/tv-invincible-2021-poster.jpg","backdrop":"assets/tv-invincible-2021-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.040159Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":95557,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":99,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.6,"tmdbVotes":6155}},{"id":"tv-the-legend-of-korra-2012","title":"The Legend of Korra","year":2012,"type":"tv","runtimeSeconds":1380,"seasonCount":4,"communityRating":8.2,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"Avatar Korra, a headstrong, rebellious, feisty young woman who continually challenges and breaks with tradition, is on her quest to become a fully realized Avatar. In this story, the Avatar struggles to find balance\u2026","poster":"assets/tv-the-legend-of-korra-2012-poster.jpg","backdrop":"assets/tv-the-legend-of-korra-2012-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-20T00:19:23.2494945Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":33880,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":88,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.221,"tmdbVotes":2482}},{"id":"tv-love-death-robots-2019","title":"Love, Death & Robots","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":4,"communityRating":8.205,"criticRating":null,"genres":["Animation","Sci-Fi & Fantasy"],"overview":"Terrifying creatures, wicked surprises and dark comedy converge in this NSFW anthology of animated stories presented by Tim Miller and David Fincher.","poster":"assets/tv-love-death-robots-2019-poster.jpg","backdrop":"assets/tv-love-death-robots-2019-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-20T11:31:20.8818757Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":86831,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":89,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.203,"tmdbVotes":4238}},{"id":"tv-norsemen-2016","title":"Norsemen","year":2016,"type":"tv","runtimeSeconds":1800,"seasonCount":3,"communityRating":7.177,"criticRating":null,"genres":["Comedy","Drama"],"overview":"Norsemen is an epic and humorous drama series set in the Viking Age. The residents of an 8th-century Viking village experience political rivalry, social change and innovations that upend their culture and way of life.","poster":"assets/tv-norsemen-2016-poster.jpg","backdrop":"assets/tv-norsemen-2016-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.034182Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":68126,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.2,"tmdbVotes":233}},{"id":"tv-rick-and-morty-2013","title":"Rick and Morty","year":2013,"type":"tv","runtimeSeconds":null,"seasonCount":9,"communityRating":8.679,"criticRating":null,"genres":["Animation","Comedy","Sci-Fi & Fantasy"],"overview":"Follows a sociopathic genius scientist who drags his inherently timid grandson on adventures across the universe.","poster":"assets/tv-rick-and-morty-2013-poster.jpg","backdrop":"assets/tv-rick-and-morty-2013-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0419581Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":60625,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":91,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.7,"tmdbVotes":11548}},{"id":"tv-secret-level-2024","title":"Secret Level","year":2024,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.411,"criticRating":null,"genres":["Animation","Sci-Fi & Fantasy","Action & Adventure"],"overview":"Adult animated series of original short stories which are set within the worlds of beloved video games. Each episode serves as a gateway to a new adventure, unlocking exciting worlds from beloved gaming classics and\u2026","poster":"assets/tv-secret-level-2024-poster.jpg","backdrop":"assets/tv-secret-level-2024-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0336417Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":261579,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":65,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.392,"tmdbVotes":612}},{"id":"tv-silo-2023","title":"Silo","year":2023,"type":"tv","runtimeSeconds":null,"seasonCount":3,"communityRating":8.178,"criticRating":null,"genres":["Sci-Fi & Fantasy","Drama"],"overview":"In a ruined and toxic future, thousands live in a giant silo deep underground. After its sheriff breaks a cardinal rule and residents die mysteriously, engineer Juliette starts to uncover shocking secrets and the\u2026","poster":"assets/tv-silo-2023-poster.jpg","backdrop":"assets/tv-silo-2023-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0346526Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":125988,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":92,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.232,"tmdbVotes":2881}},{"id":"tv-south-park-1997","title":"South Park","year":1997,"type":"tv","runtimeSeconds":null,"seasonCount":29,"communityRating":8.336,"criticRating":null,"genres":["Animation","Comedy"],"overview":"Follow the misadventures of four irreverent grade-schoolers in the quiet, dysfunctional town of South Park, Colorado.","poster":"assets/tv-south-park-1997-poster.jpg","backdrop":"assets/tv-south-park-1997-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0456124Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":2190,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":80,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.332,"tmdbVotes":5250}}],"anime":[{"id":"tv-bleach-2004","title":"Bleach","year":2004,"type":"tv","runtimeSeconds":1440,"seasonCount":17,"communityRating":8.363,"criticRating":null,"genres":["Action & Adventure","Animation","Sci-Fi & Fantasy"],"overview":"For as long as he can remember, Ichigo Kurosaki has been able to see ghosts. But when he meets Rukia, a Soul Reaper who battles evil spirits known as Hollows, he finds his life is changed forever. Now, with a\u2026","poster":"assets/tv-bleach-2004-poster.jpg","backdrop":"assets/tv-bleach-2004-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0359827Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":30984,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.4,"tmdbVotes":2331}},{"id":"tv-chainsaw-man-2022","title":"Chainsaw Man","year":2022,"type":"tv","runtimeSeconds":1440,"seasonCount":1,"communityRating":8.515,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"Denji has a simple dream\u2014to live a happy and peaceful life, spending time with a girl he likes. This is a far cry from reality, however, as Denji is forced by the yakuza into killing devils in order to pay off his\u2026","poster":"assets/tv-chainsaw-man-2022-poster.jpg","backdrop":"assets/tv-chainsaw-man-2022-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-20T22:28:13.2095501Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":114410,"artwork":{"posterKind":"center_crop","backdropKind":"backdrop"},"ratings":{"rtCritics":97,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.508,"tmdbVotes":2364}},{"id":"tv-cyberpunk-edgerunners-2022","title":"Cyberpunk: Edgerunners","year":2022,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":8.466,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"In a dystopia riddled with corruption and cybernetic implants, a talented but reckless street kid strives to become a mercenary outlaw \u2014 an edgerunner.","poster":"assets/tv-cyberpunk-edgerunners-2022-poster.jpg","backdrop":"assets/tv-cyberpunk-edgerunners-2022-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-20T22:28:13.2093107Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":105248,"artwork":{"posterKind":"center_crop","backdropKind":"backdrop"},"ratings":{"rtCritics":89,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.507,"tmdbVotes":2132}},{"id":"tv-dan-da-dan-2024","title":"Dan Da Dan","year":2024,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":8.538,"criticRating":null,"genres":["Animation","Action & Adventure","Comedy"],"overview":"In a bet to prove whether ghosts or aliens exist, two high schoolers face terrifying paranormal threats, gain superpowers and maybe even fall in love?!","poster":"assets/tv-dan-da-dan-2024-poster.jpg","backdrop":"assets/tv-dan-da-dan-2024-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-22T10:31:16.2652252Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":240411,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.517,"tmdbVotes":986}},{"id":"tv-dr-stone-2019","title":"Dr. STONE","year":2019,"type":"tv","runtimeSeconds":1440,"seasonCount":4,"communityRating":8.541,"criticRating":null,"genres":["Animation","Action & Adventure","Comedy"],"overview":"One fateful day, all of humanity was petrified by a blinding flash of light. After several millennia, high schooler Taiju awakens and finds himself lost in a world of statues. However, he\u2019s not alone! His\u2026","poster":"assets/tv-dr-stone-2019-poster.jpg","backdrop":"assets/tv-dr-stone-2019-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-20T22:28:13.2091579Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":86031,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.53,"tmdbVotes":1814}},{"id":"tv-frieren-beyond-journey-s-end-2023","title":"Frieren: Beyond Journey's End","year":2023,"type":"tv","runtimeSeconds":1500,"seasonCount":2,"communityRating":8.803,"criticRating":null,"genres":["Animation","Action & Adventure","Drama"],"overview":"After the party of heroes defeated the Demon King, they restored peace to the land and returned to lives of solitude. Generations pass, and the elven mage Frieren comes face to face with humanity\u2019s mortality. She\u2026","poster":"assets/tv-frieren-beyond-journey-s-end-2023-poster.jpg","backdrop":"assets/tv-frieren-beyond-journey-s-end-2023-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-20T22:28:13.2094435Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":209867,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.826,"tmdbVotes":1016}},{"id":"tv-fullmetal-alchemist-brotherhood-2009","title":"Fullmetal Alchemist: Brotherhood","year":2009,"type":"tv","runtimeSeconds":1500,"seasonCount":1,"communityRating":8.7,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"Disregard for alchemy\u2019s laws ripped half of Edward Elric\u2019s limbs from his body and left his brother Alphonse\u2019s soul clinging to a suit of armor. To restore what was lost, the brothers seek the Philosopher\u2019s Stone.\u2026","poster":"assets/tv-fullmetal-alchemist-brotherhood-2009-poster.jpg","backdrop":"assets/tv-fullmetal-alchemist-brotherhood-2009-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-20T10:27:12.3573939Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":31911,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.7,"tmdbVotes":2633}},{"id":"tv-jujutsu-kaisen-2020","title":"JUJUTSU KAISEN","year":2020,"type":"tv","runtimeSeconds":1440,"seasonCount":3,"communityRating":8.583,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"Yuji Itadori is a boy with tremendous physical strength, though he lives a completely ordinary high school life. One day, to save a classmate who has been attacked by curses, he eats the finger of Ryomen Sukuna,\u2026","poster":"assets/tv-jujutsu-kaisen-2020-poster.jpg","backdrop":"assets/tv-jujutsu-kaisen-2020-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-20T22:28:13.207573Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":95479,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.574,"tmdbVotes":4735}},{"id":"tv-kaiju-no-8-2024","title":"Kaiju No. 8","year":2024,"type":"tv","runtimeSeconds":1440,"seasonCount":2,"communityRating":8.436,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"In a world plagued by creatures known as Kaiju, Kafka Hibino aspired to enlist in The Defense Force. He makes a promise to enlist with his childhood friend, Mina Ashiro. Soon, life takes them in separate ways. While\u2026","poster":"assets/tv-kaiju-no-8-2024-poster.jpg","backdrop":"assets/tv-kaiju-no-8-2024-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-22T10:31:16.2646566Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":207468,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.4,"tmdbVotes":836}},{"id":"tv-my-hero-academia-2016","title":"My Hero Academia","year":2016,"type":"tv","runtimeSeconds":1440,"seasonCount":8,"communityRating":8.607,"criticRating":null,"genres":["Action & Adventure","Animation","Sci-Fi & Fantasy"],"overview":"After he saves a bully from a Villain, a normal student is granted a superpower that allows him to attend a high school training academy for Heroes.","poster":"assets/tv-my-hero-academia-2016-poster.jpg","backdrop":"assets/tv-my-hero-academia-2016-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-20T22:28:13.2089315Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":65930,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.6,"tmdbVotes":5426}},{"id":"tv-neon-genesis-evangelion-1995","title":"Neon Genesis Evangelion","year":1995,"type":"tv","runtimeSeconds":1500,"seasonCount":1,"communityRating":8.568,"criticRating":null,"genres":["Sci-Fi & Fantasy","Animation","Drama"],"overview":"At the turn of the century, the Angels returned to Earth, seeking to wipe out humanity in an apocalyptic fury. Devastated, mankind's last remnants moved underground to wait for the day when the Angels would come back\u2026","poster":"assets/tv-neon-genesis-evangelion-1995-poster.jpg","backdrop":"assets/tv-neon-genesis-evangelion-1995-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0340629Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":890,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.56,"tmdbVotes":2205}},{"id":"tv-record-of-ragnarok-2021","title":"Record of Ragnarok","year":2021,"type":"tv","runtimeSeconds":null,"seasonCount":3,"communityRating":8.402,"criticRating":null,"genres":["Action & Adventure","Animation","Sci-Fi & Fantasy"],"overview":"Before eradicating humankind from the world, the gods give them one last chance to prove themselves worthy of survival. Let the Ragnarok battles begin.","poster":"assets/tv-record-of-ragnarok-2021-poster.jpg","backdrop":"assets/tv-record-of-ragnarok-2021-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-20T22:28:13.2085766Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":114868,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.403,"tmdbVotes":1929}},{"id":"tv-sakamoto-days-2025","title":"SAKAMOTO DAYS","year":2025,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":8.071,"criticRating":null,"genres":["Animation","Comedy","Action & Adventure"],"overview":"Once the greatest hitman of all, Taro Sakamoto retired in the name of love. But when his past catches up, he must fight to protect his beloved family.","poster":"assets/tv-sakamoto-days-2025-poster.jpg","backdrop":"assets/tv-sakamoto-days-2025-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-22T10:31:16.266066Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":207332,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.057,"tmdbVotes":375}},{"id":"tv-sentenced-to-be-a-hero-2025","title":"Sentenced to Be a Hero","year":2025,"type":"tv","runtimeSeconds":1440,"seasonCount":1,"communityRating":8.338,"criticRating":null,"genres":["Animation","Sci-Fi & Fantasy","Action & Adventure"],"overview":"In a world where heroism is a punishment, Xylo Forbartz, a condemned goddess killer, battles endless hordes of monstrous abominations as part of Penal Hero Unit 9004. Death is no escape, only a cycle of resurrection\u2026","poster":"assets/tv-sentenced-to-be-a-hero-2025-poster.jpg","backdrop":"assets/tv-sentenced-to-be-a-hero-2025-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-22T10:31:16.2666791Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":249907,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.244,"tmdbVotes":129}},{"id":"tv-spy-x-family-2022","title":"SPY x FAMILY","year":2022,"type":"tv","runtimeSeconds":1440,"seasonCount":3,"communityRating":8.506,"criticRating":null,"genres":["Animation","Action & Adventure","Comedy"],"overview":"A spy, an assassin and a telepath come together to pose as a family, each for their own reasons, while hiding their true identities from each other.","poster":"assets/tv-spy-x-family-2022-poster.jpg","backdrop":"assets/tv-spy-x-family-2022-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-22T10:31:16.2670364Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":120089,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.5,"tmdbVotes":2382}},{"id":"tv-the-tatami-galaxy-2010","title":"The Tatami Galaxy","year":2010,"type":"tv","runtimeSeconds":1500,"seasonCount":2,"communityRating":8.271,"criticRating":null,"genres":["Animation","Comedy","Drama"],"overview":"When a college dropout stops for a late night bite at a mysterious ramen stand, he crosses paths with a self-proclaimed deity of matrimony. This bizarre meeting sends the young man hurtling through a horrifying\u2026","poster":"assets/tv-the-tatami-galaxy-2010-poster.jpg","backdrop":"assets/tv-the-tatami-galaxy-2010-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-18T05:52:51.0023256Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":36243,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.3,"tmdbVotes":152}},{"id":"tv-vinland-saga-2019","title":"Vinland Saga","year":2019,"type":"tv","runtimeSeconds":1500,"seasonCount":2,"communityRating":8.527,"criticRating":null,"genres":["Animation","War & Politics","Action & Adventure"],"overview":"For a thousand years, the Vikings have made quite a name and reputation for themselves as the strongest families with a thirst for violence. Thorfinn, the son of one of the Vikings' greatest warriors, spends his\u2026","poster":"assets/tv-vinland-saga-2019-poster.jpg","backdrop":"assets/tv-vinland-saga-2019-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-20T22:28:13.207389Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":88803,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.509,"tmdbVotes":1064}},{"id":"tv-yu-yu-hakusho-1992","title":"Yu Yu Hakusho","year":1992,"type":"tv","runtimeSeconds":1440,"seasonCount":4,"communityRating":8.5,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"After dying to save a boy, delinquent tough guy Yusuke Urameshi is granted another chance at life by redeeming himself as a \"Spirit Detective.\"","poster":"assets/tv-yu-yu-hakusho-1992-poster.jpg","backdrop":"assets/tv-yu-yu-hakusho-1992-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-19T21:46:01.5733049Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":30669,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.475,"tmdbVotes":519}}],"resume":[],"nextUp":[{"id":"episode-three-monsters-1992-s1e6","title":"Three Monsters","year":1992,"type":"episode","runtimeSeconds":1421,"seasonCount":null,"communityRating":7.4,"criticRating":null,"genres":["Animation","Action","Adventure"],"overview":"Yusuke receives his first real case as Spirit Detective. Three demon thieves have muscled their way into King Yama's sacred vault, stealing the three Artifacts of Darkness. Yusuke must recover the artifacts and bring\u2026","poster":"assets/episode-three-monsters-1992-s1e6-poster.jpg","backdrop":"assets/episode-three-monsters-1992-s1e6-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-19T21:59:21.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","seriesTitle":"Yu Yu Hakusho","seasonNumber":1,"episodeNumber":6,"artwork":{"posterKind":"poster","backdropKind":"backdrop"}},{"id":"episode-all-hands-on-deck-class-1-a-2021-s5e1","title":"All Hands on Deck! Class 1-A","year":2021,"type":"episode","runtimeSeconds":1433,"seasonCount":null,"communityRating":6.938,"criticRating":null,"genres":["Animation","Action","Adventure"],"overview":"There's an emergency drill at U.A. High School, and Class 1-A is requested to report for duty!","poster":"assets/episode-all-hands-on-deck-class-1-a-2021-s5e1-poster.jpg","backdrop":"assets/episode-all-hands-on-deck-class-1-a-2021-s5e1-backdrop.jpg","played":false,"playCount":2,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-22T04:04:50.0000000Z","lastPlayedAt":"2026-10-19T17:47:09.3028025Z","availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","seriesTitle":"My Hero Academia","seasonNumber":5,"episodeNumber":1,"artwork":{"posterKind":"poster","backdropKind":"backdrop"}},{"id":"episode-erickerhead-2026-s9e6","title":"Erickerhead","year":2026,"type":"episode","runtimeSeconds":1357,"seasonCount":null,"communityRating":8.196,"criticRating":null,"genres":[],"overview":"Rick's stomach is mad, broh. But it's all-you-can-eat-and-drink here, broh!","poster":"assets/episode-erickerhead-2026-s9e6-poster.jpg","backdrop":"assets/episode-erickerhead-2026-s9e6-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-28T10:23:23.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","seriesTitle":"Rick and Morty","seasonNumber":9,"episodeNumber":6,"artwork":{"posterKind":"poster","backdropKind":"backdrop"}},{"id":"episode-girls-interrupted-2007-s1e7","title":"Girls, Interrupted","year":2007,"type":"episode","runtimeSeconds":1642,"seasonCount":null,"communityRating":7.4,"criticRating":null,"genres":["Comedy","Drama"],"overview":"Hank is disturbed by Becca's negative reaction towards the concept of happy endings, even more so when he finds out that it is partially his fault.\nMarcy comes up with the idea of a threesome with Charlie and Dani,\u2026","poster":"assets/episode-girls-interrupted-2007-s1e7-poster.jpg","backdrop":"assets/episode-girls-interrupted-2007-s1e7-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-05T02:46:18.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","seriesTitle":"Californication","seasonNumber":1,"episodeNumber":7,"artwork":{"posterKind":"poster","backdropKind":"backdrop"}},{"id":"episode-announcement-2022-s1e7","title":"Announcement","year":2022,"type":"episode","runtimeSeconds":3012,"seasonCount":null,"communityRating":7.8,"criticRating":null,"genres":["Action","Adventure","Drama"],"overview":"The repercussions of the Aldhani heist reach all the way to Coruscant, while Cassian returns home.","poster":"assets/episode-announcement-2022-s1e7-poster.jpg","backdrop":"assets/episode-announcement-2022-s1e7-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T12:56:24.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","seriesTitle":"Andor","seasonNumber":1,"episodeNumber":7,"artwork":{"posterKind":"poster","backdropKind":"backdrop"}},{"id":"episode-south-american-biker-gangs-2026-s29e1","title":"South American Biker Gangs","year":2026,"type":"episode","runtimeSeconds":1335,"seasonCount":null,"communityRating":6.4,"criticRating":null,"genres":["Animation","Comedy"],"overview":"When e-bikes arrive in South America, the boys take over the streets and the parents of South America can hardly recognize South America.","poster":"assets/episode-south-american-biker-gangs-2026-s29e1-poster.jpg","backdrop":"assets/episode-south-american-biker-gangs-2026-s29e1-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-01T06:40:46.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","seriesTitle":"South Park","seasonNumber":29,"episodeNumber":1,"artwork":{"posterKind":"poster","backdropKind":"backdrop"}}],"recentlyAdded":[{"id":"movie-steins-gate-the-movie-load-region-of-deja-vu-2013","title":"Steins;Gate: The Movie - Load Region of D\u00e9j\u00e0 Vu","year":2013,"type":"movie","runtimeSeconds":5403,"seasonCount":null,"communityRating":7.8,"criticRating":null,"genres":["Animation","Mystery","Sci-Fi"],"overview":"After the events of the anime, Rintarou begins to feel the repercussions of extensive time travel, and eventually completely fades from reality. Kurisu, being the only companion to remember him, now must find a way\u2026","poster":"assets/movie-steins-gate-the-movie-load-region-of-deja-vu-2013-poster.jpg","backdrop":"assets/movie-steins-gate-the-movie-load-region-of-deja-vu-2013-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-22T14:55:54.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","artwork":{"posterKind":"poster","backdropKind":"backdrop"}},{"id":"tv-yu-yu-hakusho-1992","title":"Yu Yu Hakusho","year":1992,"type":"tv","runtimeSeconds":1440,"seasonCount":4,"communityRating":8.5,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"After dying to save a boy, delinquent tough guy Yusuke Urameshi is granted another chance at life by redeeming himself as a \"Spirit Detective.\"","poster":"assets/tv-yu-yu-hakusho-1992-poster.jpg","backdrop":"assets/tv-yu-yu-hakusho-1992-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-19T21:46:01.5733049Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":30669,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.475,"tmdbVotes":519}},{"id":"movie-memories-1995","title":"Memories","year":1995,"type":"movie","runtimeSeconds":6805,"seasonCount":null,"communityRating":7.465,"criticRating":null,"genres":["Fantasy","Animation","Science Fiction"],"overview":"Three back-to-back anime films by three different directors make up this sci-fi trilogy three years in the making.","poster":"assets/movie-memories-1995-poster.jpg","backdrop":"assets/movie-memories-1995-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-30T21:36:03.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":42994,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":82,"rtAudienceLabel":"Upright","imdb":7.5,"imdbVotes":26638,"tmdb":7.459,"tmdbVotes":622}},{"id":"tv-firefly-2002","title":"Firefly","year":2002,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":8.347,"criticRating":null,"genres":["Drama","Action & Adventure","Sci-Fi & Fantasy"],"overview":"In the year 2517, the universe is a vast frontier where lawlessness reigns and adventure awaits! Join Captain Malcolm Reynolds and his ragtag crew aboard the spaceship Serenity as they navigate the fringes of\u2026","poster":"assets/tv-firefly-2002-poster.jpg","backdrop":"assets/tv-firefly-2002-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-20T11:40:08.4202369Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":1437,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":77,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.351,"tmdbVotes":2550}},{"id":"tv-love-death-robots-2019","title":"Love, Death & Robots","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":4,"communityRating":8.205,"criticRating":null,"genres":["Animation","Sci-Fi & Fantasy"],"overview":"Terrifying creatures, wicked surprises and dark comedy converge in this NSFW anthology of animated stories presented by Tim Miller and David Fincher.","poster":"assets/tv-love-death-robots-2019-poster.jpg","backdrop":"assets/tv-love-death-robots-2019-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-20T11:31:20.8818757Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":86831,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":89,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.203,"tmdbVotes":4238}},{"id":"tv-the-legend-of-korra-2012","title":"The Legend of Korra","year":2012,"type":"tv","runtimeSeconds":1380,"seasonCount":4,"communityRating":8.2,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"Avatar Korra, a headstrong, rebellious, feisty young woman who continually challenges and breaks with tradition, is on her quest to become a fully realized Avatar. In this story, the Avatar struggles to find balance\u2026","poster":"assets/tv-the-legend-of-korra-2012-poster.jpg","backdrop":"assets/tv-the-legend-of-korra-2012-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-20T00:19:23.2494945Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":33880,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":88,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.221,"tmdbVotes":2482}},{"id":"tv-spy-x-family-2022","title":"SPY x FAMILY","year":2022,"type":"tv","runtimeSeconds":1440,"seasonCount":3,"communityRating":8.506,"criticRating":null,"genres":["Animation","Action & Adventure","Comedy"],"overview":"A spy, an assassin and a telepath come together to pose as a family, each for their own reasons, while hiding their true identities from each other.","poster":"assets/tv-spy-x-family-2022-poster.jpg","backdrop":"assets/tv-spy-x-family-2022-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-22T10:31:16.2670364Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":120089,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.5,"tmdbVotes":2382}},{"id":"tv-sentenced-to-be-a-hero-2025","title":"Sentenced to Be a Hero","year":2025,"type":"tv","runtimeSeconds":1440,"seasonCount":1,"communityRating":8.338,"criticRating":null,"genres":["Animation","Sci-Fi & Fantasy","Action & Adventure"],"overview":"In a world where heroism is a punishment, Xylo Forbartz, a condemned goddess killer, battles endless hordes of monstrous abominations as part of Penal Hero Unit 9004. Death is no escape, only a cycle of resurrection\u2026","poster":"assets/tv-sentenced-to-be-a-hero-2025-poster.jpg","backdrop":"assets/tv-sentenced-to-be-a-hero-2025-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-22T10:31:16.2666791Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":249907,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.244,"tmdbVotes":129}},{"id":"tv-sakamoto-days-2025","title":"SAKAMOTO DAYS","year":2025,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":8.071,"criticRating":null,"genres":["Animation","Comedy","Action & Adventure"],"overview":"Once the greatest hitman of all, Taro Sakamoto retired in the name of love. But when his past catches up, he must fight to protect his beloved family.","poster":"assets/tv-sakamoto-days-2025-poster.jpg","backdrop":"assets/tv-sakamoto-days-2025-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-22T10:31:16.266066Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":207332,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.057,"tmdbVotes":375}},{"id":"tv-dan-da-dan-2024","title":"Dan Da Dan","year":2024,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":8.538,"criticRating":null,"genres":["Animation","Action & Adventure","Comedy"],"overview":"In a bet to prove whether ghosts or aliens exist, two high schoolers face terrifying paranormal threats, gain superpowers and maybe even fall in love?!","poster":"assets/tv-dan-da-dan-2024-poster.jpg","backdrop":"assets/tv-dan-da-dan-2024-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-22T10:31:16.2652252Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":240411,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.517,"tmdbVotes":986}},{"id":"tv-kaiju-no-8-2024","title":"Kaiju No. 8","year":2024,"type":"tv","runtimeSeconds":1440,"seasonCount":2,"communityRating":8.436,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"In a world plagued by creatures known as Kaiju, Kafka Hibino aspired to enlist in The Defense Force. He makes a promise to enlist with his childhood friend, Mina Ashiro. Soon, life takes them in separate ways. While\u2026","poster":"assets/tv-kaiju-no-8-2024-poster.jpg","backdrop":"assets/tv-kaiju-no-8-2024-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-22T10:31:16.2646566Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":207468,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.4,"tmdbVotes":836}},{"id":"tv-chainsaw-man-2022","title":"Chainsaw Man","year":2022,"type":"tv","runtimeSeconds":1440,"seasonCount":1,"communityRating":8.515,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"Denji has a simple dream\u2014to live a happy and peaceful life, spending time with a girl he likes. This is a far cry from reality, however, as Denji is forced by the yakuza into killing devils in order to pay off his\u2026","poster":"assets/tv-chainsaw-man-2022-poster.jpg","backdrop":"assets/tv-chainsaw-man-2022-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-20T22:28:13.2095501Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":114410,"artwork":{"posterKind":"center_crop","backdropKind":"backdrop"},"ratings":{"rtCritics":97,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.508,"tmdbVotes":2364}}],"discover":[{"id":"movie-the-amazing-spider-man-2012","title":"The Amazing Spider-Man","year":2012,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"A teenage Peter Parker grapples with both high school and amazing super-human crises as his alter-ego Spider-Man.","poster":"assets/movie-the-amazing-spider-man-2012-poster.jpg","backdrop":"assets/movie-the-amazing-spider-man-2012-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1930,"ratings":{"rtCritics":71,"rtCriticsLabel":"Certified Fresh","rtAudience":77,"rtAudienceLabel":"Upright","imdb":7,"imdbVotes":791938,"tmdb":6.768,"tmdbVotes":19312}},{"id":"movie-the-amazing-spider-man-2-2014","title":"The Amazing Spider-Man 2","year":2014,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"For Peter Parker, life is busy. Between taking out the bad guys as Spider-Man and spending time with the person he loves, Gwen Stacy, high school graduation cannot come quickly enough. Peter has not forgotten about\u2026","poster":"assets/movie-the-amazing-spider-man-2-2014-poster.jpg","backdrop":"assets/movie-the-amazing-spider-man-2-2014-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":102382,"ratings":{"rtCritics":51,"rtCriticsLabel":"Rotten","rtAudience":64,"rtAudienceLabel":"Upright","imdb":6.6,"imdbVotes":611981,"tmdb":6.567,"tmdbVotes":14919}},{"id":"movie-the-animatrix-2003","title":"The Animatrix","year":2003,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Science Fiction"],"overview":"Straight from the creators of the groundbreaking Matrix trilogy, this collection of short animated films from the world's leading anime directors fuses computer graphics and Japanese anime to provide the background\u2026","poster":"assets/movie-the-animatrix-2003-poster.jpg","backdrop":"assets/movie-the-animatrix-2003-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":55931,"ratings":{"rtCritics":86,"rtCriticsLabel":"Fresh","rtAudience":78,"rtAudienceLabel":"Upright","imdb":7.3,"imdbVotes":91929,"tmdb":7.221,"tmdbVotes":1872}},{"id":"movie-ant-man-and-the-wasp-quantumania-2023","title":"Ant-Man and the Wasp: Quantumania","year":2023,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"Super-Hero partners Scott Lang and Hope van Dyne, along with with Hope's parents Janet van Dyne and Hank Pym, and Scott's daughter Cassie Lang, find themselves exploring the Quantum Realm, interacting with strange\u2026","poster":"assets/movie-ant-man-and-the-wasp-quantumania-2023-poster.jpg","backdrop":"assets/movie-ant-man-and-the-wasp-quantumania-2023-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":640146,"ratings":{"rtCritics":46,"rtCriticsLabel":"Rotten","rtAudience":81,"rtAudienceLabel":"Upright","imdb":6,"imdbVotes":281738,"tmdb":6.197,"tmdbVotes":6236}},{"id":"movie-appleseed-alpha-2014","title":"Appleseed Alpha","year":2014,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Science Fiction","Action"],"overview":"The Third World War (2099\u20132126) devastated part of the Earth. Former nations are reforming while new powers emerge, and groups of humans struggle to survive in ruined cities. The story follows Deunan Knute and her\u2026","poster":"assets/movie-appleseed-alpha-2014-poster.jpg","backdrop":"assets/movie-appleseed-alpha-2014-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"center_crop","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":269650,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":59,"rtAudienceLabel":"Spilled","imdb":6.5,"imdbVotes":9689,"tmdb":6.808,"tmdbVotes":436}},{"id":"movie-aquaman-and-the-lost-kingdom-2023","title":"Aquaman and the Lost Kingdom","year":2023,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Fantasy"],"overview":"Black Manta seeks revenge on Aquaman for his father's death. Wielding the Black Trident's power, he becomes a formidable foe. To defend Atlantis, Arthur (Aquaman) forges an alliance with his imprisoned brother. They\u2026","poster":"assets/movie-aquaman-and-the-lost-kingdom-2023-poster.jpg","backdrop":"assets/movie-aquaman-and-the-lost-kingdom-2023-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":572802,"ratings":{"rtCritics":33,"rtCriticsLabel":"Rotten","rtAudience":79,"rtAudienceLabel":"Upright","imdb":5.6,"imdbVotes":122422,"tmdb":6.48,"tmdbVotes":3748}},{"id":"movie-the-avengers-2012","title":"The Avengers","year":2012,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Science Fiction","Action","Adventure"],"overview":"When an unexpected enemy emerges and threatens global safety and security, Nick Fury, director of the international peacekeeping agency known as S.H.I.E.L.D., finds himself in need of a team to pull the world back\u2026","poster":"assets/movie-the-avengers-2012-poster.jpg","backdrop":"assets/movie-the-avengers-2012-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":24428,"ratings":{"rtCritics":91,"rtCriticsLabel":"Certified Fresh","rtAudience":91,"rtAudienceLabel":"Upright","imdb":8,"imdbVotes":1590693,"tmdb":8.084,"tmdbVotes":40262}},{"id":"movie-avengers-confidential-black-widow-punisher-2014","title":"Avengers Confidential: Black Widow & Punisher","year":2014,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Science Fiction","Action"],"overview":"When the Punisher takes out a black-market weapons dealer, he stumbles upon a far-reaching terrorist plot devised by a group known as Leviathan.","poster":"assets/movie-avengers-confidential-black-widow-punisher-2014-poster.jpg","backdrop":"assets/movie-avengers-confidential-black-widow-punisher-2014-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":257346,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":55,"rtAudienceLabel":"Spilled","imdb":5.7,"imdbVotes":5166,"tmdb":6.368,"tmdbVotes":303}},{"id":"movie-avengers-age-of-ultron-2015","title":"Avengers: Age of Ultron","year":2015,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"When Tony Stark tries to jumpstart a dormant peacekeeping program, things go awry and Earth\u2019s Mightiest Heroes are put to the ultimate test as the fate of the planet hangs in the balance. As the villainous Ultron\u2026","poster":"assets/movie-avengers-age-of-ultron-2015-poster.jpg","backdrop":"assets/movie-avengers-age-of-ultron-2015-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":99861,"ratings":{"rtCritics":75,"rtCriticsLabel":"Certified Fresh","rtAudience":82,"rtAudienceLabel":"Upright","imdb":7.3,"imdbVotes":1025876,"tmdb":7.278,"tmdbVotes":25289}},{"id":"movie-avengers-infinity-war-2018","title":"Avengers: Infinity War","year":2018,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Adventure","Action","Science Fiction"],"overview":"As the Avengers and their allies have continued to protect the world from threats too large for any one hero to handle, a new danger has emerged from the cosmic shadows: Thanos. A despot of intergalactic infamy, his\u2026","poster":"assets/movie-avengers-infinity-war-2018-poster.jpg","backdrop":"assets/movie-avengers-infinity-war-2018-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":299536,"ratings":{"rtCritics":85,"rtCriticsLabel":"Certified Fresh","rtAudience":92,"rtAudienceLabel":"Upright","imdb":8.4,"imdbVotes":1421954,"tmdb":8.246,"tmdbVotes":33331}},{"id":"movie-batman-ninja-2018","title":"Batman Ninja","year":2018,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Action","Science Fiction"],"overview":"Batman, along with many of his allies and adversaries, finds himself transported to feudal Japan by Gorilla Grodd's time displacement machine.","poster":"assets/movie-batman-ninja-2018-poster.jpg","backdrop":"assets/movie-batman-ninja-2018-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":485942,"ratings":{"rtCritics":84,"rtCriticsLabel":"Fresh","rtAudience":60,"rtAudienceLabel":"Upright","imdb":5.6,"imdbVotes":24579,"tmdb":5.9,"tmdbVotes":1106}},{"id":"movie-batman-v-superman-dawn-of-justice-2016","title":"Batman v Superman: Dawn of Justice","year":2016,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Fantasy"],"overview":"Fearing the actions of a god-like Super Hero left unchecked, Gotham City\u2019s own formidable, forceful vigilante takes on Metropolis\u2019s most revered, modern-day savior, while the world wrestles with what sort of hero it\u2026","poster":"assets/movie-batman-v-superman-dawn-of-justice-2016-poster.jpg","backdrop":"assets/movie-batman-v-superman-dawn-of-justice-2016-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":209112,"ratings":{"rtCritics":28,"rtCriticsLabel":"Rotten","rtAudience":63,"rtAudienceLabel":"Upright","imdb":6.4,"imdbVotes":801382,"tmdb":6.014,"tmdbVotes":19527}},{"id":"movie-batman-the-dark-knight-returns-part-1-2012","title":"Batman: The Dark Knight Returns, Part 1","year":2012,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Science Fiction","Action","Animation"],"overview":"Batman has not been seen for ten years. A new breed of criminal ravages Gotham City, forcing 55-year-old Bruce Wayne back into the cape and cowl. But, does he still have what it takes to fight crime in a new era?","poster":"assets/movie-batman-the-dark-knight-returns-part-1-2012-poster.jpg","backdrop":"assets/movie-batman-the-dark-knight-returns-part-1-2012-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":123025,"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":93,"rtAudienceLabel":"Upright","imdb":7.9,"imdbVotes":68943,"tmdb":7.742,"tmdbVotes":1766}},{"id":"movie-black-adam-2022","title":"Black Adam","year":2022,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"Nearly 5,000 years after he was bestowed with the almighty powers of the Egyptian gods\u2014and imprisoned just as quickly\u2014Black Adam is freed from his earthly tomb, ready to unleash his unique form of justice on the\u2026","poster":"assets/movie-black-adam-2022-poster.jpg","backdrop":"assets/movie-black-adam-2022-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":436270,"ratings":{"rtCritics":39,"rtCriticsLabel":"Rotten","rtAudience":87,"rtAudienceLabel":"Upright","imdb":6.1,"imdbVotes":309624,"tmdb":6.747,"tmdbVotes":7370}},{"id":"movie-black-panther-2018","title":"Black Panther","year":2018,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"King T'Challa returns home to the reclusive, technologically advanced African nation of Wakanda to serve as his country's new leader. However, T'Challa soon finds that he is challenged for the throne by factions\u2026","poster":"assets/movie-black-panther-2018-poster.jpg","backdrop":"assets/movie-black-panther-2018-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":284054,"ratings":{"rtCritics":96,"rtCriticsLabel":"Certified Fresh","rtAudience":null,"rtAudienceLabel":"Spilled","imdb":7.3,"imdbVotes":931120,"tmdb":7.36,"tmdbVotes":24190}},{"id":"movie-black-panther-wakanda-forever-2022","title":"Black Panther: Wakanda Forever","year":2022,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"Queen Ramonda, Shuri, M\u2019Baku, Okoye and the Dora Milaje fight to protect their nation from intervening world powers in the wake of King T\u2019Challa\u2019s death. As the Wakandans strive to embrace their next chapter, the\u2026","poster":"assets/movie-black-panther-wakanda-forever-2022-poster.jpg","backdrop":"assets/movie-black-panther-wakanda-forever-2022-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":505642,"ratings":{"rtCritics":84,"rtCriticsLabel":"Certified Fresh","rtAudience":93,"rtAudienceLabel":"Upright","imdb":6.6,"imdbVotes":405088,"tmdb":6.984,"tmdbVotes":7920}},{"id":"movie-blame-2017","title":"BLAME!","year":2017,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Drama","Science Fiction"],"overview":"In the distant technological future, civilization has reached its ultimate Net-based form. An \"infection\" in the past caused the automated systems to spiral out of order, resulting in a multi-leveled city structure\u2026","poster":"assets/movie-blame-2017-poster.jpg","backdrop":"assets/movie-blame-2017-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":409421,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":61,"rtAudienceLabel":"Upright","imdb":6.6,"imdbVotes":10258,"tmdb":6.523,"tmdbVotes":370}},{"id":"movie-bleach-the-movie-fade-to-black-2008","title":"Bleach the Movie: Fade to Black","year":2008,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Animation","Fantasy"],"overview":"A strange disturbance in the World of the Living sends Ichigo and Kon to the Soul Society, where the Seireitei has been devastated by an explosion of reishi and Rukia is missing. Stranger still, none of the Soul\u2026","poster":"assets/movie-bleach-the-movie-fade-to-black-2008-poster.jpg","backdrop":"assets/movie-bleach-the-movie-fade-to-black-2008-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":17165,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":67,"rtAudienceLabel":"Upright","imdb":7.1,"imdbVotes":3693,"tmdb":7.1,"tmdbVotes":206}},{"id":"tv-90-day-fiance-before-the-90-days-2017","title":"90 Day Fianc\u00e9: Before the 90 Days","year":2017,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Reality"],"overview":"Follow couples who have an existing relationship online, but haven't met in person. We'll experience their journey as they travel to the other's foreign country for the first time in an attempt to establish an\u2026","poster":"assets/tv-90-day-fiance-before-the-90-days-2017-poster.jpg","backdrop":"assets/tv-90-day-fiance-before-the-90-days-2017-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":73319,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.5,"tmdbVotes":45}},{"id":"tv-90-day-fiance-the-other-way-2019","title":"90 Day Fianc\u00e9: The Other Way","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Reality"],"overview":"Americans move to a foreign country all in the name of love. Will uprooting their lives in the United States pay off, or will they be packing up and moving back to America brokenhearted?","poster":"assets/tv-90-day-fiance-the-other-way-2019-poster.jpg","backdrop":"assets/tv-90-day-fiance-the-other-way-2019-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":90046,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.8,"tmdbVotes":34}},{"id":"tv-the-100-2014","title":"The 100","year":2014,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Sci-Fi & Fantasy","Action & Adventure","Drama"],"overview":"100 years in the future, when the Earth has been abandoned due to radioactivity, the last surviving humans live on an ark orbiting the planet \u2014 but the ark won't last forever. So the repressive regime picks 100\u2026","poster":"assets/tv-the-100-2014-poster.jpg","backdrop":"assets/tv-the-100-2014-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":48866,"ratings":{"rtCritics":93,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.878,"tmdbVotes":8855}},{"id":"tv-the-abbott-and-costello-show-1952","title":"The Abbott and Costello Show","year":1952,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy"],"overview":"Bud and Lou are unemployed actors living in Mr. Fields\u2019 boarding house. Lou\u2019s girlfriend Hillary lives across the hall. Many situations arise leading to slapstick and puns.","poster":"assets/tv-the-abbott-and-costello-show-1952-poster.jpg","backdrop":"assets/tv-the-abbott-and-costello-show-1952-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":11821,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.2,"tmdbVotes":22}},{"id":"tv-accel-world-2012","title":"Accel World","year":2012,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Action & Adventure","Drama"],"overview":"A short, overweight student uses the Neuro Link, an evolution of the Nerve Gear, to escape himself from the reality. But his skills in the virtual world brings him to the attention of Kuroyukihime, a popular student\u2026","poster":"assets/tv-accel-world-2012-poster.jpg","backdrop":"assets/tv-accel-world-2012-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"center_crop","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":43167,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.429,"tmdbVotes":163}},{"id":"tv-ahsoka-2023","title":"Ahsoka","year":2023,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Sci-Fi & Fantasy","Action & Adventure","Drama"],"overview":"Former Jedi Knight Ahsoka Tano investigates an emerging threat to a vulnerable galaxy.","poster":"assets/tv-ahsoka-2023-poster.jpg","backdrop":"assets/tv-ahsoka-2023-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":114461,"ratings":{"rtCritics":85,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.4,"tmdbVotes":1351}},{"id":"tv-american-dad-2005","title":"American Dad!","year":2005,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Comedy"],"overview":"The series focuses on an eccentric motley crew that is the Smith family and their three housemates: Father, husband, and breadwinner Stan Smith; his better half housewife, Francine Smith; their college-aged daughter,\u2026","poster":"assets/tv-american-dad-2005-poster.jpg","backdrop":"assets/tv-american-dad-2005-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1433,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.022,"tmdbVotes":2481}},{"id":"tv-american-gods-2017","title":"American Gods","year":2017,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Mystery","Sci-Fi & Fantasy"],"overview":"An ex-con becomes the traveling partner of a conman who turns out to be one of the older gods trying to recruit troops to battle the upstart deities. Based on Neil Gaiman's fantasy novel.","poster":"assets/tv-american-gods-2017-poster.jpg","backdrop":"assets/tv-american-gods-2017-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":46639,"ratings":{"rtCritics":75,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.1,"tmdbVotes":1852}},{"id":"tv-andromeda-2000","title":"Andromeda","year":2000,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action & Adventure","Drama","Sci-Fi & Fantasy"],"overview":"Captain Dylan Hunt and his crew quest to restore a government that once presided over an extended peace and prosperity.","poster":"assets/tv-andromeda-2000-poster.jpg","backdrop":"assets/tv-andromeda-2000-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":2985,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.068,"tmdbVotes":315}},{"id":"tv-aqua-teen-hunger-force-2000","title":"Aqua Teen Hunger Force","year":2000,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Comedy"],"overview":"The surreal adventures of three anthropomorphic fast food items: Master Shake, Frylock and Meatwad, and their human nextdoor neighbor, Carl Brutananadilewski.","poster":"assets/tv-aqua-teen-hunger-force-2000-poster.jpg","backdrop":"assets/tv-aqua-teen-hunger-force-2000-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":251,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.5,"tmdbVotes":272}},{"id":"tv-arifureta-from-commonplace-to-world-s-strongest-2019","title":"Arifureta: From Commonplace to World's Strongest","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"Hajime Nagumo and his high school class are suddenly summoned to a fantastical land as heroes. But while most of his classmates have powerful stats and abilities, Hajime does not. Underappreciated and unprepared, he\u2026","poster":"assets/tv-arifureta-from-commonplace-to-world-s-strongest-2019-poster.jpg","backdrop":"assets/tv-arifureta-from-commonplace-to-world-s-strongest-2019-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":86034,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.382,"tmdbVotes":662}},{"id":"tv-arrow-2012","title":"Arrow","year":2012,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Crime","Drama","Action & Adventure"],"overview":"Spoiled billionaire playboy Oliver Queen is missing and presumed dead when his yacht is lost at sea. He returns five years later a changed man, determined to clean up the city as a hooded vigilante armed with a bow.","poster":"assets/tv-arrow-2012-poster.jpg","backdrop":"assets/tv-arrow-2012-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1412,"ratings":{"rtCritics":86,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.834,"tmdbVotes":6537}},{"id":"tv-avatar-the-last-airbender-2024","title":"Avatar: The Last Airbender","year":2024,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action & Adventure","Sci-Fi & Fantasy","Drama"],"overview":"A young boy known as the Avatar must master the four elemental powers to save a world at war \u2014 and fight a ruthless enemy bent on stopping him.","poster":"assets/tv-avatar-the-last-airbender-2024-poster.jpg","backdrop":"assets/tv-avatar-the-last-airbender-2024-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":82452,"ratings":{"rtCritics":66,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.8,"tmdbVotes":1406}},{"id":"tv-avatar-seven-havens-2026","title":"Avatar: Seven Havens","year":2026,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"In a world shattered by a devastating cataclysm, a young Earthbender discovers she's the new Avatar after Korra. But in this dangerous era, that title marks her as humanity's destroyer, not its savior. Hunted by both\u2026","poster":"assets/tv-avatar-seven-havens-2026-poster.jpg","backdrop":"assets/tv-avatar-seven-havens-2026-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":284833,"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.9,"tmdbVotes":183}},{"id":"tv-batman-the-brave-and-the-bold-2008","title":"Batman: The Brave and the Bold","year":2008,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"The Caped Crusader is teamed up with Blue Beetle, Green Arrow, Aquaman and countless others in his quest to uphold justice.","poster":"assets/tv-batman-the-brave-and-the-bold-2008-poster.jpg","backdrop":"assets/tv-batman-the-brave-and-the-bold-2008-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":15804,"ratings":{"rtCritics":96,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.6,"tmdbVotes":295}},{"id":"tv-batwoman-2019","title":"Batwoman","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Sci-Fi & Fantasy","Action & Adventure"],"overview":"Kate Kane, armed with a passion for social justice and a flair for speaking her mind, soars onto the streets of Gotham as Batwoman, an out lesbian and highly trained street fighter primed to snuff out the failing\u2026","poster":"assets/tv-batwoman-2019-poster.jpg","backdrop":"assets/tv-batwoman-2019-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":89247,"ratings":{"rtCritics":83,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.8,"tmdbVotes":1619}},{"id":"tv-big-mouth-2017","title":"Big Mouth","year":2017,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy","Animation"],"overview":"Teenage friends find their lives upended by the wonders and horrors of puberty in this edgy comedy from real-life pals Nick Kroll and Andrew Goldberg.","poster":"assets/tv-big-mouth-2017-poster.jpg","backdrop":"assets/tv-big-mouth-2017-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":74204,"ratings":{"rtCritics":95,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.591,"tmdbVotes":1274}},{"id":"tv-black-lightning-2018","title":"Black Lightning","year":2018,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action & Adventure","Sci-Fi & Fantasy","Drama"],"overview":"Jefferson Pierce is a man wrestling with a secret. As the father of two daughters and principal of a charter high school that also serves as a safe haven for young people in a New Orleans neighborhood overrun by gang\u2026","poster":"assets/tv-black-lightning-2018-poster.jpg","backdrop":"assets/tv-black-lightning-2018-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":71663,"ratings":{"rtCritics":92,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.907,"tmdbVotes":991}}],"discoverySource":{"state":"materialized_snapshot","rankKnown":false,"selection":"First 18 alphabetically indexed movie stubs and first 18 series stubs, not ranked feed order","observedAt":"2026-11-04T20:42:12.030108+00:00"}},{"key":"mindel","name":"Mindel","initial":"M","color":"#ff9d72","destinations":{"Home":{"kind":"home"},"Movies":{"kind":"library","collection":"movies"},"Shows":{"kind":"library","collection":"shows"},"Discover":{"kind":"discover","collection":"discover"}},"movies":[{"id":"movie-chicago-2002","title":"Chicago","year":2002,"type":"movie","runtimeSeconds":6808,"seasonCount":null,"communityRating":7.131,"criticRating":87,"genres":["Comedy","Crime","Drama"],"overview":"Murderesses Velma Kelly and Roxie Hart find themselves on death row together and fight for the fame that will keep them from the gallows in 1920s Chicago.","poster":"assets/movie-chicago-2002-poster.jpg","backdrop":"assets/movie-chicago-2002-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:24:53.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":1574,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":87,"rtCriticsLabel":"Certified Fresh","rtAudience":83,"rtAudienceLabel":"Upright","imdb":7.2,"imdbVotes":259272,"tmdb":7.135,"tmdbVotes":3025}},{"id":"movie-coherence-2014","title":"Coherence","year":2014,"type":"movie","runtimeSeconds":5286,"seasonCount":null,"communityRating":7.21,"criticRating":89,"genres":["Thriller","Science Fiction"],"overview":"Four couples gather for dinner the night a mysterious and powerful comet passes overhead.","poster":"assets/movie-coherence-2014-poster.jpg","backdrop":"assets/movie-coherence-2014-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2017-08-12T23:49:00.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":220289,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":89,"rtCriticsLabel":"Certified Fresh","rtAudience":81,"rtAudienceLabel":"Upright","imdb":7.1,"imdbVotes":180354,"tmdb":7.208,"tmdbVotes":3702}},{"id":"movie-the-killing-of-a-sacred-deer-2017","title":"The Killing of a Sacred Deer","year":2017,"type":"movie","runtimeSeconds":7262,"seasonCount":null,"communityRating":7.003,"criticRating":79,"genres":["Drama","Thriller","Mystery"],"overview":"Dr. Steven Murphy is a renowned cardiovascular surgeon who presides over a spotless household with his wife and two children. Lurking at the margins of his idyllic suburban existence is Martin, a fatherless teen who\u2026","poster":"assets/movie-the-killing-of-a-sacred-deer-2017-poster.jpg","backdrop":"assets/movie-the-killing-of-a-sacred-deer-2017-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-22T21:49:26.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":399057,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":79,"rtCriticsLabel":"Certified Fresh","rtAudience":63,"rtAudienceLabel":"Upright","imdb":7,"imdbVotes":219488,"tmdb":7.001,"tmdbVotes":4640}},{"id":"movie-night-is-short-walk-on-girl-2017","title":"Night Is Short, Walk On Girl","year":2017,"type":"movie","runtimeSeconds":5548,"seasonCount":null,"communityRating":7.421,"criticRating":90,"genres":["Comedy","Romance","Animation"],"overview":"As a group of university students go out for a night on the town, a sophomore known only as \"The Girl with Black Hair\" experiences a series of surreal encounters with the local nightlife \u2013 all the while unaware of\u2026","poster":"assets/movie-night-is-short-walk-on-girl-2017-poster.jpg","backdrop":"assets/movie-night-is-short-walk-on-girl-2017-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T17:39:14.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":430214,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":90,"rtCriticsLabel":"Fresh","rtAudience":84,"rtAudienceLabel":"Upright","imdb":7.5,"imdbVotes":7640,"tmdb":7.4,"tmdbVotes":267}},{"id":"movie-thelma-louise-1991","title":"Thelma & Louise","year":1991,"type":"movie","runtimeSeconds":7786,"seasonCount":null,"communityRating":7.596,"criticRating":87,"genres":["Drama","Crime","Adventure"],"overview":"Taking a break from their dreary lives, close friends Thelma and Louise embark on a short weekend trip that ends in unforeseen incriminating circumstances. As fugitives, both women rediscover the strength of their\u2026","poster":"assets/movie-thelma-louise-1991-poster.jpg","backdrop":"assets/movie-thelma-louise-1991-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:24:40.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":1541,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":87,"rtCriticsLabel":"Certified Fresh","rtAudience":82,"rtAudienceLabel":"Upright","imdb":7.6,"imdbVotes":197947,"tmdb":7.594,"tmdbVotes":4063}},{"id":"movie-vicky-cristina-barcelona-2008","title":"Vicky Cristina Barcelona","year":2008,"type":"movie","runtimeSeconds":5804,"seasonCount":null,"communityRating":6.798,"criticRating":80,"genres":["Drama","Romance"],"overview":"Two girlfriends on a summer holiday in Spain become enamored with the same painter, unaware that his ex-wife, with whom he has a tempestuous relationship, is about to re-enter the picture.","poster":"assets/movie-vicky-cristina-barcelona-2008-poster.jpg","backdrop":"assets/movie-vicky-cristina-barcelona-2008-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:25:34.0000000Z","lastPlayedAt":"2026-08-11T20:12:57.3064089Z","availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":5038,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":80,"rtCriticsLabel":"Certified Fresh","rtAudience":74,"rtAudienceLabel":"Upright","imdb":7.1,"imdbVotes":283286,"tmdb":6.797,"tmdbVotes":4164}}],"shows":[{"id":"tv-all-creatures-great-small-2020","title":"All Creatures Great & Small","year":2020,"type":"tv","runtimeSeconds":3000,"seasonCount":7,"communityRating":8.2,"criticRating":null,"genres":["Drama","Comedy"],"overview":"The heartwarming and humorous adventures of a young country vet in the Yorkshire Dales in the 1930s. A remake of the 1978 series.","poster":"assets/tv-all-creatures-great-small-2020-poster.jpg","backdrop":"assets/tv-all-creatures-great-small-2020-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-11-04T20:13:03.4266156Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":108255,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":99,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.2,"tmdbVotes":168}},{"id":"tv-back-to-life-2019","title":"Back to Life","year":2019,"type":"tv","runtimeSeconds":1500,"seasonCount":2,"communityRating":7.367,"criticRating":null,"genres":["Comedy","Drama"],"overview":"When Miri Matteson returns home after eighteen years, can she integrate back into her old life? With a terrible event from her past hanging over her, it won\u2019t be easy.","poster":"assets/tv-back-to-life-2019-poster.jpg","backdrop":"assets/tv-back-to-life-2019-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0688758Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":88510,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.4,"tmdbVotes":65}},{"id":"tv-big-boys-2022","title":"Big Boys","year":2022,"type":"tv","runtimeSeconds":1440,"seasonCount":3,"communityRating":7.6,"criticRating":null,"genres":["Comedy","Drama"],"overview":"Two boys from very different ends of the 'spectrum of masculinity' become best friends at Brent University freshers' week; in their first year of uni, they explore, experiment and try to discover themselves, helping\u2026","poster":"assets/tv-big-boys-2022-poster.jpg","backdrop":"assets/tv-big-boys-2022-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-11-04T20:25:58.2156837Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":202851,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.6,"tmdbVotes":49}},{"id":"tv-call-the-midwife-2012","title":"Call the Midwife","year":2012,"type":"tv","runtimeSeconds":3120,"seasonCount":15,"communityRating":7.5,"criticRating":null,"genres":["Drama"],"overview":"Drama following the lives of a group of midwives working in the poverty-stricken East End of London during the 1950s, based on the best-selling memoirs of Jennifer Worth.","poster":"assets/tv-call-the-midwife-2012-poster.jpg","backdrop":"assets/tv-call-the-midwife-2012-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0377216Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":39793,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.577,"tmdbVotes":267}},{"id":"tv-derry-girls-2018","title":"Derry Girls","year":2018,"type":"tv","runtimeSeconds":1380,"seasonCount":3,"communityRating":8.144,"criticRating":null,"genres":["Comedy","Drama"],"overview":"Amidst the political conflict of Northern Ireland in the 1990s, five secondary school students square off with the universal challenges of being a teenager.","poster":"assets/tv-derry-girls-2018-poster.jpg","backdrop":"assets/tv-derry-girls-2018-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-02T20:01:55.8955537Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":76148,"artwork":{"posterKind":"center_crop","backdropKind":"backdrop"},"ratings":{"rtCritics":99,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.138,"tmdbVotes":454}},{"id":"tv-desperate-housewives-2004","title":"Desperate Housewives","year":2004,"type":"tv","runtimeSeconds":2520,"seasonCount":8,"communityRating":7.896,"criticRating":null,"genres":["Mystery","Drama","Comedy"],"overview":"Looking down on her friends and family isn't a way of life for Mary Alice Young... it's a way of death. One day, in her perfect house, in the loveliest of suburbs, Mary Alice ended it all. Now she's taking us into\u2026","poster":"assets/tv-desperate-housewives-2004-poster.jpg","backdrop":"assets/tv-desperate-housewives-2004-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-29T20:56:40.3505596Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":693,"artwork":{"posterKind":"poster","backdropKind":"center_crop"},"ratings":{"rtCritics":72,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.896,"tmdbVotes":2171}},{"id":"tv-the-diplomat-2023","title":"The Diplomat","year":2023,"type":"tv","runtimeSeconds":null,"seasonCount":4,"communityRating":7.569,"criticRating":null,"genres":["Drama","War & Politics"],"overview":"Amid an international crisis, a US diplomat contends with her high-profile job as ambassador to the UK and her strained marriage to a political star.","poster":"assets/tv-the-diplomat-2023-poster.jpg","backdrop":"assets/tv-the-diplomat-2023-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0640348Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":203857,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":93,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.652,"tmdbVotes":521}},{"id":"tv-the-durrells-2016","title":"The Durrells","year":2016,"type":"tv","runtimeSeconds":2760,"seasonCount":4,"communityRating":7.402,"criticRating":null,"genres":["Drama","Comedy","Family"],"overview":"In 1935, financially strapped widow Louisa Durrell, whose life has fallen apart, decides to move from England, with her four children (three sons, one daughter), to the island of Corfu, Greece. Once there, the family\u2026","poster":"assets/tv-the-durrells-2016-poster.jpg","backdrop":"assets/tv-the-durrells-2016-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0619436Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":66134,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":94,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.4,"tmdbVotes":127}},{"id":"tv-etoile-2025","title":"\u00c9toile","year":2025,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.197,"criticRating":null,"genres":["Comedy","Drama"],"overview":"In an ambitious gambit to save their storied institutions, two world-renowned ballet companies in New York City and Paris swap their most talented stars.","poster":"assets/tv-etoile-2025-poster.jpg","backdrop":"assets/tv-etoile-2025-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0601383Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":225383,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":86,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.3,"tmdbVotes":40}},{"id":"tv-gilmore-girls-2000","title":"Gilmore Girls","year":2000,"type":"tv","runtimeSeconds":2640,"seasonCount":7,"communityRating":7.893,"criticRating":null,"genres":["Comedy","Drama"],"overview":"Set in the charming town of Stars Hollow, Connecticut, the series follows the captivating lives of Lorelai and Rory Gilmore, a mother/daughter pair who have a relationship most people only dream of.","poster":"assets/tv-gilmore-girls-2000-poster.jpg","backdrop":"assets/tv-gilmore-girls-2000-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0631035Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":4586,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.9,"tmdbVotes":1339}},{"id":"tv-ginny-georgia-2021","title":"Ginny & Georgia","year":2021,"type":"tv","runtimeSeconds":null,"seasonCount":3,"communityRating":8.026,"criticRating":null,"genres":["Comedy","Drama"],"overview":"Free-spirited Georgia and her two kids, Ginny and Austin, move north in search of a fresh start but find that the road to new beginnings can be bumpy.","poster":"assets/tv-ginny-georgia-2021-poster.jpg","backdrop":"assets/tv-ginny-georgia-2021-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0694828Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":117581,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":68,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8,"tmdbVotes":1840}},{"id":"tv-hellcats-2010","title":"Hellcats","year":2010,"type":"tv","runtimeSeconds":2460,"seasonCount":1,"communityRating":7.5,"criticRating":null,"genres":["Drama"],"overview":"Pre-law student Marti Perkins' world is flipped upside down when she loses her scholarship, and realizes the only way she can stay in school is by reigniting her dormant teen gymnastic skills to win a place on Lancer\u2026","poster":"assets/tv-hellcats-2010-poster.jpg","backdrop":"assets/tv-hellcats-2010-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-22T20:39:02.7719318Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":32504,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":65,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.5,"tmdbVotes":116}},{"id":"tv-high-potential-2024","title":"High Potential","year":2024,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":8.201,"criticRating":null,"genres":["Drama","Crime","Mystery"],"overview":"Morgan is a single mom with an exceptional mind, whose unconventional knack for solving crimes leads to an unusual and unstoppable partnership with a by-the-book seasoned detective.","poster":"assets/tv-high-potential-2024-poster.jpg","backdrop":"assets/tv-high-potential-2024-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0686091Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":226637,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":98,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.3,"tmdbVotes":707}},{"id":"tv-how-to-get-to-heaven-from-belfast-2026","title":"How to Get to Heaven from Belfast","year":2026,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.027,"criticRating":null,"genres":["Mystery","Comedy","Drama"],"overview":"Three lifelong pals embark on a chaotic quest to solve the mystery of their old friend's suspicious death and keep their own dark secret under wraps.","poster":"assets/tv-how-to-get-to-heaven-from-belfast-2026-poster.jpg","backdrop":"assets/tv-how-to-get-to-heaven-from-belfast-2026-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0683192Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":233079,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":95,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.063,"tmdbVotes":95}},{"id":"tv-the-hunting-wives-2025","title":"The Hunting Wives","year":2025,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":6.932,"criticRating":null,"genres":["Drama","Mystery"],"overview":"Sophie trades New England for East Texas and falls into a wealthy socialite's magnetic orbit \u2014 where a clique of housewives hide deadly secrets.","poster":"assets/tv-the-hunting-wives-2025-poster.jpg","backdrop":"assets/tv-the-hunting-wives-2025-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-20T19:42:46.3227892Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":244447,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":81,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.964,"tmdbVotes":112}},{"id":"tv-the-lost-flowers-of-alice-hart-2023","title":"The Lost Flowers of Alice Hart","year":2023,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.634,"criticRating":null,"genres":["Drama","Mystery"],"overview":"After losing her parents in a mysterious fire, nine-year-old Alice Hart is raised by her grandmother June on a flower farm where she learns there are secrets within secrets. But years on, an unearthed betrayal sees\u2026","poster":"assets/tv-the-lost-flowers-of-alice-hart-2023-poster.jpg","backdrop":"assets/tv-the-lost-flowers-of-alice-hart-2023-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0607236Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":125816,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":82,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.64,"tmdbVotes":161}},{"id":"tv-love-is-blind-2020","title":"Love Is Blind","year":2020,"type":"tv","runtimeSeconds":null,"seasonCount":11,"communityRating":6.261,"criticRating":null,"genres":["Reality"],"overview":"Nick and Vanessa Lachey host this social experiment where single men and women look for love and get engaged, all before meeting in person.","poster":"assets/tv-love-is-blind-2020-poster.jpg","backdrop":"assets/tv-love-is-blind-2020-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-22T17:11:08.2447226Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":99353,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":66,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.233,"tmdbVotes":118}},{"id":"tv-maximum-pleasure-guaranteed-2026","title":"Maximum Pleasure Guaranteed","year":2026,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":6.676,"criticRating":null,"genres":["Comedy"],"overview":"A newly divorced mom falls down a dangerous rabbit hole of blackmail, murder, and youth soccer.","poster":"assets/tv-maximum-pleasure-guaranteed-2026-poster.jpg","backdrop":"assets/tv-maximum-pleasure-guaranteed-2026-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-07T21:04:02.8277793Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":285404,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":93,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.799,"tmdbVotes":92}},{"id":"tv-minx-2022","title":"Minx","year":2022,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":6.833,"criticRating":null,"genres":["Comedy","Drama"],"overview":"An earnest young feminist joins forces with a low-rent publisher to make the world's first erotic magazine for women.","poster":"assets/tv-minx-2022-poster.jpg","backdrop":"assets/tv-minx-2022-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0653613Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":118303,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":93,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.9,"tmdbVotes":123}},{"id":"tv-offspring-2010","title":"Offspring","year":2010,"type":"tv","runtimeSeconds":3600,"seasonCount":7,"communityRating":6.9,"criticRating":null,"genres":["Drama"],"overview":"An exuberant drama set in Melbourne's Fitzroy, centering on Nina Proudman and her struggle to deal with her fabulously messy family, her hunt for a decent love life and her tendency to overthink and fly off into fantasy.","poster":"assets/tv-offspring-2010-poster.jpg","backdrop":"assets/tv-offspring-2010-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0664581Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":33155,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.9,"tmdbVotes":27}},{"id":"tv-only-murders-in-the-building-2021","title":"Only Murders in the Building","year":2021,"type":"tv","runtimeSeconds":null,"seasonCount":5,"communityRating":8.398,"criticRating":null,"genres":["Comedy","Mystery","Crime"],"overview":"Three strangers share an obsession with true crime and suddenly find themselves wrapped up in one.","poster":"assets/tv-only-murders-in-the-building-2021-poster.jpg","backdrop":"assets/tv-only-murders-in-the-building-2021-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-11-04T20:25:58.2163301Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":107113,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":95,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.398,"tmdbVotes":2333}},{"id":"tv-outlander-blood-of-my-blood-2025","title":"Outlander: Blood of My Blood","year":2025,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":8.542,"criticRating":null,"genres":["Drama"],"overview":"From the battlefields of World War I to the rugged Highlands of 18th century Scotland, two fated couples must defy the forces that seek to tear them apart, unfolding in surprising and unforeseen ways in this romantic\u2026","poster":"assets/tv-outlander-blood-of-my-blood-2025-poster.jpg","backdrop":"assets/tv-outlander-blood-of-my-blood-2025-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0691373Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":207484,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":94,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.738,"tmdbVotes":124}},{"id":"tv-rivals-2024","title":"Rivals","year":2024,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":7.44,"criticRating":null,"genres":["Drama"],"overview":"It is 1986 and Britain is booming. \"Rivals\" dives headfirst into the cutthroat world of TV where hair-dos are big and ambitions are even bigger. Deals are brokered in boardrooms, as well as bedrooms. Nobody can be\u2026","poster":"assets/tv-rivals-2024-poster.jpg","backdrop":"assets/tv-rivals-2024-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0645815Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":208921,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":96,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.4,"tmdbVotes":93}},{"id":"tv-rooster-2026","title":"Rooster","year":2026,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.431,"criticRating":null,"genres":["Comedy"],"overview":"On a college campus, an author navigates a complicated relationship with his daughter.","poster":"assets/tv-rooster-2026-poster.jpg","backdrop":"assets/tv-rooster-2026-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.063389Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":254953,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":88,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.423,"tmdbVotes":144}},{"id":"tv-sex-and-the-city-1998","title":"Sex and the City","year":1998,"type":"tv","runtimeSeconds":null,"seasonCount":6,"communityRating":7.365,"criticRating":null,"genres":["Drama","Comedy"],"overview":"Based on the bestselling book by Candace Bushnell, Sex and the City tells the story of four best friends, all single and in their late thirties, as they pursue their careers and talk about their sex lives, all while\u2026","poster":"assets/tv-sex-and-the-city-1998-poster.jpg","backdrop":"assets/tv-sex-and-the-city-1998-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0679324Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":105,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":70,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.351,"tmdbVotes":2220}},{"id":"tv-skymed-2022","title":"SkyMed","year":2022,"type":"tv","runtimeSeconds":2640,"seasonCount":4,"communityRating":6.665,"criticRating":null,"genres":["Drama"],"overview":"Life, death and drama at 20,000 feet. The series weaves together intense character journeys and high-stakes medical rescues, as we follow the triumphs, heartbreaks and tribulations of budding nurses and pilots flying\u2026","poster":"assets/tv-skymed-2022-poster.jpg","backdrop":"assets/tv-skymed-2022-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-22T19:44:29.2431354Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":133908,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.699,"tmdbVotes":98}},{"id":"tv-so-you-think-you-can-dance-2008","title":"So You Think You Can Dance","year":2008,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":9.8,"criticRating":null,"genres":["Family","Reality"],"overview":"So You Think You Can Dance is a joint Belgian-Dutch dance competition show broadcast on RTL 5 and vtm television stations. The first season was broadcast only in the Netherlands, whereas starting with season 2, the\u2026","poster":"assets/tv-so-you-think-you-can-dance-2008-poster.jpg","backdrop":"assets/tv-so-you-think-you-can-dance-2008-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-23T11:11:35.4124418Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":40231,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":9.8,"tmdbVotes":4}},{"id":"tv-the-white-lotus-2021","title":"The White Lotus","year":2021,"type":"tv","runtimeSeconds":null,"seasonCount":3,"communityRating":7.597,"criticRating":null,"genres":["Comedy","Drama","Mystery"],"overview":"Follow the exploits of various guests and employees at an exclusive tropical resort over the span of a week as with each passing day, a darker complexity emerges in these picture-perfect travelers, the hotel\u2019s\u2026","poster":"assets/tv-the-white-lotus-2021-poster.jpg","backdrop":"assets/tv-the-white-lotus-2021-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-20T02:45:52.8742674Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":111803,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":90,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.599,"tmdbVotes":1604}},{"id":"tv-yellowjackets-2021","title":"Yellowjackets","year":2021,"type":"tv","runtimeSeconds":null,"seasonCount":3,"communityRating":7.431,"criticRating":null,"genres":["Drama","Mystery"],"overview":"This equal parts survival epic, psychological horror story and coming-of-age drama tells the saga of a team of wildly talented high school girls soccer players who become the (un)lucky survivors of a plane crash deep\u2026","poster":"assets/tv-yellowjackets-2021-poster.jpg","backdrop":"assets/tv-yellowjackets-2021-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-11-04T20:31:47.4592756Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":117488,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":92,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.431,"tmdbVotes":1070}}],"anime":[],"resume":[{"id":"episode-sex-and-the-country-2001-s4e9","title":"Sex and the Country","year":2001,"type":"episode","runtimeSeconds":1751,"seasonCount":null,"communityRating":5.9,"criticRating":null,"genres":["Comedy","Drama","Romance"],"overview":"Carrie is dragged to Aidan's country home in Suffern, New York. Charlotte visits the MacDougal \"compound\" in Connecticut. Miranda yells at Steve when he tells her he has testicular cancer but has no clue about his\u2026","poster":"assets/episode-sex-and-the-country-2001-s4e9-poster.jpg","backdrop":"assets/episode-sex-and-the-country-2001-s4e9-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":96.08,"progress":0.0549,"addedAt":"2026-07-17T10:53:15.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","seriesTitle":"Sex and the City","seasonNumber":4,"episodeNumber":9,"artwork":{"posterKind":"poster","backdropKind":"backdrop"}}],"nextUp":[{"id":"episode-what-more-do-i-need-2008-s5e7","title":"What More Do I Need?","year":2008,"type":"episode","runtimeSeconds":2521,"seasonCount":null,"communityRating":8,"criticRating":null,"genres":["Comedy","Drama","Mystery"],"overview":"Lynette and Tom uncover the unsettling truth about son Porter. Gabrielle suspects Carlos' best client, Virginia, has ulterior motives. Bree's moment of weakness could bring her great embarrassment and shame.\u2026","poster":"assets/episode-what-more-do-i-need-2008-s5e7-poster.jpg","backdrop":"assets/episode-what-more-do-i-need-2008-s5e7-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-29T14:37:28.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","seriesTitle":"Desperate Housewives","seasonNumber":5,"episodeNumber":7,"artwork":{"posterKind":"center_crop","backdropKind":"backdrop"}},{"id":"episode-new-day-2021-s1e2","title":"New Day","year":2021,"type":"episode","runtimeSeconds":3445,"seasonCount":null,"communityRating":7.143,"criticRating":null,"genres":["Comedy","Drama"],"overview":"A cautiously optimistic Belinda walks a fine line with Tanya, while Armond brainstorms ways to deal with Shane. After her husband belittles her work, Rachel approaches Nicole for advice. Paula and Olivia lose track\u2026","poster":"assets/episode-new-day-2021-s1e2-poster.jpg","backdrop":"assets/episode-new-day-2021-s1e2-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-19T22:18:57.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","seriesTitle":"The White Lotus","seasonNumber":1,"episodeNumber":2,"artwork":{"posterKind":"poster","backdropKind":"backdrop"}},{"id":"episode-the-uncomfortability-2010-s1e2","title":"The Uncomfortability","year":2010,"type":"episode","runtimeSeconds":2687,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy","Drama"],"overview":"Nina's insecurities are revealed when a patient poses an awkward request. Meanwhile Geraldine decides to try online dating, and Jimmy is an unexpected saviour to Cherie and Darcy.","poster":"assets/episode-the-uncomfortability-2010-s1e2-poster.jpg","backdrop":"assets/episode-the-uncomfortability-2010-s1e2-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T11:47:42.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","seriesTitle":"Offspring","seasonNumber":1,"episodeNumber":2,"artwork":{"posterKind":"poster","backdropKind":"backdrop"}},{"id":"episode-episode-2-2016-s1e2","title":"Episode 2","year":2016,"type":"episode","runtimeSeconds":2766,"seasonCount":null,"communityRating":8.3,"criticRating":null,"genres":["Biography","Comedy","Drama"],"overview":"Larry falls dangerously ill, and it falls to Theo and his neighbour Sven to put their rudimentary medical skills to the task of saving his life. The family's money worries continue, but Spiros brings them good news\u2026","poster":"assets/episode-episode-2-2016-s1e2-poster.jpg","backdrop":"assets/episode-episode-2-2016-s1e2-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-31T07:29:17.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","seriesTitle":"The Durrells","seasonNumber":1,"episodeNumber":2,"artwork":{"posterKind":"poster","backdropKind":"backdrop"}},{"id":"episode-remembrance-day-2026-s2e1","title":"Remembrance Day","year":2026,"type":"episode","runtimeSeconds":3822,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Fantasy","Romance"],"overview":"The fiery crosses have been lit across the Highlands; lovers must part, as the call to arms brings friends and enemies home and the Jacobite Rebellion begins in earnest.","poster":"assets/episode-remembrance-day-2026-s2e1-poster.jpg","backdrop":"assets/episode-remembrance-day-2026-s2e1-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-19T03:50:07.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","seriesTitle":"Outlander: Blood of My Blood","seasonNumber":2,"episodeNumber":1,"artwork":{"posterKind":"poster","backdropKind":"backdrop"}},{"id":"episode-one-of-us-2024-s1e7","title":"One of Us","year":2024,"type":"episode","runtimeSeconds":2555,"seasonCount":null,"communityRating":7.7,"criticRating":null,"genres":[],"overview":"Morgan agrees to show Ava around the LAPD, but things quickly take a dangerous turn when the precinct is held hostage by the friends of a recently convicted man. Can Morgan and the detectives outsmart their captors\u2026","poster":"assets/episode-one-of-us-2024-s1e7-poster.jpg","backdrop":"assets/episode-one-of-us-2024-s1e7-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T13:57:22.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","seriesTitle":"High Potential","seasonNumber":1,"episodeNumber":7,"artwork":{"posterKind":"poster","backdropKind":"backdrop"}},{"id":"episode-the-breakup-part-2-2001-s1e17","title":"The Breakup, Part 2","year":2001,"type":"episode","runtimeSeconds":2669,"seasonCount":null,"communityRating":7.4,"criticRating":null,"genres":["Comedy","Drama"],"overview":"After recent events, Rory keeps busy, refusing to give in to self-pity. Meanwhile, Stars Hollow's residents rally around her, much to her dismay.","poster":"assets/episode-the-breakup-part-2-2001-s1e17-poster.jpg","backdrop":"assets/episode-the-breakup-part-2-2001-s1e17-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T11:28:05.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","seriesTitle":"Gilmore Girls","seasonNumber":1,"episodeNumber":17,"artwork":{"posterKind":"poster","backdropKind":"backdrop"}},{"id":"episode-i-heart-ny-2002-s4e18","title":"I Heart NY","year":2002,"type":"episode","runtimeSeconds":1905,"seasonCount":null,"communityRating":6.667,"criticRating":null,"genres":["Comedy","Drama","Romance"],"overview":"Carrie is shocked to find out that Big is moving to Napa, California and plans to give him a New York send-off. Charlotte begins dating another recent divorc\u00e9. Miranda prepares for the new baby as her delivery date\u2026","poster":"assets/episode-i-heart-ny-2002-s4e18-poster.jpg","backdrop":"assets/episode-i-heart-ny-2002-s4e18-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T12:35:14.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","seriesTitle":"Sex and the City","seasonNumber":4,"episodeNumber":18,"artwork":{"posterKind":"poster","backdropKind":"backdrop"}}],"recentlyAdded":[{"id":"tv-yellowjackets-2021","title":"Yellowjackets","year":2021,"type":"tv","runtimeSeconds":null,"seasonCount":3,"communityRating":7.431,"criticRating":null,"genres":["Drama","Mystery"],"overview":"This equal parts survival epic, psychological horror story and coming-of-age drama tells the saga of a team of wildly talented high school girls soccer players who become the (un)lucky survivors of a plane crash deep\u2026","poster":"assets/tv-yellowjackets-2021-poster.jpg","backdrop":"assets/tv-yellowjackets-2021-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-11-04T20:31:47.4592756Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":117488,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":92,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.431,"tmdbVotes":1070}},{"id":"tv-only-murders-in-the-building-2021","title":"Only Murders in the Building","year":2021,"type":"tv","runtimeSeconds":null,"seasonCount":5,"communityRating":8.398,"criticRating":null,"genres":["Comedy","Mystery","Crime"],"overview":"Three strangers share an obsession with true crime and suddenly find themselves wrapped up in one.","poster":"assets/tv-only-murders-in-the-building-2021-poster.jpg","backdrop":"assets/tv-only-murders-in-the-building-2021-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-11-04T20:25:58.2163301Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":107113,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":95,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.398,"tmdbVotes":2333}},{"id":"tv-big-boys-2022","title":"Big Boys","year":2022,"type":"tv","runtimeSeconds":1440,"seasonCount":3,"communityRating":7.6,"criticRating":null,"genres":["Comedy","Drama"],"overview":"Two boys from very different ends of the 'spectrum of masculinity' become best friends at Brent University freshers' week; in their first year of uni, they explore, experiment and try to discover themselves, helping\u2026","poster":"assets/tv-big-boys-2022-poster.jpg","backdrop":"assets/tv-big-boys-2022-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-11-04T20:25:58.2156837Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":202851,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.6,"tmdbVotes":49}},{"id":"tv-all-creatures-great-small-2020","title":"All Creatures Great & Small","year":2020,"type":"tv","runtimeSeconds":3000,"seasonCount":7,"communityRating":8.2,"criticRating":null,"genres":["Drama","Comedy"],"overview":"The heartwarming and humorous adventures of a young country vet in the Yorkshire Dales in the 1930s. A remake of the 1978 series.","poster":"assets/tv-all-creatures-great-small-2020-poster.jpg","backdrop":"assets/tv-all-creatures-great-small-2020-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-11-04T20:13:03.4266156Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":108255,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":99,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.2,"tmdbVotes":168}},{"id":"movie-the-killing-of-a-sacred-deer-2017","title":"The Killing of a Sacred Deer","year":2017,"type":"movie","runtimeSeconds":7262,"seasonCount":null,"communityRating":7.003,"criticRating":79,"genres":["Drama","Thriller","Mystery"],"overview":"Dr. Steven Murphy is a renowned cardiovascular surgeon who presides over a spotless household with his wife and two children. Lurking at the margins of his idyllic suburban existence is Martin, a fatherless teen who\u2026","poster":"assets/movie-the-killing-of-a-sacred-deer-2017-poster.jpg","backdrop":"assets/movie-the-killing-of-a-sacred-deer-2017-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-22T21:49:26.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":399057,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":79,"rtCriticsLabel":"Certified Fresh","rtAudience":63,"rtAudienceLabel":"Upright","imdb":7,"imdbVotes":219488,"tmdb":7.001,"tmdbVotes":4640}},{"id":"tv-derry-girls-2018","title":"Derry Girls","year":2018,"type":"tv","runtimeSeconds":1380,"seasonCount":3,"communityRating":8.144,"criticRating":null,"genres":["Comedy","Drama"],"overview":"Amidst the political conflict of Northern Ireland in the 1990s, five secondary school students square off with the universal challenges of being a teenager.","poster":"assets/tv-derry-girls-2018-poster.jpg","backdrop":"assets/tv-derry-girls-2018-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-02T20:01:55.8955537Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":76148,"artwork":{"posterKind":"center_crop","backdropKind":"backdrop"},"ratings":{"rtCritics":99,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.138,"tmdbVotes":454}},{"id":"tv-so-you-think-you-can-dance-2008","title":"So You Think You Can Dance","year":2008,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":9.8,"criticRating":null,"genres":["Family","Reality"],"overview":"So You Think You Can Dance is a joint Belgian-Dutch dance competition show broadcast on RTL 5 and vtm television stations. The first season was broadcast only in the Netherlands, whereas starting with season 2, the\u2026","poster":"assets/tv-so-you-think-you-can-dance-2008-poster.jpg","backdrop":"assets/tv-so-you-think-you-can-dance-2008-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-23T11:11:35.4124418Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":40231,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":9.8,"tmdbVotes":4}},{"id":"tv-hellcats-2010","title":"Hellcats","year":2010,"type":"tv","runtimeSeconds":2460,"seasonCount":1,"communityRating":7.5,"criticRating":null,"genres":["Drama"],"overview":"Pre-law student Marti Perkins' world is flipped upside down when she loses her scholarship, and realizes the only way she can stay in school is by reigniting her dormant teen gymnastic skills to win a place on Lancer\u2026","poster":"assets/tv-hellcats-2010-poster.jpg","backdrop":"assets/tv-hellcats-2010-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-22T20:39:02.7719318Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":32504,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":65,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.5,"tmdbVotes":116}},{"id":"tv-skymed-2022","title":"SkyMed","year":2022,"type":"tv","runtimeSeconds":2640,"seasonCount":4,"communityRating":6.665,"criticRating":null,"genres":["Drama"],"overview":"Life, death and drama at 20,000 feet. The series weaves together intense character journeys and high-stakes medical rescues, as we follow the triumphs, heartbreaks and tribulations of budding nurses and pilots flying\u2026","poster":"assets/tv-skymed-2022-poster.jpg","backdrop":"assets/tv-skymed-2022-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-22T19:44:29.2431354Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":133908,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.699,"tmdbVotes":98}},{"id":"tv-love-is-blind-2020","title":"Love Is Blind","year":2020,"type":"tv","runtimeSeconds":null,"seasonCount":11,"communityRating":6.261,"criticRating":null,"genres":["Reality"],"overview":"Nick and Vanessa Lachey host this social experiment where single men and women look for love and get engaged, all before meeting in person.","poster":"assets/tv-love-is-blind-2020-poster.jpg","backdrop":"assets/tv-love-is-blind-2020-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-22T17:11:08.2447226Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":99353,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":66,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.233,"tmdbVotes":118}},{"id":"tv-the-hunting-wives-2025","title":"The Hunting Wives","year":2025,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":6.932,"criticRating":null,"genres":["Drama","Mystery"],"overview":"Sophie trades New England for East Texas and falls into a wealthy socialite's magnetic orbit \u2014 where a clique of housewives hide deadly secrets.","poster":"assets/tv-the-hunting-wives-2025-poster.jpg","backdrop":"assets/tv-the-hunting-wives-2025-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-20T19:42:46.3227892Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":244447,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":81,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.964,"tmdbVotes":112}},{"id":"tv-the-white-lotus-2021","title":"The White Lotus","year":2021,"type":"tv","runtimeSeconds":null,"seasonCount":3,"communityRating":7.597,"criticRating":null,"genres":["Comedy","Drama","Mystery"],"overview":"Follow the exploits of various guests and employees at an exclusive tropical resort over the span of a week as with each passing day, a darker complexity emerges in these picture-perfect travelers, the hotel\u2019s\u2026","poster":"assets/tv-the-white-lotus-2021-poster.jpg","backdrop":"assets/tv-the-white-lotus-2021-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-20T02:45:52.8742674Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":111803,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":90,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.599,"tmdbVotes":1604}}],"discover":[{"id":"movie-across-the-hall-2009","title":"Across the Hall","year":2009,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Thriller","Mystery"],"overview":"Convinced that his fiancee is cheating on him, a man follows her to a hotel and calls his best friend to help him avoid a calamity.","poster":"assets/movie-across-the-hall-2009-poster.jpg","backdrop":"assets/movie-across-the-hall-2009-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":21469,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":39,"rtAudienceLabel":"Spilled","imdb":5.8,"imdbVotes":5130,"tmdb":6,"tmdbVotes":129}},{"id":"movie-addicted-2014","title":"Addicted","year":2014,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Thriller"],"overview":"A gallerist risks her family and flourishing career when she enters into an affair with a talented painter and slowly loses control of her life.","poster":"assets/movie-addicted-2014-poster.jpg","backdrop":"assets/movie-addicted-2014-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":235271,"ratings":{"rtCritics":7,"rtCriticsLabel":"Rotten","rtAudience":51,"rtAudienceLabel":"Spilled","imdb":5,"imdbVotes":13131,"tmdb":5.928,"tmdbVotes":466}},{"id":"movie-adulterers-2016","title":"Adulterers","year":2016,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Thriller","Drama"],"overview":"On a steamy New Orleans afternoon, Sam Deuprey decides to surprise his beautiful wife, Ashley, for their anniversary and comes home early. But Ashley is not alone in the bedroom. She and her lover, Damien are about\u2026","poster":"assets/movie-adulterers-2016-poster.jpg","backdrop":"assets/movie-adulterers-2016-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":376004,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":37,"rtAudienceLabel":"Spilled","imdb":5.1,"imdbVotes":2144,"tmdb":6.2,"tmdbVotes":116}},{"id":"movie-alone-with-her-2006","title":"Alone With Her","year":2006,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Crime","Drama","Thriller"],"overview":"The harrowing story of a disturbed young man's attempts to win the affections of an unsuspecting young woman. When Doug first sees Amy, he instantly falls for her and begins to watch her every move, going so far as\u2026","poster":"assets/movie-alone-with-her-2006-poster.jpg","backdrop":"assets/movie-alone-with-her-2006-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":18206,"ratings":{"rtCritics":70,"rtCriticsLabel":"Fresh","rtAudience":46,"rtAudienceLabel":"Spilled","imdb":6,"imdbVotes":4359,"tmdb":5.9,"tmdbVotes":94}},{"id":"movie-awake-2007","title":"Awake","year":2007,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Thriller","Crime","Mystery"],"overview":"While undergoing heart surgery, a man experiences a phenomenon called \u2018anesthetic awareness\u2019, which leaves him awake but paralyzed throughout the operation. As various obstacles present themselves, his wife must make\u2026","poster":"assets/movie-awake-2007-poster.jpg","backdrop":"assets/movie-awake-2007-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":13483,"ratings":{"rtCritics":23,"rtCriticsLabel":"Rotten","rtAudience":55,"rtAudienceLabel":"Spilled","imdb":6.5,"imdbVotes":89389,"tmdb":6.586,"tmdbVotes":1649}},{"id":"movie-black-bear-2020","title":"Black Bear","year":2020,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Thriller"],"overview":"At a remote lake house in the Adirondack Mountains, a couple entertains an out-of-town guest looking for inspiration in her filmmaking. The group quickly falls into a calculated game of desire, manipulation, and\u2026","poster":"assets/movie-black-bear-2020-poster.jpg","backdrop":"assets/movie-black-bear-2020-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":624788,"ratings":{"rtCritics":89,"rtCriticsLabel":"Certified Fresh","rtAudience":64,"rtAudienceLabel":"Upright","imdb":6.5,"imdbVotes":18408,"tmdb":6.337,"tmdbVotes":315}},{"id":"movie-butterfly-on-a-wheel-2007","title":"Butterfly on a Wheel","year":2007,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Crime","Drama","Thriller"],"overview":"A sociopathic kidnapper methodically pushes a desperate pair of parents to their absolute breaking point.","poster":"assets/movie-butterfly-on-a-wheel-2007-poster.jpg","backdrop":"assets/movie-butterfly-on-a-wheel-2007-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":13250,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":61,"rtAudienceLabel":"Upright","imdb":6.6,"imdbVotes":35409,"tmdb":6.457,"tmdbVotes":595}},{"id":"movie-careful-what-you-wish-for-2015","title":"Careful What You Wish For","year":2015,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Thriller"],"overview":"A guy gets more than he bargained for after entering into an affair with the wife of an investment banker. Soon, a suspicious death and substantial life insurance policy embroil him in a scandal.","poster":"assets/movie-careful-what-you-wish-for-2015-poster.jpg","backdrop":"assets/movie-careful-what-you-wish-for-2015-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":317744,"ratings":{"rtCritics":17,"rtCriticsLabel":"Rotten","rtAudience":61,"rtAudienceLabel":"Upright","imdb":5.6,"imdbVotes":9452,"tmdb":6.129,"tmdbVotes":446}},{"id":"movie-crime-of-passion-1956","title":"Crime of Passion","year":1956,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Crime","Thriller"],"overview":"Kathy leaves the newspaper business to marry homicide detective Bill, but is frustrated by his lack of ambition and the banality of life in the suburbs. Her drive to advance Bill's career soon takes her down a\u2026","poster":"assets/movie-crime-of-passion-1956-poster.jpg","backdrop":"assets/movie-crime-of-passion-1956-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"center_crop","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1920,"ratings":{"rtCritics":80,"rtCriticsLabel":"Fresh","rtAudience":34,"rtAudienceLabel":"Spilled","imdb":6.4,"imdbVotes":3750,"tmdb":6.1,"tmdbVotes":57}},{"id":"movie-deep-water-2022","title":"Deep Water","year":2022,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Mystery","Thriller"],"overview":"A well-to-do husband who allows his wife to have affairs in order to avoid a divorce becomes a prime suspect in the disappearance of her lovers.","poster":"assets/movie-deep-water-2022-poster.jpg","backdrop":"assets/movie-deep-water-2022-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":619979,"ratings":{"rtCritics":35,"rtCriticsLabel":"Rotten","rtAudience":25,"rtAudienceLabel":"Spilled","imdb":5.5,"imdbVotes":74098,"tmdb":5.834,"tmdbVotes":1755}},{"id":"movie-derailed-2005","title":"Derailed","year":2005,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Thriller"],"overview":"When two married business executives having an affair are blackmailed by a violent criminal, they are forced to turn the tables on him to save their families.","poster":"assets/movie-derailed-2005-poster.jpg","backdrop":"assets/movie-derailed-2005-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":8999,"ratings":{"rtCritics":20,"rtCriticsLabel":"Rotten","rtAudience":58,"rtAudienceLabel":"Spilled","imdb":6.6,"imdbVotes":79563,"tmdb":6.405,"tmdbVotes":1193}},{"id":"movie-die-my-love-2025","title":"Die My Love","year":2025,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama"],"overview":"After inheriting a remote Montana house, Jackson moves there from New York with his partner Grace, and the couple soon welcome a child. As Jackson becomes increasingly absent and rural isolation sets in, Grace\u2026","poster":"assets/movie-die-my-love-2025-poster.jpg","backdrop":"assets/movie-die-my-love-2025-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1033148,"ratings":{"rtCritics":74,"rtCriticsLabel":"Certified Fresh","rtAudience":45,"rtAudienceLabel":"Spilled","imdb":6,"imdbVotes":50664,"tmdb":6.1,"tmdbVotes":683}},{"id":"movie-dream-house-2011","title":"Dream House","year":2011,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Mystery","Drama","Thriller"],"overview":"Publisher Will Atenton quits a lucrative job in New York to relocate his wife, Libby, and their daughters to a quaint town in New England. However, as they settle into their home the Atentons discover that a woman\u2026","poster":"assets/movie-dream-house-2011-poster.jpg","backdrop":"assets/movie-dream-house-2011-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":69668,"ratings":{"rtCritics":7,"rtCriticsLabel":"Rotten","rtAudience":36,"rtAudienceLabel":"Spilled","imdb":6,"imdbVotes":71386,"tmdb":6.212,"tmdbVotes":1480}},{"id":"movie-dream-lover-1994","title":"Dream Lover","year":1994,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Mystery","Thriller"],"overview":"Not long after they cross paths at an art gallery, architect Ray Reardon and hypnotically sensual Lena are married with children. But as strange incidents occur, Ray begins to realize he may not really know the woman\u2026","poster":"assets/movie-dream-lover-1994-poster.jpg","backdrop":"assets/movie-dream-lover-1994-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":18190,"ratings":{"rtCritics":57,"rtCriticsLabel":"Rotten","rtAudience":38,"rtAudienceLabel":"Spilled","imdb":6.2,"imdbVotes":7865,"tmdb":6.844,"tmdbVotes":167}},{"id":"movie-drop-2025","title":"Drop","year":2025,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Mystery","Thriller"],"overview":"Violet, a widowed mother on her first date in years, arrives at an upscale restaurant where she is relieved that her date, Henry, is more charming and handsome than she expected. But their chemistry begins to curdle\u2026","poster":"assets/movie-drop-2025-poster.jpg","backdrop":"assets/movie-drop-2025-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1249213,"ratings":{"rtCritics":83,"rtCriticsLabel":"Certified Fresh","rtAudience":79,"rtAudienceLabel":"Upright","imdb":6.1,"imdbVotes":61018,"tmdb":6.275,"tmdbVotes":1023}},{"id":"movie-eastern-promises-2007","title":"Eastern Promises","year":2007,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Thriller","Crime","Mystery"],"overview":"A Russian teenager living in London dies during childbirth but leaves clues in her diary that could tie her child to a rape involving a violent Russian mob family.","poster":"assets/movie-eastern-promises-2007-poster.jpg","backdrop":"assets/movie-eastern-promises-2007-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":2252,"ratings":{"rtCritics":89,"rtCriticsLabel":"Certified Fresh","rtAudience":83,"rtAudienceLabel":"Upright","imdb":7.6,"imdbVotes":281015,"tmdb":7.349,"tmdbVotes":3900}},{"id":"movie-faces-1968","title":"Faces","year":1968,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama"],"overview":"Middle-aged suburban husband Richard abruptly tells his wife, Maria, that he wants a divorce. As Richard takes up with a younger woman, Maria enjoys a night on the town with her friends and meets a younger man. As\u2026","poster":"assets/movie-faces-1968-poster.jpg","backdrop":"assets/movie-faces-1968-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"center_crop","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":753,"ratings":{"rtCritics":83,"rtCriticsLabel":"Fresh","rtAudience":86,"rtAudienceLabel":"Upright","imdb":7.4,"imdbVotes":12950,"tmdb":7.093,"tmdbVotes":227}},{"id":"movie-fatale-2020","title":"Fatale","year":2020,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Thriller"],"overview":"After a wild one-night stand, successful sports agent Derrick Tyler watches his perfect life slowly disappear when he discovers the sexy and mysterious woman he risked everything for is determined police detective\u2026","poster":"assets/movie-fatale-2020-poster.jpg","backdrop":"assets/movie-fatale-2020-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":549514,"ratings":{"rtCritics":46,"rtCriticsLabel":"Rotten","rtAudience":87,"rtAudienceLabel":"Upright","imdb":5.5,"imdbVotes":16392,"tmdb":6,"tmdbVotes":401}},{"id":"tv-56-days-2026","title":"56 Days","year":2026,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Crime","Mystery"],"overview":"When an unidentified body is found in a luxury apartment linked to Oliver Kennedy and his girlfriend Ciara Wyse, Detectives Lee Reardon and Karl Connolly reconstruct the couple's deadly romance across the past 56 days.","poster":"assets/tv-56-days-2026-poster.jpg","backdrop":"assets/tv-56-days-2026-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":241372,"ratings":{"rtCritics":60,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.388,"tmdbVotes":245}},{"id":"tv-90-day-fiance-before-the-90-days-2017","title":"90 Day Fianc\u00e9: Before the 90 Days","year":2017,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Reality"],"overview":"Follow couples who have an existing relationship online, but haven't met in person. We'll experience their journey as they travel to the other's foreign country for the first time in an attempt to establish an\u2026","poster":"assets/tv-90-day-fiance-before-the-90-days-2017-poster.jpg","backdrop":"assets/tv-90-day-fiance-before-the-90-days-2017-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":73319,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.5,"tmdbVotes":45}},{"id":"tv-90-day-fiance-the-other-way-2019","title":"90 Day Fianc\u00e9: The Other Way","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Reality"],"overview":"Americans move to a foreign country all in the name of love. Will uprooting their lives in the United States pay off, or will they be packing up and moving back to America brokenhearted?","poster":"assets/tv-90-day-fiance-the-other-way-2019-poster.jpg","backdrop":"assets/tv-90-day-fiance-the-other-way-2019-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":90046,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.8,"tmdbVotes":34}},{"id":"tv-absolutely-fabulous-1992","title":"Absolutely Fabulous","year":1992,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy"],"overview":"Set in the world of fashion and PR, immature fun-loving mother Edina Monsoon and her best friend Patsy drive Eddie's sensible daughter, Saffron, up the wall with their constant drug abuse and outrageous selfishness.\u2026","poster":"assets/tv-absolutely-fabulous-1992-poster.jpg","backdrop":"assets/tv-absolutely-fabulous-1992-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":4493,"ratings":{"rtCritics":96,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.6,"tmdbVotes":198}},{"id":"tv-the-afterparty-2022","title":"The Afterparty","year":2022,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Mystery","Comedy","Crime"],"overview":"Nothing is as it seems in this hilarious murder mystery where each suspect's story is told through a different film genre.","poster":"assets/tv-the-afterparty-2022-poster.jpg","backdrop":"assets/tv-the-afterparty-2022-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":106454,"ratings":{"rtCritics":92,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.071,"tmdbVotes":245}},{"id":"tv-agatha-christie-s-marple-2004","title":"Agatha Christie's Marple","year":2004,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Crime","Mystery"],"overview":"The adventures of Miss Jane Marple, an elderly spinster living in the quiet little village of St Mary Mead. During her many visits to friends and relatives in other villages, Miss Marple often stumbles upon\u2026","poster":"assets/tv-agatha-christie-s-marple-2004-poster.jpg","backdrop":"assets/tv-agatha-christie-s-marple-2004-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":6127,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.9,"tmdbVotes":152}},{"id":"tv-all-american-homecoming-2022","title":"All American: Homecoming","year":2022,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama"],"overview":"Follow the journeys of Simone Hicks, a tennis hopeful from Beverly Hills, and elite baseball player Damon Sims from Chicago, as they navigate life at the prestigious HBCU Bringston University.","poster":"assets/tv-all-american-homecoming-2022-poster.jpg","backdrop":"assets/tv-all-american-homecoming-2022-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":153723,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.3,"tmdbVotes":70}},{"id":"tv-and-just-like-that-2021","title":"And Just Like That\u2026","year":2021,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy","Drama"],"overview":"This new chapter of \u201cSex and the City\u201d follows Carrie, Miranda and Charlotte as they navigate the journey from the complicated reality of life and friendship in their 30s to the even more complicated reality of life\u2026","poster":"assets/tv-and-just-like-that-2021-poster.jpg","backdrop":"assets/tv-and-just-like-that-2021-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":116450,"ratings":{"rtCritics":52,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.211,"tmdbVotes":556}},{"id":"tv-anne-with-an-e-2017","title":"Anne with an E","year":2017,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Family"],"overview":"A coming-of-age story about an outsider who, against all odds and numerous challenges, fights for love and acceptance and for her place in the world. The series centers on a young orphaned girl in the late 1890s,\u2026","poster":"assets/tv-anne-with-an-e-2017-poster.jpg","backdrop":"assets/tv-anne-with-an-e-2017-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":70785,"ratings":{"rtCritics":63,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.7,"tmdbVotes":5019}},{"id":"tv-awkward-2011","title":"Awkward.","year":2011,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy","Drama"],"overview":"An irreverent look at the conflict, chaos and humor that defines teenage life through the eyes of 15-year-old Jenna Hamilton whose life begins to change when a simple accident becomes an epic misunderstanding and is\u2026","poster":"assets/tv-awkward-2011-poster.jpg","backdrop":"assets/tv-awkward-2011-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":39859,"ratings":{"rtCritics":78,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.4,"tmdbVotes":427}},{"id":"tv-the-baby-sitters-club-2020","title":"The Baby-Sitters Club","year":2020,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Kids","Drama"],"overview":"In this contemporary take on the beloved book series, five best friends launch a baby-sitting business that's big on fun and adventure.","poster":"assets/tv-the-baby-sitters-club-2020-poster.jpg","backdrop":"assets/tv-the-baby-sitters-club-2020-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":100412,"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.9,"tmdbVotes":506}},{"id":"tv-ballers-2015","title":"Ballers","year":2015,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Comedy"],"overview":"Looking at the lives of former and current football players, the show follows former superstar Spencer Strasmore as he gets his life on track in retirement while mentoring other current and former players through the\u2026","poster":"assets/tv-ballers-2015-poster.jpg","backdrop":"assets/tv-ballers-2015-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":62704,"ratings":{"rtCritics":72,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.809,"tmdbVotes":820}},{"id":"tv-baretta-1975","title":"Baretta","year":1975,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Crime","Drama","Mystery"],"overview":"Baretta is an American detective television series which ran on ABC from 1975 to 1978. The show was a milder version of a successful 1973\u201374 ABC series, Toma, starring Tony Musante as chameleon-like, real-life New\u2026","poster":"assets/tv-baretta-1975-poster.jpg","backdrop":"assets/tv-baretta-1975-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":3216,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.5,"tmdbVotes":43}},{"id":"tv-based-on-a-true-story-2023","title":"Based on a True Story","year":2023,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy","Drama"],"overview":"The lives of a realtor, a plumber and a former tennis star unexpectedly collide, exposing America\u2019s obsession with true crime, murder and the slow-close toilet seat.","poster":"assets/tv-based-on-a-true-story-2023-poster.jpg","backdrop":"assets/tv-based-on-a-true-story-2023-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":197373,"ratings":{"rtCritics":79,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7,"tmdbVotes":160}},{"id":"tv-the-beast-in-me-2025","title":"The Beast in Me","year":2025,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Mystery"],"overview":"A famous author is pulled into a twisted mind game with her rich, powerful new neighbor \u2014 who might be a murderer.","poster":"assets/tv-the-beast-in-me-2025-poster.jpg","backdrop":"assets/tv-the-beast-in-me-2025-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":250504,"ratings":{"rtCritics":85,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.072,"tmdbVotes":334}},{"id":"tv-big-city-greens-2018","title":"Big City Greens","year":2018,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Family","Animation","Comedy"],"overview":"The offbeat adventures of 10-year-old Cricket Green, a mischievous and optimistic country boy who moves to the big city with his wildly out of place family \u2013 older sister Tilly, father Bill and Gramma Alice.","poster":"assets/tv-big-city-greens-2018-poster.jpg","backdrop":"assets/tv-big-city-greens-2018-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":80587,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.555,"tmdbVotes":82}},{"id":"tv-the-bold-type-2017","title":"The Bold Type","year":2017,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Comedy"],"overview":"A glimpse into the outrageous lives of Jane, Kat and Sutton, who are working at the nation's top women's magazine, Scarlet, while navigating their careers, identities and individual voices.","poster":"assets/tv-the-bold-type-2017-poster.jpg","backdrop":"assets/tv-the-bold-type-2017-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":69630,"ratings":{"rtCritics":89,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.7,"tmdbVotes":204}},{"id":"tv-brassic-2019","title":"Brassic","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy","Drama","Crime"],"overview":"A group of working-class friends finding unconventional ways to win at life in suburban northern England. These lads have dealt, scammed, bribed and conned their way through adolescence, but now, their dealing and\u2026","poster":"assets/tv-brassic-2019-poster.jpg","backdrop":"assets/tv-brassic-2019-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":92621,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.336,"tmdbVotes":263}}],"discoverySource":{"state":"materialized_snapshot","rankKnown":false,"selection":"First 18 alphabetically indexed movie stubs and first 18 series stubs, not ranked feed order","observedAt":"2026-11-04T20:42:12.030108+00:00"}},{"key":"shared","name":"Shared","initial":"S","color":"#9be38f","destinations":{"Home":{"kind":"home"},"Movies":{"kind":"library","collection":"movies"},"Shows":{"kind":"library","collection":"shows"},"Discover":{"kind":"discover","collection":"discover"}},"movies":[{"id":"movie-american-gangster-2007","title":"American Gangster","year":2007,"type":"movie","runtimeSeconds":10546,"seasonCount":null,"communityRating":7.595,"criticRating":81,"genres":["Drama","Crime"],"overview":"Loosely based on the criminal career of Frank Lucas, a gangster from La Grange, North Carolina, who smuggled heroin into the United States on American service planes returning from the Vietnam War, before being\u2026","poster":"assets/movie-american-gangster-2007-poster.jpg","backdrop":"assets/movie-american-gangster-2007-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-08-23T10:22:36.0000000Z","lastPlayedAt":"2026-08-30T21:05:39.7952451Z","availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":4982,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":81,"rtCriticsLabel":"Certified Fresh","rtAudience":90,"rtAudienceLabel":"Upright","imdb":7.8,"imdbVotes":495656,"tmdb":7.599,"tmdbVotes":6350}},{"id":"movie-annie-hall-1977","title":"Annie Hall","year":1977,"type":"movie","runtimeSeconds":5604,"seasonCount":null,"communityRating":7.712,"criticRating":97,"genres":["Comedy","Drama","Romance"],"overview":"New York comedian Alvy Singer falls in love with the ditsy Annie Hall.","poster":"assets/movie-annie-hall-1977-poster.jpg","backdrop":"assets/movie-annie-hall-1977-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:24:23.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":703,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":97,"rtCriticsLabel":"Certified Fresh","rtAudience":92,"rtAudienceLabel":"Upright","imdb":7.9,"imdbVotes":293349,"tmdb":7.71,"tmdbVotes":4391}},{"id":"movie-apocalypse-now-1979","title":"Apocalypse Now","year":1979,"type":"movie","runtimeSeconds":8835,"seasonCount":null,"communityRating":8.261,"criticRating":91,"genres":["Drama","War"],"overview":"At the height of the Vietnam war, Captain Benjamin Willard is sent on a dangerous mission that, officially, \"does not exist, nor will it ever exist.\" His goal is to locate - and eliminate - a mysterious Green Beret\u2026","poster":"assets/movie-apocalypse-now-1979-poster.jpg","backdrop":"assets/movie-apocalypse-now-1979-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:22:25.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":28,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":91,"rtCriticsLabel":"Certified Fresh","rtAudience":94,"rtAudienceLabel":"Upright","imdb":8.4,"imdbVotes":781424,"tmdb":8.3,"tmdbVotes":9526}},{"id":"movie-arrival-2016","title":"Arrival","year":2016,"type":"movie","runtimeSeconds":6967,"seasonCount":null,"communityRating":7.636,"criticRating":94,"genres":["Drama","Science Fiction","Mystery"],"overview":"Taking place after alien crafts land around the world, an expert linguist is recruited by the military to determine whether they come in peace or are a threat.","poster":"assets/movie-arrival-2016-poster.jpg","backdrop":"assets/movie-arrival-2016-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-14T19:54:12.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":329865,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":94,"rtCriticsLabel":"Certified Fresh","rtAudience":83,"rtAudienceLabel":"Upright","imdb":7.9,"imdbVotes":883621,"tmdb":7.638,"tmdbVotes":20125}},{"id":"movie-avatar-aang-the-last-airbender-2026","title":"Avatar Aang: The Last Airbender","year":2026,"type":"movie","runtimeSeconds":5930,"seasonCount":null,"communityRating":9.147,"criticRating":90,"genres":["Animation","Adventure","Fantasy"],"overview":"Avatar Aang, the world's last Airbender, learns of an ancient power that could save his culture from extinction. With the help of his friends, he embarks on a global quest to find it before it falls into the wrong\u2026","poster":"assets/movie-avatar-aang-the-last-airbender-2026-poster.jpg","backdrop":"assets/movie-avatar-aang-the-last-airbender-2026-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-25T09:53:09.0000000Z","lastPlayedAt":"2026-08-02T19:22:33.6155383Z","availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":980431,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":90,"rtCriticsLabel":"Certified Fresh","rtAudience":99,"rtAudienceLabel":"Upright","imdb":7.7,"imdbVotes":30792,"tmdb":9,"tmdbVotes":1417}},{"id":"movie-bohemian-rhapsody-2018","title":"Bohemian Rhapsody","year":2018,"type":"movie","runtimeSeconds":8077,"seasonCount":null,"communityRating":7.95,"criticRating":60,"genres":["Music","Drama"],"overview":"Singer Freddie Mercury, guitarist Brian May, drummer Roger Taylor and bass guitarist John Deacon take the music world by storm when they form the rock 'n' roll band Queen in 1970. Hit songs become instant classics.\u2026","poster":"assets/movie-bohemian-rhapsody-2018-poster.jpg","backdrop":"assets/movie-bohemian-rhapsody-2018-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:28:20.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":424694,"artwork":{"posterKind":"center_crop","backdropKind":"backdrop"},"ratings":{"rtCritics":60,"rtCriticsLabel":"Fresh","rtAudience":85,"rtAudienceLabel":"Upright","imdb":7.9,"imdbVotes":655894,"tmdb":7.947,"tmdbVotes":18196}},{"id":"movie-the-boy-and-the-heron-2023","title":"The Boy and the Heron","year":2023,"type":"movie","runtimeSeconds":7437,"seasonCount":null,"communityRating":7.4,"criticRating":96,"genres":["Animation","Fantasy","Drama"],"overview":"While the Second World War rages, the teenage Mahito, haunted by his mother's tragic death, is relocated from Tokyo to the serene rural home of his new stepmother Natsuko, a woman who bears a striking resemblance to\u2026","poster":"assets/movie-the-boy-and-the-heron-2023-poster.jpg","backdrop":"assets/movie-the-boy-and-the-heron-2023-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:25:15.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":508883,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":96,"rtCriticsLabel":"Certified Fresh","rtAudience":88,"rtAudienceLabel":"Upright","imdb":7.3,"imdbVotes":152169,"tmdb":7.387,"tmdbVotes":2893}},{"id":"movie-braveheart-1995","title":"Braveheart","year":1995,"type":"movie","runtimeSeconds":10665,"seasonCount":null,"communityRating":7.945,"criticRating":74,"genres":["Drama","History","War"],"overview":"Enraged at the slaughter of Murron, his new bride and childhood love, Scottish warrior William Wallace slays a platoon of the local English lord's soldiers. This leads the village to revolt and, eventually, the\u2026","poster":"assets/movie-braveheart-1995-poster.jpg","backdrop":"assets/movie-braveheart-1995-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-10T21:24:28.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":197,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":74,"rtCriticsLabel":"Certified Fresh","rtAudience":85,"rtAudienceLabel":"Upright","imdb":8.3,"imdbVotes":1174990,"tmdb":7.947,"tmdbVotes":11584}},{"id":"movie-the-breakfast-club-1985","title":"The Breakfast Club","year":1985,"type":"movie","runtimeSeconds":5833,"seasonCount":null,"communityRating":7.7,"criticRating":87,"genres":["Comedy","Drama"],"overview":"Five high school students from different walks of life endure a Saturday detention under a power-hungry principal. The disparate group includes rebel John, princess Claire, outcast Allison, brainy Brian and Andrew,\u2026","poster":"assets/movie-the-breakfast-club-1985-poster.jpg","backdrop":"assets/movie-the-breakfast-club-1985-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:25:03.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":2108,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":87,"rtCriticsLabel":"Certified Fresh","rtAudience":92,"rtAudienceLabel":"Upright","imdb":7.8,"imdbVotes":478743,"tmdb":7.713,"tmdbVotes":8815}},{"id":"movie-castle-in-the-sky-1986","title":"Castle in the Sky","year":1986,"type":"movie","runtimeSeconds":7474,"seasonCount":null,"communityRating":8,"criticRating":97,"genres":["Adventure","Fantasy","Animation"],"overview":"A young boy and a girl with a magic crystal must race against pirates and foreign agents in a search for a legendary floating castle.","poster":"assets/movie-castle-in-the-sky-1986-poster.jpg","backdrop":"assets/movie-castle-in-the-sky-1986-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:23:09.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":10515,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":96,"rtCriticsLabel":"Fresh","rtAudience":91,"rtAudienceLabel":"Upright","imdb":8,"imdbVotes":201545,"tmdb":7.972,"tmdbVotes":4868}},{"id":"movie-citizen-kane-1941","title":"Citizen Kane","year":1941,"type":"movie","runtimeSeconds":7173,"seasonCount":null,"communityRating":7.966,"criticRating":99,"genres":["Mystery","Drama"],"overview":"Newspaper magnate Charles Foster Kane is taken from his mother as a boy and made the ward of a rich industrialist. As a result, every well-meaning, tyrannical or self-destructive move he makes for the rest of his\u2026","poster":"assets/movie-citizen-kane-1941-poster.jpg","backdrop":"assets/movie-citizen-kane-1941-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:22:16.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":15,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":99,"rtCriticsLabel":"Certified Fresh","rtAudience":98,"rtAudienceLabel":"Upright","imdb":8.2,"imdbVotes":498321,"tmdb":7.962,"tmdbVotes":6324}},{"id":"movie-conclave-2024","title":"Conclave","year":2024,"type":"movie","runtimeSeconds":7230,"seasonCount":null,"communityRating":7.19,"criticRating":93,"genres":["Drama","Thriller"],"overview":"After the unexpected death of the Pope, Cardinal Lawrence is tasked with managing the covert and ancient ritual of electing a new one. Sequestered in the Vatican with the Catholic Church\u2019s most powerful leaders until\u2026","poster":"assets/movie-conclave-2024-poster.jpg","backdrop":"assets/movie-conclave-2024-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:30:41.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":974576,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":93,"rtCriticsLabel":"Certified Fresh","rtAudience":86,"rtAudienceLabel":"Upright","imdb":7.4,"imdbVotes":290498,"tmdb":7.19,"tmdbVotes":3744}},{"id":"movie-the-dry-2021","title":"The Dry","year":2021,"type":"movie","runtimeSeconds":7037,"seasonCount":null,"communityRating":6.779,"criticRating":null,"genres":["Crime","Thriller","Mystery"],"overview":"Aaron Falk returns to his drought-stricken hometown to attend a tragic funeral. But his return opens a decades-old wound - the unsolved death of a teenage girl.","poster":"assets/movie-the-dry-2021-poster.jpg","backdrop":"assets/movie-the-dry-2021-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:29:20.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":567797,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":90,"rtCriticsLabel":"Certified Fresh","rtAudience":89,"rtAudienceLabel":"Upright","imdb":6.8,"imdbVotes":41935,"tmdb":6.8,"tmdbVotes":892}},{"id":"movie-easy-a-2010","title":"Easy A","year":2010,"type":"movie","runtimeSeconds":5545,"seasonCount":null,"communityRating":6.8,"criticRating":85,"genres":["Comedy"],"overview":"Olive, an average high school student, sees her below-the-radar existence turn around overnight once she decides to use the school's gossip grapevine to advance her social standing. Now her classmates are turning\u2026","poster":"assets/movie-easy-a-2010-poster.jpg","backdrop":"assets/movie-easy-a-2010-backdrop.jpg","played":true,"playCount":1,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-17T19:59:04.0000000Z","lastPlayedAt":"2026-10-17T20:02:51.1083337Z","availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":37735,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":85,"rtCriticsLabel":"Certified Fresh","rtAudience":77,"rtAudienceLabel":"Upright","imdb":7,"imdbVotes":448780,"tmdb":6.8,"tmdbVotes":7619}},{"id":"movie-el-camino-a-breaking-bad-movie-2019","title":"El Camino: A Breaking Bad Movie","year":2019,"type":"movie","runtimeSeconds":7362,"seasonCount":null,"communityRating":7.055,"criticRating":92,"genres":["Crime","Drama","Thriller"],"overview":"In the wake of his dramatic escape from captivity, Jesse Pinkman must come to terms with his past in order to forge some kind of future.","poster":"assets/movie-el-camino-a-breaking-bad-movie-2019-poster.jpg","backdrop":"assets/movie-el-camino-a-breaking-bad-movie-2019-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-21T23:31:11.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":559969,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":92,"rtCriticsLabel":"Certified Fresh","rtAudience":81,"rtAudienceLabel":"Upright","imdb":7.3,"imdbVotes":354488,"tmdb":7.056,"tmdbVotes":5950}},{"id":"movie-emancipation-2022","title":"Emancipation","year":2022,"type":"movie","runtimeSeconds":7949,"seasonCount":null,"communityRating":7.7,"criticRating":45,"genres":["Drama","War","History"],"overview":"Inspired by the gripping true story of a man who would do anything for his family\u2014and for freedom. When Peter, an enslaved man, risks his life to escape and return to his family, he embarks on a perilous journey of\u2026","poster":"assets/movie-emancipation-2022-poster.jpg","backdrop":"assets/movie-emancipation-2022-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-10T20:37:34.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":715931,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":45,"rtCriticsLabel":"Rotten","rtAudience":58,"rtAudienceLabel":"Spilled","imdb":6.3,"imdbVotes":32079,"tmdb":7.744,"tmdbVotes":1319}},{"id":"movie-fighting-with-my-family-2019","title":"Fighting with My Family","year":2019,"type":"movie","runtimeSeconds":6509,"seasonCount":null,"communityRating":6.84,"criticRating":93,"genres":["Comedy"],"overview":"Born into a tight-knit wrestling family, Paige and her brother Zak are ecstatic when they get the once-in-a-lifetime opportunity to try out for the WWE. But when only Paige earns a spot in the competitive training\u2026","poster":"assets/movie-fighting-with-my-family-2019-poster.jpg","backdrop":"assets/movie-fighting-with-my-family-2019-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-22T22:10:59.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":445629,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":93,"rtCriticsLabel":"Certified Fresh","rtAudience":86,"rtAudienceLabel":"Upright","imdb":7,"imdbVotes":94576,"tmdb":6.837,"tmdbVotes":1952}},{"id":"movie-force-of-nature-the-dry-2-2024","title":"Force of Nature: The Dry 2","year":2024,"type":"movie","runtimeSeconds":6753,"seasonCount":null,"communityRating":6.3,"criticRating":66,"genres":["Mystery","Thriller"],"overview":"When five women take part in a corporate hiking retreat and only four come out on the other side, Federal Agents Aaron Falk and Carmen Cooper head deep into the Victorian mountain ranges to investigate in the hopes\u2026","poster":"assets/movie-force-of-nature-the-dry-2-2024-poster.jpg","backdrop":"assets/movie-force-of-nature-the-dry-2-2024-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:29:49.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":832262,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":66,"rtCriticsLabel":"Fresh","rtAudience":43,"rtAudienceLabel":"Spilled","imdb":5.9,"imdbVotes":10343,"tmdb":6.269,"tmdbVotes":305}},{"id":"movie-free-state-of-jones-2016","title":"Free State of Jones","year":2016,"type":"movie","runtimeSeconds":8376,"seasonCount":null,"communityRating":6.79,"criticRating":48,"genres":["Action","Drama","History"],"overview":"In 1863, Mississippi farmer Newt Knight serves as a medic for the Confederate Army. Opposed to slavery, Knight would rather help the wounded than fight the Union. After his nephew dies in battle, Newt returns home to\u2026","poster":"assets/movie-free-state-of-jones-2016-poster.jpg","backdrop":"assets/movie-free-state-of-jones-2016-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-11T17:56:53.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":316152,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":48,"rtCriticsLabel":"Rotten","rtAudience":64,"rtAudienceLabel":"Upright","imdb":6.9,"imdbVotes":68931,"tmdb":6.788,"tmdbVotes":1730}},{"id":"movie-from-up-on-poppy-hill-2011","title":"From Up on Poppy Hill","year":2011,"type":"movie","runtimeSeconds":5482,"seasonCount":null,"communityRating":7.5,"criticRating":87,"genres":["Animation","Drama"],"overview":"Yokohama, 1963. Japan is picking itself up from the devastation of World War II and preparing to host the 1964 Olympics\u2014and the mood is one of both optimism and conflict as the young generation struggles to throw off\u2026","poster":"assets/movie-from-up-on-poppy-hill-2011-poster.jpg","backdrop":"assets/movie-from-up-on-poppy-hill-2011-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-07T14:41:03.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":83389,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":87,"rtCriticsLabel":"Certified Fresh","rtAudience":80,"rtAudienceLabel":"Upright","imdb":7.4,"imdbVotes":60769,"tmdb":7.509,"tmdbVotes":2026}},{"id":"movie-ghost-in-the-shell-1995","title":"Ghost in the Shell","year":1995,"type":"movie","runtimeSeconds":4959,"seasonCount":null,"communityRating":7.929,"criticRating":95,"genres":["Action","Animation","Science Fiction"],"overview":"In the year 2029, the barriers of our world have been broken down by the net and by cybernetics, but this brings new vulnerability to humans in the form of brain-hacking. When a highly-wanted hacker known as 'The\u2026","poster":"assets/movie-ghost-in-the-shell-1995-poster.jpg","backdrop":"assets/movie-ghost-in-the-shell-1995-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:22:57.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":9323,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":97,"rtCriticsLabel":"Certified Fresh","rtAudience":90,"rtAudienceLabel":"Upright","imdb":7.9,"imdbVotes":178061,"tmdb":7.928,"tmdbVotes":4130}},{"id":"movie-ghost-in-the-shell-2-innocence-2004","title":"Ghost in the Shell 2: Innocence","year":2004,"type":"movie","runtimeSeconds":5950,"seasonCount":null,"communityRating":7.352,"criticRating":65,"genres":["Animation","Drama","Science Fiction"],"overview":"Cyborg detective Batou is assigned to investigate a series of murders committed by gynoids\u2014doll-like cyborgs, which all malfunctioned, killed, then self-destructed afterwards. The brains of the gynoids initialize in\u2026","poster":"assets/movie-ghost-in-the-shell-2-innocence-2004-poster.jpg","backdrop":"assets/movie-ghost-in-the-shell-2-innocence-2004-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:23:38.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":12140,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":65,"rtCriticsLabel":"Fresh","rtAudience":80,"rtAudienceLabel":"Upright","imdb":7.4,"imdbVotes":43280,"tmdb":7.356,"tmdbVotes":1049}},{"id":"movie-the-godfather-1972","title":"The Godfather","year":1972,"type":"movie","runtimeSeconds":10603,"seasonCount":null,"communityRating":8.687,"criticRating":97,"genres":["Drama","Crime"],"overview":"Spanning the years 1945 to 1955, a chronicle of the fictional Italian-American Corleone crime family. When organized crime family patriarch, Vito Corleone barely survives an attempt on his life, his youngest son,\u2026","poster":"assets/movie-the-godfather-1972-poster.jpg","backdrop":"assets/movie-the-godfather-1972-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:24:12.0000000Z","lastPlayedAt":"2026-09-18T19:24:44.9855867Z","availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":238,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":97,"rtCriticsLabel":"Certified Fresh","rtAudience":98,"rtAudienceLabel":"Upright","imdb":9.2,"imdbVotes":2262024,"tmdb":8.688,"tmdbVotes":23907}},{"id":"movie-the-godfather-part-ii-1974","title":"The Godfather Part II","year":1974,"type":"movie","runtimeSeconds":12031,"seasonCount":null,"communityRating":8.573,"criticRating":96,"genres":["Drama","Crime"],"overview":"In the continuing saga of the Corleone crime family, a young Vito Corleone grows up in Sicily and in 1910s New York. In the 1950s, Michael Corleone attempts to expand the family business into Las Vegas, Hollywood and\u2026","poster":"assets/movie-the-godfather-part-ii-1974-poster.jpg","backdrop":"assets/movie-the-godfather-part-ii-1974-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:24:18.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":240,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":96,"rtCriticsLabel":"Certified Fresh","rtAudience":97,"rtAudienceLabel":"Upright","imdb":9,"imdbVotes":1521308,"tmdb":8.571,"tmdbVotes":14559}},{"id":"movie-the-godfather-part-iii-1990","title":"The Godfather Part III","year":1990,"type":"movie","runtimeSeconds":9476,"seasonCount":null,"communityRating":7.416,"criticRating":67,"genres":["Crime","Drama","Thriller"],"overview":"In the midst of trying to legitimize his business dealings in 1979 New York and Italy, aging mafia don, Michael Corleone seeks forgiveness for his sins while taking a young protege under his wing.","poster":"assets/movie-the-godfather-part-iii-1990-poster.jpg","backdrop":"assets/movie-the-godfather-part-iii-1990-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:26:29.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":242,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":68,"rtCriticsLabel":"Fresh","rtAudience":68,"rtAudienceLabel":"Upright","imdb":7.5,"imdbVotes":462448,"tmdb":7.413,"tmdbVotes":7159}},{"id":"movie-gosford-park-2001","title":"Gosford Park","year":2001,"type":"movie","runtimeSeconds":8247,"seasonCount":null,"communityRating":6.815,"criticRating":87,"genres":["Drama","Mystery","Thriller"],"overview":"In 1930s England, a group of pretentious rich and famous gather together for a weekend of relaxation at a hunting resort. But when a murder occurs, each one of these interesting characters becomes a suspect.","poster":"assets/movie-gosford-park-2001-poster.jpg","backdrop":"assets/movie-gosford-park-2001-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:25:49.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":5279,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":87,"rtCriticsLabel":"Certified Fresh","rtAudience":78,"rtAudienceLabel":"Upright","imdb":7.2,"imdbVotes":103883,"tmdb":6.822,"tmdbVotes":1317}},{"id":"movie-grave-of-the-fireflies-1988","title":"Grave of the Fireflies","year":1988,"type":"movie","runtimeSeconds":5313,"seasonCount":null,"communityRating":8.45,"criticRating":100,"genres":["Animation","Drama","War"],"overview":"In the final months of World War II, 14-year-old Seita and his sister Setsuko are orphaned when their mother is killed during an air raid in Kobe, Japan. After a falling out with their aunt, they move into an\u2026","poster":"assets/movie-grave-of-the-fireflies-1988-poster.jpg","backdrop":"assets/movie-grave-of-the-fireflies-1988-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:24:14.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":12477,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Certified Fresh","rtAudience":95,"rtAudienceLabel":"Upright","imdb":8.5,"imdbVotes":399802,"tmdb":8.45,"tmdbVotes":6938}},{"id":"movie-the-great-gatsby-2013","title":"The Great Gatsby","year":2013,"type":"movie","runtimeSeconds":8525,"seasonCount":null,"communityRating":7.346,"criticRating":49,"genres":["Drama","Romance"],"overview":"Nick Carraway arrives in New York in 1922, an era of loose morals, glittering jazz and bootleg kings. Chasing his own American Dream, Nick encounters the mysterious millionaire Gatsby and his bewitching cousin Daisy.\u2026","poster":"assets/movie-the-great-gatsby-2013-poster.jpg","backdrop":"assets/movie-the-great-gatsby-2013-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:26:50.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":64682,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":48,"rtCriticsLabel":"Rotten","rtAudience":67,"rtAudienceLabel":"Upright","imdb":7.2,"imdbVotes":651882,"tmdb":7.346,"tmdbVotes":13290}},{"id":"movie-the-hours-2002","title":"The Hours","year":2002,"type":"movie","runtimeSeconds":6887,"seasonCount":null,"communityRating":7.25,"criticRating":79,"genres":["Drama"],"overview":"The story of three women searching for more potent, meaningful lives. Each is alive at a different time and place, all are linked by their yearnings and their fears. Their stories intertwine, and finally come\u2026","poster":"assets/movie-the-hours-2002-poster.jpg","backdrop":"assets/movie-the-hours-2002-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:23:27.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":590,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":79,"rtCriticsLabel":"Certified Fresh","rtAudience":84,"rtAudienceLabel":"Upright","imdb":7.5,"imdbVotes":148367,"tmdb":7.249,"tmdbVotes":1931}},{"id":"movie-howl-s-moving-castle-2004","title":"Howl's Moving Castle","year":2004,"type":"movie","runtimeSeconds":7149,"seasonCount":null,"communityRating":8.4,"criticRating":88,"genres":["Fantasy","Animation","Adventure"],"overview":"Sophie, a young milliner, is turned into an elderly woman by a witch who enters her shop and curses her. She encounters a wizard named Howl and gets caught up in his resistance to fighting for the king.","poster":"assets/movie-howl-s-moving-castle-2004-poster.jpg","backdrop":"assets/movie-howl-s-moving-castle-2004-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:22:47.0000000Z","lastPlayedAt":"2026-05-21T18:46:08.0000000Z","availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":4935,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":87,"rtCriticsLabel":"Certified Fresh","rtAudience":93,"rtAudienceLabel":"Upright","imdb":8.2,"imdbVotes":524520,"tmdb":8.391,"tmdbVotes":11642}},{"id":"movie-in-the-heights-2021","title":"In the Heights","year":2021,"type":"movie","runtimeSeconds":8579,"seasonCount":null,"communityRating":7.111,"criticRating":null,"genres":["Drama","Romance"],"overview":"The story of Usnavi, a bodega owner who has mixed feelings about closing his store and retiring to the Dominican Republic or staying in Washington Heights.","poster":"assets/movie-in-the-heights-2021-poster.jpg","backdrop":"assets/movie-in-the-heights-2021-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:29:11.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":467909,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":94,"rtCriticsLabel":"Certified Fresh","rtAudience":94,"rtAudienceLabel":"Upright","imdb":7.2,"imdbVotes":56513,"tmdb":7.104,"tmdbVotes":826}},{"id":"movie-jennifer-s-body-2009","title":"Jennifer's Body","year":2009,"type":"movie","runtimeSeconds":6450,"seasonCount":null,"communityRating":6.012,"criticRating":47,"genres":["Horror","Comedy"],"overview":"Jennifer, a gorgeous, seductive cheerleader takes evil to a whole new level after she's possessed by a sinister demon. Now it's up to her best friend to stop Jennifer's reign of terror before it's too late.","poster":"assets/movie-jennifer-s-body-2009-poster.jpg","backdrop":"assets/movie-jennifer-s-body-2009-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-30T03:37:04.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":19994,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":47,"rtCriticsLabel":"Rotten","rtAudience":36,"rtAudienceLabel":"Spilled","imdb":5.6,"imdbVotes":179710,"tmdb":6.013,"tmdbVotes":4481}},{"id":"movie-kiki-s-delivery-service-1989","title":"Kiki's Delivery Service","year":1989,"type":"movie","runtimeSeconds":6183,"seasonCount":null,"communityRating":7.839,"criticRating":null,"genres":["Animation","Family","Fantasy"],"overview":"A young witch, on her mandatory year of independent life, finds fitting into a new community difficult while she supports herself by running an air courier service.","poster":"assets/movie-kiki-s-delivery-service-1989-poster.jpg","backdrop":"assets/movie-kiki-s-delivery-service-1989-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:24:20.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":16859,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":98,"rtCriticsLabel":"Certified Fresh","rtAudience":89,"rtAudienceLabel":"Upright","imdb":7.8,"imdbVotes":192477,"tmdb":7.839,"tmdbVotes":4811}},{"id":"movie-the-last-of-the-mohicans-1992","title":"The Last of the Mohicans","year":1992,"type":"movie","runtimeSeconds":6874,"seasonCount":null,"communityRating":7.388,"criticRating":88,"genres":["History","War","Drama"],"overview":"In war-torn colonial America, in the midst of a bloody battle between British, the French and Native American allies, the aristocratic daughter of a British Colonel and her party are captured by a group of Huron\u2026","poster":"assets/movie-the-last-of-the-mohicans-1992-poster.jpg","backdrop":"assets/movie-the-last-of-the-mohicans-1992-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-10T20:59:22.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":9361,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":88,"rtCriticsLabel":"Certified Fresh","rtAudience":88,"rtAudienceLabel":"Upright","imdb":7.6,"imdbVotes":210791,"tmdb":7.39,"tmdbVotes":3593}},{"id":"movie-the-last-samurai-2003","title":"The Last Samurai","year":2003,"type":"movie","runtimeSeconds":9251,"seasonCount":null,"communityRating":7.61,"criticRating":65,"genres":["Drama","Action","War"],"overview":"Nathan Algren is an American hired to instruct the Japanese army in the ways of modern warfare, which finds him learning to respect the samurai and the honorable principles that rule them. Pressed to destroy the\u2026","poster":"assets/movie-the-last-samurai-2003-poster.jpg","backdrop":"assets/movie-the-last-samurai-2003-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-26T05:25:10.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":616,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":66,"rtCriticsLabel":"Fresh","rtAudience":83,"rtAudienceLabel":"Upright","imdb":7.8,"imdbVotes":512982,"tmdb":7.614,"tmdbVotes":7887}},{"id":"movie-lemony-snicket-s-a-series-of-unfortunate-events-2004","title":"Lemony Snicket's A Series of Unfortunate Events","year":2004,"type":"movie","runtimeSeconds":6479,"seasonCount":null,"communityRating":6.8,"criticRating":72,"genres":["Adventure","Comedy","Family"],"overview":"Three wealthy children's parents are killed in a fire. When they are sent to a distant relative, they find out that he is plotting to kill them and seize their fortune.","poster":"assets/movie-lemony-snicket-s-a-series-of-unfortunate-events-2004-poster.jpg","backdrop":"assets/movie-lemony-snicket-s-a-series-of-unfortunate-events-2004-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:26:09.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":11774,"artwork":{"posterKind":"center_crop","backdropKind":"backdrop"},"ratings":{"rtCritics":72,"rtCriticsLabel":"Fresh","rtAudience":63,"rtAudienceLabel":"Upright","imdb":6.8,"imdbVotes":233636,"tmdb":6.82,"tmdbVotes":5447}},{"id":"movie-the-lighthouse-2019","title":"The Lighthouse","year":2019,"type":"movie","runtimeSeconds":6549,"seasonCount":null,"communityRating":7.463,"criticRating":90,"genres":["Drama","Fantasy","Thriller"],"overview":"Two lighthouse keepers try to maintain their sanity while living on a remote and mysterious New England island in the 1890s.","poster":"assets/movie-the-lighthouse-2019-poster.jpg","backdrop":"assets/movie-the-lighthouse-2019-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:29:01.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":503919,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":90,"rtCriticsLabel":"Certified Fresh","rtAudience":72,"rtAudienceLabel":"Upright","imdb":7.3,"imdbVotes":312344,"tmdb":7.46,"tmdbVotes":5895}},{"id":"movie-locke-2014","title":"Locke","year":2014,"type":"movie","runtimeSeconds":5090,"seasonCount":null,"communityRating":6.888,"criticRating":91,"genres":["Drama","Thriller"],"overview":"Ivan Locke has worked hard to craft a good life for himself. Tonight, that life will collapse around him. On the eve of the biggest challenge of his career, Ivan receives a phone call that sets in motion a series of\u2026","poster":"assets/movie-locke-2014-poster.jpg","backdrop":"assets/movie-locke-2014-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T17:38:48.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":210479,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":90,"rtCriticsLabel":"Certified Fresh","rtAudience":72,"rtAudienceLabel":"Upright","imdb":7.1,"imdbVotes":171265,"tmdb":6.889,"tmdbVotes":3088}},{"id":"movie-the-matrix-1999","title":"The Matrix","year":1999,"type":"movie","runtimeSeconds":8179,"seasonCount":null,"communityRating":8.259,"criticRating":83,"genres":["Action","Science Fiction"],"overview":"Set in the 22nd century, The Matrix tells the story of a computer hacker who joins a group of underground insurgents fighting the vast and powerful computers who now rule the earth.","poster":"assets/movie-the-matrix-1999-poster.jpg","backdrop":"assets/movie-the-matrix-1999-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:18:12.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":603,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":83,"rtCriticsLabel":"Certified Fresh","rtAudience":85,"rtAudienceLabel":"Upright","imdb":8.7,"imdbVotes":2289808,"tmdb":8.262,"tmdbVotes":29153}},{"id":"movie-the-matrix-reloaded-2003","title":"The Matrix Reloaded","year":2003,"type":"movie","runtimeSeconds":8298,"seasonCount":null,"communityRating":7.085,"criticRating":74,"genres":["Adventure","Action","Thriller"],"overview":"The Resistance builds in numbers as humans are freed from the Matrix and brought to the city of Zion. Neo discovers his superpowers, including the ability to see the code inside the Matrix. With machine sentinels\u2026","poster":"assets/movie-the-matrix-reloaded-2003-poster.jpg","backdrop":"assets/movie-the-matrix-reloaded-2003-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:24:03.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":604,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":73,"rtCriticsLabel":"Certified Fresh","rtAudience":72,"rtAudienceLabel":"Upright","imdb":7.2,"imdbVotes":678013,"tmdb":7.087,"tmdbVotes":12504}},{"id":"movie-the-matrix-resurrections-2021","title":"The Matrix Resurrections","year":2021,"type":"movie","runtimeSeconds":8875,"seasonCount":null,"communityRating":6.303,"criticRating":63,"genres":["Science Fiction","Action","Adventure"],"overview":"Plagued by strange memories, Neo's life takes an unexpected turn when he finds himself back inside the Matrix.","poster":"assets/movie-the-matrix-resurrections-2021-poster.jpg","backdrop":"assets/movie-the-matrix-resurrections-2021-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:31:22.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":624860,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":63,"rtCriticsLabel":"Fresh","rtAudience":63,"rtAudienceLabel":"Upright","imdb":5.6,"imdbVotes":307780,"tmdb":6.294,"tmdbVotes":6842}},{"id":"movie-the-matrix-revolutions-2003","title":"The Matrix Revolutions","year":2003,"type":"movie","runtimeSeconds":7756,"seasonCount":null,"communityRating":6.758,"criticRating":33,"genres":["Adventure","Action","Thriller"],"overview":"The human city of Zion defends itself against the massive invasion of the machines as Neo fights to end the war at another front while also opposing the rogue Agent Smith.","poster":"assets/movie-the-matrix-revolutions-2003-poster.jpg","backdrop":"assets/movie-the-matrix-revolutions-2003-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:25:47.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":605,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":34,"rtCriticsLabel":"Rotten","rtAudience":60,"rtAudienceLabel":"Upright","imdb":6.8,"imdbVotes":581553,"tmdb":6.762,"tmdbVotes":11292}},{"id":"movie-mercy-2026","title":"Mercy","year":2026,"type":"movie","runtimeSeconds":5968,"seasonCount":null,"communityRating":7.077,"criticRating":null,"genres":["Science Fiction","Thriller","Crime"],"overview":"In the near future, a detective stands on trial accused of murdering his wife. He has ninety minutes to prove his innocence to the advanced AI Judge he once championed, before it determines his fate.","poster":"assets/movie-mercy-2026-poster.jpg","backdrop":"assets/movie-mercy-2026-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:31:40.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":1236153,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":24,"rtCriticsLabel":"Rotten","rtAudience":81,"rtAudienceLabel":"Upright","imdb":6.2,"imdbVotes":83878,"tmdb":7.054,"tmdbVotes":1812}},{"id":"movie-normal-2026","title":"Normal","year":2026,"type":"movie","runtimeSeconds":5514,"seasonCount":null,"communityRating":7.267,"criticRating":77,"genres":["Action","Crime","Thriller"],"overview":"An unassuming substitute sheriff with a troubled past responds to a bank robbery in a small, sleepy town and unknowingly uncovers something far more explosive.","poster":"assets/movie-normal-2026-poster.jpg","backdrop":"assets/movie-normal-2026-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-08-21T08:33:18.0000000Z","lastPlayedAt":"2026-09-24T23:07:52.1161581Z","availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":1242332,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":78,"rtCriticsLabel":"Certified Fresh","rtAudience":75,"rtAudienceLabel":"Upright","imdb":6.3,"imdbVotes":37501,"tmdb":7.241,"tmdbVotes":786}},{"id":"movie-outlaw-king-2018","title":"Outlaw King","year":2018,"type":"movie","runtimeSeconds":7297,"seasonCount":null,"communityRating":6.829,"criticRating":63,"genres":["Action","History","War"],"overview":"Forced into exile by the English after being crowned King of Scotland, legendary warrior Robert the Bruce fights to reclaim the throne.","poster":"assets/movie-outlaw-king-2018-poster.jpg","backdrop":"assets/movie-outlaw-king-2018-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:28:32.0000000Z","lastPlayedAt":"2026-07-26T22:35:35.0000000Z","availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":454227,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":63,"rtCriticsLabel":"Fresh","rtAudience":69,"rtAudienceLabel":"Upright","imdb":6.9,"imdbVotes":89876,"tmdb":6.844,"tmdbVotes":1780}},{"id":"movie-panic-room-2002","title":"Panic Room","year":2002,"type":"movie","runtimeSeconds":6717,"seasonCount":null,"communityRating":6.794,"criticRating":76,"genres":["Crime","Drama","Thriller"],"overview":"Trapped in their New York brownstone's panic room, a hidden chamber built as a sanctuary in the event of break-ins, newly divorced Meg Altman and her young daughter Sarah play a deadly game of cat-and-mouse with\u2026","poster":"assets/movie-panic-room-2002-poster.jpg","backdrop":"assets/movie-panic-room-2002-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-19T20:50:01.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":4547,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":76,"rtCriticsLabel":"Certified Fresh","rtAudience":64,"rtAudienceLabel":"Upright","imdb":6.8,"imdbVotes":317468,"tmdb":6.796,"tmdbVotes":5653}},{"id":"movie-the-patriot-2000","title":"The Patriot","year":2000,"type":"movie","runtimeSeconds":9887,"seasonCount":null,"communityRating":7.214,"criticRating":61,"genres":["Drama","History","War"],"overview":"After proving himself on the field of battle in the French and Indian War, Benjamin Martin wants nothing more to do with such things, preferring the simple life of a farmer. But when his son Gabriel enlists in the\u2026","poster":"assets/movie-the-patriot-2000-poster.jpg","backdrop":"assets/movie-the-patriot-2000-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-10T20:50:00.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":2024,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":61,"rtCriticsLabel":"Fresh","rtAudience":81,"rtAudienceLabel":"Upright","imdb":7.2,"imdbVotes":317639,"tmdb":7.215,"tmdbVotes":4535}},{"id":"movie-peaky-blinders-the-immortal-man-2026","title":"Peaky Blinders: The Immortal Man","year":2026,"type":"movie","runtimeSeconds":6844,"seasonCount":null,"communityRating":7.187,"criticRating":90,"genres":["Crime","Drama"],"overview":"After his estranged son gets embroiled in a Nazi plot, self-exiled gangster Tommy Shelby must return to Birmingham to save his family \u2014 and his nation.","poster":"assets/movie-peaky-blinders-the-immortal-man-2026-poster.jpg","backdrop":"assets/movie-peaky-blinders-the-immortal-man-2026-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:30:01.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":875828,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":90,"rtCriticsLabel":"Certified Fresh","rtAudience":87,"rtAudienceLabel":"Upright","imdb":6.5,"imdbVotes":92130,"tmdb":7.189,"tmdbVotes":1304}},{"id":"movie-ponyo-2008","title":"Ponyo","year":2008,"type":"movie","runtimeSeconds":6530,"seasonCount":null,"communityRating":7.763,"criticRating":91,"genres":["Animation","Fantasy","Family"],"overview":"When Sosuke, a young boy who lives on a clifftop overlooking the sea, rescues a stranded goldfish named Ponyo, he discovers more than he bargained for. Ponyo is a curious, energetic young creature who yearns to be\u2026","poster":"assets/movie-ponyo-2008-poster.jpg","backdrop":"assets/movie-ponyo-2008-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:23:53.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":12429,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":91,"rtCriticsLabel":"Certified Fresh","rtAudience":84,"rtAudienceLabel":"Upright","imdb":7.6,"imdbVotes":190294,"tmdb":7.759,"tmdbVotes":5050}},{"id":"movie-porco-rosso-1992","title":"Porco Rosso","year":1992,"type":"movie","runtimeSeconds":5600,"seasonCount":null,"communityRating":7.794,"criticRating":96,"genres":["Animation","Adventure","Fantasy"],"overview":"In Italy in the 1930s, sky pirates in biplanes terrorize wealthy cruise ships as they sail the Adriatic Sea. The only pilot brave enough to stop the scourge is the mysterious Porco Rosso, a former World War I flying\u2026","poster":"assets/movie-porco-rosso-1992-poster.jpg","backdrop":"assets/movie-porco-rosso-1992-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:23:22.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":11621,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":96,"rtCriticsLabel":"Fresh","rtAudience":87,"rtAudienceLabel":"Upright","imdb":7.7,"imdbVotes":119396,"tmdb":7.789,"tmdbVotes":3879}},{"id":"movie-princess-mononoke-1997","title":"Princess Mononoke","year":1997,"type":"movie","runtimeSeconds":8003,"seasonCount":null,"communityRating":8.321,"criticRating":93,"genres":["Adventure","Fantasy","Animation"],"overview":"Ashitaka, a prince of the disappearing Emishi people, is cursed by a demonized boar god and must journey to the west to find a cure. Along the way, he encounters San, a young human woman fighting to protect the\u2026","poster":"assets/movie-princess-mononoke-1997-poster.jpg","backdrop":"assets/movie-princess-mononoke-1997-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:22:28.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":128,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":94,"rtCriticsLabel":"Certified Fresh","rtAudience":99,"rtAudienceLabel":"Upright","imdb":8.3,"imdbVotes":493735,"tmdb":8.321,"tmdbVotes":9438}},{"id":"movie-prisoners-2013","title":"Prisoners","year":2013,"type":"movie","runtimeSeconds":9192,"seasonCount":null,"communityRating":8.106,"criticRating":81,"genres":["Drama","Thriller","Crime"],"overview":"After his six-year-old daughter is abducted, a desperate father takes justice into his own hands when he loses faith in the detective leading the investigation, setting both men on a collision course as the search\u2026","poster":"assets/movie-prisoners-2013-poster.jpg","backdrop":"assets/movie-prisoners-2013-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:27:33.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":146233,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":82,"rtCriticsLabel":"Certified Fresh","rtAudience":87,"rtAudienceLabel":"Upright","imdb":8.2,"imdbVotes":963062,"tmdb":8.105,"tmdbVotes":13722}},{"id":"movie-racing-stripes-2005","title":"Racing Stripes","year":2005,"type":"movie","runtimeSeconds":6117,"seasonCount":null,"communityRating":5.945,"criticRating":35,"genres":["Comedy","Family"],"overview":"Shattered illusions are hard to repair -- especially for a good-hearted zebra named Stripes who's spent his life on a Kentucky farm amidst the sorely mistaken notion that he's a debonair thoroughbred. Once he faces\u2026","poster":"assets/movie-racing-stripes-2005-poster.jpg","backdrop":"assets/movie-racing-stripes-2005-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:26:02.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":6439,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":35,"rtCriticsLabel":"Rotten","rtAudience":42,"rtAudienceLabel":"Spilled","imdb":5.3,"imdbVotes":19569,"tmdb":5.962,"tmdbVotes":705}},{"id":"movie-requiem-for-a-dream-2000","title":"Requiem for a Dream","year":2000,"type":"movie","runtimeSeconds":6086,"seasonCount":null,"communityRating":8.005,"criticRating":80,"genres":["Crime","Drama"],"overview":"The drug-induced utopias of four Coney Island residents are shattered when their addictions run deep.","poster":"assets/movie-requiem-for-a-dream-2000-poster.jpg","backdrop":"assets/movie-requiem-for-a-dream-2000-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:24:15.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":641,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":81,"rtCriticsLabel":"Certified Fresh","rtAudience":93,"rtAudienceLabel":"Upright","imdb":8.3,"imdbVotes":989090,"tmdb":8,"tmdbVotes":11454}},{"id":"movie-rogue-one-a-star-wars-story-2016","title":"Rogue One: A Star Wars Story","year":2016,"type":"movie","runtimeSeconds":8038,"seasonCount":null,"communityRating":7.51,"criticRating":84,"genres":["Action","Adventure","Science Fiction"],"overview":"A rogue band of resistance fighters unite for a mission to steal the Death Star plans and bring a new hope to the galaxy.","poster":"assets/movie-rogue-one-a-star-wars-story-2016-poster.jpg","backdrop":"assets/movie-rogue-one-a-star-wars-story-2016-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:28:00.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":330459,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":84,"rtCriticsLabel":"Certified Fresh","rtAudience":87,"rtAudienceLabel":"Upright","imdb":7.8,"imdbVotes":768365,"tmdb":7.512,"tmdbVotes":16937}},{"id":"movie-secondhand-lions-2003","title":"Secondhand Lions","year":2003,"type":"movie","runtimeSeconds":6547,"seasonCount":null,"communityRating":7.237,"criticRating":60,"genres":["Family","Comedy","Drama"],"overview":"The comedic adventures of an introverted boy left on the doorstep of a pair of reluctant, eccentric great-uncles, whose exotic remembrances stir the boy's spirit and re-ignite the men's lives.","poster":"assets/movie-secondhand-lions-2003-poster.jpg","backdrop":"assets/movie-secondhand-lions-2003-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-12T07:16:13.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":13156,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":59,"rtCriticsLabel":"Rotten","rtAudience":84,"rtAudienceLabel":"Upright","imdb":7.5,"imdbVotes":66911,"tmdb":7.242,"tmdbVotes":872}},{"id":"movie-the-secret-life-of-pets-2-2019","title":"The Secret Life of Pets 2","year":2019,"type":"movie","runtimeSeconds":5164,"seasonCount":null,"communityRating":6.95,"criticRating":60,"genres":["Animation","Comedy","Family"],"overview":"Max the terrier must cope with some major life changes when his owner gets married and has a baby. When the family takes a trip to the countryside, nervous Max has numerous run-ins with canine-intolerant cows,\u2026","poster":"assets/movie-the-secret-life-of-pets-2-2019-poster.jpg","backdrop":"assets/movie-the-secret-life-of-pets-2-2019-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-05T07:00:53.0000000Z","lastPlayedAt":"2026-09-17T18:07:00.7024231Z","availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":412117,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":60,"rtCriticsLabel":"Fresh","rtAudience":90,"rtAudienceLabel":"Upright","imdb":6.4,"imdbVotes":80464,"tmdb":6.948,"tmdbVotes":3450}},{"id":"movie-self-reliance-2024","title":"Self Reliance","year":2024,"type":"movie","runtimeSeconds":5461,"seasonCount":null,"communityRating":5.888,"criticRating":73,"genres":["Comedy","Thriller"],"overview":"When a man is offered a million dollars to play a game in which hunters try to kill him, he thinks he has found the perfect loophole: they can only attack when he\u2019s alone. His only problem is that none of his friends\u2026","poster":"assets/movie-self-reliance-2024-poster.jpg","backdrop":"assets/movie-self-reliance-2024-backdrop.jpg","played":false,"playCount":1,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:31:00.0000000Z","lastPlayedAt":"2026-10-02T19:59:16.9286868Z","availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":1072876,"artwork":{"posterKind":"center_crop","backdropKind":"backdrop"},"ratings":{"rtCritics":73,"rtCriticsLabel":"Certified Fresh","rtAudience":56,"rtAudienceLabel":"Spilled","imdb":6,"imdbVotes":23771,"tmdb":5.866,"tmdbVotes":346}},{"id":"movie-serpico-1973","title":"Serpico","year":1973,"type":"movie","runtimeSeconds":7805,"seasonCount":null,"communityRating":7.5,"criticRating":93,"genres":["Crime","Drama","History"],"overview":"New York cop Frank Serpico blows the whistle on the rampant corruption in the force only to have his comrades turn against him.","poster":"assets/movie-serpico-1973-poster.jpg","backdrop":"assets/movie-serpico-1973-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-10T21:45:02.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":9040,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":93,"rtCriticsLabel":"Certified Fresh","rtAudience":88,"rtAudienceLabel":"Upright","imdb":7.7,"imdbVotes":149750,"tmdb":7.525,"tmdbVotes":2323}},{"id":"movie-shaft-2000","title":"Shaft","year":2000,"type":"movie","runtimeSeconds":5960,"seasonCount":null,"communityRating":6.026,"criticRating":67,"genres":["Action","Adventure","Crime"],"overview":"New York police detective John Shaft arrests Walter Wade Jr. for a racially motivated slaying. But the only eyewitness disappears, and Wade jumps bail for Switzerland. Two years later Wade returns to face trial,\u2026","poster":"assets/movie-shaft-2000-poster.jpg","backdrop":"assets/movie-shaft-2000-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-10T21:30:40.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":479,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":68,"rtCriticsLabel":"Fresh","rtAudience":40,"rtAudienceLabel":"Spilled","imdb":6,"imdbVotes":86257,"tmdb":6.024,"tmdbVotes":1531}},{"id":"movie-a-silent-voice-the-movie-2016","title":"A Silent Voice: The Movie","year":2016,"type":"movie","runtimeSeconds":7804,"seasonCount":null,"communityRating":8.398,"criticRating":null,"genres":["Animation","Drama","Romance"],"overview":"Shouya Ishida starts bullying the new girl in class, Shouko Nishimiya, because she is deaf. But as the teasing continues, the rest of the class starts to turn on Shouya for his lack of compassion. When they leave\u2026","poster":"assets/movie-a-silent-voice-the-movie-2016-poster.jpg","backdrop":"assets/movie-a-silent-voice-the-movie-2016-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T17:39:46.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":378064,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":95,"rtCriticsLabel":"Fresh","rtAudience":94,"rtAudienceLabel":"Upright","imdb":8.2,"imdbVotes":151926,"tmdb":8.396,"tmdbVotes":4764}},{"id":"movie-sinbad-legend-of-the-seven-seas-2003","title":"Sinbad: Legend of the Seven Seas","year":2003,"type":"movie","runtimeSeconds":5143,"seasonCount":null,"communityRating":7.067,"criticRating":44,"genres":["Action","Adventure","Animation"],"overview":"The sailor of legend is framed by the goddess Eris for the theft of the Book of Peace, and must travel to her realm at the end of the world to retrieve it and save the life of his childhood friend Prince Proteus.","poster":"assets/movie-sinbad-legend-of-the-seven-seas-2003-poster.jpg","backdrop":"assets/movie-sinbad-legend-of-the-seven-seas-2003-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:26:25.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":14411,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":46,"rtCriticsLabel":"Rotten","rtAudience":57,"rtAudienceLabel":"Spilled","imdb":6.8,"imdbVotes":65298,"tmdb":7.071,"tmdbVotes":2323}},{"id":"movie-sleeping-with-other-people-2015","title":"Sleeping with Other People","year":2015,"type":"movie","runtimeSeconds":6076,"seasonCount":null,"communityRating":6.274,"criticRating":64,"genres":["Comedy","Romance"],"overview":"Can two serial cheaters get a second chance at love? After a one-night stand in college, New Yorkers Lainey and Jake meet by chance twelve years later and discover they each have the same problem: because of their\u2026","poster":"assets/movie-sleeping-with-other-people-2015-poster.jpg","backdrop":"assets/movie-sleeping-with-other-people-2015-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:27:42.0000000Z","lastPlayedAt":"2026-07-17T17:50:44.0000000Z","availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":288036,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":64,"rtCriticsLabel":"Fresh","rtAudience":56,"rtAudienceLabel":"Spilled","imdb":6.5,"imdbVotes":52275,"tmdb":6.275,"tmdbVotes":1020}},{"id":"movie-snowpiercer-2013","title":"Snowpiercer","year":2013,"type":"movie","runtimeSeconds":7573,"seasonCount":null,"communityRating":6.909,"criticRating":94,"genres":["Action","Science Fiction","Drama"],"overview":"In a future where a failed global-warming experiment kills off most life on the planet, a class system evolves aboard the Snowpiercer; a train that travels around the globe via a perpetual-motion engine.","poster":"assets/movie-snowpiercer-2013-poster.jpg","backdrop":"assets/movie-snowpiercer-2013-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:27:07.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":110415,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":94,"rtCriticsLabel":"Certified Fresh","rtAudience":72,"rtAudienceLabel":"Upright","imdb":7.1,"imdbVotes":426976,"tmdb":6.9,"tmdbVotes":10807}},{"id":"movie-spider-man-brand-new-day-2026","title":"Spider-Man: Brand New Day","year":2026,"type":"movie","runtimeSeconds":8701,"seasonCount":null,"communityRating":8.374,"criticRating":90,"genres":["Science Fiction","Action","Adventure"],"overview":"Fighting crime full-time as Spider-Man in a world that doesn't remember him\u2014and the pressure of seeing his old friends move on without him\u2014sparks a change in Peter Parker he may not have the power to control. But\u2026","poster":"assets/movie-spider-man-brand-new-day-2026-poster.jpg","backdrop":"assets/movie-spider-man-brand-new-day-2026-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-10T14:14:27.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":969681,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":90,"rtCriticsLabel":"Certified Fresh","rtAudience":97,"rtAudienceLabel":"Upright","imdb":7.9,"imdbVotes":366886,"tmdb":8.5,"tmdbVotes":6537}},{"id":"movie-spider-man-far-from-home-2019","title":"Spider-Man: Far From Home","year":2019,"type":"movie","runtimeSeconds":7762,"seasonCount":null,"communityRating":7.389,"criticRating":91,"genres":["Action","Adventure","Science Fiction"],"overview":"Peter Parker and his friends go on a summer trip to Europe. However, they will hardly be able to rest - Peter will have to agree to help Nick Fury uncover the mystery of creatures that cause natural disasters and\u2026","poster":"assets/movie-spider-man-far-from-home-2019-poster.jpg","backdrop":"assets/movie-spider-man-far-from-home-2019-backdrop.jpg","played":true,"playCount":1,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-10T14:25:47.0000000Z","lastPlayedAt":"2026-10-10T17:50:35.6193119Z","availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":429617,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":91,"rtCriticsLabel":"Certified Fresh","rtAudience":95,"rtAudienceLabel":"Upright","imdb":7.3,"imdbVotes":682887,"tmdb":7.387,"tmdbVotes":17847}},{"id":"movie-spider-man-homecoming-2017","title":"Spider-Man: Homecoming","year":2017,"type":"movie","runtimeSeconds":8008,"seasonCount":null,"communityRating":7.333,"criticRating":92,"genres":["Action","Adventure","Science Fiction"],"overview":"Following the events of Captain America: Civil War, Peter Parker, with the help of his mentor Tony Stark, tries to balance his life as an ordinary high school student in Queens, New York City, with fighting crime as\u2026","poster":"assets/movie-spider-man-homecoming-2017-poster.jpg","backdrop":"assets/movie-spider-man-homecoming-2017-backdrop.jpg","played":true,"playCount":1,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-10T14:33:13.0000000Z","lastPlayedAt":"2026-10-10T20:31:23.4444213Z","availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":315635,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":92,"rtCriticsLabel":"Certified Fresh","rtAudience":87,"rtAudienceLabel":"Upright","imdb":7.4,"imdbVotes":840974,"tmdb":7.332,"tmdbVotes":24183}},{"id":"movie-spider-man-no-way-home-2021","title":"Spider-Man: No Way Home","year":2021,"type":"movie","runtimeSeconds":8890,"seasonCount":null,"communityRating":7.947,"criticRating":93,"genres":["Action","Adventure","Science Fiction"],"overview":"Peter Parker is unmasked and no longer able to separate his normal life from the high-stakes of being a super-hero. When he asks for help from Doctor Strange the stakes become even more dangerous, forcing him to\u2026","poster":"assets/movie-spider-man-no-way-home-2021-poster.jpg","backdrop":"assets/movie-spider-man-no-way-home-2021-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-10T14:21:05.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":634649,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":93,"rtCriticsLabel":"Certified Fresh","rtAudience":97,"rtAudienceLabel":"Upright","imdb":8.1,"imdbVotes":1082660,"tmdb":7.947,"tmdbVotes":23505}},{"id":"movie-spirited-away-2001","title":"Spirited Away","year":2001,"type":"movie","runtimeSeconds":7472,"seasonCount":null,"communityRating":8.535,"criticRating":96,"genres":["Animation","Family","Fantasy"],"overview":"A young girl, Chihiro, becomes trapped in a strange new world of spirits. When her parents undergo a mysterious transformation, she must call upon the courage she never knew she had to free her family.","poster":"assets/movie-spirited-away-2001-poster.jpg","backdrop":"assets/movie-spirited-away-2001-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:22:42.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":129,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":96,"rtCriticsLabel":"Certified Fresh","rtAudience":99,"rtAudienceLabel":"Upright","imdb":8.6,"imdbVotes":987658,"tmdb":8.536,"tmdbVotes":19176}},{"id":"movie-the-substance-2024","title":"The Substance","year":2024,"type":"movie","runtimeSeconds":8531,"seasonCount":null,"communityRating":7.135,"criticRating":89,"genres":["Horror","Science Fiction"],"overview":"A fading celebrity decides to use a black market drug, a cell-replicating substance that temporarily creates a younger, better version of herself.","poster":"assets/movie-the-substance-2024-poster.jpg","backdrop":"assets/movie-the-substance-2024-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:30:22.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":933260,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":89,"rtCriticsLabel":"Certified Fresh","rtAudience":76,"rtAudienceLabel":"Upright","imdb":7.2,"imdbVotes":466542,"tmdb":7.13,"tmdbVotes":6746}},{"id":"movie-the-tale-of-the-princess-kaguya-2013","title":"The Tale of The Princess Kaguya","year":2013,"type":"movie","runtimeSeconds":8218,"seasonCount":null,"communityRating":8.1,"criticRating":100,"genres":["Animation","Drama","Fantasy"],"overview":"Found inside a shining stalk of bamboo by an old bamboo cutter and his wife, a tiny girl grows rapidly into an exquisite young lady. The mysterious young princess enthrals all who encounter her. But, ultimately, she\u2026","poster":"assets/movie-the-tale-of-the-princess-kaguya-2013-poster.jpg","backdrop":"assets/movie-the-tale-of-the-princess-kaguya-2013-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-07T13:40:15.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":149871,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Certified Fresh","rtAudience":90,"rtAudienceLabel":"Upright","imdb":8,"imdbVotes":63574,"tmdb":8.083,"tmdbVotes":2079}},{"id":"movie-taxi-driver-1976","title":"Taxi Driver","year":1976,"type":"movie","runtimeSeconds":6831,"seasonCount":null,"communityRating":8.121,"criticRating":89,"genres":["Crime","Drama"],"overview":"Suffering from insomnia, disturbed loner Travis Bickle takes a job as a New York City cabbie, haunting the streets nightly, growing increasingly detached from reality as he dreams of cleaning up the filthy city.","poster":"assets/movie-taxi-driver-1976-poster.jpg","backdrop":"assets/movie-taxi-driver-1976-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:23:04.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":103,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":89,"rtCriticsLabel":"Certified Fresh","rtAudience":93,"rtAudienceLabel":"Upright","imdb":8.2,"imdbVotes":1046700,"tmdb":8.116,"tmdbVotes":13973}},{"id":"movie-triangle-of-sadness-2022","title":"Triangle of Sadness","year":2022,"type":"movie","runtimeSeconds":8833,"seasonCount":null,"communityRating":7.005,"criticRating":71,"genres":["Comedy","Drama"],"overview":"A celebrity model couple are invited on a luxury cruise for the uber-rich, helmed by an unhinged, alcoholic captain. What first appears Instagrammable ends catastrophically, leaving the survivors stranded on a desert\u2026","poster":"assets/movie-triangle-of-sadness-2022-poster.jpg","backdrop":"assets/movie-triangle-of-sadness-2022-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:29:00.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":497828,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":72,"rtCriticsLabel":"Fresh","rtAudience":80,"rtAudienceLabel":"Upright","imdb":7.2,"imdbVotes":221069,"tmdb":7,"tmdbVotes":3104}},{"id":"movie-tsotsi-2005","title":"Tsotsi","year":2005,"type":"movie","runtimeSeconds":5663,"seasonCount":null,"communityRating":6.937,"criticRating":82,"genres":["Crime","Drama"],"overview":"A young South African boy from the Johannesburg ghetto named Tsotsi, meaning Gangster, leaves home as a child to get away from his helpless parents. Now a teenage thug, Tsotsi finds a baby in the back seat of a car\u2026","poster":"assets/movie-tsotsi-2005-poster.jpg","backdrop":"assets/movie-tsotsi-2005-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:24:58.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":868,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":82,"rtCriticsLabel":"Certified Fresh","rtAudience":86,"rtAudienceLabel":"Upright","imdb":7.2,"imdbVotes":32526,"tmdb":6.941,"tmdbVotes":372}},{"id":"movie-unknown-number-the-high-school-catfish-2025","title":"Unknown Number: The High School Catfish","year":2025,"type":"movie","runtimeSeconds":5691,"seasonCount":null,"communityRating":6.746,"criticRating":85,"genres":["Documentary"],"overview":"Vulgar, taunting texts blow up the phones of a teen and her boyfriend. Who's sending them \u2014 and why? This twisty documentary reveals the shocking answer.","poster":"assets/movie-unknown-number-the-high-school-catfish-2025-poster.jpg","backdrop":"assets/movie-unknown-number-the-high-school-catfish-2025-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:32:10.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":1517772,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":85,"rtCriticsLabel":"Fresh","rtAudience":69,"rtAudienceLabel":"Upright","imdb":6.7,"imdbVotes":23039,"tmdb":6.737,"tmdbVotes":257}},{"id":"movie-the-uprising-2026","title":"The Uprising","year":2026,"type":"movie","runtimeSeconds":7684,"seasonCount":null,"communityRating":7.846,"criticRating":55,"genres":["Action","Drama","History"],"overview":"As war and plague devastate 14th-century England, a humble peasant ignites a rebellion, uniting an army of commoners to defy the King\u2019s might in a desperate fight for justice and survival.","poster":"assets/movie-the-uprising-2026-poster.jpg","backdrop":"assets/movie-the-uprising-2026-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-11-02T16:37:03.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":977942,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":57,"rtCriticsLabel":"Rotten","rtAudience":65,"rtAudienceLabel":"Upright","imdb":6.2,"imdbVotes":6622,"tmdb":7.8,"tmdbVotes":404}},{"id":"movie-vice-2018","title":"Vice","year":2018,"type":"movie","runtimeSeconds":7948,"seasonCount":null,"communityRating":7.013,"criticRating":64,"genres":["Drama","Comedy","History"],"overview":"George W. Bush picks Dick Cheney, the CEO of Halliburton Co., to be his Republican running mate in the 2000 presidential election. No stranger to politics, Cheney's impressive r\u00e9sum\u00e9 includes stints as White House\u2026","poster":"assets/movie-vice-2018-poster.jpg","backdrop":"assets/movie-vice-2018-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:28:30.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":429197,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":64,"rtCriticsLabel":"Fresh","rtAudience":61,"rtAudienceLabel":"Upright","imdb":7.2,"imdbVotes":177299,"tmdb":7.014,"tmdbVotes":3723}},{"id":"movie-warriors-of-the-wind-1984","title":"Warriors of the Wind","year":1984,"type":"movie","runtimeSeconds":7022,"seasonCount":null,"communityRating":7.941,"criticRating":87,"genres":["Adventure","Animation","Fantasy"],"overview":"After a global war, the seaside kingdom known as the Valley of the Wind remains one of the last strongholds on Earth untouched by a poisonous jungle and the powerful insects that guard it. Led by the courageous\u2026","poster":"assets/movie-warriors-of-the-wind-1984-poster.jpg","backdrop":"assets/movie-warriors-of-the-wind-1984-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:22:20.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":81,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":87,"rtCriticsLabel":"Fresh","rtAudience":91,"rtAudienceLabel":"Upright","imdb":8,"imdbVotes":200154,"tmdb":7.94,"tmdbVotes":4260}},{"id":"movie-we-bury-the-dead-2026","title":"We Bury the Dead","year":2026,"type":"movie","runtimeSeconds":5693,"seasonCount":null,"communityRating":6.153,"criticRating":87,"genres":["Horror","Thriller"],"overview":"After a catastrophic military disaster reanimates the dead, Ava searches for her missing husband amidst an increasingly hostile world.","poster":"assets/movie-we-bury-the-dead-2026-poster.jpg","backdrop":"assets/movie-we-bury-the-dead-2026-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-08-26T05:36:00.4272328Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":1198984,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":87,"rtCriticsLabel":"Certified Fresh","rtAudience":45,"rtAudienceLabel":"Spilled","imdb":5.6,"imdbVotes":20693,"tmdb":6.137,"tmdbVotes":433}},{"id":"movie-white-hot-the-rise-fall-of-abercrombie-fitch-2022","title":"White Hot: The Rise & Fall of Abercrombie & Fitch","year":2022,"type":"movie","runtimeSeconds":5187,"seasonCount":null,"communityRating":5.663,"criticRating":66,"genres":["Documentary"],"overview":"All the cool kids were wearing it. This documentary explores A&F's pop culture reign in the late '90s and early 2000s and how it thrived on exclusion.","poster":"assets/movie-white-hot-the-rise-fall-of-abercrombie-fitch-2022-poster.jpg","backdrop":"assets/movie-white-hot-the-rise-fall-of-abercrombie-fitch-2022-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:30:34.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":953371,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":66,"rtCriticsLabel":"Fresh","rtAudience":29,"rtAudienceLabel":"Spilled","imdb":5.8,"imdbVotes":5655,"tmdb":5.7,"tmdbVotes":102}},{"id":"movie-the-wild-robot-2024","title":"The Wild Robot","year":2024,"type":"movie","runtimeSeconds":6101,"seasonCount":null,"communityRating":8.308,"criticRating":97,"genres":["Family","Animation","Science Fiction"],"overview":"After a shipwreck, an intelligent robot called Roz is stranded on an uninhabited island. To survive the harsh environment, Roz bonds with the island's animals and cares for an orphaned baby goose.","poster":"assets/movie-the-wild-robot-2024-poster.jpg","backdrop":"assets/movie-the-wild-robot-2024-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:31:49.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":1184918,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":97,"rtCriticsLabel":"Certified Fresh","rtAudience":98,"rtAudienceLabel":"Upright","imdb":8.1,"imdbVotes":241134,"tmdb":8.309,"tmdbVotes":6819}},{"id":"movie-the-wind-rises-2013","title":"The Wind Rises","year":2013,"type":"movie","runtimeSeconds":8031,"seasonCount":null,"communityRating":7.784,"criticRating":88,"genres":["Animation","Drama","History"],"overview":"A lifelong love of flight inspires Japanese aviation engineer Jiro Horikoshi, whose storied career includes the creation of the A-6M World War II fighter plane.","poster":"assets/movie-the-wind-rises-2013-poster.jpg","backdrop":"assets/movie-the-wind-rises-2013-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:24:38.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":149870,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":89,"rtCriticsLabel":"Certified Fresh","rtAudience":85,"rtAudienceLabel":"Upright","imdb":7.8,"imdbVotes":115756,"tmdb":7.783,"tmdbVotes":3536}},{"id":"movie-the-witcher-nightmare-of-the-wolf-2021","title":"The Witcher: Nightmare of the Wolf","year":2021,"type":"movie","runtimeSeconds":5015,"seasonCount":null,"communityRating":7.422,"criticRating":100,"genres":["Animation","Action","Fantasy"],"overview":"Escaping from poverty to become a witcher, Vesemir slays monsters for coin and glory, but when a new menace rises, he must face the demons of his past.","poster":"assets/movie-the-witcher-nightmare-of-the-wolf-2021-poster.jpg","backdrop":"assets/movie-the-witcher-nightmare-of-the-wolf-2021-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-26T04:42:03.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":666243,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":83,"rtAudienceLabel":"Upright","imdb":7.2,"imdbVotes":55759,"tmdb":7.407,"tmdbVotes":1159}},{"id":"movie-the-witcher-sirens-of-the-deep-2025","title":"The Witcher: Sirens of the Deep","year":2025,"type":"movie","runtimeSeconds":5517,"seasonCount":null,"communityRating":6.8,"criticRating":60,"genres":["Animation","Fantasy","Action"],"overview":"When human sailors are attacked by mysterious creatures of the deep, only one person can stop the war between land and sea: the Witcher, Geralt of Rivia.","poster":"assets/movie-the-witcher-sirens-of-the-deep-2025-poster.jpg","backdrop":"assets/movie-the-witcher-sirens-of-the-deep-2025-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-26T05:03:26.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":1203329,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":60,"rtCriticsLabel":"Fresh","rtAudience":37,"rtAudienceLabel":"Spilled","imdb":6.1,"imdbVotes":11799,"tmdb":6.741,"tmdbVotes":303}}],"shows":[{"id":"tv-adults-2025","title":"Adults","year":2025,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":6.495,"criticRating":null,"genres":["Comedy"],"overview":"A group of twenty-somethings in New York trying to be good people, despite being neither \"good\" nor \"people\" yet.","poster":"assets/tv-adults-2025-poster.jpg","backdrop":"assets/tv-adults-2025-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-23T21:01:41.1814765Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":260458,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":88,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.5,"tmdbVotes":102}},{"id":"tv-american-crime-story-2016","title":"American Crime Story","year":2016,"type":"tv","runtimeSeconds":null,"seasonCount":3,"communityRating":7.573,"criticRating":null,"genres":["Crime","Drama"],"overview":"An anthology series centered around some of history's most famous criminal investigations.","poster":"assets/tv-american-crime-story-2016-poster.jpg","backdrop":"assets/tv-american-crime-story-2016-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0490488Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":64513,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":84,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.585,"tmdbVotes":1094}},{"id":"tv-american-primeval-2025","title":"American Primeval","year":2025,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.691,"criticRating":null,"genres":["Western","Drama","Action & Adventure"],"overview":"A mother and son fleeing from their past form a found family while confronting a harsh landscape of freedom and cruelty in the American West.","poster":"assets/tv-american-primeval-2025-poster.jpg","backdrop":"assets/tv-american-primeval-2025-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0573221Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":215866,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":71,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.741,"tmdbVotes":538}},{"id":"tv-arrested-development-2003","title":"Arrested Development","year":2003,"type":"tv","runtimeSeconds":null,"seasonCount":5,"communityRating":7.925,"criticRating":null,"genres":["Comedy"],"overview":"The story of a wealthy family that lost everything, and the one son who had no choice but to keep them all together.","poster":"assets/tv-arrested-development-2003-poster.jpg","backdrop":"assets/tv-arrested-development-2003-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0513048Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":4589,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":75,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.906,"tmdbVotes":1793}},{"id":"tv-avatar-the-last-airbender-2005","title":"Avatar: The Last Airbender","year":2005,"type":"tv","runtimeSeconds":1440,"seasonCount":3,"communityRating":8.78,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"In a war-torn world of elemental magic, a young boy reawakens to undertake a dangerous mystic quest to fulfill his destiny as the Avatar, and bring peace to the world.","poster":"assets/tv-avatar-the-last-airbender-2005-poster.jpg","backdrop":"assets/tv-avatar-the-last-airbender-2005-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0504536Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":246,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.787,"tmdbVotes":5215}},{"id":"tv-b-b-vol-liefde-2021","title":"B&B Vol Liefde","year":2021,"type":"tv","runtimeSeconds":3720,"seasonCount":1,"communityRating":7.2,"criticRating":null,"genres":["Reality"],"overview":"B&B Vol Liefde is a heartwarming adventure, full of romance, excitement, and unforgettable moments. Adventurous Dutch B&B owners abroad throw their doors wide open and let their hearts speak, determined to find that\u2026","poster":"assets/tv-b-b-vol-liefde-2021-poster.jpg","backdrop":"assets/tv-b-b-vol-liefde-2021-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-31T22:30:11.129657Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":124428,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.2,"tmdbVotes":10}},{"id":"tv-the-bear-2022","title":"The Bear","year":2022,"type":"tv","runtimeSeconds":null,"seasonCount":5,"communityRating":8.139,"criticRating":null,"genres":["Drama","Comedy"],"overview":"Carmy, a young fine-dining chef, comes home to Chicago to run his family sandwich shop. As he fights to transform the shop and himself, he works alongside a rough-around-the-edges crew that ultimately reveal\u2026","poster":"assets/tv-the-bear-2022-poster.jpg","backdrop":"assets/tv-the-bear-2022-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0467371Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":136315,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":93,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.158,"tmdbVotes":2025}},{"id":"tv-blue-planet-ii-2017","title":"Blue Planet II","year":2017,"type":"tv","runtimeSeconds":3600,"seasonCount":1,"communityRating":8.64,"criticRating":null,"genres":["Documentary","Family"],"overview":"There is nowhere more powerful and unforgiving yet more beautiful and compelling than the ocean. Join us and explore the greatest yet least known parts of our planet.","poster":"assets/tv-blue-planet-ii-2017-poster.jpg","backdrop":"assets/tv-blue-planet-ii-2017-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0634849Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":74313,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":97,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.6,"tmdbVotes":380}},{"id":"tv-breeders-2020","title":"Breeders","year":2020,"type":"tv","runtimeSeconds":1500,"seasonCount":4,"communityRating":6.8,"criticRating":null,"genres":["Comedy"],"overview":"Exposing the parental-paradox that it is possible, in the very same moment, to love your child to the horizon of the universe, while being apoplectically angry enough to want to send them there.","poster":"assets/tv-breeders-2020-poster.jpg","backdrop":"assets/tv-breeders-2020-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-28T18:54:21.8885435Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":93290,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":92,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.8,"tmdbVotes":104}},{"id":"tv-chernobyl-2019","title":"Chernobyl","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":8.718,"criticRating":null,"genres":["Drama"],"overview":"The true story of one of the worst man-made catastrophes in history: the catastrophic nuclear accident at Chernobyl. A tale of the brave men and women who sacrificed to save Europe from unimaginable disaster.","poster":"assets/tv-chernobyl-2019-poster.jpg","backdrop":"assets/tv-chernobyl-2019-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0469903Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":87108,"artwork":{"posterKind":"poster","backdropKind":"center_crop"},"ratings":{"rtCritics":95,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.731,"tmdbVotes":8496}},{"id":"tv-community-2009","title":"Community","year":2009,"type":"tv","runtimeSeconds":null,"seasonCount":6,"communityRating":7.997,"criticRating":null,"genres":["Comedy"],"overview":"Follow the lives of a group of students at what is possibly the world\u2019s worst community college in the fictional locale of Greendale, Colorado.","poster":"assets/tv-community-2009-poster.jpg","backdrop":"assets/tv-community-2009-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0531416Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":18347,"artwork":{"posterKind":"center_crop","backdropKind":"backdrop"},"ratings":{"rtCritics":88,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.994,"tmdbVotes":2226}},{"id":"tv-dead-end-job-2026","title":"Dead-End Job","year":2026,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.7,"criticRating":null,"genres":["Mystery"],"overview":"Drowning in debt, a young man takes lucrative part-time jobs from a mysterious agency \u2014 only to find his hustle for more money turning nightmarish.","poster":"assets/tv-dead-end-job-2026-poster.jpg","backdrop":"assets/tv-dead-end-job-2026-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-11-04T18:58:01.8352857Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":289517,"artwork":{"posterKind":"center_crop","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.7,"tmdbVotes":38}},{"id":"tv-dopesick-2021","title":"Dopesick","year":2021,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":8.015,"criticRating":null,"genres":["Crime","Drama"],"overview":"The story of how one company triggered the worst drug epidemic in American history. Look into the epicenter of America's struggle with opioid addiction, from a distressed Virginia mining community, to the hallways of\u2026","poster":"assets/tv-dopesick-2021-poster.jpg","backdrop":"assets/tv-dopesick-2021-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0580168Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":110695,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":89,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.035,"tmdbVotes":565}},{"id":"tv-dune-prophecy-2024","title":"Dune: Prophecy","year":2024,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.213,"criticRating":null,"genres":["Sci-Fi & Fantasy","Drama","Action & Adventure"],"overview":"Ten thousand years before the ascension of Paul Atreides, sisters Valya and Tula Harkonnen establish the fabled sect and female order that would become known as the Bene Gesserit to control the future of humankind.","poster":"assets/tv-dune-prophecy-2024-poster.jpg","backdrop":"assets/tv-dune-prophecy-2024-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-11-04T18:23:01.2908568Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":90228,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":64,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.213,"tmdbVotes":677}},{"id":"tv-east-of-eden-2026","title":"East of Eden","year":2026,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.931,"criticRating":null,"genres":["Drama","Crime"],"overview":"In the early 1900s, a fiercely independent woman drives a rift between two brothers, igniting a saga that unfolds across two generations.","poster":"assets/tv-east-of-eden-2026-poster.jpg","backdrop":"assets/tv-east-of-eden-2026-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-23T18:07:11.3965096Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":258165,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":88,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.982,"tmdbVotes":139}},{"id":"tv-fallout-2024","title":"Fallout","year":2024,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":8.124,"criticRating":null,"genres":["Action & Adventure","Sci-Fi & Fantasy","Drama"],"overview":"The story of haves and have-nots in a world in which there's almost nothing left to have. 200 years after the apocalypse, the gentle denizens of luxury fallout shelters are forced to return to the irradiated\u2026","poster":"assets/tv-fallout-2024-poster.jpg","backdrop":"assets/tv-fallout-2024-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0669262Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":106379,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":94,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.106,"tmdbVotes":3233}},{"id":"tv-fawlty-towers-1975","title":"Fawlty Towers","year":1975,"type":"tv","runtimeSeconds":1800,"seasonCount":2,"communityRating":8.295,"criticRating":null,"genres":["Comedy"],"overview":"Owner Basil Fawlty, his wife Sybil, a chambermaid Polly, and Spanish waiter Manuel attempt to run their hotel amidst farcical situations and an array of demanding guests.","poster":"assets/tv-fawlty-towers-1975-poster.jpg","backdrop":"assets/tv-fawlty-towers-1975-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-19T23:26:39.7658344Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":2207,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.296,"tmdbVotes":867}},{"id":"tv-the-final-problem-2026","title":"The Final Problem","year":2026,"type":"tv","runtimeSeconds":3420,"seasonCount":1,"communityRating":7.041,"criticRating":null,"genres":["Crime","Mystery"],"overview":"In the spring of 1959, a retired actor, who previously played Sherlock Holmes, must investigate a murder at a small hotel on a tiny island near Mallorca.","poster":"assets/tv-the-final-problem-2026-poster.jpg","backdrop":"assets/tv-the-final-problem-2026-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-11-04T19:16:12.4822791Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":290720,"artwork":{"posterKind":"center_crop","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.041,"tmdbVotes":49}},{"id":"tv-the-four-seasons-2025","title":"The Four Seasons","year":2025,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":6.879,"criticRating":null,"genres":["Comedy"],"overview":"The decades-long friendship between three married couples is tested when one divorces, complicating their tradition of quarterly weekend getaways.","poster":"assets/tv-the-four-seasons-2025-poster.jpg","backdrop":"assets/tv-the-four-seasons-2025-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-28T18:36:42.5326384Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":243316,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":81,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.892,"tmdbVotes":204}},{"id":"tv-the-get-down-2016","title":"The Get Down","year":2016,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.3,"criticRating":null,"genres":["Drama"],"overview":"In 1977 New York City, the talented and soulful youth of the South Bronx chase dreams and breakneck beats to transform music history.","poster":"assets/tv-the-get-down-2016-poster.jpg","backdrop":"assets/tv-the-get-down-2016-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0497737Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":65345,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":81,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.3,"tmdbVotes":196}},{"id":"tv-how-to-change-your-mind-2022","title":"How to Change Your Mind","year":2022,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.76,"criticRating":null,"genres":["Documentary","Mystery"],"overview":"Author Michael Pollan leads the way in this docuseries exploring the history and uses of psychedelics, including LSD, psilocybin, MDMA and mescaline.","poster":"assets/tv-how-to-change-your-mind-2022-poster.jpg","backdrop":"assets/tv-how-to-change-your-mind-2022-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0601399Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":204304,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.8,"tmdbVotes":53}},{"id":"tv-interrogation-2020","title":"Interrogation","year":2020,"type":"tv","runtimeSeconds":2700,"seasonCount":1,"communityRating":6.788,"criticRating":null,"genres":["Drama","Crime"],"overview":"Based on a true story that spanned more than 30 years in which a young man was charged and convicted of brutally murdering his mother. Each episode is structured around an interrogation taken directly from the real\u2026","poster":"assets/tv-interrogation-2020-poster.jpg","backdrop":"assets/tv-interrogation-2020-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-26T05:52:06.1068207Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":96488,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":57,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.9,"tmdbVotes":54}},{"id":"tv-it-s-always-sunny-in-philadelphia-2005","title":"It's Always Sunny in Philadelphia","year":2005,"type":"tv","runtimeSeconds":null,"seasonCount":18,"communityRating":8.2,"criticRating":null,"genres":["Comedy"],"overview":"Four egocentric friends run a neighborhood Irish pub in Philadelphia and try to find their way through the adult world of work and relationships. Unfortunately, their warped views and precarious judgments often lead\u2026","poster":"assets/tv-it-s-always-sunny-in-philadelphia-2005-poster.jpg","backdrop":"assets/tv-it-s-always-sunny-in-philadelphia-2005-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0485774Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":2710,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":94,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.253,"tmdbVotes":1457}},{"id":"tv-a-knight-of-the-seven-kingdoms-2026","title":"A Knight of the Seven Kingdoms","year":2026,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":8.431,"criticRating":null,"genres":["Drama","Sci-Fi & Fantasy","Action & Adventure"],"overview":"A century before the events of Game of Thrones, two unlikely heroes wandered Westeros: a young, naive but courageous knight, Ser Duncan the Tall, and his diminutive squire, Egg. Set in an age when the Targaryen line\u2026","poster":"assets/tv-a-knight-of-the-seven-kingdoms-2026-poster.jpg","backdrop":"assets/tv-a-knight-of-the-seven-kingdoms-2026-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0493344Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":224372,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":94,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.4,"tmdbVotes":1266}},{"id":"tv-landman-2024","title":"Landman","year":2024,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":8.194,"criticRating":null,"genres":["Drama"],"overview":"Set in the proverbial boomtowns of West-Texas and a modern-day tale of fortune-seeking in the world of oil rigs, the series is an upstairs/downstairs story of roughnecks and wildcat billionaires that are fueling a\u2026","poster":"assets/tv-landman-2024-poster.jpg","backdrop":"assets/tv-landman-2024-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-22T21:38:18.0973875Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":157741,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":80,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.209,"tmdbVotes":668}},{"id":"tv-the-last-of-us-2023","title":"The Last of Us","year":2023,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":8.419,"criticRating":null,"genres":["Drama"],"overview":"Twenty years after modern civilization has been destroyed, Joel, a hardened survivor, is hired to smuggle Ellie, a 14-year-old girl, out of an oppressive quarantine zone. What starts as a small job soon becomes a\u2026","poster":"assets/tv-the-last-of-us-2023-poster.jpg","backdrop":"assets/tv-the-last-of-us-2023-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0640955Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":100088,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":94,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.4,"tmdbVotes":7485}},{"id":"tv-last-samurai-standing-2025","title":"Last Samurai Standing","year":2025,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.805,"criticRating":null,"genres":["Action & Adventure","Drama"],"overview":"In the early Meiji era, Shujiro, once known as an undefeated samurai, decides to participate in a deadly survival game to save his family and villagers.","poster":"assets/tv-last-samurai-standing-2025-poster.jpg","backdrop":"assets/tv-last-samurai-standing-2025-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0622526Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":252193,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.812,"tmdbVotes":271}},{"id":"tv-lord-of-the-flies-2026","title":"Lord of the Flies","year":2026,"type":"tv","runtimeSeconds":3480,"seasonCount":1,"communityRating":7.571,"criticRating":null,"genres":["Drama","Action & Adventure"],"overview":"After a plane crash, a group of schoolboys find themselves stranded on a tropical island without adults. Ralph is elected leader and, with the help of the intelligent Piggy, strives to maintain order and\u2026","poster":"assets/tv-lord-of-the-flies-2026-poster.jpg","backdrop":"assets/tv-lord-of-the-flies-2026-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0645648Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":270572,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":91,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.58,"tmdbVotes":138}},{"id":"tv-the-lord-of-the-rings-the-rings-of-power-2022","title":"The Lord of the Rings: The Rings of Power","year":2022,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":7.233,"criticRating":null,"genres":["Action & Adventure","Sci-Fi & Fantasy","Drama"],"overview":"Beginning in a time of relative peace, we follow an ensemble cast of characters as they confront the re-emergence of evil to Middle-earth. From the darkest depths of the Misty Mountains, to the majestic forests of\u2026","poster":"assets/tv-the-lord-of-the-rings-the-rings-of-power-2022-poster.jpg","backdrop":"assets/tv-the-lord-of-the-rings-the-rings-of-power-2022-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0618904Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":84773,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":84,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.211,"tmdbVotes":4003}},{"id":"tv-love-death-robots-2019","title":"Love, Death & Robots","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":4,"communityRating":8.21,"criticRating":null,"genres":["Animation","Sci-Fi & Fantasy"],"overview":"Terrifying creatures, wicked surprises and dark comedy converge in this NSFW anthology of animated stories presented by Tim Miller and David Fincher.","poster":"assets/tv-love-death-robots-2019-poster.jpg","backdrop":"assets/tv-love-death-robots-2019-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0460964Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":86831,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":89,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.203,"tmdbVotes":4238}},{"id":"tv-maniac-2018","title":"Maniac","year":2018,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.338,"criticRating":null,"genres":["Comedy","Drama","Sci-Fi & Fantasy"],"overview":"Two strangers are drawn to a mysterious pharmaceutical trial that will, they're assured, with no complications or side-effects whatsoever, solve all of their problems, permanently. Things do not go as planned.","poster":"assets/tv-maniac-2018-poster.jpg","backdrop":"assets/tv-maniac-2018-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-20T19:42:30.955548Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":73411,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":84,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.323,"tmdbVotes":1073}},{"id":"tv-mussolini-son-of-the-century-2024","title":"Mussolini: Son of the Century","year":2024,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":8.135,"criticRating":null,"genres":["Drama","War & Politics"],"overview":"The rise of Benito Mussolini and the birth of fascism in Italy, chronicling a country\u2019s surrender to dictatorship and the relentless ascent of a man who rose from his ashes time and again.","poster":"assets/tv-mussolini-son-of-the-century-2024-poster.jpg","backdrop":"assets/tv-mussolini-son-of-the-century-2024-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0586588Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":212333,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":96,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.1,"tmdbVotes":179}},{"id":"tv-the-night-manager-2016","title":"The Night Manager","year":2016,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":7.74,"criticRating":null,"genres":["Drama","Mystery","Crime"],"overview":"Former British soldier Jonathan Pine navigates the shadowy recesses of Whitehall and Washington where an unholy alliance operates between the intelligence community and the secret arms trade. To infiltrate the inner\u2026","poster":"assets/tv-the-night-manager-2016-poster.jpg","backdrop":"assets/tv-the-night-manager-2016-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0663672Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":61859,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":88,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.7,"tmdbVotes":1422}},{"id":"tv-one-piece-2023","title":"ONE PIECE","year":2023,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":8.118,"criticRating":null,"genres":["Action & Adventure","Sci-Fi & Fantasy"],"overview":"With his straw hat and ragtag crew, young pirate Monkey D. Luffy goes on an epic voyage for treasure.","poster":"assets/tv-one-piece-2023-poster.jpg","backdrop":"assets/tv-one-piece-2023-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0439378Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":111110,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":93,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.104,"tmdbVotes":1988}},{"id":"tv-only-murders-in-the-building-2021","title":"Only Murders in the Building","year":2021,"type":"tv","runtimeSeconds":null,"seasonCount":5,"communityRating":8.438,"criticRating":null,"genres":["Comedy","Mystery","Crime"],"overview":"Three strangers share an obsession with true crime and suddenly find themselves wrapped up in one.","poster":"assets/tv-only-murders-in-the-building-2021-poster.jpg","backdrop":"assets/tv-only-murders-in-the-building-2021-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0594614Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":107113,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":95,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.398,"tmdbVotes":2333}},{"id":"tv-over-the-garden-wall-2014","title":"Over the Garden Wall","year":2014,"type":"tv","runtimeSeconds":720,"seasonCount":1,"communityRating":8.6,"criticRating":null,"genres":["Mystery","Sci-Fi & Fantasy","Animation"],"overview":"Two brothers, Wirt and Greg, find themselves lost in the Unknown; a strange forest adrift in time. With the help of a wise old Woodsman and a foul-tempered bluebird named Beatrice, Wirt and Greg must travel across\u2026","poster":"assets/tv-over-the-garden-wall-2014-poster.jpg","backdrop":"assets/tv-over-the-garden-wall-2014-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0455757Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":61617,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.6,"tmdbVotes":1854}},{"id":"tv-peaky-blinders-2013","title":"Peaky Blinders","year":2013,"type":"tv","runtimeSeconds":null,"seasonCount":6,"communityRating":8.5,"criticRating":null,"genres":["Drama","Crime"],"overview":"A gangster family epic set in 1919 Birmingham, England and centered on a gang who sew razor blades in the peaks of their caps, and their fierce boss Tommy Shelby, who means to move up in the world.","poster":"assets/tv-peaky-blinders-2013-poster.jpg","backdrop":"assets/tv-peaky-blinders-2013-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0539185Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":60574,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":93,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.5,"tmdbVotes":11506}},{"id":"tv-the-pitt-2025","title":"The Pitt","year":2025,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":8.729,"criticRating":null,"genres":["Drama"],"overview":"The staff of Pittsburgh's Trauma Medical Center work around the clock to save lives in an overcrowded and underfunded emergency department.","poster":"assets/tv-the-pitt-2025-poster.jpg","backdrop":"assets/tv-the-pitt-2025-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0582942Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":250307,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":96,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.731,"tmdbVotes":1046}},{"id":"tv-pluribus-2025","title":"Pluribus","year":2025,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.936,"criticRating":null,"genres":["Drama","Sci-Fi & Fantasy"],"overview":"The most miserable person on Earth must save the world from happiness.","poster":"assets/tv-pluribus-2025-poster.jpg","backdrop":"assets/tv-pluribus-2025-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0674462Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":225171,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":99,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.921,"tmdbVotes":1191}},{"id":"tv-ripley-2024","title":"RIPLEY","year":2024,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.911,"criticRating":null,"genres":["Drama"],"overview":"A grifter in 1960s New York is hired to convince a wealthy man's son to return home from Italy and begins a life of deceit, fraud and murder.","poster":"assets/tv-ripley-2024-poster.jpg","backdrop":"assets/tv-ripley-2024-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0658942Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":94028,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":86,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.909,"tmdbVotes":453}},{"id":"tv-say-nothing-2024","title":"Say Nothing","year":2024,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.462,"criticRating":null,"genres":["Drama","Crime"],"overview":"Through the eyes of various Irish Republican Army (IRA) members, explore the extremes some people will go to in the name of their beliefs, the way a deeply divided society can suddenly tip over into armed conflict,\u2026","poster":"assets/tv-say-nothing-2024-poster.jpg","backdrop":"assets/tv-say-nothing-2024-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0499752Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":245256,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":94,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.475,"tmdbVotes":122}},{"id":"tv-severance-2022","title":"Severance","year":2022,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":8.396,"criticRating":null,"genres":["Drama","Mystery","Sci-Fi & Fantasy"],"overview":"Mark leads a team of office workers whose memories have been surgically divided between their work and personal lives. When a mysterious colleague appears outside of work, it begins a journey to discover the truth\u2026","poster":"assets/tv-severance-2022-poster.jpg","backdrop":"assets/tv-severance-2022-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0598478Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":95396,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":95,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.389,"tmdbVotes":3095}},{"id":"tv-six-feet-under-2001","title":"Six Feet Under","year":2001,"type":"tv","runtimeSeconds":null,"seasonCount":5,"communityRating":8.115,"criticRating":null,"genres":["Drama"],"overview":"When death is your business, what is your life? For the Fisher family, the world outside of their family-owned funeral home continues to be at least as challenging as\u2014and far less predictable than\u2014the one inside.","poster":"assets/tv-six-feet-under-2001-poster.jpg","backdrop":"assets/tv-six-feet-under-2001-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-26T04:04:05.108787Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":1274,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":81,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.1,"tmdbVotes":1192}},{"id":"tv-undercover-2019","title":"Undercover","year":2019,"type":"tv","runtimeSeconds":3000,"seasonCount":3,"communityRating":7.5,"criticRating":null,"genres":["Crime","Drama"],"overview":"Undercover agents infiltrate a drug kingpin's operation by posing as a couple at the campground where he spends his weekends. Inspired by real events.","poster":"assets/tv-undercover-2019-poster.jpg","backdrop":"assets/tv-undercover-2019-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0570153Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":86942,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.5,"tmdbVotes":186}},{"id":"tv-upload-2020","title":"Upload","year":2020,"type":"tv","runtimeSeconds":null,"seasonCount":4,"communityRating":7.781,"criticRating":null,"genres":["Comedy","Sci-Fi & Fantasy","Drama"],"overview":"In 2033, people who are near death can be \u201cuploaded\u201d into virtual reality hotels run by 6 tech firms. Cash-strapped Nora lives in Brooklyn and works customer service for the luxurious \u201cLakeview\u201d digital afterlife.\u2026","poster":"assets/tv-upload-2020-poster.jpg","backdrop":"assets/tv-upload-2020-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0566967Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":86248,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":87,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.757,"tmdbVotes":1511}},{"id":"tv-the-walk-in-2022","title":"The Walk-In","year":2022,"type":"tv","runtimeSeconds":2760,"seasonCount":1,"communityRating":6.9,"criticRating":null,"genres":["Drama","Crime"],"overview":"Forced to flee the UK and go into hiding in the 1990s, when he became a mole from within the BNP, Matthew Collins returned to Britain to make a new life for himself, working with young white men in the fight against\u2026","poster":"assets/tv-the-walk-in-2022-poster.jpg","backdrop":"assets/tv-the-walk-in-2022-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0654569Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":210664,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":80,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7,"tmdbVotes":50}},{"id":"tv-the-white-lotus-2021","title":"The White Lotus","year":2021,"type":"tv","runtimeSeconds":null,"seasonCount":3,"communityRating":7.596,"criticRating":null,"genres":["Comedy","Drama","Mystery"],"overview":"Follow the exploits of various guests and employees at an exclusive tropical resort over the span of a week as with each passing day, a darker complexity emerges in these picture-perfect travelers, the hotel\u2019s\u2026","poster":"assets/tv-the-white-lotus-2021-poster.jpg","backdrop":"assets/tv-the-white-lotus-2021-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0546438Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":111803,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":90,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.599,"tmdbVotes":1604}},{"id":"tv-widow-s-bay-2026","title":"Widow's Bay","year":2026,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":8.172,"criticRating":null,"genres":["Drama","Comedy","Mystery"],"overview":"The mayor of a New England town resolves to turn it into the next tourist hot spot, despite local warnings that it's cursed.","poster":"assets/tv-widow-s-bay-2026-poster.jpg","backdrop":"assets/tv-widow-s-bay-2026-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-20T10:27:12.7003979Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":270476,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":98,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8,"tmdbVotes":585}},{"id":"tv-the-witcher-2019","title":"The Witcher","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":4,"communityRating":7.905,"criticRating":null,"genres":["Drama","Action & Adventure"],"overview":"Geralt of Rivia, a mutated monster-hunter for hire, journeys toward his destiny in a turbulent world where people often prove more wicked than beasts.","poster":"assets/tv-the-witcher-2019-poster.jpg","backdrop":"assets/tv-the-witcher-2019-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0552369Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":71912,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":75,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.88,"tmdbVotes":6997}},{"id":"tv-yellowjackets-2021","title":"Yellowjackets","year":2021,"type":"tv","runtimeSeconds":null,"seasonCount":3,"communityRating":7.449,"criticRating":null,"genres":["Drama","Mystery"],"overview":"This equal parts survival epic, psychological horror story and coming-of-age drama tells the saga of a team of wildly talented high school girls soccer players who become the (un)lucky survivors of a plane crash deep\u2026","poster":"assets/tv-yellowjackets-2021-poster.jpg","backdrop":"assets/tv-yellowjackets-2021-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0577612Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":117488,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":92,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.431,"tmdbVotes":1070}}],"anime":[],"resume":[{"id":"episode-episode-1-2025-s1e1","title":"Episode 1","year":2025,"type":"episode","runtimeSeconds":3618,"seasonCount":null,"communityRating":7.357,"criticRating":null,"genres":["Biography","Drama","History"],"overview":"Mussolini had just founded the Fasci di Combattimento when D'Annunzio took Fiume. Mussolini hoped to capitalize on the poet's achievement in the electoral arena.","poster":"assets/episode-episode-1-2025-s1e1-poster.jpg","backdrop":"assets/episode-episode-1-2025-s1e1-backdrop.jpg","played":false,"playCount":1,"resumeSeconds":489.55,"progress":0.1353,"addedAt":"2026-07-17T13:52:48.0000000Z","lastPlayedAt":"2026-11-04T19:01:49.9731435Z","availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","seriesTitle":"Mussolini: Son of the Century","seasonNumber":1,"episodeNumber":1,"artwork":{"posterKind":"center_crop","backdropKind":"backdrop"}}],"nextUp":[{"id":"episode-no-honeymoon-2020-s1e8","title":"No Honeymoon","year":2020,"type":"episode","runtimeSeconds":1499,"seasonCount":null,"communityRating":6.7,"criticRating":null,"genres":["Comedy","Drama"],"overview":"Paul attempts to focus on his upcoming wedding but is constantly thwarted by his family; Ally is distracted by work, Luke is misbehaving at school and Leah (Stella Gonet) doesn\u2019t understand why they are even\u2026","poster":"assets/episode-no-honeymoon-2020-s1e8-poster.jpg","backdrop":"assets/episode-no-honeymoon-2020-s1e8-backdrop.jpg","played":false,"playCount":1,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-28T19:00:50.0000000Z","lastPlayedAt":"2026-11-03T20:57:58.3314947Z","availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","seriesTitle":"Breeders","seasonNumber":1,"episodeNumber":8,"artwork":{"posterKind":"poster","backdropKind":"backdrop"}},{"id":"episode-notapusy-2005-s3e4","title":"Notapusy","year":2005,"type":"episode","runtimeSeconds":1334,"seasonCount":null,"communityRating":8.125,"criticRating":null,"genres":["Comedy"],"overview":"To prove he's a man to Rita, Michael and his nephew Steve Holt compete in the Church And State Fair's annual triathlon. George Sr. tries to scare some at-risk youth, and Tobias tries to help George-Michael's\u2026","poster":"assets/episode-notapusy-2005-s3e4-poster.jpg","backdrop":"assets/episode-notapusy-2005-s3e4-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T12:17:58.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","seriesTitle":"Arrested Development","seasonNumber":3,"episodeNumber":4,"artwork":{"posterKind":"poster","backdropKind":"backdrop"}},{"id":"episode-the-man-who-would-be-vogue-2018-s2e1","title":"The Man Who Would Be Vogue","year":2018,"type":"episode","runtimeSeconds":3203,"seasonCount":null,"communityRating":7.459,"criticRating":null,"genres":["Biography","Crime","Drama"],"overview":"The murder of Gianni Versace turns the eyes of the world onto Miami Beach.","poster":"assets/episode-the-man-who-would-be-vogue-2018-s2e1-poster.jpg","backdrop":"assets/episode-the-man-who-would-be-vogue-2018-s2e1-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T13:29:35.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","seriesTitle":"American Crime Story","seasonNumber":2,"episodeNumber":1,"artwork":{"posterKind":"poster","backdropKind":"backdrop"}},{"id":"episode-tomorrow-2024-s3e1","title":"Tomorrow","year":2024,"type":"episode","runtimeSeconds":2207,"seasonCount":null,"communityRating":7.6,"criticRating":null,"genres":["Comedy","Drama"],"overview":"The next day and the days that led to it.","poster":"assets/episode-tomorrow-2024-s3e1-poster.jpg","backdrop":"assets/episode-tomorrow-2024-s3e1-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T13:46:39.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","seriesTitle":"The Bear","seasonNumber":3,"episodeNumber":1,"artwork":{"posterKind":"poster","backdropKind":"backdrop"}},{"id":"episode-episode-1-2025-s1e1","title":"Episode 1","year":2025,"type":"episode","runtimeSeconds":3618,"seasonCount":null,"communityRating":7.357,"criticRating":null,"genres":["Biography","Drama","History"],"overview":"Mussolini had just founded the Fasci di Combattimento when D'Annunzio took Fiume. Mussolini hoped to capitalize on the poet's achievement in the electoral arena.","poster":"assets/episode-episode-1-2025-s1e1-poster.jpg","backdrop":"assets/episode-episode-1-2025-s1e1-backdrop.jpg","played":false,"playCount":1,"resumeSeconds":489.55,"progress":0.1353,"addedAt":"2026-07-17T13:52:48.0000000Z","lastPlayedAt":"2026-11-04T19:01:49.9731435Z","availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","seriesTitle":"Mussolini: Son of the Century","seasonNumber":1,"episodeNumber":1,"artwork":{"posterKind":"center_crop","backdropKind":"backdrop"}}],"recentlyAdded":[{"id":"tv-the-final-problem-2026","title":"The Final Problem","year":2026,"type":"tv","runtimeSeconds":3420,"seasonCount":1,"communityRating":7.041,"criticRating":null,"genres":["Crime","Mystery"],"overview":"In the spring of 1959, a retired actor, who previously played Sherlock Holmes, must investigate a murder at a small hotel on a tiny island near Mallorca.","poster":"assets/tv-the-final-problem-2026-poster.jpg","backdrop":"assets/tv-the-final-problem-2026-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-11-04T19:16:12.4822791Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":290720,"artwork":{"posterKind":"center_crop","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.041,"tmdbVotes":49}},{"id":"tv-dead-end-job-2026","title":"Dead-End Job","year":2026,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.7,"criticRating":null,"genres":["Mystery"],"overview":"Drowning in debt, a young man takes lucrative part-time jobs from a mysterious agency \u2014 only to find his hustle for more money turning nightmarish.","poster":"assets/tv-dead-end-job-2026-poster.jpg","backdrop":"assets/tv-dead-end-job-2026-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-11-04T18:58:01.8352857Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":289517,"artwork":{"posterKind":"center_crop","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.7,"tmdbVotes":38}},{"id":"tv-dune-prophecy-2024","title":"Dune: Prophecy","year":2024,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.213,"criticRating":null,"genres":["Sci-Fi & Fantasy","Drama","Action & Adventure"],"overview":"Ten thousand years before the ascension of Paul Atreides, sisters Valya and Tula Harkonnen establish the fabled sect and female order that would become known as the Bene Gesserit to control the future of humankind.","poster":"assets/tv-dune-prophecy-2024-poster.jpg","backdrop":"assets/tv-dune-prophecy-2024-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-11-04T18:23:01.2908568Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":90228,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":64,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.213,"tmdbVotes":677}},{"id":"movie-the-uprising-2026","title":"The Uprising","year":2026,"type":"movie","runtimeSeconds":7684,"seasonCount":null,"communityRating":7.846,"criticRating":55,"genres":["Action","Drama","History"],"overview":"As war and plague devastate 14th-century England, a humble peasant ignites a rebellion, uniting an army of commoners to defy the King\u2019s might in a desperate fight for justice and survival.","poster":"assets/movie-the-uprising-2026-poster.jpg","backdrop":"assets/movie-the-uprising-2026-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-11-02T16:37:03.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":977942,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":57,"rtCriticsLabel":"Rotten","rtAudience":65,"rtAudienceLabel":"Upright","imdb":6.2,"imdbVotes":6622,"tmdb":7.8,"tmdbVotes":404}},{"id":"movie-jennifer-s-body-2009","title":"Jennifer's Body","year":2009,"type":"movie","runtimeSeconds":6450,"seasonCount":null,"communityRating":6.012,"criticRating":47,"genres":["Horror","Comedy"],"overview":"Jennifer, a gorgeous, seductive cheerleader takes evil to a whole new level after she's possessed by a sinister demon. Now it's up to her best friend to stop Jennifer's reign of terror before it's too late.","poster":"assets/movie-jennifer-s-body-2009-poster.jpg","backdrop":"assets/movie-jennifer-s-body-2009-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-30T03:37:04.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":19994,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":47,"rtCriticsLabel":"Rotten","rtAudience":36,"rtAudienceLabel":"Spilled","imdb":5.6,"imdbVotes":179710,"tmdb":6.013,"tmdbVotes":4481}},{"id":"tv-breeders-2020","title":"Breeders","year":2020,"type":"tv","runtimeSeconds":1500,"seasonCount":4,"communityRating":6.8,"criticRating":null,"genres":["Comedy"],"overview":"Exposing the parental-paradox that it is possible, in the very same moment, to love your child to the horizon of the universe, while being apoplectically angry enough to want to send them there.","poster":"assets/tv-breeders-2020-poster.jpg","backdrop":"assets/tv-breeders-2020-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-28T18:54:21.8885435Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":93290,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":92,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.8,"tmdbVotes":104}},{"id":"tv-the-four-seasons-2025","title":"The Four Seasons","year":2025,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":6.879,"criticRating":null,"genres":["Comedy"],"overview":"The decades-long friendship between three married couples is tested when one divorces, complicating their tradition of quarterly weekend getaways.","poster":"assets/tv-the-four-seasons-2025-poster.jpg","backdrop":"assets/tv-the-four-seasons-2025-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-28T18:36:42.5326384Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":243316,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":81,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.892,"tmdbVotes":204}},{"id":"tv-adults-2025","title":"Adults","year":2025,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":6.495,"criticRating":null,"genres":["Comedy"],"overview":"A group of twenty-somethings in New York trying to be good people, despite being neither \"good\" nor \"people\" yet.","poster":"assets/tv-adults-2025-poster.jpg","backdrop":"assets/tv-adults-2025-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-23T21:01:41.1814765Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":260458,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":88,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.5,"tmdbVotes":102}},{"id":"tv-east-of-eden-2026","title":"East of Eden","year":2026,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.931,"criticRating":null,"genres":["Drama","Crime"],"overview":"In the early 1900s, a fiercely independent woman drives a rift between two brothers, igniting a saga that unfolds across two generations.","poster":"assets/tv-east-of-eden-2026-poster.jpg","backdrop":"assets/tv-east-of-eden-2026-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-23T18:07:11.3965096Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":258165,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":88,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.982,"tmdbVotes":139}},{"id":"movie-fighting-with-my-family-2019","title":"Fighting with My Family","year":2019,"type":"movie","runtimeSeconds":6509,"seasonCount":null,"communityRating":6.84,"criticRating":93,"genres":["Comedy"],"overview":"Born into a tight-knit wrestling family, Paige and her brother Zak are ecstatic when they get the once-in-a-lifetime opportunity to try out for the WWE. But when only Paige earns a spot in the competitive training\u2026","poster":"assets/movie-fighting-with-my-family-2019-poster.jpg","backdrop":"assets/movie-fighting-with-my-family-2019-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-22T22:10:59.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":445629,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":93,"rtCriticsLabel":"Certified Fresh","rtAudience":86,"rtAudienceLabel":"Upright","imdb":7,"imdbVotes":94576,"tmdb":6.837,"tmdbVotes":1952}},{"id":"tv-landman-2024","title":"Landman","year":2024,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":8.194,"criticRating":null,"genres":["Drama"],"overview":"Set in the proverbial boomtowns of West-Texas and a modern-day tale of fortune-seeking in the world of oil rigs, the series is an upstairs/downstairs story of roughnecks and wildcat billionaires that are fueling a\u2026","poster":"assets/tv-landman-2024-poster.jpg","backdrop":"assets/tv-landman-2024-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-22T21:38:18.0973875Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":157741,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":80,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.209,"tmdbVotes":668}},{"id":"tv-fawlty-towers-1975","title":"Fawlty Towers","year":1975,"type":"tv","runtimeSeconds":1800,"seasonCount":2,"communityRating":8.295,"criticRating":null,"genres":["Comedy"],"overview":"Owner Basil Fawlty, his wife Sybil, a chambermaid Polly, and Spanish waiter Manuel attempt to run their hotel amidst farcical situations and an array of demanding guests.","poster":"assets/tv-fawlty-towers-1975-poster.jpg","backdrop":"assets/tv-fawlty-towers-1975-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-19T23:26:39.7658344Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":2207,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.296,"tmdbVotes":867}}],"discover":[{"id":"movie-12-years-a-slave-2013","title":"12 Years a Slave","year":2013,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","History"],"overview":"In the pre-Civil War United States, Solomon Northup, a free black man from upstate New York, is abducted and sold into slavery. Facing cruelty as well as unexpected kindnesses Solomon struggles not only to stay\u2026","poster":"assets/movie-12-years-a-slave-2013-poster.jpg","backdrop":"assets/movie-12-years-a-slave-2013-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":76203,"ratings":{"rtCritics":95,"rtCriticsLabel":"Certified Fresh","rtAudience":90,"rtAudienceLabel":"Upright","imdb":8.1,"imdbVotes":796313,"tmdb":7.927,"tmdbVotes":12335}},{"id":"movie-101-dalmatians-ii-patch-s-london-adventure-2003","title":"101 Dalmatians II: Patch's London Adventure","year":2003,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Family","Animation","Adventure"],"overview":"Being one of 101 takes its toll on Patch, who doesn't feel unique. When he's accidentally left behind on moving day, he meets his idol, Thunderbolt, who enlists him on a publicity campaign.","poster":"assets/movie-101-dalmatians-ii-patch-s-london-adventure-2003-poster.jpg","backdrop":"assets/movie-101-dalmatians-ii-patch-s-london-adventure-2003-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":13654,"ratings":{"rtCritics":67,"rtCriticsLabel":"Fresh","rtAudience":41,"rtAudienceLabel":"Spilled","imdb":5.7,"imdbVotes":12343,"tmdb":6.025,"tmdbVotes":1274}},{"id":"movie-absolution-2024","title":"Absolution","year":2024,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Crime","Thriller"],"overview":"An aging ex-boxer gangster working as muscle for a Boston crime boss receives an upsetting diagnosis. Despite a faltering memory, he attempts to rectify the sins of his past and reconnect with his estranged\u2026","poster":"assets/movie-absolution-2024-poster.jpg","backdrop":"assets/movie-absolution-2024-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":974453,"ratings":{"rtCritics":56,"rtCriticsLabel":"Rotten","rtAudience":32,"rtAudienceLabel":"Spilled","imdb":5.2,"imdbVotes":11413,"tmdb":5.906,"tmdbVotes":564}},{"id":"movie-addicted-2014","title":"Addicted","year":2014,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Thriller"],"overview":"A gallerist risks her family and flourishing career when she enters into an affair with a talented painter and slowly loses control of her life.","poster":"assets/movie-addicted-2014-poster.jpg","backdrop":"assets/movie-addicted-2014-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":235271,"ratings":{"rtCritics":7,"rtCriticsLabel":"Rotten","rtAudience":51,"rtAudienceLabel":"Spilled","imdb":5,"imdbVotes":13131,"tmdb":5.928,"tmdbVotes":466}},{"id":"movie-all-the-money-in-the-world-2017","title":"All the Money in the World","year":2017,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Crime","History","Thriller"],"overview":"The story of the kidnapping of 16-year-old John Paul Getty III and the desperate attempt by his devoted mother to convince his billionaire grandfather Jean Paul Getty to pay the ransom.","poster":"assets/movie-all-the-money-in-the-world-2017-poster.jpg","backdrop":"assets/movie-all-the-money-in-the-world-2017-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":446791,"ratings":{"rtCritics":79,"rtCriticsLabel":"Certified Fresh","rtAudience":66,"rtAudienceLabel":"Upright","imdb":6.8,"imdbVotes":97448,"tmdb":6.493,"tmdbVotes":2493}},{"id":"movie-the-alto-knights-2025","title":"The Alto Knights","year":2025,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Crime","Drama"],"overview":"Two of New York's most notorious organized crime bosses, Frank Costello and Vito Genovese, vie for control of the city's streets. Once the best of friends, petty jealousies and a series of betrayals place them on a\u2026","poster":"assets/movie-the-alto-knights-2025-poster.jpg","backdrop":"assets/movie-the-alto-knights-2025-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1013601,"ratings":{"rtCritics":39,"rtCriticsLabel":"Rotten","rtAudience":71,"rtAudienceLabel":"Upright","imdb":5.9,"imdbVotes":23017,"tmdb":6.223,"tmdbVotes":485}},{"id":"movie-the-amazing-spider-man-2-2014","title":"The Amazing Spider-Man 2","year":2014,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"For Peter Parker, life is busy. Between taking out the bad guys as Spider-Man and spending time with the person he loves, Gwen Stacy, high school graduation cannot come quickly enough. Peter has not forgotten about\u2026","poster":"assets/movie-the-amazing-spider-man-2-2014-poster.jpg","backdrop":"assets/movie-the-amazing-spider-man-2-2014-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":102382,"ratings":{"rtCritics":51,"rtCriticsLabel":"Rotten","rtAudience":64,"rtAudienceLabel":"Upright","imdb":6.6,"imdbVotes":611981,"tmdb":6.567,"tmdbVotes":14919}},{"id":"movie-ant-man-and-the-wasp-quantumania-2023","title":"Ant-Man and the Wasp: Quantumania","year":2023,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"Super-Hero partners Scott Lang and Hope van Dyne, along with with Hope's parents Janet van Dyne and Hank Pym, and Scott's daughter Cassie Lang, find themselves exploring the Quantum Realm, interacting with strange\u2026","poster":"assets/movie-ant-man-and-the-wasp-quantumania-2023-poster.jpg","backdrop":"assets/movie-ant-man-and-the-wasp-quantumania-2023-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":640146,"ratings":{"rtCritics":46,"rtCriticsLabel":"Rotten","rtAudience":81,"rtAudienceLabel":"Upright","imdb":6,"imdbVotes":281738,"tmdb":6.197,"tmdbVotes":6236}},{"id":"movie-arctic-dogs-2019","title":"Arctic Dogs","year":2019,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Adventure","Comedy"],"overview":"Animals band together to save the day when the evil Otto Von Walrus hatches a sinister scheme to accelerate global warming and melt the Arctic Circle.","poster":"assets/movie-arctic-dogs-2019-poster.jpg","backdrop":"assets/movie-arctic-dogs-2019-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":416153,"ratings":{"rtCritics":13,"rtCriticsLabel":"Rotten","rtAudience":60,"rtAudienceLabel":"Upright","imdb":4.9,"imdbVotes":2580,"tmdb":5.9,"tmdbVotes":148}},{"id":"movie-the-avengers-2012","title":"The Avengers","year":2012,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Science Fiction","Action","Adventure"],"overview":"When an unexpected enemy emerges and threatens global safety and security, Nick Fury, director of the international peacekeeping agency known as S.H.I.E.L.D., finds himself in need of a team to pull the world back\u2026","poster":"assets/movie-the-avengers-2012-poster.jpg","backdrop":"assets/movie-the-avengers-2012-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":24428,"ratings":{"rtCritics":91,"rtCriticsLabel":"Certified Fresh","rtAudience":91,"rtAudienceLabel":"Upright","imdb":8,"imdbVotes":1590693,"tmdb":8.084,"tmdbVotes":40262}},{"id":"movie-black-bear-2020","title":"Black Bear","year":2020,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Thriller"],"overview":"At a remote lake house in the Adirondack Mountains, a couple entertains an out-of-town guest looking for inspiration in her filmmaking. The group quickly falls into a calculated game of desire, manipulation, and\u2026","poster":"assets/movie-black-bear-2020-poster.jpg","backdrop":"assets/movie-black-bear-2020-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":624788,"ratings":{"rtCritics":89,"rtCriticsLabel":"Certified Fresh","rtAudience":64,"rtAudienceLabel":"Upright","imdb":6.5,"imdbVotes":18408,"tmdb":6.337,"tmdbVotes":315}},{"id":"movie-black-panther-2018","title":"Black Panther","year":2018,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"King T'Challa returns home to the reclusive, technologically advanced African nation of Wakanda to serve as his country's new leader. However, T'Challa soon finds that he is challenged for the throne by factions\u2026","poster":"assets/movie-black-panther-2018-poster.jpg","backdrop":"assets/movie-black-panther-2018-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":284054,"ratings":{"rtCritics":96,"rtCriticsLabel":"Certified Fresh","rtAudience":null,"rtAudienceLabel":"Spilled","imdb":7.3,"imdbVotes":931120,"tmdb":7.36,"tmdbVotes":24190}},{"id":"movie-a-bronx-tale-1993","title":"A Bronx Tale","year":1993,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Crime"],"overview":"Set in the Bronx during the tumultuous 1960s, an adolescent boy is torn between his honest, working-class father and a violent yet charismatic crime boss. Complicating matters is the youngster's growing attraction -\u2026","poster":"assets/movie-a-bronx-tale-1993-poster.jpg","backdrop":"assets/movie-a-bronx-tale-1993-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1607,"ratings":{"rtCritics":97,"rtCriticsLabel":"Fresh","rtAudience":92,"rtAudienceLabel":"Upright","imdb":7.8,"imdbVotes":191930,"tmdb":7.913,"tmdbVotes":3075}},{"id":"movie-by-any-means-2026","title":"By Any Means","year":2026,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Thriller","History","Crime"],"overview":"In 1960s Mississippi, a young Black FBI agent is forced to partner with a notorious mafia hitman to investigate a wave of brutal civil rights murders.","poster":"assets/movie-by-any-means-2026-poster.jpg","backdrop":"assets/movie-by-any-means-2026-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1380417,"ratings":{"rtCritics":49,"rtCriticsLabel":"Rotten","rtAudience":88,"rtAudienceLabel":"Upright","imdb":6.8,"imdbVotes":3534,"tmdb":6.6,"tmdbVotes":92}},{"id":"movie-captain-america-civil-war-2016","title":"Captain America: Civil War","year":2016,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Adventure","Action","Science Fiction"],"overview":"Following the events of Age of Ultron, the collective governments of the world pass an act designed to regulate all superhuman activity. This polarizes opinion amongst the Avengers, causing two factions to side with\u2026","poster":"assets/movie-captain-america-civil-war-2016-poster.jpg","backdrop":"assets/movie-captain-america-civil-war-2016-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":271110,"ratings":{"rtCritics":90,"rtCriticsLabel":"Certified Fresh","rtAudience":89,"rtAudienceLabel":"Upright","imdb":7.8,"imdbVotes":954943,"tmdb":7.455,"tmdbVotes":24808}},{"id":"movie-captain-america-the-first-avenger-2011","title":"Captain America: The First Avenger","year":2011,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"During World War II, Steve Rogers is a sickly man from Brooklyn who's transformed into super-soldier Captain America to aid in the war effort. Rogers must stop the Red Skull \u2013 Adolf Hitler's ruthless head of\u2026","poster":"assets/movie-captain-america-the-first-avenger-2011-poster.jpg","backdrop":"assets/movie-captain-america-the-first-avenger-2011-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1771,"ratings":{"rtCritics":80,"rtCriticsLabel":"Certified Fresh","rtAudience":75,"rtAudienceLabel":"Upright","imdb":6.9,"imdbVotes":993453,"tmdb":7.012,"tmdbVotes":23473}},{"id":"movie-captain-america-the-winter-soldier-2014","title":"Captain America: The Winter Soldier","year":2014,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"After the cataclysmic events in New York with The Avengers, Steve Rogers, aka Captain America is living quietly in Washington, D.C. and trying to adjust to the modern world. But when a S.H.I.E.L.D. colleague comes\u2026","poster":"assets/movie-captain-america-the-winter-soldier-2014-poster.jpg","backdrop":"assets/movie-captain-america-the-winter-soldier-2014-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":100402,"ratings":{"rtCritics":90,"rtCriticsLabel":"Certified Fresh","rtAudience":92,"rtAudienceLabel":"Upright","imdb":7.7,"imdbVotes":995514,"tmdb":7.654,"tmdbVotes":20688}},{"id":"movie-careful-what-you-wish-for-2015","title":"Careful What You Wish For","year":2015,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Thriller"],"overview":"A guy gets more than he bargained for after entering into an affair with the wife of an investment banker. Soon, a suspicious death and substantial life insurance policy embroil him in a scandal.","poster":"assets/movie-careful-what-you-wish-for-2015-poster.jpg","backdrop":"assets/movie-careful-what-you-wish-for-2015-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":317744,"ratings":{"rtCritics":17,"rtCriticsLabel":"Rotten","rtAudience":61,"rtAudienceLabel":"Upright","imdb":5.6,"imdbVotes":9452,"tmdb":6.129,"tmdbVotes":446}},{"id":"tv-12-monkeys-2015","title":"12 Monkeys","year":2015,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Sci-Fi & Fantasy","Drama"],"overview":"The provocative story of Cole, a time traveler from a decimated future in a high-stakes race against the clock. Utilizing a dangerous and untested method of time travel, he journeys from 2043 to the present day on a\u2026","poster":"assets/tv-12-monkeys-2015-poster.jpg","backdrop":"assets/tv-12-monkeys-2015-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":60948,"ratings":{"rtCritics":88,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.441,"tmdbVotes":1245}},{"id":"tv-90-day-fiance-before-the-90-days-2017","title":"90 Day Fianc\u00e9: Before the 90 Days","year":2017,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Reality"],"overview":"Follow couples who have an existing relationship online, but haven't met in person. We'll experience their journey as they travel to the other's foreign country for the first time in an attempt to establish an\u2026","poster":"assets/tv-90-day-fiance-before-the-90-days-2017-poster.jpg","backdrop":"assets/tv-90-day-fiance-before-the-90-days-2017-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":73319,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.5,"tmdbVotes":45}},{"id":"tv-90-day-fiance-the-other-way-2019","title":"90 Day Fianc\u00e9: The Other Way","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Reality"],"overview":"Americans move to a foreign country all in the name of love. Will uprooting their lives in the United States pay off, or will they be packing up and moving back to America brokenhearted?","poster":"assets/tv-90-day-fiance-the-other-way-2019-poster.jpg","backdrop":"assets/tv-90-day-fiance-the-other-way-2019-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":90046,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.8,"tmdbVotes":34}},{"id":"tv-the-100-2014","title":"The 100","year":2014,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Sci-Fi & Fantasy","Action & Adventure","Drama"],"overview":"100 years in the future, when the Earth has been abandoned due to radioactivity, the last surviving humans live on an ark orbiting the planet \u2014 but the ark won't last forever. So the repressive regime picks 100\u2026","poster":"assets/tv-the-100-2014-poster.jpg","backdrop":"assets/tv-the-100-2014-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":48866,"ratings":{"rtCritics":93,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.878,"tmdbVotes":8855}},{"id":"tv-accused-2023","title":"Accused","year":2023,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Crime","Drama"],"overview":"In this crime anthology series, viewers discover how an ordinary person got caught up in an extraordinary situation, ultimately revealing how one wrong turn leads to another, until it\u2019s too late to turn back. Told\u2026","poster":"assets/tv-accused-2023-poster.jpg","backdrop":"assets/tv-accused-2023-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":202097,"ratings":{"rtCritics":61,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.851,"tmdbVotes":87}},{"id":"tv-after-life-2019","title":"After Life","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy","Drama"],"overview":"Tony had a perfect life. But after his wife Lisa suddenly dies, Tony changes. After contemplating taking his own life, he decides instead to live long enough to punish the world by saying and doing whatever he likes\u2026","poster":"assets/tv-after-life-2019-poster.jpg","backdrop":"assets/tv-after-life-2019-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":79410,"ratings":{"rtCritics":72,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.902,"tmdbVotes":1169}},{"id":"tv-all-creatures-great-and-small-1978","title":"All Creatures Great and Small","year":1978,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy","Drama"],"overview":"The trials and misadventures of the staff at a country veterinary office in Yorkshire. James Herriot, a young animal surgeon, moves to a small Yorkshire town to begin his first job.","poster":"assets/tv-all-creatures-great-and-small-1978-poster.jpg","backdrop":"assets/tv-all-creatures-great-and-small-1978-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"center_crop","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":7406,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.8,"tmdbVotes":50}},{"id":"tv-american-housewife-2016","title":"American Housewife","year":2016,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy"],"overview":"A family comedy narrated by Katie, a strong-willed mother, raising her flawed family in a wealthy town filled with perfect wives and their perfect offspring.","poster":"assets/tv-american-housewife-2016-poster.jpg","backdrop":"assets/tv-american-housewife-2016-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":67117,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.7,"tmdbVotes":187}},{"id":"tv-atlantis-2013","title":"Atlantis","year":2013,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action & Adventure","Sci-Fi & Fantasy"],"overview":"A fantasy drama set in a world of legendary heroes and mythical creatures. Far from home and desperate for answers, Jason washes up on the shores of an ancient land. A mysterious place; a world of bull leaping, of\u2026","poster":"assets/tv-atlantis-2013-poster.jpg","backdrop":"assets/tv-atlantis-2013-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":47054,"ratings":{"rtCritics":62,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.4,"tmdbVotes":155}},{"id":"tv-avatar-the-last-airbender-2024","title":"Avatar: The Last Airbender","year":2024,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action & Adventure","Sci-Fi & Fantasy","Drama"],"overview":"A young boy known as the Avatar must master the four elemental powers to save a world at war \u2014 and fight a ruthless enemy bent on stopping him.","poster":"assets/tv-avatar-the-last-airbender-2024-poster.jpg","backdrop":"assets/tv-avatar-the-last-airbender-2024-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":82452,"ratings":{"rtCritics":66,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.8,"tmdbVotes":1406}},{"id":"tv-avatar-seven-havens-2026","title":"Avatar: Seven Havens","year":2026,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"In a world shattered by a devastating cataclysm, a young Earthbender discovers she's the new Avatar after Korra. But in this dangerous era, that title marks her as humanity's destroyer, not its savior. Hunted by both\u2026","poster":"assets/tv-avatar-seven-havens-2026-poster.jpg","backdrop":"assets/tv-avatar-seven-havens-2026-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":284833,"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.9,"tmdbVotes":183}},{"id":"tv-batman-the-brave-and-the-bold-2008","title":"Batman: The Brave and the Bold","year":2008,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"The Caped Crusader is teamed up with Blue Beetle, Green Arrow, Aquaman and countless others in his quest to uphold justice.","poster":"assets/tv-batman-the-brave-and-the-bold-2008-poster.jpg","backdrop":"assets/tv-batman-the-brave-and-the-bold-2008-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":15804,"ratings":{"rtCritics":96,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.6,"tmdbVotes":295}},{"id":"tv-beat-bobby-flay-2013","title":"Beat Bobby Flay","year":2013,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Reality"],"overview":"Two talented chefs go head-to-head for the chance to Beat Bobby Flay. To get to Bobby the chefs must first face off against each other, creating a spectacular dish with a secret ingredient of Bobby's choice. Judges\u2026","poster":"assets/tv-beat-bobby-flay-2013-poster.jpg","backdrop":"assets/tv-beat-bobby-flay-2013-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":62481,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.5,"tmdbVotes":31}},{"id":"tv-benson-1979","title":"Benson","year":1979,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy"],"overview":"A butler deals with life at the governor's mansion.","poster":"assets/tv-benson-1979-poster.jpg","backdrop":"assets/tv-benson-1979-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1849,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7,"tmdbVotes":46}},{"id":"tv-black-rabbit-2025","title":"Black Rabbit","year":2025,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Crime"],"overview":"A rising-star restaurateur is forced into New York's criminal underworld when his chaotic brother returns to town with loan sharks on his trail.","poster":"assets/tv-black-rabbit-2025-poster.jpg","backdrop":"assets/tv-black-rabbit-2025-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":249039,"ratings":{"rtCritics":66,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.983,"tmdbVotes":240}},{"id":"tv-black-ish-2014","title":"black-ish","year":2014,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy"],"overview":"A family man struggles to gain a sense of cultural identity while raising his kids in a predominantly white, upper-middle-class neighborhood.","poster":"assets/tv-black-ish-2014-poster.jpg","backdrop":"assets/tv-black-ish-2014-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":61381,"ratings":{"rtCritics":92,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.1,"tmdbVotes":352}},{"id":"tv-breaking-bad-2008","title":"Breaking Bad","year":2008,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Crime"],"overview":"Walter White, a New Mexico chemistry teacher, is diagnosed with Stage III cancer and given a prognosis of only two years left to live. He becomes filled with a sense of fearlessness and an unrelenting desire to\u2026","poster":"assets/tv-breaking-bad-2008-poster.jpg","backdrop":"assets/tv-breaking-bad-2008-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"center_crop","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1396,"ratings":{"rtCritics":96,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.959,"tmdbVotes":19030}},{"id":"tv-the-capture-2019","title":"The Capture","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Crime","Drama","Mystery"],"overview":"When soldier Shaun Emery's conviction for a murder in Afghanistan is overturned due to flawed video evidence, he returns to life as a free man with his young daughter. But when damning CCTV footage from a night out\u2026","poster":"assets/tv-the-capture-2019-poster.jpg","backdrop":"assets/tv-the-capture-2019-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":93166,"ratings":{"rtCritics":97,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.634,"tmdbVotes":361}}],"discoverySource":{"state":"materialized_snapshot","rankKnown":false,"selection":"First 18 alphabetically indexed movie stubs and first 18 series stubs, not ranked feed order","observedAt":"2026-11-04T20:42:12.030108+00:00"}}],"cast":{"movie:14069":[{"name":"Riisa Naka","character":"Makoto Konno (voice)","img":"tqFHQiJUycuob3ytvilaAWDlbQ8.jpg","order":0},{"name":"Takuya Ishida","character":"Chiaki Mamiya (voice)","img":"aHQk6XxqyJinfvrVRADEIg31nOk.jpg","order":1},{"name":"Mitsutaka Itakura","character":"Kousuke Tsuda (voice)","img":"mnKYU7HIsuRkx3YjdRO5FLXCdX5.jpg","order":2},{"name":"Ayami Kakiuchi","character":"Yuri Hayakawa (voice)","img":"lUhVV9u5VGMCNdCAVhGzg8GFSdo.jpg","order":3},{"name":"Mitsuki Tanimura","character":"Kaho Fujitani (voice)","img":"xV9UrP8kZdxOiTI2efDFhp3t4iA.jpg","order":4},{"name":"Yuki Sekido","character":"Miyuki Konno (voice)","img":"7LVYeFTgUpjYDrxnU7Wm5ZQUX9t.jpg","order":5},{"name":"Utawaka Katsura","character":"Makoto's Father (voice)","img":"j0GXzLelO1dOzI7d1WVIwQXhLz8.jpg","order":6},{"name":"Midori Ando","character":"Makoto's Mother (voice)","img":"26R1LBiSOkkP08saSsz6LmVTY16.jpg","order":7},{"name":"Fumihiko Tachiki","character":"Fukushima (voice)","img":null,"order":8},{"name":"Keiko Yamamoto","character":"Auntie (voice)","img":null,"order":9},{"name":"Shiori Yokohari","character":"Moriko Uesugi (voice)","img":null,"order":10},{"name":"Sonoka Matsuoka","character":"Sekimi Nowake (voice)","img":null,"order":11},{"name":"Takayuki Handa","character":"Kato (voice)","img":null,"order":12},{"name":"Maho Kurashima","character":"Female Newscaster A (voice)","img":null,"order":13},{"name":"Keiko Aizawa","character":"School Nurse / Answering Machine (voice)","img":null,"order":14}],"movie:210479":[{"name":"Tom Hardy","character":"Ivan Locke","img":"d81K0RH8UX7tZj49tZaQhZ9ewH.jpg","order":0},{"name":"Ruth Wilson","character":"Katrina (voice)","img":"fdFVatb9pt5dfT9tDK8tikKWafS.jpg","order":1},{"name":"Andrew Scott","character":"Donal (voice)","img":"4F8XpjyQCvtuu21WFm5d8RF5Rl.jpg","order":2},{"name":"Olivia Colman","character":"Bethan (voice)","img":"lawJUdbLb1YW0niaYjiVGR9IBG8.jpg","order":3},{"name":"Tom Holland","character":"Eddie (voice)","img":"xKBAaPIa1c7tzZD3Y0MhBLv4hPE.jpg","order":4},{"name":"Ben Daniels","character":"Gareth (voice)","img":"phEIvq0Ay2KjI5ewmVvpQ1a29GJ.jpg","order":5},{"name":"Bill Milner","character":"Sean (voice)","img":"bLgmpSFgEiSFNshqmdl3EIZkRZm.jpg","order":6},{"name":"Alice Lowe","character":"Sister Margaret (voice)","img":"v4sRxLpRpHEd9vbk43wm9VL0v0K.jpg","order":7},{"name":"Danny Webb","character":"Cassidy (voice)","img":null,"order":8},{"name":"Lee Ross","character":"PC Davids (voice)","img":null,"order":9},{"name":"Silas Carson","character":"Dr. Gullu (voice)","img":null,"order":10},{"name":"Kirsty Dillon","character":"Gareth's Wife (voice)","img":null,"order":11}],"movie:318917":[{"name":"Oliver Masucci","character":"Adolf Hitler","img":"fjJc03z1cOlMxLZbC1qihsq3C37.jpg","order":0},{"name":"Fabian Busch","character":"Fabian Sawatzki","img":"lpEs9yDsMQlsbm0RVsvDIleCXcI.jpg","order":1},{"name":"Christoph Maria Herbst","character":"Christoph Sensenbrink","img":"8v3lDa0QCJlDtOx9flqlMNIA3uf.jpg","order":2},{"name":"Katja Riemann","character":"Bellini","img":"waayy0jpNbxLDQ8JO1uRDng8SLF.jpg","order":3},{"name":"Franziska Wulf","character":"Kr\u00f6meier","img":"yxGsAKnrJZs2g6q4unNgkfm7NAA.jpg","order":4},{"name":"Lars Rudolph","character":"Shop Owner","img":"qiWu2gphSg64igYl3Nx0hEIOQF3.jpg","order":5},{"name":"Michael Kessler","character":"Michael Witzigmann","img":"sh4ANkKoIHgmHtkKQbJvF8y0Fjq.jpg","order":6},{"name":"Thomas Thieme","character":"K\u00e4rrner","img":"omWXGbeRR32MWuYhpnLOSqJ6xkO.jpg","order":7},{"name":"Frank Plasberg","character":"Frank Plasberg","img":null,"order":8},{"name":"Thomas M. K\u00f6ppl","character":"Etiquette Coach","img":null,"order":9},{"name":"Marc-Marvin Israel","character":"Football Boy","img":null,"order":10},{"name":"David Gebigke","character":"Football Boy","img":null,"order":11},{"name":"Paul Busche","character":"Football Boy","img":null,"order":12},{"name":"Gerdy Zint","character":"Pantomime","img":null,"order":13},{"name":"Nancy Maria Br\u00fcning","character":"Mother with Stroller","img":null,"order":14}],"movie:42994":[{"name":"Tsutomu Isobe","character":"Heinz Beckner (voice)","img":"qP5vQMIbLNvSsSFkEMLZRVt4ZW5.jpg","order":0},{"name":"Koichi Yamadera","character":"Miguel Costrela (voice)","img":"biov5jENp2XYfehkcdnRvi796Wi.jpg","order":1},{"name":"Shozo Iizuka","character":"Ivanov (voice)","img":"msqe5A1q5YUUND0RhzB9dw1AEAO.jpg","order":2},{"name":"Shigeru Chiba","character":"Aoshima (voice)","img":"mF5YJxApTYKBVFJDrEC9IrJ5ooV.jpg","order":3},{"name":"Gara Takashima","character":"Eva Friedel (voice)","img":"5L0kzTVITmOQdQ94NhicKM8ecCX.jpg","order":4},{"name":"Ami Hasegawa","character":"Emily (voice)","img":null,"order":5},{"name":"Hideyuki Hori","character":"Nobuo Tanaka (voice)","img":"fbWWkhe7GVSlhBljY89rJd01OnA.jpg","order":6},{"name":"Kenichi Ogata","character":"Oomaeda (voice)","img":"iQLEawIP2593yrGy22GzLSpnfgl.jpg","order":7},{"name":"Osamu Saka","character":"Kamata (voice)","img":null,"order":8},{"name":"Hisako Kyoda","character":"Grandma (voice)","img":null,"order":9},{"name":"Michio Hazama","character":"Nirasaki (voice)","img":null,"order":10},{"name":"Kayoko Fujii","character":"Sakiko (voice)","img":null,"order":11},{"name":"Ryoichi Tanaka","character":"General Officer (voice)","img":null,"order":12},{"name":"Tetsuya Iwanaga","character":"Nobuo's Brother (voice)","img":null,"order":13},{"name":"Yuu Hayashi","character":"Boy (voice)","img":null,"order":14}],"movie:123431":[{"name":"Snoop Dogg","character":"Self","img":"rbxBK2m6fEq0oHeMoBUMVYkJEdJ.jpg","order":0},{"name":"Dr. Dre","character":"Self","img":"xm2pYFBggiXSkFBV2I612i3fDd3.jpg","order":1},{"name":"Daz Dillinger","character":"Self","img":"nAxS5l9vb1C8ltCp9yAa0f6lazu.jpg","order":2},{"name":"Angela Hunte","character":"Self","img":null,"order":3},{"name":"Ariel Rechtshaid","character":"Self","img":"lF06PTNYrlXeJ3DYApvOcPXJr87.jpg","order":4},{"name":"Dre Skull","character":"Self","img":null,"order":5},{"name":"Jahdan Blakkamoore","character":"Self","img":null,"order":6},{"name":"Bunny Wailer","character":"Self","img":"oMHMLhoV3ry8aOfdvs1kxyowhNq.jpg","order":7},{"name":"Damian Marley","character":"Self","img":null,"order":8},{"name":"Louis Farrakhan","character":"Self","img":null,"order":9},{"name":"Shante Broadus","character":"Self","img":null,"order":10},{"name":"Cori Broadus","character":"Self","img":null,"order":11},{"name":"Maxine Stowe","character":"Self","img":null,"order":12},{"name":"Stewart Copeland","character":"Self","img":null,"order":13},{"name":"Diplo","character":"Self","img":null,"order":14}],"movie:1715492":[{"name":"John Scimone","character":"Self","img":null,"order":0},{"name":"Meghan Scimone","character":"Self","img":null,"order":1},{"name":"Patricia Sandoval","character":"Self","img":null,"order":2},{"name":"Nicholas Taliaferro","character":"Self","img":null,"order":3},{"name":"Stefania Vincenzi","character":"Self","img":null,"order":4},{"name":"Manrico Giampedroni","character":"Self","img":null,"order":5},{"name":"Rose Metcalf","character":"Self","img":null,"order":6},{"name":"Francesco Schettino","character":"Self (Archive Footage)","img":null,"order":7},{"name":"Manoj Singh","character":"Self","img":null,"order":8},{"name":"Francesco Boaria","character":"Self","img":null,"order":9},{"name":"Pier Luigi Foschi","character":"Self","img":null,"order":10},{"name":"Alessandro Cantelli-Forti","character":"Self","img":null,"order":11},{"name":"Barbie Latza Nadeau","character":"Self","img":null,"order":12}],"tv:83867":[{"name":"Diego Luna","character":"Cassian Andor","img":"qwbjcyDN37OHiln4a6P4LP31o7F.jpg","order":0},{"name":"Anton Lesser","character":"Major Partagaz","img":"x0lmqgkmphvOCSTHk2oxsaxA9Ra.jpg","order":3},{"name":"Muhannad Bhaier","character":"Wilmon","img":"5hn7WeXi6wguwxTPTSmcmzHAs9.jpg","order":4},{"name":"Stellan Skarsg\u00e5rd","character":"Luthen Rael","img":"mW7xmtGV4y79kQGn0zkKVGDMAmw.jpg","order":5},{"name":"Denise Gough","character":"Dedra Meero","img":"wWcby4ARYqr8d92ZdjyQcfaiJBW.jpg","order":6},{"name":"Kyle Soller","character":"Syril Karn","img":"rLzduQpYJnZJKkXv3jRc7MDHwoI.jpg","order":7},{"name":"Adria Arjona","character":"Bix Caleen","img":"hzu7OqTmNCT1xdCdmwBSf2yJvaO.jpg","order":8},{"name":"Genevieve O'Reilly","character":"Mon Mothma","img":"4eOoW1PNBZfxRW0g8RmYH8bL6mq.jpg","order":9},{"name":"Faye Marsay","character":"Vel Sartha","img":null,"order":10},{"name":"Elizabeth Dulau","character":"Kleya Marki","img":null,"order":11},{"name":"Alastair Mackenzie","character":"Perrin Fertha","img":null,"order":501},{"name":"Dave Chapman","character":"B2EMO (voice)","img":null,"order":509},{"name":"Robert Emms","character":"Supervisor Lonni Jung","img":null,"order":538},{"name":"Michael Jenn","character":"Supervisor Lagret","img":null,"order":539},{"name":"Jacob James Beswick","character":"Attendant Heert","img":null,"order":619}],"tv:76479":[{"name":"Karl Urban","character":"Billy Butcher","img":"7Y96dAfg0HcFrcLjlD5eD9N0uj4.jpg","order":0},{"name":"Jack Quaid","character":"Hugh 'Hughie' Campbell","img":"320qW5yEbxpmyxQ3evmClJbtKag.jpg","order":1},{"name":"Antony Starr","character":"Homelander","img":"xx3As5SWcE8vYOKZgtjDjqmT3jc.jpg","order":2},{"name":"Erin Moriarty","character":"Annie January / Starlight","img":"2tFblvjBRUYwcVT1V1i79Ppd6wG.jpg","order":3},{"name":"Jessie T. Usher","character":"Reggie Franklin / A-Train","img":null,"order":5},{"name":"Laz Alonso","character":"Marvin T. 'Mother's Milk' Milk / M.M.","img":"nmgOd3X2Xn3jIp9OLCRJzLExRWN.jpg","order":6},{"name":"Chace Crawford","character":"Kevin Moskowitz / The Deep","img":"yWZILsr2mBQBCG1LOcCYYshIbts.jpg","order":7},{"name":"Tomer Capone","character":"Serge / Frenchie","img":"r6s7dkYZUJpd00C7p9itT6Gp8XB.jpg","order":8},{"name":"Karen Fukuhara","character":"Kimiko Miyashiro","img":"sG6CWLdENCtzukNFkFYyPxqyRmS.jpg","order":9},{"name":"Nathan Mitchell","character":"Earving / Black Noir","img":null,"order":10},{"name":"Colby Minifie","character":"Ashley Barrett","img":null,"order":13},{"name":"Claudia Doumit","character":"Victoria 'Vic' Neuman","img":null,"order":14},{"name":"Cameron Crovetti","character":"Ryan","img":null,"order":16},{"name":"Dominique McElligott","character":"Maggie Shaw / Queen Maeve","img":null,"order":242},{"name":"Nneka Elliott","character":"Nneka Elliott","img":null,"order":894}],"tv:1215":[{"name":"David Duchovny","character":"Hank Moody","img":"fD8GuLHOOSSAG98VjwN3NhAmqAJ.jpg","order":0},{"name":"Natascha McElhone","character":"Karen van der Beek","img":"9pxaoasotR1pdCXYSfN1pkm0geO.jpg","order":1},{"name":"Evan Handler","character":"Charlie Runkle","img":"yU0OXeZ0K1ppe1o9SpHiAMHy1rc.jpg","order":2},{"name":"Pamela Adlon","character":"Marcy Runkle","img":"5eIRHUbNOH7ibXa0IfUfFvj5I5L.jpg","order":3},{"name":"Rachel Miner","character":"Dani","img":null,"order":4},{"name":"Madeleine Martin","character":"Becca Moody","img":"hOntuFYAMwn9QaXT0AwLCPU79ce.jpg","order":5},{"name":"Madeline Zima","character":"Mia Lewis","img":"6hmUs5Q1EETapn32yKNt3c3i1sn.jpg","order":6},{"name":"Callum Keith Rennie","character":"Lew Ashby","img":null,"order":7},{"name":"Carla Gallo","character":"Daisy","img":null,"order":513},{"name":"Embeth Davidtz","character":"Felicia Koons","img":null,"order":514},{"name":"Kathleen Turner","character":"Sue Collini","img":null,"order":522},{"name":"Jason Beghe","character":"Richard Bates","img":"hrTwOPWek7nkPJgFhh6inFu7vSS.jpg","order":534},{"name":"Stephen Tobolowsky","character":"Stu Beggs","img":"uL8yfcm0dCFYFQt052kczVQVvac.jpg","order":671},{"name":"Michael Imperioli","character":"Rick Rath","img":null,"order":843},{"name":"Oliver Cooper","character":"Levon","img":null,"order":844}],"tv:1437":[{"name":"Nathan Fillion","character":"Mal Reynolds","img":"aW6vCxkUZtwb6iH2Wf88Uq0XNVv.jpg","order":0},{"name":"Gina Torres","character":"Zo\u00eb Washburne","img":"ukDNHgvEgdU95eaw6pngiAPucv4.jpg","order":1},{"name":"Alan Tudyk","character":"Hoban Washburne","img":"jUuUbPuMGonFT5E2pcs4alfqaCN.jpg","order":2},{"name":"Morena Baccarin","character":"Inara Serra","img":"4gyHyg6FJ1oFczOm5pmMkdEEo2J.jpg","order":3},{"name":"Adam Baldwin","character":"Jayne Cobb","img":"lfbyJWNWGbiYfU0Yr1tjn6Trd2V.jpg","order":4},{"name":"Jewel Staite","character":"Kaylee Frye","img":"ztxDIZvuPreUixO6ZPxwkCg2UlO.jpg","order":5},{"name":"Sean Maher","character":"Simon Tam","img":"o7splcOIVAdbHnwohJOUAvXWVlk.jpg","order":6},{"name":"Michael Fairman","character":"Adelai Niska","img":null,"order":7},{"name":"Summer Glau","character":"River Tam","img":"rDTxblyw0AwU5oSeksgP7GwMyrF.jpg","order":8},{"name":"Ron Glass","character":"Derrial Book","img":null,"order":9},{"name":"Gregory Itzin","character":"Magistrate Higgins","img":null,"order":10},{"name":"Isabella Hofmann","character":"Regan Tam","img":null,"order":11},{"name":"Jeff Ricketts","character":"Man","img":null,"order":540},{"name":"Dennis Cockrum","character":"Other Man","img":null,"order":541},{"name":"Mark Sheppard","character":"Badger","img":null,"order":613}],"tv:67070":[{"name":"Phoebe Waller-Bridge","character":"Fleabag","img":"5VbadQsYCKdiJysP01LJFPmYB8A.jpg","order":0},{"name":"Sian Clifford","character":"Claire","img":"rIqcwewXdkrDTMTwX56QPa7MNTC.jpg","order":1},{"name":"Jenny Rainsford","character":"Boo","img":"pVwR9hsEiHzVTo9giCjR3vIfa8W.jpg","order":2},{"name":"Andrew Scott","character":"The Priest","img":"4F8XpjyQCvtuu21WFm5d8RF5Rl.jpg","order":3},{"name":"Ben Aldridge","character":"Arsehole Guy","img":null,"order":507},{"name":"Jo Martin","character":"Pam","img":null,"order":509},{"name":"Olivia Colman","character":"Godmother","img":"lawJUdbLb1YW0niaYjiVGR9IBG8.jpg","order":510},{"name":"Bill Paterson","character":"Dad","img":"mk5vpYLmER7asqOhEwVKXD3P3YE.jpg","order":511},{"name":"Jamie Demetriou","character":"Bus Rodent","img":null,"order":512},{"name":"Hugh Skinner","character":"Harry","img":"7MHdNxS1gRnx0ZQGPrfS5eRjMe3.jpg","order":513},{"name":"Hugh Dennis","character":"Bank Manager","img":null,"order":514},{"name":"Brett Gelman","character":"Martin","img":"ub2IuMWFNQGYghHTPq0lpmn2Ue0.jpg","order":515},{"name":"Anthony Welsh","character":"Jack","img":null,"order":516},{"name":"Kae Alexander","character":"Elaine","img":null,"order":523},{"name":"David Hargreaves","character":"Chatty Joe","img":null,"order":530}],"tv:40075":[{"name":"Jason Ritter","character":"Dipper Pines (voice)","img":null,"order":0},{"name":"Kristen Schaal","character":"Mabel Pines (voice)","img":null,"order":1},{"name":"Alex Hirsch","character":"Grunkle Stan / Soos Ramirez / Bill Cipher / Old Man McGucket (voice)","img":null,"order":2},{"name":"Linda Cardellini","character":"Wendy Corduroy (voice)","img":null,"order":3},{"name":"Will Forte","character":"Tyler Cutebiker (voice)","img":null,"order":13},{"name":"Gregg Turkington","character":"Young Toby Determined (voice)","img":null,"order":166},{"name":"Carl Faruolo","character":"Grenda (voice)","img":null,"order":170},{"name":"Jennifer Coolidge","character":"Lazy Susan (voice)","img":null,"order":175},{"name":"Dee Bradley Baker","character":"Waddles (voice) / Additional Voices (voice)","img":null,"order":399},{"name":"John DiMaggio","character":"Manly Dan / Pinball Cowboy Skeleton (voice)","img":null,"order":469},{"name":"Kevin Michael Richardson","character":"Sheriff Blubs / Ghost / Ancient Heads / Basketball Player / Manly Dan (voice)","img":null,"order":561},{"name":"Grey DeLisle","character":"Mrs. Gleeful (voice)","img":null,"order":582},{"name":"Matt Chapman","character":"Mermando / Additional Voices (voice)","img":null,"order":591},{"name":"Niki Yang","character":"Candy Chiu (voice)","img":null,"order":650},{"name":"T.J. Miller","character":"Robbie Valentino (voice)","img":null,"order":664}],"tv:95557":[{"name":"Steven Yeun","character":"Mark Grayson / Invincible (voice)","img":null,"order":0},{"name":"Sandra Oh","character":"Debbie Grayson (voice)","img":null,"order":1},{"name":"J.K. Simmons","character":"Nolan Grayson / Omni-Man (voice)","img":null,"order":2},{"name":"Dan Navarro","character":"Hazmat Responder / Additional Voices (voice)","img":null,"order":547},{"name":"Ami Shukla","character":"Additional Voices (voice)","img":null,"order":548},{"name":"Nyima Funk","character":"Additional Voices (voice)","img":null,"order":558},{"name":"Zazie Beetz","character":"Amber (voice)","img":null,"order":567},{"name":"Walton Goggins","character":"Cecil (voice)","img":null,"order":568},{"name":"Gillian Jacobs","character":"Eve (voice)","img":null,"order":574},{"name":"Jason Mantzoukas","character":"Rex / Zombie #2 (voice)","img":null,"order":588},{"name":"Chris Diamantopoulos","character":"Pete / Donald / Todd (voice)","img":null,"order":596},{"name":"Khary Payton","character":"Black Samson / Martian (voice)","img":null,"order":601},{"name":"Ross Marquand","character":"Immortal / Rudy / Kursk (voice)","img":null,"order":628},{"name":"Grey DeLisle","character":"Monster Girl / Thula / Woman (voice)","img":null,"order":656},{"name":"Bobby Kesselman","character":"Additional Voices (voice)","img":null,"order":734}],"tv:33880":[{"name":"Janet Varney","character":"Korra (voice)","img":null,"order":0},{"name":"David Faustino","character":"Mako (voice)","img":null,"order":1},{"name":"P.J. Byrne","character":"Bolin (voice)","img":null,"order":2},{"name":"Seychelle Gabriel","character":"Asami Sato (voice)","img":null,"order":3},{"name":"Mindy Sterling","character":"Lin Beifong (voice)","img":null,"order":6},{"name":"Adrian LaTourelle","character":"Unalaq (voice)","img":null,"order":9},{"name":"J.K. Simmons","character":"Mushi (voice)","img":null,"order":68},{"name":"Dee Bradley Baker","character":"Juicy (voice)","img":null,"order":324},{"name":"Kiernan Shipka","character":"Jinora (voice)","img":null,"order":500},{"name":"Darcy Rose Byrnes","character":"Ikki (voice)","img":null,"order":503},{"name":"Logan Wells","character":"Meelo (voice)","img":null,"order":504},{"name":"John Michael Higgins","character":"Varrick (voice)","img":null,"order":549},{"name":"Richard Riehle","character":"Bumi (voice)","img":null,"order":551},{"name":"Maria Bamford","character":"Pema (voice)","img":null,"order":556},{"name":"Jeff Bennett","character":"Shiro Shinobi / Fishmonger / Ring Announcer (voice)","img":null,"order":568}],"tv:86831":[{"name":"Josh Brener","character":"K-VRC (voice)","img":null,"order":237},{"name":"Matthew Yang King","character":"Host (voice)","img":null,"order":520},{"name":"Nolan North","character":"Vladimir (voice)","img":null,"order":555},{"name":"Ike Amadi","character":"Platoon Sergeant (voice)","img":null,"order":566},{"name":"Time Winters","character":"Dicko (voice)","img":null,"order":584},{"name":"Emily O'Brien","character":"Eve (voice)","img":null,"order":592},{"name":"Chris Cox","character":"Male Backgound (voice)","img":null,"order":592},{"name":"Noshir Dalal","character":"Beaumont (voice)","img":null,"order":599},{"name":"Michelle C. Bonilla","character":"Commander / Pilot (voice)","img":null,"order":599},{"name":"Stanton Lee","character":"Sergeant Howard (voice)","img":null,"order":602},{"name":"Fred Tatasciore","character":"Creature / TV Santa (voice)","img":null,"order":608},{"name":"Scott Whyte","character":"Trot (voice)","img":null,"order":618},{"name":"Caitlin McKenna","character":"Additional Voices (voice)","img":null,"order":622},{"name":"Piotr Michael","character":"Investor (voice)","img":null,"order":636},{"name":"Chris Parnell","character":"The Cat (voice)","img":null,"order":653}],"tv:68126":[{"name":"Trond Fausa Aurv\u00e5g","character":"Rufus","img":null,"order":0},{"name":"Jon \u00d8igarden","character":"Jarl Varg","img":null,"order":1},{"name":"K\u00e5re Conradi","character":"Orm","img":null,"order":2},{"name":"Silje Torp","character":"Fr\u00f8ya","img":null,"order":3},{"name":"Mikkel Bratt Silset","character":"Ragnar","img":null,"order":4},{"name":"Nils J\u00f8rgen Kaalstad","character":"Arvid","img":null,"order":5},{"name":"Kristine Riis","character":"Liv","img":null,"order":6},{"name":"Bj\u00f8rn Hallgeir Myrene","character":"Torstein Hund","img":null,"order":7},{"name":"Marian Saastad Ottesen","character":"Hildur","img":null,"order":8},{"name":"Nikis Theophilakis","character":"Olvar / Eigil","img":null,"order":9},{"name":"\u00d8ystein Martinsen","character":"Kark","img":null,"order":10},{"name":"Christian Skolmen","character":"H\u00e5kon","img":null,"order":11},{"name":"Erik Aleksander Schjerven","character":"Magnus","img":null,"order":12},{"name":"Ingar Helge Gimle","character":"Nomadeh\u00f8vding","img":null,"order":13},{"name":"Finn Schau","character":"Lovgiveren","img":null,"order":14}],"tv:60625":[{"name":"Justin Roiland","character":"Rick Sanchez / Morty Smith (voice)","img":null,"order":0},{"name":"Chris Parnell","character":"Jerry Smith (voice)","img":null,"order":1},{"name":"Spencer Grammer","character":"Summer Smith (voice)","img":null,"order":2},{"name":"Sarah Chalke","character":"Beth Smith (voice)","img":null,"order":3},{"name":"Ian Cardoni","character":"Rick Sanchez (voice)","img":null,"order":4},{"name":"Harry Belden","character":"Morty Smith (voice)","img":null,"order":6},{"name":"Dan Harmon","character":"Alejandro (voice)","img":null,"order":688},{"name":"Kari Wahlgren","character":"Roy's Wife (voice)","img":null,"order":715},{"name":"Maurice LaMarche","character":"Roy's Teacher / Gromflamite Fed (voice)","img":null,"order":743},{"name":"Tom Kenny","character":"Conroy / Federation Admiral / Additional Voices (voice)","img":null,"order":750},{"name":"Ryan Ridley","character":"Head Vampire (voice)","img":null,"order":752},{"name":"Brandon Johnson","character":"Mr. Goldenfold / Black Mailman (voice)","img":null,"order":755},{"name":"Nolan North","character":"Jerry's High School Friend (voice)","img":null,"order":757},{"name":"Rob Schrab","character":"Town Resident (voice)","img":null,"order":768},{"name":"Nick Reczynski","character":"Snake Humanoid (voice)","img":null,"order":815}],"tv:261579":[{"name":"Keanu Reeves","character":"Pilot (voice)","img":null,"order":500},{"name":"Parry Shen","character":"MC (young) (voice)","img":null,"order":501},{"name":"Ping Wu","character":"MC (Old) (voice)","img":null,"order":502},{"name":"Lydia Look","character":"Li (voice)","img":null,"order":503},{"name":"Nelson Lee","character":"Sean (voice)","img":null,"order":504},{"name":"Feodor Chin","character":"Thug (voice)","img":null,"order":505},{"name":"Rae Lim","character":"MC (Child) (voice)","img":null,"order":506},{"name":"Erin Yvette","character":"The Voice (voice)","img":null,"order":507},{"name":"Temuera Morrison","character":"Old Salt (voice)","img":null,"order":508},{"name":"Patrick Schwarzenegger","character":"The Kid (voice)","img":null,"order":509},{"name":"Steve Blum","character":"Dispatch / Mechanic (voice)","img":null,"order":510},{"name":"Noah Manzoor","character":"Solon (voice)","img":null,"order":511},{"name":"Madeleine Knight","character":"Mora (voice)","img":null,"order":512},{"name":"Aleks Le","character":"Yi Xing (voice)","img":null,"order":556},{"name":"Fred Tatasciore","character":"Helldiver (voice)","img":null,"order":558}],"tv:125988":[{"name":"Rebecca Ferguson","character":"Juliette Nichols","img":null,"order":0},{"name":"Common","character":"Robert Sims","img":null,"order":1},{"name":"Chinaza Uche","character":"Paul Billings","img":null,"order":2},{"name":"Shane McRae","character":"Knox","img":null,"order":4},{"name":"Remmie Milner","character":"Shirley Campbell","img":null,"order":6},{"name":"Alexandria Riley","character":"Camille Sims","img":null,"order":7},{"name":"Billy Postlethwaite","character":"Hank Murphy","img":null,"order":12},{"name":"Tim Robbins","character":"Bernard Holland","img":null,"order":15},{"name":"Clare Perkins","character":"Carla McLain","img":null,"order":17},{"name":"Harriet Walter","character":"Martha Walker","img":null,"order":18},{"name":"Avi Nash","character":"Lukas Kyle","img":null,"order":20},{"name":"Rick Gomez","character":"Patrick Kennedy","img":null,"order":21},{"name":"Olatunji Ayofe","character":"Teddy","img":null,"order":502},{"name":"Chipo Chung","character":"Sandy","img":null,"order":506},{"name":"Caitlin Zoz","character":"Kathleen Billings","img":null,"order":542}],"tv:2190":[{"name":"Isaac Hayes","character":"Chef (voice)","img":null,"order":2},{"name":"Mary Kay Bergman","character":"Liane Cartman / Wendy Testaburger / Sharon Marsh / Sheila Broflovski (voice)","img":null,"order":3},{"name":"Mona Marshall","character":"Sheila Broflovski (voice)","img":null,"order":4},{"name":"Sebastian Yu","character":"Ike Broflovski (voice)","img":null,"order":7},{"name":"Jessie Jo Thomas","character":"","img":null,"order":8},{"name":"Milan Agnone","character":"Ike Broflovski (voice)","img":null,"order":9},{"name":"Trey Parker","character":"Eric Cartman / Stan Marsh / Randy Marsh / Mr. Garrison (voice)","img":null,"order":10},{"name":"Matt Stone","character":"Kyle Broflovski / Kenny McCormick / Butters Stotch (voice)","img":null,"order":11},{"name":"Jennifer Howell","character":"Bebe Stevens (voice)","img":null,"order":11},{"name":"April Stewart","character":"Wendy Testaburger / Liane Cartman / Dora M\u00e1rquez (voice)","img":null,"order":65},{"name":"Eliza Schneider","character":"Liane Cartman (voice)","img":null,"order":79},{"name":"Kyle McCulloch","character":"Excalibur Hotel Employee (voice)","img":null,"order":512},{"name":"Jonathan Kimmel","character":"","img":null,"order":528},{"name":"John Hansen","character":"","img":null,"order":534},{"name":"Adrien Beard","character":"Tolkien Black (voice)","img":null,"order":554}],"tv:30984":[{"name":"Masakazu Morita","character":"Ichigo Kurosaki (voice)","img":null,"order":0},{"name":"Fumiko Orikasa","character":"Rukia Kuchiki (voice)","img":null,"order":1},{"name":"Hiroki Yasumoto","character":"Yasutora Sado (voice)","img":null,"order":2},{"name":"Yuki Matsuoka","character":"Orihime Inoue (voice)","img":null,"order":3},{"name":"Noriaki Sugiyama","character":"Uryu Ishida (voice)","img":null,"order":4},{"name":"Kentaro Ito","character":"Renji Abarai (voice)","img":null,"order":47},{"name":"Ryotaro Okiayu","character":"Byakuya Kuchki (voice)","img":null,"order":48},{"name":"Mitsuaki Madono","character":"Kon (voice)","img":null,"order":49},{"name":"Toshiyuki Morikawa","character":"Kaname T\u014dsen (voice)","img":null,"order":133},{"name":"Kiyoyuki Yanada","character":"Tessai Tsukabishi (voice)","img":null,"order":500},{"name":"Katsuyuki Konishi","character":"Shuhei Hisagi (voice)","img":null,"order":501},{"name":"Shin-ichiro Miki","character":"Kisuke Urahara (voice)","img":null,"order":504},{"name":"Masaya Onosaka","character":"Gejigeji mukade (voice)","img":null,"order":505},{"name":"Ryusei Nakao","character":"Mayuri Kurotsuchi (voice)","img":null,"order":509},{"name":"Takayuki Sugo","character":"Yhwach (voice)","img":null,"order":517}],"tv:114410":[{"name":"Kikunosuke Toya","character":"Denji (voice)","img":null,"order":0},{"name":"Tomori Kusunoki","character":"Makima (voice)","img":null,"order":1},{"name":"Shogo Sakata","character":"Aki Hayakawa (voice)","img":null,"order":2},{"name":"Fairouz Ai","character":"Power (voice)","img":null,"order":3},{"name":"Mariya Ise","character":"Himeno (voice)","img":null,"order":500},{"name":"Marina Inoue","character":"Child Denji (voice)","img":null,"order":507},{"name":"Kenjiro Tsuda","character":"Kishibe (voice)","img":null,"order":519},{"name":"Taku Yashiro","character":"Arai (voice)","img":null,"order":520},{"name":"Karin Takahashi","character":"Kobeni (voice)","img":null,"order":521},{"name":"Shiori Izawa","character":"Pochita (voice)","img":null,"order":522},{"name":"Mitsutoshi Shundo","character":"Announcer (voice)","img":null,"order":528},{"name":"Hironori Kondoh","character":"Eternity Devil (voice)","img":null,"order":538},{"name":"Noriyuki Tsuyuki","character":"Henchman D (voice)","img":null,"order":542},{"name":"Daiki Hamano","character":"Samurai Sword (voice)","img":null,"order":546},{"name":"You Taichi","character":"Sawatari (voice)","img":null,"order":547}],"tv:105248":[{"name":"KENN","character":"David Martinez (voice)","img":null,"order":0},{"name":"Koki Uchiyama","character":"D (voice)","img":null,"order":1},{"name":"Momoka Terasawa","character":"Roman (voice)","img":null,"order":2},{"name":"Akari Kito","character":"Talia (voice)","img":null,"order":3},{"name":"Kentaro Tone","character":"Weak (voice)","img":null,"order":4},{"name":"Kento Shiraishi","character":"Maelstrom (voice)","img":null,"order":531},{"name":"Kenjiro Tsuda","character":"Ripperdoc (voice)","img":null,"order":568},{"name":"Aoi Yuki","character":"Lucy (voice)","img":null,"order":571},{"name":"Kazuhiko Inoue","character":"Faraday (voice)","img":null,"order":573},{"name":"Tomoyo Kurosawa","character":"Rebecca (voice)","img":null,"order":574},{"name":"Takako Honda","character":"Kiwi (voice)","img":null,"order":575},{"name":"Ryuho Nagaoka","character":"Driver (voice)","img":null,"order":627},{"name":"Akemi Okamura","character":"Avida (voice)","img":null,"order":668},{"name":"Aoi Koga","character":"Capri (voice)","img":null,"order":669},{"name":"Yasunori Matsumoto","character":"Kiril (voice)","img":null,"order":670}],"tv:240411":[{"name":"Shion Wakayama","character":"Momo Ayase (voice)","img":null,"order":0},{"name":"Natsuki Hanae","character":"Ken 'Okarun' Takakura (voice)","img":null,"order":1},{"name":"Mayumi Tanaka","character":"Turbo Granny (voice)","img":null,"order":2},{"name":"Nana Mizuki","character":"Seiko (voice)","img":null,"order":3},{"name":"Kaori Maeda","character":"Miko (voice)","img":null,"order":500},{"name":"Miyu Tomita","character":"Muko (voice)","img":null,"order":501},{"name":"Ayane Sakura","character":"Aira Shiratori (voice)","img":null,"order":519},{"name":"Aya Yamane","character":"Tsubame (voice)","img":null,"order":520},{"name":"Natsu Yorita","character":"Anzu (voice)","img":null,"order":521},{"name":"Koji Seki","character":"Male Teacher (voice)","img":null,"order":527},{"name":"Koseki Kogure","character":"Student C (voice)","img":null,"order":527},{"name":"Yukiko Motoyoshi","character":"Female Student A (voice)","img":null,"order":528},{"name":"Kazuya Nakai","character":"Serpoian A / Serpoian B / Serpoian C (voice)","img":null,"order":533},{"name":"Tomokazu Seki","character":"Dover Demon (voice)","img":null,"order":536},{"name":"Kaito Ishikawa","character":"Jin 'Jiji' Enjouji (voice)","img":null,"order":541}],"tv:86031":[{"name":"Yusuke Kobayashi","character":"Senku Ishigami (voice)","img":null,"order":0},{"name":"Makoto Furukawa","character":"Taiju Oki (voice)","img":null,"order":1},{"name":"Kana Ichinose","character":"Yuzuriha Ogawa (voice)","img":null,"order":2},{"name":"Manami Numakura","character":"Kohaku (voice)","img":null,"order":3},{"name":"Yuichi Nakamura","character":"Tsukasa (voice)","img":null,"order":4},{"name":"Gen Sato","character":"Chrome (voice)","img":null,"order":5},{"name":"Ayumu Murase","character":"Ginro (voice)","img":null,"order":6},{"name":"Yuya Hirose","character":"Science Club Member (voice)","img":null,"order":501},{"name":"Aoi Ichikawa","character":"Science Club Member (voice)","img":null,"order":502},{"name":"Haruki Ishiya","character":"Science Club Member (voice)","img":null,"order":503},{"name":"Kouichi Souma","character":"Teacher (voice)","img":null,"order":504},{"name":"Keiji Fujiwara","character":"Byakuya Ishigami (voice)","img":null,"order":508},{"name":"Kengo Kawanishi","character":"Asagiri Gen (voice)","img":null,"order":526},{"name":"Ryota Suzuki","character":"Nanami Ryuusui (voice)","img":null,"order":527},{"name":"Karin Takahashi","character":"Suika (Voice)","img":null,"order":528}],"tv:209867":[{"name":"Atsumi Tanezaki","character":"Frieren (voice)","img":null,"order":0},{"name":"Kana Ichinose","character":"Fern (voice)","img":null,"order":1},{"name":"Chiaki Kobayashi","character":"Stark (voice)","img":null,"order":2},{"name":"Nobuhiko Okamoto","character":"Himmel (voice)","img":null,"order":3},{"name":"Hiroki Touchi","character":"\ud558\uc774\ud130 (voice)","img":null,"order":4},{"name":"Yoji Ueda","character":"Eisen (voice)","img":null,"order":5},{"name":"Kento Shiraishi","character":"Falsch (voice)","img":null,"order":550},{"name":"Azumi Waki","character":"Kanne (voice)","img":null,"order":565},{"name":"Sayumi Suzushiro","character":"Lawine (voice)","img":null,"order":566},{"name":"Ikumi Hasegawa","character":"\u00dcbel (voice)","img":null,"order":567},{"name":"Haruka Terui","character":"Sense (voice)","img":null,"order":568},{"name":"Tarusuke Shingaki","character":"Genau (voice)","img":null,"order":569},{"name":"Masafumi Kobatake","character":"Mining Worker (voice)","img":null,"order":572},{"name":"Jiro Saito","character":"Denken (voice)","img":null,"order":576},{"name":"Eiji Hanawa","character":"Richter (voice)","img":null,"order":577}],"tv:31911":[{"name":"Romi Park","character":"Edward Elric (voice)","img":null,"order":0},{"name":"Shin-ichiro Miki","character":"Roy Mustang (voice)","img":null,"order":2},{"name":"Fumiko Orikasa","character":"Riza Hawkeye (voice)","img":null,"order":3},{"name":"Megumi Takamoto","character":"Winry Rockbell (voice)","img":null,"order":4},{"name":"Rie Kugimiya","character":"Alphonse Elric (voice)","img":null,"order":341},{"name":"Hidekatsu Shibata","character":"King Bradley (voice)","img":null,"order":505},{"name":"Kenji Utsumi","character":"Louis Armstrong (voice)","img":null,"order":507},{"name":"Kenta Miyake","character":"Scar (voice)","img":null,"order":535},{"name":"Minami Takayama","character":"Envy (voice)","img":null,"order":541},{"name":"Iemasa Kayumi","character":"Father (voice)","img":null,"order":552},{"name":"Biichi Sato","character":"Doctor (voice)","img":null,"order":559},{"name":"Mamoru Miyano","character":"Ling Yao (voice)","img":null,"order":587},{"name":"Mai Gotou","character":"May Chang (voice)","img":null,"order":590},{"name":"Yuki Hayashi","character":"Military (voice)","img":null,"order":630},{"name":"Takashi Hikida","character":"Ishvalan A (voice)","img":null,"order":689}],"tv:95479":[{"name":"Takaya Kuroda","character":"Masamichi Yaga (voice)","img":null,"order":549},{"name":"Nobunaga Shimazaki","character":"Mahito (voice)","img":null,"order":600},{"name":"Tomokazu Seki","character":"Panda (voice)","img":null,"order":602},{"name":"Mikako Komatsu","character":"Maki Zen'in (voice)","img":null,"order":603},{"name":"Kenjiro Tsuda","character":"Kento Nanami (voice)","img":null,"order":605},{"name":"Junichi Suwabe","character":"Ryomen Sukuna (voice)","img":null,"order":612},{"name":"Shigeru Chiba","character":"Grandfather (voice)","img":"mF5YJxApTYKBVFJDrEC9IrJ5ooV.jpg","order":654},{"name":"Aya Endo","character":"Shoko Ieiri (voice)","img":null,"order":666},{"name":"Mitsuo Iwata","character":"Spectator (voice)","img":null,"order":685},{"name":"Yuichi Nakamura","character":"Satoru Gojo (voice)","img":null,"order":686},{"name":"Asami Seto","character":"Transfigured Human (voice)","img":null,"order":687},{"name":"Junya Enoki","character":"Yuji Itadori (voice)","img":null,"order":689},{"name":"Takahiro Sakurai","character":"Suguru Geto (voice)","img":null,"order":718},{"name":"Yoshiko Sakakibara","character":"Tengen (voice)","img":null,"order":739},{"name":"Yuma Uchida","character":"Megumi Fushiguro (voice)","img":null,"order":849}],"tv:207468":[{"name":"Masaya Fukunishi","character":"Kafka Hibino / Kaiju No. 8 (voice)","img":null,"order":0},{"name":"Asami Seto","character":"Mina Ashiro (voice)","img":null,"order":1},{"name":"Wataru Katoh","character":"Reno Ichikawa (voice)","img":null,"order":2},{"name":"Kengo Kawanishi","character":"Soshiro Hoshina (voice)","img":null,"order":4},{"name":"Fairouz Ai","character":"Kaiju No. 15 (voice)","img":null,"order":322},{"name":"Nene Hieda","character":"Akari Minase (voice)","img":null,"order":546},{"name":"Tomohiro Ono","character":"Shimoen (voice)","img":null,"order":551},{"name":"Genta Nakamura","character":"Operator (voice)","img":null,"order":569},{"name":"Takayuki Sugo","character":"Keiji Itami (voice)","img":null,"order":575},{"name":"Ryosuke Higa","character":"Ministry of Defense Staff Member (voice)","img":null,"order":577},{"name":"Shota Yamamoto","character":"Mebari (voice)","img":null,"order":612},{"name":"Yuuki Shin","character":"Iharu Furuhashi (voice)","img":null,"order":625},{"name":"Keisuke Komoto","character":"Haruichi Izumo (voice)","img":null,"order":626},{"name":"Shunsuke Takeuchi","character":"Aoi Kaguragi (voice)","img":null,"order":627},{"name":"Sayaka Senbongi","character":"Konomi Okonogi (voice)","img":null,"order":628}],"tv:65930":[{"name":"Daiki Yamashita","character":"Izuku Midoriya (voice)","img":null,"order":0},{"name":"Tarusuke Shingaki","character":"Mirio Togata (voice)","img":null,"order":1},{"name":"Nobuhiko Okamoto","character":"Katsuki Bakugo (voice)","img":null,"order":2},{"name":"Kenta Miyake","character":"All Might (voice)","img":null,"order":3},{"name":"Yuki Kaji","character":"Shoto Todoroki (voice)","img":null,"order":6},{"name":"Seiran Kobayashi","character":"Eri (voice)","img":null,"order":8},{"name":"Ayane Sakura","character":"Ochako Uraraka (voice)","img":null,"order":48},{"name":"Aoi Yuki","character":"Setsuna Tokage (voice)","img":null,"order":51},{"name":"Junichi Suwabe","character":"Jurota Shishida (voice)","img":null,"order":60},{"name":"Kaito Ishikawa","character":"Tenya Iida (voice)","img":null,"order":91},{"name":"Kenjiro Tsuda","character":"Overhaul (voice)","img":null,"order":505},{"name":"Koki Uchiyama","character":"Tomura Shigaraki (voice)","img":null,"order":546},{"name":"Hiro Shimono","character":"Dabi (voice)","img":null,"order":571},{"name":"Kiyotaka Furushima","character":"Kido (voice)","img":null,"order":583},{"name":"Tetsu Inada","character":"Endeavor (voice)","img":null,"order":585}],"tv:890":[{"name":"Megumi Ogata","character":"Shinji Ikari (voice)","img":null,"order":0},{"name":"Kotono Mitsuishi","character":"Misato Katsuragi (voice)","img":null,"order":1},{"name":"Megumi Hayashibara","character":"Rei Ayanami (voice)","img":null,"order":2},{"name":"Yuko Miyamura","character":"Asuka Langley Soryu (voice)","img":null,"order":3},{"name":"Yuriko Yamaguchi","character":"Dr. Ritsuko Akagi (voice)","img":null,"order":4},{"name":"Fumihiko Tachiki","character":"Gendo Ikari (voice)","img":null,"order":5},{"name":"Motomu Kiyokawa","character":"Kozo Fuyutsuki (voice)","img":null,"order":6},{"name":"Miki Nagasawa","character":"Maya Ibuki (voice)","img":null,"order":7},{"name":"Mugihito","character":"Keel Lorenz (voice)","img":null,"order":500},{"name":"Junko Iwao","character":"Hikari Horaki (voice)","img":null,"order":501},{"name":"Tetsuya Iwanaga","character":"Kensuke Aida (voice)","img":null,"order":503},{"name":"Tomokazu Seki","character":"Touji Suzuhara (voice)","img":null,"order":505},{"name":"Takehito Koyasu","character":"Shigeru Aoba (voice)","img":null,"order":505},{"name":"Koichi Yamadera","character":"Ryouji Kaji (voice)","img":"biov5jENp2XYfehkcdnRvi796Wi.jpg","order":506},{"name":"Hiro Yuuki","character":"Makoto Hyuuga (voice)","img":null,"order":508}],"tv:114868":[{"name":"Tomoyo Kurosawa","character":"G\u00f6ll (voice)","img":null,"order":1},{"name":"Wataru Takagi","character":"Zeus (voice)","img":null,"order":2},{"name":"Junichi Suwabe","character":"Hermes (voice)","img":null,"order":3},{"name":"Soma Saito","character":"Adam (voice)","img":null,"order":4},{"name":"Miyuki Sawashiro","character":"Narrator (voice)","img":null,"order":87},{"name":"Tatsuhisa Suzuki","character":"Shiva (voice)","img":null,"order":501},{"name":"Rie Tanaka","character":"Aphrodite (voice)","img":null,"order":502},{"name":"Yukihiro Nozuyama","character":"Heimdall (voice)","img":null,"order":516},{"name":"Koji Ishii","character":"Zhang Fei (voice)","img":null,"order":524},{"name":"Naoko Komatsu","character":"Eve (voice)","img":null,"order":525},{"name":"Taisuke Nakano","character":"Huginn (voice)","img":null,"order":531},{"name":"Daiki Hamano","character":"God (voice)","img":null,"order":532},{"name":"Chiko Mizumori","character":"Iori Miyamoto (voice)","img":null,"order":534},{"name":"Tomohiro Yamaguchi","character":"Man (voice)","img":null,"order":535},{"name":"Satoru Ito","character":"Spectator (voice)","img":null,"order":539}],"tv:207332":[{"name":"Tomokazu Sugita","character":"Taro Sakamoto (voice)","img":null,"order":0},{"name":"Nobunaga Shimazaki","character":"Shin Asakura (voice)","img":null,"order":1},{"name":"Ayane Sakura","character":"Lu Shaotang (voice)","img":null,"order":2},{"name":"Nao Toyama","character":"Aoi Sakamoto (voice)","img":null,"order":3},{"name":"Hina Kino","character":"Hana Sakamoto (voice)","img":null,"order":4},{"name":"Ryota Suzuki","character":"Heisuke Mashimo (voice)","img":null,"order":5},{"name":"Natsuki Hanae","character":"Yoichi Nagumo (voice)","img":null,"order":6},{"name":"Taku Yashiro","character":"Shishiba (voice)","img":null,"order":7},{"name":"Saori Hayami","character":"Osaragi (voice)","img":null,"order":8},{"name":"Hiroki Yasumoto","character":"Hyou (voice)","img":null,"order":9},{"name":"Hochu Otsuka","character":"Takamura (voice)","img":null,"order":10},{"name":"Daisuke Namikawa","character":"Kei Uzuki (voice)","img":null,"order":11},{"name":"Nobuhiko Okamoto","character":"Natsuki Seba (voice)","img":null,"order":500},{"name":"Yuhko Kaida","character":"Dump (voice)","img":null,"order":501},{"name":"Ryota Takeuchi","character":"Minimalist (voice)","img":null,"order":502}],"tv:249907":[{"name":"Yohei Azakami","character":"Xylo Forbartz (voice)","img":null,"order":0},{"name":"Mayu Iizuka","character":"Teoritta (voice)","img":null,"order":1},{"name":"Shizuka Ishigami","character":"Patouche Kivia (voice)","img":null,"order":2},{"name":"Shun Horie","character":"Dotta Luzlas (voice)","img":null,"order":3},{"name":"Shunichi Toki","character":"Venetim Leopold (voice)","img":null,"order":4},{"name":"Yoji Ueda","character":"Norgayu Senridge (voice)","img":null,"order":5},{"name":"Yoshitsugu Matsuoka","character":"Tatsuya (voice)","img":null,"order":6},{"name":"Jun Fukushima","character":"Tsav (voice)","img":null,"order":7},{"name":"Shoya Chiba","character":"Jace Pertilact (voice)","img":null,"order":8},{"name":"Yoko Hikasa","character":"Neely (voice)","img":null,"order":9}],"tv:120089":[{"name":"Takuya Eguchi","character":"Loid Forger (voice)","img":null,"order":0},{"name":"Atsumi Tanezaki","character":"Anya Forger (voice)","img":null,"order":1},{"name":"Saori Hayami","character":"Yor Forger (voice)","img":null,"order":2},{"name":"Kenichirou Matsuda","character":"Narration (voice)","img":null,"order":345},{"name":"Emiri Kato","character":"Agent M (voice)","img":null,"order":602},{"name":"Hiroki Goto","character":"Eden College Teacher (voice)","img":null,"order":611},{"name":"Hana Sato","character":"Onboard Announcement (voice)","img":null,"order":618},{"name":"Emiko Takeuchi","character":"Nurse (voice)","img":null,"order":643},{"name":"Mirei Kumagai","character":"Berlint General Hospital Doctor (voice)","img":null,"order":644},{"name":"Shoumaru Zouza","character":"Vides (voice)","img":null,"order":677},{"name":"Yuhko Kaida","character":"Sylvia Sherwood (voice)","img":null,"order":685},{"name":"Marie Ooi","character":"Gram Gretcher (voice)","img":null,"order":687},{"name":"Motoki Sakuma","character":"Wolson (voice)","img":null,"order":689},{"name":"Takaaki Torashima","character":"Secretary (voice)","img":null,"order":690},{"name":"Masafumi Kobatake","character":"Barkeeper (voice)","img":null,"order":694}],"tv:36243":[{"name":"Shintaro Asanuma","character":"'I' (Watashi) (voice)","img":null,"order":0},{"name":"Hiroyuki Yoshino","character":"Ozu (voice)","img":null,"order":1},{"name":"Keiji Fujiwara","character":"Higuchi (voice)","img":null,"order":2},{"name":"Yuhko Kaida","character":"Hanuki (voice)","img":null,"order":4},{"name":"Maaya Sakamoto","character":"Akashi (voice)","img":null,"order":5},{"name":"Junichi Suwabe","character":"Jogasaki (voice)","img":null,"order":6}],"tv:88803":[{"name":"Yuto Uemura","character":"Thorfinn (voice)","img":null,"order":0},{"name":"Naoya Uchida","character":"Askeladd (voice)","img":null,"order":1},{"name":"Shunsuke Takeuchi","character":"Einar (voice)","img":null,"order":2},{"name":"Kensho Ono","character":"Canute (voice)","img":null,"order":3},{"name":"Hiroki Yasumoto","character":"Bjorn (voice)","img":null,"order":4},{"name":"Akio Otsuka","character":"Thorkell (voice)","img":null,"order":5},{"name":"Yusuke Ohta","character":"Guard (voice)","img":null,"order":589},{"name":"Yoshiaki Hasegawa","character":"Scout of Thorkell's Troops (voice)","img":null,"order":596},{"name":"Takayuki Nakatsukasa","character":"Member of Askeladd's Mercenaries (voice)","img":null,"order":631},{"name":"Takuya Nakashima","character":"Retainer (voice)","img":null,"order":639},{"name":"Tsuguo Mogami","character":"Soldier of the English Army (voice)","img":null,"order":644},{"name":"Hiroki Goto","character":"Torgrim (voice)","img":null,"order":657},{"name":"Masato Niwa","character":"Wild Dog (voice)","img":null,"order":660},{"name":"Taito Ban","character":"Merchant (voice)","img":null,"order":662},{"name":"Shinya Takahashi","character":"Thegn (voice)","img":null,"order":678}],"tv:30669":[{"name":"Nozomu Sasaki","character":"Y\u016bsuke Urameshi (voice)","img":null,"order":1},{"name":"Shigeru Chiba","character":"Kazuma Kuwabara (voice)","img":"mF5YJxApTYKBVFJDrEC9IrJ5ooV.jpg","order":2},{"name":"Megumi Ogata","character":"Kurama/Sh\u016bichi Minamino (voice)","img":null,"order":3},{"name":"Nobuyuki Hiyama","character":"Hiei (voice)","img":null,"order":4},{"name":"Yuri Amano","character":"Keiko Yukimura (voice)","img":null,"order":5},{"name":"Hisako Kyoda","character":"Genkai (voice)","img":null,"order":6},{"name":"Mayumi Tanaka","character":"Koenma (voice)","img":null,"order":7},{"name":"Ai Orikasa","character":"Shizuru Kuwabara (voice)","img":null,"order":8},{"name":"Tomomichi Nishimura","character":"Narration (voice)","img":null,"order":10},{"name":"Sanae Miyuki","character":"Botan","img":null,"order":10},{"name":"Yuri Shiratori","character":"Yukina (voice)","img":null,"order":12},{"name":"Katsumi Suzuki","character":"Toguro (voice)","img":null,"order":13},{"name":"Rokuro Naya","character":"Sensui Shinobu (voice)","img":null,"order":14}],"movie:1930":[{"name":"Andrew Garfield","character":"Spider-Man / Peter Parker","img":null,"order":0},{"name":"Emma Stone","character":"Gwen Stacy","img":null,"order":1},{"name":"Rhys Ifans","character":"The Lizard / Dr. Curt Connors","img":null,"order":2},{"name":"Denis Leary","character":"Captain Stacy","img":null,"order":3},{"name":"Martin Sheen","character":"Uncle Ben","img":null,"order":4},{"name":"Sally Field","character":"Aunt May","img":null,"order":5},{"name":"Irrfan Khan","character":"Rajit Ratha","img":null,"order":6},{"name":"Campbell Scott","character":"Richard Parker","img":null,"order":7},{"name":"Embeth Davidtz","character":"Mary Parker","img":null,"order":8},{"name":"Chris Zylka","character":"Flash Thompson","img":null,"order":9},{"name":"Max Charles","character":"Peter Parker (Age 4)","img":null,"order":10},{"name":"C. Thomas Howell","character":"Jack's Father","img":null,"order":11},{"name":"Jake Keiffer","character":"Jack","img":null,"order":12},{"name":"Kari Coleman","character":"Helen Stacy","img":null,"order":13},{"name":"Michael Barra","character":"Store Clerk","img":null,"order":14}],"movie:102382":[{"name":"Andrew Garfield","character":"Spider-Man / Peter Parker","img":null,"order":0},{"name":"Emma Stone","character":"Gwen Stacy","img":null,"order":1},{"name":"Jamie Foxx","character":"Electro / Max Dillon","img":null,"order":2},{"name":"Dane DeHaan","character":"Green Goblin / Harry Osborn","img":null,"order":3},{"name":"Colm Feore","character":"Donald Menken","img":null,"order":4},{"name":"Felicity Jones","character":"Felicia","img":null,"order":5},{"name":"Paul Giamatti","character":"Aleksei Sytsevich","img":null,"order":6},{"name":"Sally Field","character":"Aunt May","img":null,"order":7},{"name":"Embeth Davidtz","character":"Mary Parker","img":null,"order":8},{"name":"Campbell Scott","character":"Richard Parker","img":null,"order":9},{"name":"Marton Csokas","character":"Dr. Ashley Kafka","img":null,"order":10},{"name":"Louis Cancelmi","character":"Man in Black Suit","img":null,"order":11},{"name":"Max Charles","character":"Young Peter Parker","img":null,"order":12},{"name":"B.J. Novak","character":"Allistar Smythe","img":null,"order":13},{"name":"Sarah Gadon","character":"Kari","img":null,"order":14}],"movie:55931":[{"name":"Keanu Reeves","character":"Neo (\"Kid's Story\") (voice)","img":null,"order":0},{"name":"Carrie-Anne Moss","character":"Trinity (\"Kid's Story\" and \"A Detective Story\") (voice)","img":null,"order":1},{"name":"Clayton Watson","character":"The Kid (\"Kid's Story\") (voice)","img":null,"order":2},{"name":"Pamela Adlon","character":"Jue (\"Final Flight of the Osiris\") / Manabu (\"Beyond\") (voice)","img":"5eIRHUbNOH7ibXa0IfUfFvj5I5L.jpg","order":3},{"name":"Melinda Clarke","character":"Alexa (\"Matriculated\") (voice)","img":null,"order":4},{"name":"John DiMaggio","character":"Crew Man (\"Final Flight of the Osiris\") / Kaiser (\"Program\") (voice)","img":null,"order":5},{"name":"Kevin Michael Richardson","character":"Thadeus (\"Final Flight of Osiris\") / Cop (\"Kid's Story\") / Agent #2 (\"World Record\")","img":null,"order":6},{"name":"Tom Kenny","character":"Operator (\"Final Flight of Osiris\")","img":null,"order":7},{"name":"Rick Gomez","character":"Pilot (\"Final Flight of Osiris\")","img":null,"order":8},{"name":"Tara Strong","character":"Crew Woman (\"Final Flight of Osiris\") / Nurse (\"World Record\") / Misha (\"Beyond\")","img":null,"order":9},{"name":"Bette Ford","character":"Old Woman (\"Final Flight of Osiris\")","img":null,"order":10},{"name":"Julia Fletcher","character":"The Instructor (\"The Second Renaissance Part I & II\") / Narrator (\"World Record\")","img":null,"order":11},{"name":"Dane A. Davis","character":"01 Versatran Spokesman (\"The Second Renaissance Part I & II\")","img":null,"order":12},{"name":"Debi Derryberry","character":"Kid (\"The Second Renaissance Part I & II\")","img":null,"order":13},{"name":"Jill Talley","character":"Mother (\"The Second Renaissance Part I & II\")","img":null,"order":14}],"movie:640146":[{"name":"Paul Rudd","character":"Scott Lang / Ant-Man","img":null,"order":0},{"name":"Evangeline Lilly","character":"Hope Van Dyne / The Wasp","img":null,"order":1},{"name":"Michael Douglas","character":"Dr. Hank Pym","img":null,"order":2},{"name":"Michelle Pfeiffer","character":"Janet Van Dyne","img":null,"order":3},{"name":"Jonathan Majors","character":"Kang the Conqueror","img":null,"order":4},{"name":"Kathryn Newton","character":"Cassie Lang","img":null,"order":5},{"name":"Corey Stoll","character":"M.O.D.O.K.","img":null,"order":6},{"name":"Bill Murray","character":"Lord Krylar","img":null,"order":7},{"name":"Katy O'Brian","character":"Jentorra","img":null,"order":8},{"name":"William Jackson Harper","character":"Quaz","img":null,"order":9},{"name":"Jamie Andrew Cutler","character":"Xolum","img":null,"order":10},{"name":"David Dastmalchian","character":"Veb (voice)","img":null,"order":11},{"name":"Randall Park","character":"Jimmy Woo","img":null,"order":12},{"name":"Mark Weinman","character":"Patrol Police Officer / On-Set M.O.D.O.K.","img":null,"order":13},{"name":"Ross Mullan","character":"Axia Restaurant Maitre D'","img":null,"order":14}],"movie:269650":[{"name":"Yuka Komatsu","character":"Deunan Knute (voice)","img":null,"order":0},{"name":"Junichi Suwabe","character":"Briareos Hecatonchires (voice)","img":null,"order":1},{"name":"Aoi Yuki","character":"Iris (voice)","img":null,"order":2},{"name":"Hiroki Takahashi","character":"Olson (voice)","img":null,"order":3},{"name":"Hiroki Touchi","character":"Talos (voice)","img":null,"order":4},{"name":"Kaori Nazuka","character":"Nyx (voice)","img":null,"order":5},{"name":"Tessyo Genda","character":"Two Horns (voice)","img":null,"order":6},{"name":"Katsunosuke Hori","character":"Mathews (voice)","img":null,"order":7},{"name":"Hisanori Yoshida","character":"(voice)","img":null,"order":8},{"name":"Shinichiro Kamio","character":"(voice)","img":null,"order":9},{"name":"Yoshiki Nakajima","character":"(voice)","img":null,"order":10},{"name":"Yuki Yonai","character":"(voice)","img":null,"order":11},{"name":"Mao Satonaka","character":"(voice)","img":null,"order":12}],"movie:572802":[{"name":"Jason Momoa","character":"Arthur","img":null,"order":0},{"name":"Patrick Wilson","character":"Orm","img":null,"order":1},{"name":"Yahya Abdul-Mateen II","character":"Black Manta","img":null,"order":2},{"name":"Randall Park","character":"Dr. Shin","img":null,"order":3},{"name":"Amber Heard","character":"Mera","img":null,"order":4},{"name":"Nicole Kidman","character":"Atlanna","img":null,"order":5},{"name":"Dolph Lundgren","character":"King Nereus","img":null,"order":6},{"name":"Temuera Morrison","character":"Tom","img":null,"order":7},{"name":"Lillah Indya Moore","character":"Karshon","img":null,"order":8},{"name":"Pilou Asb\u00e6k","character":"Kordax","img":null,"order":9},{"name":"Jani Zhao","character":"Stingray","img":null,"order":10},{"name":"Vincent Regan","character":"Atlan","img":null,"order":11},{"name":"Martin Short","character":"Kingfish (voice)","img":null,"order":12},{"name":"John Rhys-Davies","character":"Brine King (voice)","img":null,"order":13},{"name":"Natalia Safran","character":"Council Lady","img":null,"order":14}],"movie:24428":[{"name":"Robert Downey Jr.","character":"Tony Stark / Iron Man","img":null,"order":0},{"name":"Chris Evans","character":"Steve Rogers / Captain America","img":null,"order":1},{"name":"Mark Ruffalo","character":"Bruce Banner / The Hulk","img":null,"order":2},{"name":"Chris Hemsworth","character":"Thor","img":null,"order":3},{"name":"Scarlett Johansson","character":"Natasha Romanoff / Black Widow","img":null,"order":4},{"name":"Jeremy Renner","character":"Clint Barton / Hawkeye","img":null,"order":5},{"name":"Tom Hiddleston","character":"Loki","img":null,"order":6},{"name":"Clark Gregg","character":"Agent Phil Coulson","img":null,"order":7},{"name":"Cobie Smulders","character":"Agent Maria Hill","img":null,"order":8},{"name":"Stellan Skarsg\u00e5rd","character":"Selvig","img":"mW7xmtGV4y79kQGn0zkKVGDMAmw.jpg","order":9},{"name":"Samuel L. Jackson","character":"Nick Fury","img":null,"order":10},{"name":"Gwyneth Paltrow","character":"Pepper Potts","img":null,"order":11},{"name":"Paul Bettany","character":"Jarvis (voice)","img":null,"order":12},{"name":"Alexis Denisof","character":"The Other","img":null,"order":13},{"name":"Tina Benko","character":"NASA Scientist","img":null,"order":14}],"movie:257346":[{"name":"Miyuki Sawashiro","character":"Natasha Romanoff / Black Widow (voice)","img":null,"order":0},{"name":"Tessyo Genda","character":"Frank Castle / The Punisher (voice)","img":null,"order":1},{"name":"Hideaki Tezuka","character":"Nick Fury (voice)","img":null,"order":2},{"name":"Hiroki Touchi","character":"Elihas Starr (voice)","img":null,"order":3},{"name":"Ryuuzaburou Ootomo","character":"Cain (voice)","img":null,"order":4},{"name":"Junko Minagawa","character":"Maria Hill (voice)","img":null,"order":5},{"name":"Daisuke Namikawa","character":"Amadeus Cho (voice)","img":null,"order":6},{"name":"Masashi Sugawara","character":"Orion (voice)","img":null,"order":7},{"name":"Yuichi Karasuma","character":"The Hulk (voice)","img":null,"order":8},{"name":"Hisashi Izumi","character":"Ren (voice)","img":null,"order":9},{"name":"Keiji Fujiwara","character":"Tony Stark / Iron Man (voice)","img":null,"order":10},{"name":"Shuhei Sakaguchi","character":"Clint Barton / Hawkeye (voice)","img":null,"order":11}],"movie:99861":[{"name":"Robert Downey Jr.","character":"Tony Stark / Iron Man","img":null,"order":0},{"name":"Chris Hemsworth","character":"Thor","img":null,"order":1},{"name":"Mark Ruffalo","character":"Bruce Banner / Hulk","img":null,"order":2},{"name":"Chris Evans","character":"Steve Rogers / Captain America","img":null,"order":3},{"name":"Scarlett Johansson","character":"Natasha Romanoff / Black Widow","img":null,"order":4},{"name":"Jeremy Renner","character":"Clint Barton / Hawkeye","img":null,"order":5},{"name":"James Spader","character":"Ultron (voice)","img":null,"order":6},{"name":"Samuel L. Jackson","character":"Nick Fury","img":null,"order":7},{"name":"Don Cheadle","character":"James Rhodes / War Machine","img":null,"order":8},{"name":"Aaron Taylor-Johnson","character":"Pietro Maximoff / Quicksilver","img":null,"order":9},{"name":"Elizabeth Olsen","character":"Wanda Maximoff / Scarlet Witch","img":null,"order":10},{"name":"Paul Bettany","character":"Jarvis / Vision","img":null,"order":11},{"name":"Cobie Smulders","character":"Maria Hill","img":null,"order":12},{"name":"Anthony Mackie","character":"Sam Wilson / The Falcon","img":null,"order":13},{"name":"Hayley Atwell","character":"Peggy Carter","img":null,"order":14}],"movie:299536":[{"name":"Robert Downey Jr.","character":"Tony Stark / Iron Man","img":null,"order":0},{"name":"Chris Evans","character":"Steve Rogers / Captain America","img":null,"order":1},{"name":"Chris Hemsworth","character":"Thor","img":null,"order":2},{"name":"Josh Brolin","character":"Thanos","img":null,"order":3},{"name":"Mark Ruffalo","character":"Bruce Banner / Hulk","img":null,"order":4},{"name":"Scarlett Johansson","character":"Natasha Romanoff / Black Widow","img":null,"order":5},{"name":"Don Cheadle","character":"James Rhodes / War Machine","img":null,"order":6},{"name":"Benedict Cumberbatch","character":"Doctor Strange","img":null,"order":7},{"name":"Tom Holland","character":"Peter Parker / Spider-Man","img":"xKBAaPIa1c7tzZD3Y0MhBLv4hPE.jpg","order":8},{"name":"Chadwick Boseman","character":"T'Challa / Black Panther","img":null,"order":9},{"name":"Zoe Salda\u00f1a","character":"Gamora","img":null,"order":10},{"name":"Karen Gillan","character":"Nebula","img":null,"order":11},{"name":"Tom Hiddleston","character":"Loki","img":null,"order":12},{"name":"Paul Bettany","character":"Vision","img":null,"order":13},{"name":"Elizabeth Olsen","character":"Wanda Maximoff / Scarlet Witch","img":null,"order":14}],"movie:485942":[{"name":"Koichi Yamadera","character":"Batman / Bruce Wayne (voice)","img":"biov5jENp2XYfehkcdnRvi796Wi.jpg","order":0},{"name":"Wataru Takagi","character":"The Joker (voice)","img":null,"order":1},{"name":"Rie Kugimiya","character":"Harley Quinn / Harleen Quinzel (voice)","img":null,"order":2},{"name":"Ai Kakuma","character":"Catwoman / Selina Kyle (voice)","img":null,"order":3},{"name":"Hochu Otsuka","character":"Alfred Pennyworth (voice)","img":null,"order":4},{"name":"Daisuke Ono","character":"Nightwing / Dick Grayson (voice)","img":null,"order":5},{"name":"Akira Ishida","character":"Red Hood / Jason Todd (voice)","img":null,"order":6},{"name":"Kengo Kawanishi","character":"Red Robin / Tim Drake (voice)","img":null,"order":7},{"name":"Yuki Kaji","character":"Robin / Damian Wayne (voice)","img":null,"order":8},{"name":"Junichi Suwabe","character":"Deathstroke / Slade Wilson (voice)","img":null,"order":9},{"name":"Cho","character":"The Penguin / Oswald Cobblepot (voice)","img":null,"order":10},{"name":"Takehito Koyasu","character":"Gorilla Grodd (voice)","img":null,"order":11},{"name":"Toshiyuki Morikawa","character":"Two-Face / Harvey Dent (voice)","img":null,"order":12},{"name":"Atsuko Tanaka","character":"Poison Ivy / Pamela Isley (voice)","img":null,"order":13},{"name":"Kenta Miyake","character":"Bane (voice)","img":null,"order":14}],"movie:209112":[{"name":"Ben Affleck","character":"Bruce Wayne / Batman","img":null,"order":0},{"name":"Henry Cavill","character":"Clark Kent / Superman","img":null,"order":1},{"name":"Jesse Eisenberg","character":"Lex Luthor","img":null,"order":2},{"name":"Gal Gadot","character":"Diana Prince / Wonder Woman","img":null,"order":3},{"name":"Amy Adams","character":"Lois","img":null,"order":4},{"name":"Diane Lane","character":"Martha Kent","img":null,"order":5},{"name":"Laurence Fishburne","character":"Perry White","img":null,"order":6},{"name":"Jeremy Irons","character":"Alfred","img":null,"order":7},{"name":"Holly Hunter","character":"Senator Finch","img":null,"order":8},{"name":"Scoot McNairy","character":"Wallace Keefe","img":null,"order":9},{"name":"Callan Mulvey","character":"Anatoli Knyazev","img":null,"order":10},{"name":"Tao Okamoto","character":"Mercy Graves","img":null,"order":11},{"name":"Brandon Spink","character":"Young Bruce Wayne","img":null,"order":12},{"name":"Lauren Cohan","character":"Martha Wayne","img":null,"order":13},{"name":"Alan D. Purwin","character":"Wayne Industries Pilot","img":null,"order":14}],"movie:123025":[{"name":"Peter Weller","character":"Batman / Bruce Wayne (voice)","img":null,"order":0},{"name":"Ariel Winter","character":"Robin / Carrie Kelley (voice)","img":null,"order":1},{"name":"David Selby","character":"James Gordon (voice)","img":null,"order":2},{"name":"Wade Williams","character":"Harvey Dent / Two-Face (voice)","img":null,"order":3},{"name":"Michael McKean","character":"Dr. Bartholomew Wolper (voice)","img":null,"order":4},{"name":"Rob Paulsen","character":"Jeff Adams (voice)","img":null,"order":5},{"name":"Gary Anthony Williams","character":"Mutant Leader / Anchor Bill (voice)","img":null,"order":6},{"name":"Gary Anthony Sturgis","character":"Silk / Other Spike / Young Mutant (voice)","img":null,"order":7},{"name":"Paget Brewster","character":"Lana Lang (voice)","img":null,"order":8},{"name":"Carlos Alazraqui","character":"Hernando / TV Host (voice)","img":null,"order":9},{"name":"Dee Bradley Baker","character":"Don / Fat Mutant / Additional Voices (voice)","img":null,"order":10},{"name":"Maria Canals-Barrera","character":"Yindel (voice)","img":null,"order":11},{"name":"Cathy Cavadini","character":"Joanie / Woman with Hot Dog (voice)","img":null,"order":12},{"name":"Townsend Coleman","character":"Morrie / Civilian (voice)","img":null,"order":13},{"name":"Grey DeLisle","character":"Anchor Carla / Female Mutant (voice)","img":null,"order":14}],"movie:436270":[{"name":"Dwayne Johnson","character":"Black Adam / Teth Adam","img":null,"order":0},{"name":"Aldis Hodge","character":"Hawkman / Carter Hall","img":null,"order":1},{"name":"Noah Centineo","character":"Atom Smasher / Al Rothstein","img":null,"order":2},{"name":"Sarah Shahi","character":"Adrianna Tomaz","img":null,"order":3},{"name":"Quintessa Swindell","character":"Cyclone / Maxine Hunkel","img":null,"order":4},{"name":"Marwan Kenzari","character":"Ishmael / Sabbac / King Ahk-Ton","img":null,"order":5},{"name":"Mo Amer","character":"Karim","img":null,"order":6},{"name":"Bodhi Sabongui","character":"Amon Tomaz","img":null,"order":7},{"name":"Pierce Brosnan","character":"Dr. Fate / Kent Nelson","img":null,"order":8},{"name":"James Cusati-Moyer","character":"Samir","img":null,"order":9},{"name":"Jalon Christian","character":"Hurut","img":null,"order":10},{"name":"Benjamin Patterson","character":"Skinny Teth Adam","img":null,"order":11},{"name":"Odelya Halevi","character":"Shiruta","img":null,"order":12},{"name":"Uli Latukefu","character":"The Champion","img":null,"order":13},{"name":"Jennifer Holland","character":"Emilia Harcourt","img":null,"order":14}],"movie:284054":[{"name":"Chadwick Boseman","character":"T'Challa / Black Panther","img":null,"order":0},{"name":"Michael B. Jordan","character":"Erik Killmonger","img":null,"order":1},{"name":"Lupita Nyong'o","character":"Nakia","img":null,"order":2},{"name":"Danai Gurira","character":"Okoye","img":null,"order":3},{"name":"Martin Freeman","character":"Everett K. Ross","img":null,"order":4},{"name":"Daniel Kaluuya","character":"W'Kabi","img":null,"order":5},{"name":"Letitia Wright","character":"Shuri","img":null,"order":6},{"name":"Winston Duke","character":"M'Baku","img":null,"order":7},{"name":"Sterling K. Brown","character":"N'Jobu","img":null,"order":8},{"name":"Angela Bassett","character":"Ramonda","img":null,"order":9},{"name":"Forest Whitaker","character":"Zuri","img":null,"order":10},{"name":"Andy Serkis","character":"Ulysses Klaue","img":null,"order":11},{"name":"Florence Kasumba","character":"Ayo","img":null,"order":12},{"name":"John Kani","character":"T'Chaka","img":null,"order":13},{"name":"David S. Lee","character":"Limbani","img":null,"order":14}],"movie:505642":[{"name":"Letitia Wright","character":"Shuri","img":null,"order":0},{"name":"Tenoch Huerta Mej\u00eda","character":"Namor","img":null,"order":1},{"name":"Lupita Nyong'o","character":"Nakia","img":null,"order":2},{"name":"Danai Gurira","character":"Okoye","img":null,"order":3},{"name":"Winston Duke","character":"M'Baku","img":null,"order":4},{"name":"Angela Bassett","character":"Ramonda","img":null,"order":5},{"name":"Martin Freeman","character":"Everett Ross","img":null,"order":6},{"name":"Julia Louis-Dreyfus","character":"Valentina Allegra de Fontaine","img":null,"order":7},{"name":"Dominique Thorne","character":"Riri","img":null,"order":8},{"name":"Florence Kasumba","character":"Ayo","img":null,"order":9},{"name":"Michaela Coel","character":"Aneka","img":null,"order":10},{"name":"Alex Livinalli","character":"Attuma","img":null,"order":11},{"name":"Mabel Cadena","character":"Namora","img":null,"order":12},{"name":"Michael B. Jordan","character":"Killmonger","img":null,"order":13},{"name":"Isaach de Bankol\u00e9","character":"River Tribe Elder","img":null,"order":14}],"movie:409421":[{"name":"Takahiro Sakurai","character":"Killy (voice)","img":null,"order":0},{"name":"Kana Hanazawa","character":"Cibo (voice)","img":null,"order":1},{"name":"Sora Amamiya","character":"Zuru (voice)","img":null,"order":2},{"name":"Mamoru Miyano","character":"Sutezo (voice)","img":null,"order":3},{"name":"Aya Suzaki","character":"Tae (voice)","img":null,"order":4},{"name":"Nobunaga Shimazaki","character":"Fusata (voice)","img":null,"order":5},{"name":"Nanako Mori","character":"Fuku (voice)","img":null,"order":6},{"name":"Kazuhiro Yamaji","character":"Pop (voice)","img":null,"order":7}],"movie:17165":[{"name":"Masakazu Morita","character":"Ichigo Kurosaki (voice)","img":null,"order":0},{"name":"Fumiko Orikasa","character":"Rukia Kuchiki (voice)","img":null,"order":1},{"name":"Kentaro Ito","character":"Renji Abarai (voice)","img":null,"order":2},{"name":"Aya Hirano","character":"Homura (voice)","img":null,"order":3},{"name":"Hiroshi Kamiya","character":"Shizuku (voice)","img":null,"order":4},{"name":"Mitsuaki Madono","character":"Kon (voice)","img":null,"order":5},{"name":"Jun Fukuyama","character":"Yumichika Ayasegawa (voice)","img":null,"order":6},{"name":"Taro Yamaguchi","character":"Sasakibe Choujiro (voice)","img":null,"order":7},{"name":"Takako Honda","character":"Jinta Hanakari (voice)","img":null,"order":8},{"name":"Katsuyuki Konishi","character":"Shuuhei Hisagi (voice)","img":null,"order":9},{"name":"Rintaro Nishi","character":"Tetsuzaemon Iba (voice)","img":null,"order":10},{"name":"Tetsu Inada","character":"Sajin Komamura (voice)","img":null,"order":11},{"name":"Fumiko Orikasa","character":"Hisana Kuchiki (voice)","img":null,"order":12},{"name":"Rie Kugimiya","character":"Nemu Kurotsuchi (voice)","img":null,"order":13},{"name":"Hisayo Mochizuki","character":"Yachiru Kusajishi (voice)","img":null,"order":14}],"tv:73319":[{"name":"Ed Brown","character":"Self","img":null,"order":0},{"name":"Rosemarie Vega","character":"Self","img":null,"order":1},{"name":"Angela Deem","character":"Self","img":null,"order":2},{"name":"Michael Ilesanmi","character":"Self","img":null,"order":3}],"tv:90046":[],"tv:48866":[{"name":"Eliza Taylor","character":"Clarke Griffin","img":null,"order":0},{"name":"Bob Morley","character":"Bellamy Blake","img":null,"order":1},{"name":"Lindsey Morgan","character":"Raven Reyes","img":null,"order":2},{"name":"Marie Avgeropoulos","character":"Octavia Blake","img":null,"order":3},{"name":"Richard Harmon","character":"John Murphy","img":null,"order":5},{"name":"Luisa d'Oliveira","character":"Emori","img":null,"order":7},{"name":"Christopher Larkin","character":"Monty Green","img":null,"order":9},{"name":"Tati Gabrielle","character":"Gaia","img":null,"order":11},{"name":"Devon Bostick","character":"Jasper Jordan","img":null,"order":13},{"name":"Isaiah Washington","character":"Thelonious Jaha","img":null,"order":15},{"name":"Henry Ian Cusick","character":"Marcus Kane","img":null,"order":16},{"name":"Adina Porter","character":"Indra","img":null,"order":20},{"name":"Jarod Joseph","character":"Nathan Miller","img":null,"order":21},{"name":"Ricky Whittle","character":"Lincoln","img":null,"order":25},{"name":"Paige Turco","character":"Judge","img":null,"order":102}],"tv:11821":[{"name":"Bud Abbott","character":"Bud Abbott","img":null,"order":0},{"name":"Lou Costello","character":"Lou Costello","img":null,"order":1},{"name":"Joe Besser","character":"Stinky Davis","img":null,"order":500},{"name":"Hillary Brooke","character":"Hillary Brooke","img":null,"order":539},{"name":"Gordon Jones","character":"Mike Kelly","img":null,"order":540},{"name":"Sid Fields","character":"Sid Fields","img":null,"order":561},{"name":"Joe Kirk","character":"Mr. Bacciagalupe","img":null,"order":567},{"name":"Murray Leonard","character":"Doctor","img":null,"order":587},{"name":"Bobby Barber","character":"Tout","img":null,"order":614},{"name":"Joan Shawlee","character":"Marriage License Bureau Clerk","img":null,"order":634},{"name":"Dorothy Granger","character":"Arguing Wife","img":null,"order":642},{"name":"Milt Bronson","character":"Sam Speedy","img":null,"order":646},{"name":"Ray Walker","character":"Police Lt. Smith","img":null,"order":653},{"name":"Lucien Littlefield","character":"First Man","img":null,"order":658},{"name":"Veda Ann Borg","character":"Crazy Lady","img":null,"order":663}],"tv:43167":[{"name":"Yuki Kaji","character":"Arita Haruyuki","img":null,"order":0},{"name":"Sachika Misawa","character":"Kuroyuki-hime","img":null,"order":1},{"name":"Ayako Kawasumi","character":"Kakei Mihaya","img":null,"order":2},{"name":"Kenichi Suzumura","character":"Ash Roller","img":null,"order":3},{"name":"Aki Toyosaki","character":"Kurashima Chiyuri","img":null,"order":4},{"name":"Shintaro Asanuma","character":"Mayuzumi Takumu","img":null,"order":5},{"name":"Sanae Kobayashi","character":"Noumi Seiji","img":null,"order":6},{"name":"Rina Hidaka","character":"Kouzuki Yuniko","img":null,"order":7},{"name":"Haruka Tomatsu","character":"Wakamiya Megumi","img":null,"order":8}],"tv:114461":[{"name":"Rosario Dawson","character":"Ahsoka Tano","img":null,"order":0},{"name":"Natasha Liu Bordizzo","character":"Sabine Wren","img":null,"order":1},{"name":"Ivanna Sakhno","character":"Shin Hati","img":null,"order":2},{"name":"Ray Stevenson","character":"Baylan Skoll","img":null,"order":2},{"name":"Mary Elizabeth Winstead","character":"Hera Syndulla","img":null,"order":3},{"name":"Lars Mikkelsen","character":"Grand Admiral Thrawn","img":null,"order":4},{"name":"Eman Esfandi","character":"Ezra Bridger","img":null,"order":5},{"name":"Diana Lee Inosanto","character":"Morgan Elsbeth","img":null,"order":5},{"name":"Rory McCann","character":"Baylan Skoll","img":null,"order":6},{"name":"David Tennant","character":"Huyang (voice)","img":null,"order":6},{"name":"Hayden Christensen","character":"Anakin Skywalker","img":null,"order":7},{"name":"Paul Darnell","character":"Marrok","img":null,"order":512},{"name":"Chris Bartlett","character":"Home One Protocol Droid","img":null,"order":519},{"name":"Nican Robinson","character":"First Officer Vic Hawkins","img":null,"order":526},{"name":"Dawn Dininger","character":"Lieutenant Beyta","img":null,"order":546}],"tv:1433":[{"name":"Seth MacFarlane","character":"Stan Smith / Roger (voice)","img":null,"order":0},{"name":"Wendy Schaal","character":"Francine Smith (voice)","img":null,"order":1},{"name":"Rachael MacFarlane","character":"Hayley Smith (voice)","img":null,"order":2},{"name":"Scott Grimes","character":"Steve Smith (voice)","img":null,"order":3},{"name":"Dee Bradley Baker","character":"Klaus (voice)","img":null,"order":4},{"name":"Jeff Fischer","character":"Kidnapper (voice)","img":null,"order":204},{"name":"Mike Barker","character":"Terry Bates / Researcher (voice)","img":null,"order":1590},{"name":"Kevin Michael Richardson","character":"Rerun (voice)","img":null,"order":1824},{"name":"Mike Henry","character":"Jackson / Maitre D' (voice)","img":null,"order":1927},{"name":"Eddie Kaye Thomas","character":"Barry Robinson / Spike (voice)","img":null,"order":1967},{"name":"Patrick Stewart","character":"Avery Bullock as B (voice)","img":null,"order":2007},{"name":"Curtis Armstrong","character":"","img":null,"order":2028},{"name":"David Koechner","character":"Dick Reynolds (voice)","img":null,"order":2052},{"name":"Daisuke Suzuki","character":"Toshi Yoshida (voice)","img":null,"order":2054},{"name":"Richard Kind","character":"Car Salesman (voice)","img":null,"order":2057}],"tv:46639":[{"name":"Ricky Whittle","character":"Shadow Moon","img":null,"order":0},{"name":"Ian McShane","character":"Mr. Wednesday / God Odin","img":null,"order":1},{"name":"Emily Browning","character":"Laura Moon","img":null,"order":2},{"name":"Pablo Schreiber","character":"Mad Sweeney","img":null,"order":3},{"name":"Yetide Badaki","character":"Bilquis","img":null,"order":4},{"name":"Crispin Glover","character":"Mr. World","img":null,"order":5},{"name":"Bruce Langley","character":"Technical Boy","img":null,"order":6},{"name":"Omid Abtahi","character":"Salim","img":null,"order":7},{"name":"Kenzie Delo","character":"Herman","img":null,"order":9},{"name":"Demore Barnes","character":"Mr. Ibis","img":null,"order":611},{"name":"Herizen F. Guardiola","character":"Oshun","img":null,"order":612},{"name":"Peter Stormare","character":"Czernobog","img":null,"order":619},{"name":"Julia Sweeney","character":"Ann-Marie Hinzelmann","img":null,"order":620},{"name":"Eric Johnson","character":"Chad Mulligan","img":null,"order":621},{"name":"Lela Loren","character":"Marguerite Olsen","img":null,"order":624}],"tv:2985":[{"name":"Kevin Sorbo","character":"Dylan Hunt","img":null,"order":0},{"name":"Lisa Ryder","character":"Beka Valentine","img":null,"order":1},{"name":"Laura Bertram","character":"Trance Gemini","img":null,"order":2},{"name":"Gordon Michael Woolvett","character":"Seamus Harper","img":null,"order":3},{"name":"Lexa Doig","character":"Andromeda Ascendant","img":null,"order":4},{"name":"Brandy Ledford","character":"Doyle","img":null,"order":5},{"name":"Keith Hamilton Cobb","character":"Tyr Anasazi","img":null,"order":6},{"name":"Brent Stait","character":"Rev Bem","img":null,"order":7},{"name":"Steve Bacic","character":"Gaheris Rhade","img":null,"order":424},{"name":"Rik Kiviaho","character":"High Guard Captain Perim","img":null,"order":666},{"name":"Janyse Jaud","character":"Cavava (voice)","img":null,"order":677},{"name":"Robert Saunders","character":"Spirit of the Abyss","img":null,"order":689},{"name":"Jim Byrnes","character":"Virgil Vox","img":null,"order":775},{"name":"Nicholas Lea","character":"Tri-Lorn","img":null,"order":777},{"name":"Carmen Moore","character":"Tri-Jema","img":null,"order":813}],"tv:251":[{"name":"Dana Snyder","character":"Master Shake (voice)","img":null,"order":0},{"name":"Carey Means","character":"Frylock (voice)","img":null,"order":1},{"name":"Dave Willis","character":"Meatwad / Carl (voice)","img":null,"order":3},{"name":"Mike Schatz","character":"Emory (voice)","img":null,"order":6},{"name":"Andy Merrill","character":"Oglethorpe / Merle (voice)","img":null,"order":145},{"name":"Schoolly D","character":"Boost Mobile Phone (voice)","img":null,"order":333},{"name":"Matt Maiellaro","character":"Err / Cybernetic Ghost of Christmas Past (voice)","img":null,"order":396},{"name":"C. Martin Croker","character":"Birds (voice)","img":null,"order":455},{"name":"H. Jon Benjamin","character":"Don Shake","img":null,"order":576},{"name":"MC Chris","character":"Little Brittle (voice)","img":null,"order":617},{"name":"George Lowe","character":"George Lowe (voice)","img":null,"order":642},{"name":"Scott Hilley","character":"George Washington (voice)","img":null,"order":642},{"name":"Eugene Mirman","character":"Dr. Eugene Mirman (voice)","img":null,"order":660},{"name":"Edward Hastings","character":"Panda Furry (voice)","img":null,"order":661},{"name":"Nick Ingkatanuwat","character":"Tour Guide (voice)","img":null,"order":670}],"tv:86034":[{"name":"Toshinari Fukamachi","character":"Hajime Nagumo (voice)","img":null,"order":0},{"name":"Yuuki Kuwahara","character":"Yue (voice)","img":null,"order":1},{"name":"Minami Takahashi","character":"Shea Haulia (voice)","img":null,"order":2},{"name":"Yoko Hikasa","character":"Tio Klarus (voice)","img":null,"order":3},{"name":"Saori Onishi","character":"Kaori Shirasaki (voice)","img":null,"order":4},{"name":"Yumiri Hanamori","character":"Shizuku Yaegashi (voice)","img":null,"order":5},{"name":"Ai Kakuma","character":"Aiko Hatayama (voice)","img":null,"order":6},{"name":"Yui Ogura","character":"Myu (voice)","img":null,"order":7},{"name":"Noriko Shibasaki","character":"Liliana S. B. Heiligh (voice)","img":null,"order":8},{"name":"Katsuyuki Konishi","character":"Freid Bagwa (voice)","img":null,"order":10},{"name":"Makoto Ishii","character":"Mikhail (voice)","img":null,"order":11},{"name":"Hiroshi Watanabe","character":"Haulia Yao (voice)","img":null,"order":12},{"name":"Rina Sato","character":"Nun (voice)","img":null,"order":269},{"name":"Tetsuya Kakihara","character":"K\u014dki Amanogawa (voice)","img":null,"order":563},{"name":"Yasuhiro Mamiya","character":"Meld Loggins (voice)","img":null,"order":564}],"tv:1412":[{"name":"Stephen Amell","character":"Oliver Queen / Green Arrow","img":null,"order":0},{"name":"David Ramsey","character":"John Diggle / Spartan","img":null,"order":1},{"name":"Rick Gonzalez","character":"Rene Ramirez / Wild Dog","img":null,"order":4},{"name":"Susanna Thompson","character":"Moira Queen","img":null,"order":5},{"name":"Emily Bett Rickards","character":"Felicity Smoak","img":null,"order":13},{"name":"Paul Blackthorne","character":"Quentin Lance","img":null,"order":14},{"name":"Colton Haynes","character":"Roy Harper / Arsenal","img":null,"order":19},{"name":"John Barrowman","character":"Malcolm Merlyn","img":null,"order":26},{"name":"Echo Kellum","character":"Curtis Holt","img":null,"order":27},{"name":"Katie Cassidy","character":"Black Siren","img":null,"order":34},{"name":"Willa Holland","character":"Thea Queen / Speedy","img":null,"order":96},{"name":"Juliana Harkavy","character":"Dinah Drake / Black Canary","img":null,"order":223},{"name":"Manu Bennett","character":"Slade Wilson / Deathstroke","img":null,"order":279},{"name":"Audrey Marie Anderson","character":"Lyla Michaels","img":null,"order":1444},{"name":"Keri Adams","character":"Bethany Snow","img":null,"order":1589}],"tv:82452":[{"name":"Gordon Cormier","character":"Aang","img":null,"order":0},{"name":"Kiawentiio","character":"Katara","img":null,"order":1},{"name":"Ian Ousley","character":"Sokka","img":null,"order":2},{"name":"Dallas Liu","character":"Zuko","img":null,"order":3},{"name":"Paul Sun-Hyung Lee","character":"Uncle Iroh","img":null,"order":4},{"name":"Miyako","character":"Toph Beifong","img":null,"order":5},{"name":"Ken Leung","character":"Commander Zhao","img":null,"order":6},{"name":"Daniel Dae Kim","character":"Fire Lord Ozai","img":null,"order":7},{"name":"Ruy Iskandar","character":"Lieutenant Jee","img":null,"order":504},{"name":"Maria Zhang","character":"Suki","img":null,"order":523},{"name":"Thalia Tran","character":"Mai","img":null,"order":532},{"name":"Momona Tamada","character":"Ty Lee","img":null,"order":533},{"name":"Matthew Yang King","character":"Appa/Momo(voice)","img":null,"order":568},{"name":"Elizabeth Yu","character":"Azula","img":null,"order":575},{"name":"Dee Bradley Baker","character":"Appa/Momo(voice)","img":null,"order":629}],"tv:284833":[{"name":"Saheli Khan","character":"Pavi (voice)","img":null,"order":0},{"name":"Aishwarya 'Aishu' Devan","character":"Nisha (voice)","img":null,"order":1},{"name":"Major Curda","character":"Jae (voice)","img":null,"order":2},{"name":"Akshay Khanna","character":"Karthik (voice)","img":null,"order":3},{"name":"Sakina Jaffrey","character":"Agam (voice)","img":null,"order":4},{"name":"Eric Bauza","character":"Jin-woo / Pich / One Guy in the Crowd / Crowd / Airbenders / Firebenders (voice)","img":null,"order":502},{"name":"Tobit Raphael","character":"Ulan / Bending Cadet #1 / Bending Cadets (voice)","img":null,"order":509},{"name":"Dee Bradley Baker","character":"Geet / Ruhi (voice)","img":null,"order":510},{"name":"Darren Barnet","character":"Daemin (voice)","img":null,"order":511},{"name":"Clyde Kusatsu","character":"Commander Bountan / Crowd / Airbenders / Firebenders (voice)","img":null,"order":513},{"name":"Sarah Anne","character":"Fang / Bending Cadet #5 / Elloran Girl (voice)","img":null,"order":514},{"name":"Brycen Taylor Hall","character":"Ping (voice)","img":null,"order":515},{"name":"Xanthe Huynh","character":"Lan / Elloran Citizens (voice)","img":null,"order":516},{"name":"Kimberly Woods","character":"Lucky Cadet / Bending Cadet #2 / Bending Cadets (voice)","img":null,"order":519},{"name":"Zehra Fazal","character":"Bending Cadet #3 / Bending Cadet #4 / Masked Elloran Woman (voice)","img":null,"order":519}],"tv:15804":[{"name":"Diedrich Bader","character":"Batman (voice)","img":null,"order":0},{"name":"James Arnold Taylor","character":"Green Arrow / Jace (voice)","img":null,"order":523},{"name":"John DiMaggio","character":"Aquaman / Mr. Freeze (voice)","img":null,"order":549},{"name":"Jeff Bennett","character":"Buddy / OMAC (voice)","img":null,"order":553},{"name":"Tara Strong","character":"Mary/Billy Batson (voice)","img":null,"order":606},{"name":"Jason Marsden","character":"Scooby-Doo Robin (voice)","img":null,"order":628},{"name":"Tom Kenny","character":"Babyface / Plastic Man (voice)","img":null,"order":648},{"name":"Dee Bradley Baker","character":"Felix Faust / Fluke (voice)","img":null,"order":665},{"name":"Kevin Michael Richardson","character":"Lex Luthor (voice)","img":null,"order":667},{"name":"Greg Ellis","character":"Cavalier / Computer Voice / Nuclear Engineer (voice)","img":null,"order":673},{"name":"Corey Burton","character":"Silver Cyclone (voice)","img":null,"order":682},{"name":"Nicholas Guest","character":"Question (voice)","img":null,"order":722},{"name":"Will Friedle","character":"Blue Beetle (voice)","img":null,"order":733},{"name":"Grey DeLisle","character":"Daphne (voice)","img":null,"order":736},{"name":"Tom Everett Scott","character":"Booster Gold (voice)","img":null,"order":759}],"tv:89247":[{"name":"Ruby Rose","character":"Kate Kane / Batwoman","img":null,"order":1},{"name":"Javicia Leslie","character":"Ryan Wilder / Batwoman","img":null,"order":2},{"name":"Rachel Skarsten","character":"Alice / Beth Kane","img":null,"order":3},{"name":"Meagan Tandy","character":"Sophie Moore","img":null,"order":4},{"name":"Nicole Kang","character":"Mary Hamilton","img":null,"order":5},{"name":"Camrus Johnson","character":"Luke Fox","img":null,"order":6},{"name":"Dougray Scott","character":"Jacob Kane","img":null,"order":7},{"name":"Nick Creegan","character":"Marquis Jet","img":null,"order":8},{"name":"Robin Givens","character":"Jada Jet","img":null,"order":9},{"name":"Elizabeth Anweis","character":"Catherine Hamilton-Kane","img":null,"order":10},{"name":"Christina Wolfe","character":"Julia Pennyworth","img":null,"order":679},{"name":"Rachel Maddow","character":"Vesper Fairchild (voice)","img":null,"order":836},{"name":"Sam Littlefield","character":"Mouse","img":null,"order":841},{"name":"Nathan Owens","character":"Ocean","img":null,"order":842},{"name":"Allison Riley","character":"Dana Dewitt","img":null,"order":859}],"tv:74204":[{"name":"Nick Kroll","character":"Nick Birch / Maury the Hormone Monster / Coach Steve / Lola Skumpy / Rick the Hormone Monster (voice)","img":null,"order":0},{"name":"John Mulaney","character":"Andrew Glouberman (voice)","img":null,"order":1},{"name":"Jessi Klein","character":"Jessi Glaser (voice)","img":null,"order":2},{"name":"Jason Mantzoukas","character":"Jay Bilzerian (voice)","img":null,"order":4},{"name":"Maya Rudolph","character":"Diane Birch / Connie the Hormone Monstress (voice)","img":null,"order":5},{"name":"Fred Armisen","character":"Elliot Birch (voice)","img":null,"order":6},{"name":"Jordan Peele","character":"Ghost of Duke Ellington (voice)","img":null,"order":7},{"name":"Ayo Edebiri","character":"Missy Foreman-Greenwald (voice)","img":null,"order":91},{"name":"Andrew Rannells","character":"Leander / Matthew MacDell (voice)","img":null,"order":136},{"name":"Jenny Slate","character":"Pharmacist / Caitlin (voice)","img":null,"order":149},{"name":"Joe Wengert","character":"Caleb / Go-Gurt Thief / Teacher (voice)","img":null,"order":578},{"name":"Paula Pell","character":"(voice)","img":null,"order":622},{"name":"Neil Casey","character":"Detective Dumont (voice)","img":null,"order":624},{"name":"June Diane Raphael","character":"","img":null,"order":639},{"name":"Richard Kind","character":"","img":null,"order":674}],"tv:71663":[{"name":"Cress Williams","character":"Jefferson Pierce / Black Lightning","img":null,"order":0},{"name":"Nafessa Williams","character":"Anissa Pierce / Thunder / Blackbird","img":null,"order":1},{"name":"China Anne McClain","character":"Jennifer Pierce / Lightning","img":null,"order":2},{"name":"Christine Adams","character":"Lynn Pierce","img":null,"order":3},{"name":"Marvin Jones III","character":"Tobias Whale","img":null,"order":4},{"name":"James Remar","character":"Peter Gambi","img":null,"order":5},{"name":"Jordan Calloway","character":"Khalil Payne / Painkiller","img":null,"order":6},{"name":"Chantal Thuy","character":"Grace Choi","img":null,"order":7},{"name":"Damon Gupton","character":"Bill Henderson (voice)","img":null,"order":211},{"name":"Clifton Powell","character":"Reverend Jeremiah Holt","img":null,"order":730},{"name":"Bill Duke","character":"Agent Percy Odell","img":null,"order":801},{"name":"Jennifer Riker","character":"Dr. Helga Jace","img":null,"order":802},{"name":"Christopher Ammanuel","character":"Baron / TC","img":null,"order":818},{"name":"William Catlett","character":"Latavious 'Lala' Johnson","img":null,"order":853},{"name":"Jahking Guillory","character":"Brandon","img":null,"order":855}],"movie:1574":[{"name":"Ren\u00e9e Zellweger","character":"Roxie Hart","img":null,"order":0},{"name":"Catherine Zeta-Jones","character":"Velma Kelly","img":null,"order":1},{"name":"Richard Gere","character":"Billy Flynn","img":null,"order":2},{"name":"Queen Latifah","character":"Matron Mama Morton","img":null,"order":3},{"name":"John C. Reilly","character":"Amos Hart","img":null,"order":4},{"name":"Colm Feore","character":"Harrison","img":null,"order":5},{"name":"Dominic West","character":"Fred Casely","img":null,"order":6},{"name":"Christine Baranski","character":"Mary Sunshine","img":null,"order":7},{"name":"Ekaterina Chtchelkanova","character":"Katalin Helinski \"Hunyak\"","img":null,"order":8},{"name":"Lucy Liu","character":"Kitty Baxter","img":null,"order":9},{"name":"Taye Diggs","character":"Bandleader","img":null,"order":10},{"name":"Mya","character":"Mona","img":null,"order":11},{"name":"Deidre Goodwin","character":"June","img":null,"order":12},{"name":"Denise Faye","character":"Annie","img":null,"order":13},{"name":"Susan Misner","character":"Liz","img":null,"order":14}],"movie:220289":[{"name":"Emily Baldoni","character":"Em","img":null,"order":0},{"name":"Maury Sterling","character":"Kevin","img":null,"order":1},{"name":"Nicholas Brendon","character":"Mike","img":null,"order":2},{"name":"Lauren Maher","character":"Laurie","img":null,"order":3},{"name":"Elizabeth Gracen","character":"Beth","img":null,"order":4},{"name":"Hugo Armstrong","character":"Hugh","img":null,"order":5},{"name":"Lorene Scafaria","character":"Lee","img":null,"order":6},{"name":"Alex Manugian","character":"Amir","img":null,"order":7},{"name":"Kelly Donovan","character":"Extra","img":null,"order":8},{"name":"Aqueela Zoll","character":"Extra","img":null,"order":9},{"name":"Mark Ballou","character":"Extra","img":null,"order":10},{"name":"Alexis Boozer Sterling","character":"Extra","img":null,"order":11}],"movie:399057":[{"name":"Colin Farrell","character":"Steven Murphy","img":null,"order":0},{"name":"Nicole Kidman","character":"Anna Murphy","img":null,"order":1},{"name":"Barry Keoghan","character":"Martin Lang","img":null,"order":2},{"name":"Raffey Cassidy","character":"Kim Murphy","img":null,"order":3},{"name":"Sunny Suljic","character":"Bob Murphy","img":null,"order":4},{"name":"Bill Camp","character":"Matthew Williams","img":null,"order":5},{"name":"Alicia Silverstone","character":"Martin's Mother","img":null,"order":6},{"name":"Herb Caillouet","character":"Ed Thompson (Hospital Director)","img":null,"order":7},{"name":"Barry G. Bernson","character":"Dr. Larry Banks","img":null,"order":8},{"name":"Denise Dal Vera","character":"Mary Williams","img":null,"order":9},{"name":"Drew Logan","character":"Principal","img":null,"order":10},{"name":"Michael Trester","character":"Elderly Man","img":null,"order":11},{"name":"Anita Farmer Bergman","character":"Nurse (uncredited)","img":null,"order":12},{"name":"Lea Hutton Beasmore","character":"Conference Guest (uncredited)","img":null,"order":13},{"name":"Dylan Keith Adams","character":"Doctor at Conference and Event Dinner (uncredited)","img":null,"order":14}],"movie:430214":[{"name":"Gen Hoshino","character":"Senpai (voice)","img":null,"order":0},{"name":"Kana Hanazawa","character":"The Girl with Black Hair (voice)","img":null,"order":1},{"name":"Ami Koshimizu","character":"Naoko (voice)","img":null,"order":2},{"name":"Aoi Yuki","character":"Princess Daruma (voice)","img":null,"order":3},{"name":"Hiroshi Kamiya","character":"Director of School Festival Operations (voice)","img":null,"order":4},{"name":"Chikara Honda","character":"Bookstore Shopkeeper Gabi (voice)","img":null,"order":5},{"name":"Hiroyuki Yoshino","character":"God of the Old Books Market (voice)","img":null,"order":6},{"name":"Junichi Suwabe","character":"Nise-J\u014dgasaki (voice)","img":null,"order":7},{"name":"Kazuhiro Yamaji","character":"Todo (voice)","img":null,"order":8},{"name":"Kazuya Nakai","character":"Seitar\u014d Higuchi (voice)","img":null,"order":9},{"name":"Mugihito","character":"Ri Haku (voice)","img":null,"order":10},{"name":"Nobuyuki Hiyama","character":"Johnny (voice)","img":null,"order":11},{"name":"Ryuji Akiyama","character":"Don Underwear (voice)","img":null,"order":12},{"name":"Seiko Niizuma","character":"Kiko (voice)","img":null,"order":13},{"name":"S\u014dichi Nakaoka","character":"K\u014dsaka (voice)","img":null,"order":14}],"movie:1541":[{"name":"Susan Sarandon","character":"Louise","img":null,"order":0},{"name":"Geena Davis","character":"Thelma","img":null,"order":1},{"name":"Harvey Keitel","character":"Hal","img":null,"order":2},{"name":"Michael Madsen","character":"Jimmy","img":null,"order":3},{"name":"Christopher McDonald","character":"Darryl","img":null,"order":4},{"name":"Stephen Tobolowsky","character":"Max","img":"uL8yfcm0dCFYFQt052kczVQVvac.jpg","order":5},{"name":"Brad Pitt","character":"J.D.","img":null,"order":6},{"name":"Timothy Carhart","character":"Harlan","img":null,"order":7},{"name":"Lucinda Jenney","character":"Lena, the Waitress","img":null,"order":8},{"name":"Jason Beghe","character":"State Trooper","img":"hrTwOPWek7nkPJgFhh6inFu7vSS.jpg","order":9},{"name":"Sonny Carl Davis","character":"Albert","img":null,"order":10},{"name":"Ken Swofford","character":"Major","img":null,"order":11},{"name":"Shelly Desai","character":"East Indian Motel Clerk","img":null,"order":12},{"name":"Carol Mansell","character":"Waitress","img":null,"order":13},{"name":"Stephen Polk","character":"Surveilance Man","img":null,"order":14}],"movie:5038":[{"name":"Scarlett Johansson","character":"Cristina","img":null,"order":0},{"name":"Rebecca Hall","character":"Vicky","img":null,"order":1},{"name":"Javier Bardem","character":"Juan Antonio Gonzalo","img":null,"order":2},{"name":"Pen\u00e9lope Cruz","character":"Mar\u00eda Elena","img":null,"order":3},{"name":"Christopher Evan Welch","character":"Narrator (voice)","img":null,"order":4},{"name":"Chris Messina","character":"Doug","img":null,"order":5},{"name":"Patricia Clarkson","character":"Judy Nash","img":null,"order":6},{"name":"Kevin Dunn","character":"Mark Nash","img":null,"order":7},{"name":"Julio Perill\u00e1n","character":"Charles","img":null,"order":8},{"name":"Ricard Salom","character":"Art Gallery Guest","img":null,"order":9},{"name":"Maurice Sonnenberg","character":"Art Gallery Guest","img":null,"order":10},{"name":"Manel Barcel\u00f3","character":"Doctor","img":null,"order":11},{"name":"Josep Maria Dom\u00e8nech","character":"Julio","img":null,"order":12},{"name":"Emilio de Benito","character":"Guitarist in Asturias","img":null,"order":13},{"name":"Jaume Montan\u00e9","character":"Juan Antonio's Friend","img":null,"order":14}],"tv:108255":[{"name":"Nicholas Ralph","character":"James Herriot","img":null,"order":0},{"name":"Samuel West","character":"Siegfried Farnon","img":null,"order":1},{"name":"Anna Madeley","character":"Mrs Hall","img":null,"order":2},{"name":"Callum Woodhouse","character":"Tristan Farnon","img":null,"order":3},{"name":"Rachel Shenton","character":"Helen Herriot","img":null,"order":4},{"name":"James Anthony-Rose","character":"Richard Carmody","img":null,"order":500},{"name":"Mollie Winnard","character":"Maggie","img":null,"order":528},{"name":"Maimie McCoy","character":"Dorothy","img":null,"order":530},{"name":"Drew Cain","character":"James Herriot Senior","img":null,"order":535},{"name":"Will Thorp","character":"Gerald Hammond","img":null,"order":539},{"name":"Matthew Lewis","character":"Hugh Hulton","img":null,"order":552},{"name":"Patricia Hodge","character":"Mrs. Pumphrey","img":null,"order":553},{"name":"Imogen Clawson","character":"Jenny Alderson","img":null,"order":556},{"name":"Tony Pitts","character":"Richard Alderson","img":null,"order":558},{"name":"Gaia Wise","character":"Charlotte Beauvoir","img":null,"order":582}],"tv:88510":[{"name":"Daisy Haggard","character":"Miri Matteson","img":null,"order":0},{"name":"Geraldine James","character":"Caroline Matteson","img":null,"order":1},{"name":"Richard Durden","character":"Oscar Matteson","img":null,"order":2},{"name":"Jo Martin","character":"Janice","img":null,"order":3},{"name":"Adeel Akhtar","character":"Billy","img":null,"order":4},{"name":"Jamie Michie","character":"Dom","img":null,"order":5},{"name":"Christine Bottomley","character":"Mandy","img":null,"order":6},{"name":"Frank Feys","character":"Samuel","img":null,"order":502},{"name":"Liam Williams","character":"Nathan","img":null,"order":504},{"name":"Imogen Gurney","character":"Lara","img":null,"order":505},{"name":"Juliet Cowan","character":"Tina","img":null,"order":510},{"name":"Lizzy McInnerny","character":"Norah Boback","img":null,"order":515},{"name":"Adrian Edmondson","character":"John Boback","img":null,"order":516},{"name":"Keir Charles","character":"Mark","img":null,"order":520},{"name":"Hebe Bartlett","character":"Cerys","img":null,"order":522}],"tv:202851":[{"name":"Jack Rooke","character":"Narrator (voice)","img":null,"order":0},{"name":"Dylan Llewellyn","character":"Jack","img":null,"order":1},{"name":"Camille Coduri","character":"Peggy","img":null,"order":2},{"name":"Jon Pointing","character":"Danny","img":null,"order":3},{"name":"Katy Wix","character":"Jules","img":null,"order":4},{"name":"Izuka Hoyle","character":"Corinne","img":null,"order":5},{"name":"Olisa Odele","character":"Yemi","img":null,"order":6},{"name":"Harriet Webb","character":"Shannon","img":null,"order":7},{"name":"Annette Badland","character":"Nanny Bingo","img":null,"order":8},{"name":"Robert Gilbert","character":"Tim","img":null,"order":501},{"name":"Sheila Reid","character":"Iris","img":null,"order":502},{"name":"Lucia Keskin","character":"Kelly","img":null,"order":503},{"name":"Shane Zaza","character":"Tariq","img":null,"order":504},{"name":"Callum Mardy","character":"Ash","img":null,"order":505},{"name":"Ian Burfield","character":"Lance (voice)","img":null,"order":506}],"tv:39793":[{"name":"Vanessa Redgrave","character":"Jennifer Worth (voice)","img":null,"order":0},{"name":"Jenny Agutter","character":"Sister Julienne","img":null,"order":2},{"name":"Judy Parfitt","character":"Sister Monica Joan","img":null,"order":3},{"name":"Linda Bassett","character":"Phyllis Crane","img":null,"order":12},{"name":"Stephen McGann","character":"Dr Patrick Turner","img":null,"order":14},{"name":"Alice Brown","character":"Angela Turner","img":null,"order":23},{"name":"Laura Main","character":"Nurse Shelagh Turner","img":null,"order":24},{"name":"Cliff Parisi","character":"Fred Buckle","img":null,"order":26},{"name":"Georgie Glen","character":"Miss Millicent Higgins","img":null,"order":28},{"name":"Victoria Yeates","character":"Sister Winifred","img":null,"order":35},{"name":"Annabelle Apsion","character":"Violet Buckle","img":null,"order":87},{"name":"Max Macmillan","character":"Timothy Turner","img":null,"order":94},{"name":"Helen George","character":"Nurse Trixie Aylward","img":null,"order":102},{"name":"Zephryn Taitte","character":"Cyril Robinson","img":null,"order":104},{"name":"April Rae Hoang","character":"May Tang","img":null,"order":237}],"tv:76148":[{"name":"Saoirse-Monica Jackson","character":"Erin Quinn","img":null,"order":0},{"name":"Louisa Harland","character":"Orla McCool","img":null,"order":1},{"name":"Nicola Coughlan","character":"Clare Devlin","img":null,"order":2},{"name":"Jamie Lee O'Donnell","character":"Michelle Mallon","img":null,"order":3},{"name":"Dylan Llewellyn","character":"James Maguire","img":null,"order":4},{"name":"Tara Lynne O'Neill","character":"Ma Mary Quinn","img":null,"order":5},{"name":"Tommy Tiernan","character":"Da Gerry Quinn","img":null,"order":6},{"name":"Kathy Kiera Clarke","character":"Aunt Sarah","img":null,"order":7},{"name":"Ian McElhinney","character":"Granda Joe","img":null,"order":8},{"name":"Siobh\u00e1n McSweeney","character":"Sister Michael","img":null,"order":9},{"name":"Leah O'Rourke","character":"Jenny Joyce","img":null,"order":10},{"name":"Donna Traynor","character":"Donna Traynor","img":null,"order":555},{"name":"Beccy Henderson","character":"Aisling","img":null,"order":558},{"name":"Claire Rafferty","character":"Miss Mooney","img":null,"order":559},{"name":"Phillipa Dunne","character":"Geraldine Devlin","img":null,"order":572}],"tv:693":[{"name":"Teri Hatcher","character":"Susan Mayer","img":null,"order":0},{"name":"Felicity Huffman","character":"Lynette Scavo","img":null,"order":1},{"name":"Marcia Cross","character":"Bree Van de Kamp","img":null,"order":2},{"name":"Eva Longoria","character":"Gabrielle Solis","img":null,"order":3},{"name":"Ricardo Chavira","character":"Carlos Solis","img":null,"order":4},{"name":"Doug Savant","character":"Tom Scavo","img":null,"order":5},{"name":"Nicollette Sheridan","character":"Edie Britt","img":null,"order":6},{"name":"Mark Moses","character":"Paul Young","img":null,"order":7},{"name":"James Denton","character":"Mike Delfino","img":null,"order":8},{"name":"Brenda Strong","character":"Mary Alice Young","img":null,"order":10},{"name":"Kyle MacLachlan","character":"Orson Hodge","img":null,"order":14},{"name":"Shawn Pyfrom","character":"Andrew Van de Kamp","img":null,"order":19},{"name":"Andrea Bowen","character":"Julie Mayer","img":null,"order":21},{"name":"Kathryn Joosten","character":"Karen McCluskey","img":null,"order":25},{"name":"Joy Jorgensen","character":"Danielle Van de Kamp","img":null,"order":717}],"tv:203857":[{"name":"Keri Russell","character":"Ambassador Kate Wyler","img":null,"order":0},{"name":"Rufus Sewell","character":"Hal Wyler","img":null,"order":1},{"name":"David Gyasi","character":"Foreign Secretary Austin Dennison","img":null,"order":2},{"name":"Ali Ahn","character":"Eidra Park","img":null,"order":3},{"name":"Rory Kinnear","character":"Prime Minister Nicol Trowbridge","img":null,"order":4},{"name":"Ato Essandoh","character":"Stuart Hayford","img":null,"order":5},{"name":"Allison Janney","character":"Grace Penn","img":null,"order":7},{"name":"Nana Mensah","character":"Billie Appiah","img":null,"order":8},{"name":"Celia Imrie","character":"Meg Roylin","img":null,"order":500},{"name":"Penny Downie","character":"Frances Munning","img":null,"order":505},{"name":"Adam Silver","character":"Howard","img":null,"order":506},{"name":"Tim Delap","character":"Byron","img":null,"order":507},{"name":"Kenichiro Thomson","character":"Martin","img":null,"order":508},{"name":"Georgie Henley","character":"Pensy","img":null,"order":509},{"name":"Graham Miller","character":"Neil Barrow","img":null,"order":572}],"tv:66134":[{"name":"Keeley Hawes","character":"Louisa Durrell","img":null,"order":0},{"name":"Josh O'Connor","character":"Lawrence Durrell","img":null,"order":1},{"name":"Milo Parker","character":"Gerald Durrell","img":null,"order":2},{"name":"Daisy Waterstone","character":"Margo Durrell","img":null,"order":3},{"name":"Callum Woodhouse","character":"Leslie Durrell","img":null,"order":4},{"name":"Alexis Georgoulis","character":"Spiros Halikiopoulos","img":null,"order":5},{"name":"Yorgos Karamihos","character":"Theo Stephanides","img":null,"order":6},{"name":"Anna Savva","character":"Lugaretzia","img":null,"order":8},{"name":"Lucy Black","character":"Florence Petridis","img":null,"order":79},{"name":"Ben Hall","character":"Donald","img":null,"order":525},{"name":"Nikorestis Chaniotakis","character":"Pavlos the Monk","img":null,"order":553},{"name":"Elli Tringou","character":"Daphne Likourgou","img":null,"order":553},{"name":"Ulric von der Esch","character":"Sven","img":null,"order":578},{"name":"Alexis Conran","character":"Dr. Petridis","img":null,"order":579},{"name":"Miles Jupp","character":"Basil","img":null,"order":584}],"tv:225383":[{"name":"Luke Kirby","character":"Jack McMillan","img":null,"order":0},{"name":"Charlotte Gainsbourg","character":"Genevi\u00e8ve Lavigne","img":null,"order":1},{"name":"Lou de La\u00e2ge","character":"Cheyenne Toussaint","img":null,"order":2},{"name":"Gideon Glick","character":"Tobias Bell","img":null,"order":3},{"name":"David Alvarez","character":"Gael Rodriguez","img":null,"order":4},{"name":"Ivan du Pontavice","character":"Gabin","img":null,"order":5},{"name":"Ta\u00efs Vinolo","character":"Mishi Duplessis","img":null,"order":6},{"name":"David Haig","character":"Nicholas","img":null,"order":7},{"name":"LaMay Zhang","character":"Susu","img":null,"order":8},{"name":"Simon Callow","character":"Crispin Shamblee","img":null,"order":9},{"name":"Yanic Truesdale","character":"Rapha\u00ebl Marchand","img":null,"order":10},{"name":"Etai Benson","character":"Curt","img":null,"order":500},{"name":"Steve Belanger","character":"Al","img":null,"order":501}],"tv:4586":[{"name":"Lauren Graham","character":"Lorelai Gilmore","img":null,"order":0},{"name":"Alexis Bledel","character":"Rory Gilmore","img":null,"order":1},{"name":"Scott Patterson","character":"Luke Danes","img":null,"order":2},{"name":"Melissa McCarthy","character":"Sookie St. James","img":null,"order":3},{"name":"Keiko Agena","character":"Lane Kim","img":null,"order":4},{"name":"Yanic Truesdale","character":"Michel Gerard","img":null,"order":5},{"name":"Liza Weil","character":"Paris Geller","img":null,"order":6},{"name":"Jared Padalecki","character":"Dean Forester","img":null,"order":8},{"name":"Matt Czuchry","character":"Logan Huntzberger","img":null,"order":9},{"name":"Kelly Bishop","character":"Emily Gilmore","img":null,"order":10},{"name":"Edward Herrmann","character":"Richard Gilmore","img":null,"order":12},{"name":"Sean Gunn","character":"Kirk Gleason","img":null,"order":60},{"name":"Liz Torres","character":"Miss Patty","img":null,"order":692},{"name":"Michael Winters","character":"Taylor Doose","img":null,"order":697},{"name":"Jackson Douglas","character":"Jackson Belleville","img":null,"order":746}],"tv:117581":[{"name":"Antonia Gentry","character":"Virginia 'Ginny' Miller","img":null,"order":0},{"name":"Brianne Howey","character":"Georgia Miller","img":null,"order":1},{"name":"Diesel La Torraca","character":"Austin Miller","img":null,"order":2},{"name":"Jennifer Robertson","character":"Ellen Baker","img":null,"order":3},{"name":"Felix Mallard","character":"Marcus Baker","img":null,"order":4},{"name":"Sara Waisglass","character":"Maxine Baker","img":null,"order":5},{"name":"Scott Porter","character":"Mayor Paul Randolph","img":null,"order":6},{"name":"Raymond Ablack","character":"Joe","img":null,"order":7},{"name":"Mason Temple","character":"Hunter Chen","img":null,"order":8},{"name":"Katie Douglas","character":"Abigail 'Abby' Littman","img":null,"order":9},{"name":"Chelsea Clark","character":"Norah","img":null,"order":10},{"name":"Nikki Roumel","character":"Young Georgia","img":null,"order":205},{"name":"Sabrina Grdevich","character":"Cynthia Fuller","img":null,"order":502},{"name":"Colton Gobbo","character":"Jordan","img":null,"order":503},{"name":"Tyssen Smith","character":"Brodie","img":null,"order":504}],"tv:32504":[{"name":"Aly Michalka","character":"Marti Perkins","img":null,"order":0},{"name":"Ashley French","character":"Savannah Monroe","img":null,"order":1},{"name":"Heather Hemmens","character":"Alice Verdura","img":null,"order":2},{"name":"Robbie Jones","character":"Lewis Flynn","img":null,"order":3},{"name":"Matt Barr","character":"Dan Patch","img":null,"order":4},{"name":"Gail O'Grady","character":"Wanda Perkins","img":null,"order":5},{"name":"Sharon Leal","character":"Vanessa Lodge","img":null,"order":6},{"name":"Aaron Douglas","character":"Bill Marsh","img":null,"order":500},{"name":"Jeff Hephner","character":"Red Raymond","img":null,"order":501},{"name":"D. B. Woodside","character":"Derrick Altman","img":null,"order":502},{"name":"Elena Esovolova","character":"Patty Wedgerman","img":null,"order":503},{"name":"Emma Lahana","character":"Charlotte Monroe","img":null,"order":504},{"name":"Teryl Rothery","character":"Layne Monroe","img":null,"order":505},{"name":"Lauren Watson","character":"Judge (uncredited)","img":null,"order":506},{"name":"John Pyper-Ferguson","character":"Rex Perkins","img":null,"order":507}],"tv:226637":[{"name":"Kaitlin Olson","character":"Morgan Gillory","img":null,"order":0},{"name":"Daniel Sunjata","character":"Adam Karadec","img":null,"order":1},{"name":"Judy Reyes","character":"Selena Soto","img":null,"order":2},{"name":"Javicia Leslie","character":"Daphne Forrester","img":null,"order":3},{"name":"Deniz Akdeniz","character":"Lev 'Oz' Ozdil","img":null,"order":4},{"name":"Amirah J","character":"Ava Gillory","img":null,"order":5},{"name":"Matthew Lamb","character":"Elliot Radovic","img":null,"order":6},{"name":"Taran Killam","character":"Ludo Radovic","img":null,"order":500},{"name":"Garret Dillahunt","character":"Lieutenant Melon","img":null,"order":520},{"name":"TJ Lee","character":"Officer Lin","img":null,"order":522},{"name":"J. D. Pardo","character":"Tom","img":null,"order":570},{"name":"Mekhi Phifer","character":"Arthur Ellis","img":null,"order":686},{"name":"Steve Howey","character":"Nick Wagner","img":null,"order":719},{"name":"John Pyper-Ferguson","character":"Eric Hayworth","img":null,"order":754},{"name":"Susan Kelechi Watson","character":"Lucia","img":null,"order":774}],"tv:233079":[{"name":"Roisin Gallagher","character":"Saoirse","img":null,"order":0},{"name":"Sin\u00e9ad Keenan","character":"Robyn","img":null,"order":1},{"name":"Caoilfhionn Dunne","character":"Dara","img":null,"order":2},{"name":"Darragh Hand","character":"Liam","img":null,"order":3},{"name":"Natasha O'Keeffe","character":"Greta","img":null,"order":500},{"name":"Bronagh Gallagher","character":"Booker","img":null,"order":501},{"name":"Emmett J. Scanlan","character":"Owen","img":null,"order":502},{"name":"Emma Canning","character":"Young Greta","img":null,"order":503},{"name":"Michelle Fairley","character":"Margo","img":null,"order":504},{"name":"Jenn Murray","character":"Jamie","img":null,"order":510},{"name":"Eleanor Methven","character":"Mrs. Friel","img":null,"order":511},{"name":"Ardal O'Hanlon","character":"S\u00e9amus","img":null,"order":512},{"name":"Tom Basden","character":"Seb","img":null,"order":513},{"name":"Matilda Freeman","character":"Maria","img":null,"order":515},{"name":"Josh Finan","character":"Andrew Sampson","img":null,"order":520}],"tv:244447":[{"name":"Brittany Snow","character":"Sophie O\u2019Neill","img":null,"order":0},{"name":"Malin Akerman","character":"Margo Banks","img":null,"order":1},{"name":"Dermot Mulroney","character":"Jed Banks","img":null,"order":2},{"name":"Katie Lowes","character":"Jill Thompson","img":null,"order":3},{"name":"Jaime Ray Newman","character":"Callie","img":null,"order":4},{"name":"George Ferrier","character":"Brad Thompson","img":null,"order":4},{"name":"Chrissy Metz","character":"Starr Jackson","img":null,"order":4},{"name":"Evan Jonigkeit","character":"Graham O\u2019Neill","img":null,"order":5},{"name":"Alex Fitzalan","character":"Lincoln Trout","img":null,"order":6},{"name":"Hunter Emery","character":"Deputy Walter Flynn","img":null,"order":8},{"name":"Steve D'Avria","character":"Sheriff's Deputy","img":null,"order":9},{"name":"Karen Rodriguez","character":"Deputy Wanda Salazar","img":null,"order":10},{"name":"Madison Wolfe","character":"Abby Jackson","img":null,"order":503},{"name":"Alexandria DeBerry","character":"Taylor Tucker","img":null,"order":504},{"name":"Joyce Glenn","character":"Monae Roberts","img":null,"order":505}],"tv:125816":[{"name":"Sigourney Weaver","character":"June Hart","img":null,"order":0},{"name":"Alycia Debnam-Carey","character":"Alice Hart","img":null,"order":1},{"name":"Alyla Browne","character":"Young Alice Hart","img":null,"order":2},{"name":"Leah Purcell","character":"Twig","img":null,"order":3},{"name":"Frankie Adams","character":"Candy","img":null,"order":4},{"name":"Tilda Cobham-Hervey","character":"Agnes Hart","img":null,"order":5},{"name":"Asher Keddie","character":"Sally Morgan","img":null,"order":6},{"name":"Alexander England","character":"John Morgan","img":null,"order":7},{"name":"Charlie Vickers","character":"Clem Hart","img":null,"order":8},{"name":"Sebasti\u00e1n Zurita","character":"Dylan","img":null,"order":9},{"name":"Maggie Dence","character":"Boo","img":null,"order":502},{"name":"Dalara Williams","character":"Myf","img":null,"order":504},{"name":"Amy Kersey","character":"Rosie","img":null,"order":506},{"name":"Vivienne Awosoga","character":"Lulu","img":null,"order":514},{"name":"Shareena Clanton","character":"Ruby","img":null,"order":515}],"tv:99353":[{"name":"Nick Lachey","character":"Self - Host","img":null,"order":0},{"name":"Vanessa Lachey","character":"Self - Host","img":null,"order":1},{"name":"Cole Barnett","character":"Self - Contestant","img":null,"order":511},{"name":"Paul Peden","character":"Self - Contestant","img":null,"order":545},{"name":"Colleen Reed","character":"Self - Contestant","img":null,"order":581},{"name":"Matt Bolton","character":"Self - Contestant","img":null,"order":583},{"name":"Micah Lussier","character":"Self - Video Message","img":null,"order":594},{"name":"Alexa Alfia","character":"Self - Contestant","img":null,"order":602},{"name":"Brennon Lemieux","character":"Self - Video Message","img":null,"order":605},{"name":"Tiffany Pennywell","character":"Self - Contestant","img":null,"order":606},{"name":"Brett Brown","character":"Self - Video Message","img":null,"order":607},{"name":"Chelsea Griffin Appiah","character":"Self - Contestant","img":null,"order":607},{"name":"Zack Goytowski","character":"Self - Video Message","img":null,"order":608},{"name":"Kwame Appiah","character":"Self - Video Message","img":null,"order":610},{"name":"Bliss Poureetezadi","character":"Self - Video Message","img":null,"order":616}],"tv:285404":[{"name":"Tatiana Maslany","character":"Paula Sanders","img":null,"order":0},{"name":"Jake Johnson","character":"Karl Hendricks","img":null,"order":1},{"name":"Dolly de Leon","character":"Detective Sofia Gonzalez","img":null,"order":2},{"name":"Jessy Hodges","character":"Mallory","img":null,"order":3},{"name":"Jon Michael Hill","character":"Detective Baxter","img":null,"order":4},{"name":"Charlie Hall","character":"Rudy","img":null,"order":5},{"name":"Kiarra Hamagami Goldberg","character":"Geri","img":null,"order":6},{"name":"Nola Wallace","character":"Hazel","img":null,"order":7},{"name":"Brandon Flynn","character":"Trevor","img":null,"order":500},{"name":"Nina Bloomgarden","character":"Ash","img":null,"order":502},{"name":"Daniel Dale","character":"Sky","img":null,"order":504},{"name":"Remy Auberjonois","character":"Doug","img":null,"order":505},{"name":"Grace Porter","character":"Gwynn","img":null,"order":506},{"name":"Raymond Lee","character":"Steve","img":null,"order":507},{"name":"Murray Bartlett","character":"Dennis","img":null,"order":509}],"tv:118303":[{"name":"Ophelia Lovibond","character":"Joyce","img":null,"order":0},{"name":"Jake Johnson","character":"Doug","img":null,"order":1},{"name":"Michael Angarano","character":"Glenn","img":null,"order":2},{"name":"Jessica Lowe","character":"Bambi","img":null,"order":3},{"name":"Oscar Montoya","character":"Richie","img":null,"order":4},{"name":"Lennon Parham","character":"Shelly","img":null,"order":5},{"name":"Idara Victor","character":"Tina","img":null,"order":6},{"name":"Sofia Gonzalez","character":"Rita","img":null,"order":554},{"name":"David Lee Hess","character":"Phil","img":null,"order":557},{"name":"Jaeden Bettencourt","character":"Tommy","img":null,"order":572},{"name":"Rich Sommer","character":"Lenny","img":null,"order":576},{"name":"Elizabeth Perkins","character":"Constance","img":null,"order":579},{"name":"Lincoln A. Castellanos","character":"Daniel","img":null,"order":597},{"name":"Liora Belle","character":"Millie","img":null,"order":744},{"name":"Vera Myers","character":"Bottom Dollar Playmate (uncredited)","img":null,"order":766}],"tv:33155":[{"name":"Don Hany","character":"","img":null,"order":0},{"name":"Asher Keddie","character":"Nina Proudman","img":null,"order":1},{"name":"Deborah Mailman","character":"","img":null,"order":2},{"name":"Kat Stewart","character":"Billie Proudman","img":null,"order":3},{"name":"Richard Davies","character":"Jimmy Proudman","img":null,"order":4},{"name":"Linda Cropper","character":"Geraldine Proudman","img":null,"order":5},{"name":"Jane Harber","character":"Zara Perkich","img":null,"order":6},{"name":"Alicia Gardiner","character":"Kim Akerholt","img":null,"order":7},{"name":"Eddie Perfect","character":"Mick Holland","img":null,"order":8},{"name":"John Waters","character":"","img":null,"order":9},{"name":"Matthew Le Nevez","character":"","img":null,"order":10},{"name":"Lachy Hulme","character":"","img":null,"order":11},{"name":"Clare Bowditch","character":"Rosanna Harding","img":null,"order":12},{"name":"Kate Atkinson","character":"Renee","img":null,"order":508},{"name":"Shannon Berry","character":"Brody Jordan","img":null,"order":511}],"tv:107113":[{"name":"Steve Martin","character":"Charles-Haden Savage","img":null,"order":0},{"name":"Martin Short","character":"Oliver Putnam","img":null,"order":1},{"name":"Selena Gomez","character":"Mabel Mora","img":null,"order":2},{"name":"Amy Ryan","character":"Jan Bellows","img":null,"order":3},{"name":"Michael Cyril Creighton","character":"Howard Morris","img":null,"order":5},{"name":"Jackie Hoffman","character":"Uma Heller","img":null,"order":542},{"name":"Da'Vine Joy Randolph","character":"Donna Williams","img":null,"order":567},{"name":"Vanessa Aspillaga","character":"Ursula","img":null,"order":576},{"name":"Teddy Coluca","character":"Lester","img":null,"order":577},{"name":"Russell G. Jones","character":"Grover Stanley","img":null,"order":583},{"name":"Jayne Houdyshell","character":"Bunny Folger","img":null,"order":586},{"name":"Ryan Broussard","character":"Will Putnam","img":null,"order":588},{"name":"Meryl Streep","character":"Loretta Durkin","img":null,"order":598},{"name":"Paul Rudd","character":"Ben Glenroy","img":null,"order":644},{"name":"Richard Kind","character":"Vince Fish","img":null,"order":659}],"tv:207484":[{"name":"Harriet Slater","character":"Ellen MacKenzie","img":null,"order":0},{"name":"Jamie Roy","character":"Brian Fraser","img":null,"order":1},{"name":"Hermione Corfield","character":"Julia Moriston Beauchamp","img":null,"order":2},{"name":"Jeremy Irvine","character":"Henry Beauchamp","img":null,"order":3},{"name":"Tony Curran","character":"Lord Lovat (Simon Fraser)","img":null,"order":4},{"name":"S\u00e9amus McLean Ross","character":"Colum MacKenzie","img":null,"order":5},{"name":"Sam Retford","character":"Dougal MacKenzie","img":null,"order":6},{"name":"Rory Alexander","character":"Murtagh Fitzgibbons Fraser","img":null,"order":7},{"name":"Conor MacNeill","character":"Ned Gowan","img":null,"order":8},{"name":"Sara Vickers","character":"Davina Porter","img":null,"order":9},{"name":"Sadhbh Malin","character":"Jocasta MacKenzie Cameron","img":null,"order":502},{"name":"Jhon Lumsden","character":"Malcolm Grant","img":null,"order":503},{"name":"Sally Messham","character":"Mrs. Fitzgibbons","img":null,"order":506},{"name":"Terence Rae","character":"Arch Bug","img":null,"order":507},{"name":"Chick Allan","character":"Balloch","img":null,"order":514}],"tv:208921":[{"name":"David Tennant","character":"Tony Baddingham","img":null,"order":0},{"name":"Aidan Turner","character":"Declan O'Hara","img":null,"order":1},{"name":"Danny Dyer","character":"Freddie Jones","img":null,"order":2},{"name":"Alex Hassell","character":"Rupert Campbell-Black","img":null,"order":3},{"name":"Nafessa Williams","character":"Cameron Cook","img":null,"order":4},{"name":"Bella Maclean","character":"Taggie O'Hara","img":null,"order":5},{"name":"Katherine Parkinson","character":"Lizzie Vereker","img":null,"order":6},{"name":"Victoria Smurfit","character":"Maud O'Hara","img":null,"order":7},{"name":"Claire Rushbrook","character":"Lady Monica Baddingham","img":null,"order":8},{"name":"Oliver Chris","character":"James Vereker","img":null,"order":9},{"name":"Lisa McGrillis","character":"Valerie Jones","img":null,"order":10},{"name":"Emily Atack","character":"Sarah Stratton","img":null,"order":11},{"name":"Rufus Jones","character":"Paul Stratton","img":null,"order":12},{"name":"Luke Pasqualino","character":"Basil 'Bas' Baddingham","img":null,"order":13},{"name":"Catriona Chandler","character":"Caitlin O'Hara","img":null,"order":14}],"tv:254953":[{"name":"Steve Carell","character":"Greg Russo","img":null,"order":0},{"name":"Charly Clive","character":"Katie Russo","img":null,"order":1},{"name":"Danielle Deadwyler","character":"Dylan Shepard","img":null,"order":2},{"name":"Phil Dunster","character":"Archie Bates","img":null,"order":3},{"name":"John C. McGinley","character":"Walter Mann","img":null,"order":4},{"name":"Lauren Tsai","character":"Sunny Salewski","img":null,"order":5},{"name":"Annie Mumolo","character":"Cristle","img":null,"order":500},{"name":"Rory Scovel","character":"Officer Donnie Mullins","img":null,"order":501},{"name":"Cece Abbey","character":"Maya","img":null,"order":503},{"name":"Xavier Beloved","character":"George Heitman","img":null,"order":504},{"name":"Noah Grismer","character":"J.D.","img":null,"order":507},{"name":"Madison Hu","character":"Eva","img":null,"order":508},{"name":"Evan Jachelski","character":"Spooner","img":null,"order":509},{"name":"Maximo Salas","character":"Tommy Newton","img":null,"order":515},{"name":"Robby Hoffman","character":"Mo","img":null,"order":518}],"tv:105":[{"name":"Sarah Jessica Parker","character":"Carrie Bradshaw","img":null,"order":0},{"name":"Kim Cattrall","character":"Samantha Jones","img":null,"order":1},{"name":"Cynthia Nixon","character":"Miranda Hobbes","img":null,"order":2},{"name":"Kristin Davis","character":"Charlotte York","img":null,"order":3},{"name":"Lynn Cohen","character":"Magda","img":null,"order":826},{"name":"Willie Garson","character":"Stanford Blatch","img":null,"order":962},{"name":"Mario Cantone","character":"Anthony Marantino","img":null,"order":964},{"name":"Chris Noth","character":"Mr. Big","img":null,"order":982},{"name":"Kyle MacLachlan","character":"Trey MacDougal","img":null,"order":1001},{"name":"David Eigenberg","character":"Steve Brady","img":null,"order":1022},{"name":"Frances Sternhagen","character":"Bunny MacDougal","img":null,"order":1023},{"name":"John Corbett","character":"Aidan Shaw","img":null,"order":1026},{"name":"James Remar","character":"Richard Wright","img":null,"order":1040},{"name":"Jason Lewis","character":"Jerry 'Smith' Jerrod","img":null,"order":1062},{"name":"Evan Handler","character":"Harry Goldenblatt","img":"yU0OXeZ0K1ppe1o9SpHiAMHy1rc.jpg","order":1096}],"tv:133908":[{"name":"Natasha Calis","character":"Hayley Roberts","img":null,"order":0},{"name":"Morgan Holmstrom","character":"Crystal Highway","img":null,"order":1},{"name":"Aaron Ashmore","character":"William \"Wheezer\" Heaseman","img":null,"order":2},{"name":"Mercedes Morris","character":"Lexi Martine","img":null,"order":3},{"name":"Praneet Akilla","character":"Jay \"Chopper\" Chopra","img":null,"order":4},{"name":"Aason Nadjiwan","character":"Austen Bodie","img":null,"order":5},{"name":"Alexander Eling","character":"Wyatt Ellis","img":null,"order":6},{"name":"Thomas Elms","character":"Milosz Nowak","img":null,"order":8},{"name":"Kheon Clarke","character":"Tristan Green","img":null,"order":10},{"name":"Rebecca Kwan","character":"Emma Lin","img":null,"order":12},{"name":"Braeden Clarke","character":"Jeremy Wood","img":null,"order":14},{"name":"Emilia McCarthy","character":"Madison Van Camp","img":null,"order":15},{"name":"Patrick Kwok-Choon","character":"Trevor Sung","img":null,"order":16},{"name":"Sydney Kuhne","character":"Stef","img":null,"order":17},{"name":"Anthony Grant","character":"TJ","img":null,"order":18}],"tv:40231":[{"name":"EliZe","character":"Self - Presenter","img":null,"order":0},{"name":"Dan Karaty","character":"Self - Jury","img":null,"order":1},{"name":"Euvgenia Parakhina","character":"Self - Jury","img":null,"order":2},{"name":"Jaakko Toivonen","character":"Self - Jury","img":null,"order":3},{"name":"Nicky Vernieuwe","character":"Self - Jury","img":null,"order":4},{"name":"Marco Gerris","character":"Self - Jury","img":null,"order":5},{"name":"Min Hee Bervoets","character":"Self - Jury","img":null,"order":6},{"name":"Roberto da Costa","character":"Guest Jury","img":null,"order":7},{"name":"Brahim Attaeb","character":"Guest Jury","img":null,"order":8},{"name":"Eszteca Noya","character":"Guest Jury","img":null,"order":9},{"name":"Dennis Weening","character":"Self - Presenter","img":null,"order":10},{"name":"Ish A\u00eft Hamou","character":"Self - Jury","img":null,"order":11},{"name":"Eline De Munck","character":"Self - Presenter","img":null,"order":12},{"name":"Jan Kooijman","character":"Self - Jury","img":null,"order":13},{"name":"Wendy van Dijk","character":"Self - Jury","img":null,"order":257}],"tv:111803":[{"name":"Leslie Bibb","character":"Kate Bohr","img":null,"order":0},{"name":"Jennifer Coolidge","character":"Tanya McQuoid","img":null,"order":1},{"name":"Carrie Coon","character":"Laurie Duffy","img":null,"order":1},{"name":"Walton Goggins","character":"Rick Hatchett","img":null,"order":2},{"name":"Sarah Catherine Hook","character":"Piper Ratliff","img":null,"order":3},{"name":"Jason Isaacs","character":"Timothy Ratliff","img":null,"order":4},{"name":"Jon Gries","character":"Greg Hunt","img":null,"order":5},{"name":"Parker Posey","character":"Victoria Ratliff","img":null,"order":5},{"name":"Patrick Schwarzenegger","character":"Saxon Ratliff","img":null,"order":6},{"name":"Tayme Thapthimthong","character":"Gaitok","img":null,"order":7},{"name":"Aimee Lou Wood","character":"Chelsea","img":null,"order":8},{"name":"LISA","character":"Mook","img":null,"order":9},{"name":"Michelle Monaghan","character":"Jaclyn Lemon","img":null,"order":10},{"name":"Natasha Rothwell","character":"Belinda Lindsey","img":null,"order":11},{"name":"Sam Nivola","character":"Lochlan Ratliff","img":null,"order":12}],"tv:117488":[{"name":"Melanie Lynskey","character":"Shauna Sadecki","img":null,"order":0},{"name":"Tawny Cypress","character":"Taissa Turner","img":null,"order":1},{"name":"Sophie N\u00e9lisse","character":"Teen Shauna Shipman","img":null,"order":3},{"name":"Jasmin Savoy Brown","character":"Teen Taissa Turner","img":null,"order":4},{"name":"Sophie Thatcher","character":"Teen Natalie Scatorccio","img":null,"order":5},{"name":"Sammi Hanratty","character":"Teen Misty Quigley","img":null,"order":6},{"name":"Steven Krueger","character":"Ben Scott","img":null,"order":7},{"name":"Warren Kole","character":"Jeff Sadecki","img":null,"order":8},{"name":"Courtney Eaton","character":"Teen Lottie Matthews","img":null,"order":9},{"name":"Lauren Ambrose","character":"Van Palmer","img":null,"order":10},{"name":"Christina Ricci","character":"Misty Quigley","img":null,"order":11},{"name":"Kevin Alves","character":"Teen Travis","img":null,"order":14},{"name":"Liv Hewson","character":"Teen Van Palmer","img":null,"order":15},{"name":"Sarah Desjardins","character":"Callie Sadecki","img":null,"order":16},{"name":"Alexa Barajas","character":"Teen Mari Ibarra","img":null,"order":18}],"movie:21469":[{"name":"Mike Vogel","character":"Julian","img":null,"order":0},{"name":"Brittany Murphy","character":"June","img":null,"order":1},{"name":"Danny Pino","character":"Terry","img":null,"order":2},{"name":"Brad Greenquist","character":"The Porter","img":null,"order":3},{"name":"Arie Verveen","character":"Lucas","img":null,"order":4},{"name":"Natalie Smyka","character":"Anna","img":null,"order":5},{"name":"Guillermo D\u00edaz","character":"The Cook","img":null,"order":6},{"name":"Dov Davidoff","character":"The Bellhop","img":null,"order":7},{"name":"William Stanford Davis","character":"Custodian","img":null,"order":8},{"name":"Erika Ringor","character":"Detective","img":null,"order":9},{"name":"Mark Engelhardt","character":"Officer","img":null,"order":10},{"name":"Chase Kim","character":"Police Officer","img":null,"order":11},{"name":"Erika Seifred","character":"Dominatrix","img":null,"order":12},{"name":"Cameron Cash","character":"Georgie (uncredited)","img":null,"order":13}],"movie:235271":[{"name":"Sharon Leal","character":"Zoe Reynard","img":null,"order":0},{"name":"Boris Kodjoe","character":"Jason Reynard","img":null,"order":1},{"name":"John Newberg","character":"Balthazar Crayne","img":null,"order":2},{"name":"Brandon Gonzales","character":"Tony","img":null,"order":3},{"name":"Cameron Mills","character":"10-year-old Zoe","img":null,"order":4},{"name":"Emayatzy Corinealdi","character":"Brina","img":null,"order":5},{"name":"Garrett Hines","character":"Benny","img":null,"order":6},{"name":"Hunter Burke","character":"Shane","img":null,"order":7},{"name":"Kat Graham","character":"Diamond","img":null,"order":8},{"name":"Maria Howell","character":"Nina","img":null,"order":9},{"name":"Omer Mughal","character":"Handsome Man","img":null,"order":10},{"name":"Tasha Smith","character":"Marcella","img":null,"order":11},{"name":"Tyson Beckford","character":"Corey","img":null,"order":12},{"name":"William Levy","character":"Quentin Matthews","img":null,"order":13},{"name":"Mikayla Donlon","character":"Girl on Playground","img":null,"order":14}],"movie:376004":[{"name":"Sean Faris","character":"Samuel Deuprey","img":null,"order":0},{"name":"Danielle Savre","character":"Ashley","img":null,"order":1},{"name":"Mehcad Brooks","character":"Damien","img":null,"order":2},{"name":"Stephanie Charles","character":"Lola","img":null,"order":3},{"name":"Steffinnie Phrommany","character":"Jasmine","img":null,"order":4},{"name":"Rebecca Reaney","character":"Rebecca Scott","img":null,"order":5},{"name":"Phillip Brock","character":"Jimmy","img":null,"order":6},{"name":"Robert Eric Wise","character":"Tyrone","img":null,"order":7}],"movie:18206":[{"name":"Colin Hanks","character":"Doug","img":null,"order":0},{"name":"Ana Claudia Talanc\u00f3n","character":"Amy","img":null,"order":1},{"name":"Jordana Spiro","character":"Jen","img":null,"order":2},{"name":"Jonathon Trent","character":"Matt","img":null,"order":3},{"name":"Alex Boling","character":"Barrista","img":null,"order":4},{"name":"Tony Armatrading","character":"Cop","img":null,"order":5}],"movie:13483":[{"name":"Hayden Christensen","character":"Clay Beresford","img":null,"order":0},{"name":"Jessica Alba","character":"Sam Lockwood","img":null,"order":1},{"name":"Terrence Howard","character":"Dr. Jack Harper","img":null,"order":2},{"name":"Lena Olin","character":"Lilith Beresford","img":null,"order":3},{"name":"Christopher McDonald","character":"Dr. Larry Lupin","img":null,"order":4},{"name":"Sam Robards","character":"Clayton Beresford Sr.","img":null,"order":5},{"name":"Arliss Howard","character":"Dr. Jonathan Neyer","img":null,"order":6},{"name":"Fisher Stevens","character":"Dr. Puttnam","img":null,"order":7},{"name":"Georgina Chapman","character":"Penny Carver","img":null,"order":8},{"name":"David Harbour","character":"Dracula","img":null,"order":9},{"name":"Steven Hinkle","character":"Young Clay","img":null,"order":10},{"name":"Denis O'Hare","character":"Financial News Analyst","img":null,"order":11},{"name":"Charlie Hewson","character":"Brian the Orderly","img":null,"order":12},{"name":"Court Young","character":"Officer Doherty","img":null,"order":13},{"name":"Poorna Jagannathan","character":"Dr. Neyer's Nurse","img":null,"order":14}],"movie:624788":[{"name":"Aubrey Plaza","character":"Allison","img":null,"order":0},{"name":"Christopher Abbott","character":"Gabe","img":null,"order":1},{"name":"Sarah Gadon","character":"Blair","img":null,"order":2},{"name":"Paola L\u00e1zaro","character":"Cahya","img":null,"order":3},{"name":"Grantham Coleman","character":"Baako","img":null,"order":4},{"name":"Lindsay Burdge","character":"Maude","img":null,"order":5},{"name":"Lou Gonzalez","character":"Chris","img":null,"order":6},{"name":"Shannon O'Neill","character":"Simone","img":null,"order":7},{"name":"Alexander Koch","character":"Mike","img":null,"order":8},{"name":"Jennifer Kim","character":"Nora","img":null,"order":9},{"name":"Kevin Barker","character":"John the Gaffer","img":null,"order":10},{"name":"Mary Borrello","character":"1st AC","img":null,"order":11},{"name":"Rob Turbovsky","character":"2nd AC","img":null,"order":12},{"name":"Haitao Zeng","character":"PA","img":null,"order":13}],"movie:13250":[{"name":"Pierce Brosnan","character":"Tom Ryan","img":null,"order":0},{"name":"Maria Bello","character":"Abby Randall","img":null,"order":1},{"name":"Gerard Butler","character":"Neil Randall","img":null,"order":2},{"name":"Emma Karwandy","character":"Sophie Randall","img":null,"order":3},{"name":"Claudette Mink","character":"Judy Ryan","img":null,"order":4},{"name":"Desiree Zurowski","character":"Helen Schriver","img":null,"order":5},{"name":"Nicholas Lea","character":"Jerry Crane","img":null,"order":6},{"name":"Peter Keleghan","character":"Karl Granger","img":null,"order":7},{"name":"Samantha Ferris","character":"Diane","img":null,"order":8},{"name":"Malcolm Stewart","character":"Dave Carver","img":null,"order":9},{"name":"Callum Keith Rennie","character":"Det. McGill","img":null,"order":10},{"name":"Dustin Milligan","character":"Matt Ryan","img":null,"order":11},{"name":"Julie Patzwald","character":"Female Teller","img":null,"order":12},{"name":"Jason McKinnon","character":"French Waiter","img":null,"order":13},{"name":"Kurt Evans","character":"Maitre D'","img":null,"order":14}],"movie:317744":[{"name":"Nick Jonas","character":"Doug Martin","img":null,"order":0},{"name":"Isabel Lucas","character":"Lena Harper","img":null,"order":1},{"name":"Dermot Mulroney","character":"Eliott Harper","img":null,"order":2},{"name":"Kandyse McClure","character":"Angie Alvarez","img":null,"order":3},{"name":"Graham Rogers","character":"Carson","img":null,"order":4},{"name":"Marc Macaulay","character":"Gordon","img":null,"order":5},{"name":"David Sherrill","character":"Brian Martin","img":null,"order":6},{"name":"Kevin J. O'Connor","character":"Pier/Patron du bar","img":null,"order":7},{"name":"Paul Sorvino","character":"Sheriff Big Jack","img":null,"order":8},{"name":"Alex ter Avest","character":"Emma Shalloway","img":null,"order":9},{"name":"John Driskell Hopkins","character":"Gus","img":null,"order":10},{"name":"Ross Gallo","character":"Lena's Lawyer","img":null,"order":11},{"name":"Kiki Harris","character":"Emily/Doug's mother","img":null,"order":12}],"movie:1920":[{"name":"Barbara Stanwyck","character":"Kathy Ferguson","img":null,"order":0},{"name":"Sterling Hayden","character":"Lt. William Doyle","img":null,"order":1},{"name":"Raymond Burr","character":"Inspector Tony Pope","img":null,"order":2},{"name":"Fay Wray","character":"Alice Pope","img":null,"order":3},{"name":"Virginia Grey","character":"Sara Alidas","img":null,"order":4},{"name":"Royal Dano","character":"Capt. Charlie Alidas","img":null,"order":5},{"name":"Robert Griffin","character":"Police Sgt. James","img":null,"order":6},{"name":"Dennis Cross","character":"Police Sgt. Jules","img":null,"order":7},{"name":"Jay Adler","character":"Mr. Nalence","img":null,"order":8},{"name":"Stuart Whitman","character":"Laboratory Technician","img":null,"order":9},{"name":"Malcolm Atterbury","character":"Police Officer Spitz","img":null,"order":10},{"name":"Robert Quarry","character":"Sam, Reporter","img":null,"order":11},{"name":"Gail Bonney","character":"Mrs. London","img":null,"order":12},{"name":"Joe Conley","character":"Delivery Boy","img":null,"order":13},{"name":"Ralph Brooks","character":"Reporter in Newspaper Office (Uncredited)","img":null,"order":14}],"movie:619979":[{"name":"Ben Affleck","character":"Vic Van Allen","img":null,"order":0},{"name":"Ana de Armas","character":"Melinda Van Allen","img":null,"order":1},{"name":"Tracy Letts","character":"Don Wilson","img":null,"order":2},{"name":"Lil Rel Howery","character":"Grant","img":null,"order":3},{"name":"Dash Mihok","character":"Jonas Fernandez","img":null,"order":4},{"name":"Finn Wittrock","character":"Tony Cameron","img":null,"order":5},{"name":"Kristen Connolly","character":"Kelly Wilson","img":null,"order":6},{"name":"Jacob Elordi","character":"Charlie De Lisle","img":null,"order":7},{"name":"Rachel Blanchard","character":"Kristin Peterson","img":null,"order":8},{"name":"Michael Braun","character":"Jeff Peterson","img":null,"order":9},{"name":"Jade Fernandez","character":"Jen Fernandez","img":null,"order":10},{"name":"Grace Jenkins","character":"Trixie","img":null,"order":11},{"name":"Brendan Miller","character":"Joel Dash","img":null,"order":12},{"name":"Devyn A. Tyler","character":"Mary Washington","img":null,"order":13},{"name":"Jeff Pope","character":"Chief Nichols","img":null,"order":14}],"movie:8999":[{"name":"Clive Owen","character":"Charles Schine","img":null,"order":0},{"name":"Jennifer Aniston","character":"Lucinda Harris","img":null,"order":1},{"name":"Vincent Cassel","character":"LaRoche","img":null,"order":2},{"name":"Melissa George","character":"Deanna Schine","img":null,"order":3},{"name":"Giancarlo Esposito","character":"Detective Church","img":null,"order":4},{"name":"David Morrissey","character":"Sam Griffin","img":null,"order":5},{"name":"Georgina Chapman","character":"Candy","img":null,"order":6},{"name":"Denis O'Hare","character":"Jerry","img":null,"order":7},{"name":"Tom Conti","character":"Eliot Firth","img":null,"order":8},{"name":"Addison Timlin","character":"Amy Schine","img":null,"order":9},{"name":"RZA","character":"Winston Boyko","img":null,"order":10},{"name":"Xzibit","character":"Dexter","img":null,"order":11},{"name":"Sandra Bee","character":"Train Conductor","img":null,"order":12},{"name":"William Armstrong","character":"Accountant","img":null,"order":13},{"name":"Rachael Blake","character":"Susan Davis","img":null,"order":14}],"movie:1033148":[{"name":"Jennifer Lawrence","character":"Grace","img":null,"order":0},{"name":"Robert Pattinson","character":"Jackson","img":null,"order":1},{"name":"Sissy Spacek","character":"Pam","img":null,"order":2},{"name":"LaKeith Stanfield","character":"Karl","img":null,"order":3},{"name":"Nick Nolte","character":"Harry","img":null,"order":4},{"name":"Gabrielle Rose","character":"Jen","img":null,"order":5},{"name":"Clare Coulter","character":"Courtney","img":null,"order":6},{"name":"Sarah Lind","character":"Cheryl","img":null,"order":7},{"name":"Luke Camilleri","character":"Greg","img":null,"order":8},{"name":"Victor Zinck Jr.","character":"Tom","img":null,"order":9},{"name":"Debs Howard","character":"Marsha","img":null,"order":10},{"name":"Phillip Forest Lewitski","character":"Charlie","img":null,"order":11},{"name":"Georgina Lightning","character":"Kathleen the Orderly","img":null,"order":12},{"name":"Darren Moore","character":"Gerry","img":null,"order":13},{"name":"Lauren Viau","character":"Bessie","img":null,"order":14}],"movie:69668":[{"name":"Daniel Craig","character":"Will Atenton","img":null,"order":0},{"name":"Naomi Watts","character":"Ann Patterson","img":null,"order":1},{"name":"Rachel Weisz","character":"Libby Atenton","img":null,"order":2},{"name":"Marton Csokas","character":"Jack Patterson","img":null,"order":3},{"name":"Elias Koteas","character":"Boyce","img":null,"order":4},{"name":"Taylor Geare","character":"Trish","img":null,"order":5},{"name":"Claire Geare","character":"Dee Dee","img":null,"order":6},{"name":"Rachel Fox","character":"Chloe Patterson","img":null,"order":7},{"name":"Jane Alexander","character":"Dr. Greeley","img":null,"order":8},{"name":"Brian Murray","character":"Dr. Medlin","img":null,"order":9},{"name":"Bernadette Quigley","character":"Heather Keeler","img":null,"order":10},{"name":"Sarah Gadon","character":"Cindi","img":null,"order":11},{"name":"Gregory Smith","character":"Artie","img":null,"order":12},{"name":"David Huband","character":"Officer Nelson","img":null,"order":13},{"name":"Martin Roach","character":"Tommy","img":null,"order":14}],"movie:18190":[{"name":"James Spader","character":"Ray Reardon","img":null,"order":0},{"name":"M\u00e4dchen Amick","character":"Lena Mathers","img":null,"order":1},{"name":"Fredric Lehne","character":"Larry","img":null,"order":2},{"name":"Bess Armstrong","character":"Elaine","img":null,"order":3},{"name":"Larry Miller","character":"Norman","img":null,"order":4},{"name":"Kathleen York","character":"Martha","img":null,"order":5},{"name":"Kate Williamson","character":"Mrs. Sneeder","img":null,"order":6},{"name":"Tom Lillard","character":"Hank Sneeder","img":null,"order":7},{"name":"William Shockley","character":"Buddy","img":null,"order":8},{"name":"Carl Sundstrom","character":"Officer","img":null,"order":9},{"name":"Irwin Keyes","character":"Officer","img":null,"order":10},{"name":"Joel McKinnon Miller","character":"Minister","img":null,"order":11},{"name":"Joseph Scoren","character":"Martha's Lawyer (as Joseph Scorsiani)","img":null,"order":12},{"name":"Archie Lang","character":"Judge #1","img":null,"order":13},{"name":"Clyde Kusatsu","character":"Judge Kurita","img":null,"order":14}],"movie:1249213":[{"name":"Meghann Fahy","character":"Violet","img":null,"order":0},{"name":"Brandon Sklenar","character":"Henry","img":null,"order":1},{"name":"Reed Diamond","character":"Richard","img":null,"order":2},{"name":"Violett Beane","character":"Jen","img":null,"order":3},{"name":"Jeffery Self","character":"Matt","img":null,"order":4},{"name":"Jacob Robinson","character":"Toby","img":null,"order":5},{"name":"Gabrielle Ryan","character":"Cara","img":null,"order":6},{"name":"Ed Weeks","character":"Phil","img":null,"order":7},{"name":"Travis Nelson","character":"Connor","img":null,"order":8},{"name":"Sarah McCormack","character":"Hostess","img":null,"order":9},{"name":"Ben Pelletier","character":"Bearded Man / Masked Man","img":null,"order":10},{"name":"Saoirse Hayden","character":"Green Prom Dress Girl","img":null,"order":11},{"name":"Fiona Browne","character":"Diane","img":null,"order":12},{"name":"Stephanie Karam","character":"Young Woman (Zoom)","img":null,"order":13},{"name":"Michael Shea","character":"Blake","img":null,"order":14}],"movie:2252":[{"name":"Viggo Mortensen","character":"Nikolai Luzhin","img":null,"order":0},{"name":"Naomi Watts","character":"Anna Ivanovna Khitrova","img":null,"order":1},{"name":"Vincent Cassel","character":"Kirill","img":null,"order":2},{"name":"Armin Mueller-Stahl","character":"Semyon","img":null,"order":3},{"name":"Sin\u00e9ad Cusack","character":"Helen","img":null,"order":4},{"name":"Donald Sumpter","character":"Yuri","img":null,"order":5},{"name":"Jerzy Skolimowski","character":"Stepan","img":null,"order":6},{"name":"Josef Altin","character":"Ekrem","img":null,"order":7},{"name":"Aleksandar Mikic","character":"Soyka","img":null,"order":8},{"name":"Sarah-Jeanne Labrosse","character":"Tatiana","img":null,"order":9},{"name":"Lalita Ahmed","character":"Customer","img":null,"order":10},{"name":"Tatiana Maslany","character":"Tatiana (voice)","img":null,"order":11},{"name":"Tereza Srbova","character":"Kirilenko","img":null,"order":12},{"name":"David Papava","character":"Chechen","img":null,"order":13},{"name":"Tamer Hassan","character":"Chechen","img":null,"order":14}],"movie:753":[{"name":"John Marley","character":"Richard Forst","img":null,"order":0},{"name":"Gena Rowlands","character":"Jeannie Rapp","img":null,"order":1},{"name":"Lynn Carlin","character":"Maria Forst","img":null,"order":2},{"name":"Fred Draper","character":"Freddie Draper","img":null,"order":3},{"name":"Seymour Cassel","character":"Chet","img":null,"order":4},{"name":"Val Avery","character":"Jim McCarthy","img":null,"order":5},{"name":"Dorothy Gulliver","character":"Florence","img":null,"order":6},{"name":"Joanne Moore Jordan","character":"Louise Draper","img":null,"order":7},{"name":"Darlene Conley","character":"Billy Mae","img":null,"order":8},{"name":"Gene Darfler","character":"Joe Jackson","img":null,"order":9},{"name":"Elizabeth Deering","character":"Stella","img":null,"order":10},{"name":"George Dunn","character":"Comedian (as George Dunne)","img":null,"order":11},{"name":"Laurie Mock","character":"Barmaid","img":null,"order":12},{"name":"Christina Crawford","character":"Woman Scattering Coins on Bar","img":null,"order":13},{"name":"George Sims","character":"Bartender","img":null,"order":14}],"movie:549514":[{"name":"Hilary Swank","character":"Det. Valerie Quinlan","img":null,"order":0},{"name":"Michael Ealy","character":"Derrick Tyler","img":null,"order":1},{"name":"Mike Colter","character":"Rafe Grimes","img":null,"order":2},{"name":"Damaris Lewis","character":"Tracie Tyler","img":null,"order":3},{"name":"Tyrin Turner","character":"Tyrin Abenathy","img":null,"order":4},{"name":"Danny Pino","character":"Carter Heywood","img":null,"order":5},{"name":"Geoffrey Owens","character":"Bill Cranepool","img":null,"order":6},{"name":"David Hoflin","character":"Officer Lowe","img":null,"order":7},{"name":"Sam Daly","character":"Officer Stallman","img":null,"order":8},{"name":"Lance Stephenson","character":"Sports Star Cameo","img":null,"order":9},{"name":"Chic Daniel","character":"Investigator","img":null,"order":10},{"name":"Stephen O'Mahoney","character":"Uniformed Police Officer","img":null,"order":11},{"name":"Lexa Gluck","character":"Carter's Trophy Wife","img":null,"order":12},{"name":"Oakley Bull","character":"Haley Older / Haley Younger","img":null,"order":13},{"name":"Ian Stanley","character":"First Officer","img":null,"order":14}],"tv:241372":[{"name":"Dove Cameron","character":"Ciara Wyse","img":null,"order":0},{"name":"Avan Jogia","character":"Oliver Kennedy","img":null,"order":1},{"name":"Dorian Missick","character":"Karl Connelly","img":null,"order":2},{"name":"Karla Souza","character":"Lee Reardon","img":null,"order":3},{"name":"Kingston Rumi Southwick","character":"Young Oliver","img":null,"order":4},{"name":"Patch Darragh","character":"Dan Troxler","img":null,"order":5},{"name":"Kira Guloien","character":"Jane Miller","img":null,"order":6},{"name":"Megan Peta Hill","character":"Shyla","img":null,"order":7}],"tv:4493":[{"name":"Jennifer Saunders","character":"Edina Monsoon","img":null,"order":0},{"name":"Joanna Lumley","character":"Patsy Stone","img":null,"order":1},{"name":"Julia Sawalha","character":"Saffron 'Saffy' Monsoon","img":null,"order":2},{"name":"June Whitfield","character":"Mother","img":null,"order":3},{"name":"Jane Horrocks","character":"Bubble / Katy Grin","img":null,"order":118},{"name":"Naoko Mori","character":"Sarah","img":null,"order":505},{"name":"Christopher Malcolm","character":"Justin","img":null,"order":509},{"name":"Kathy Burke","character":"Magda","img":null,"order":510},{"name":"Harriet Thorpe","character":"Fleur","img":null,"order":512},{"name":"Christopher Ryan","character":"Marshall","img":null,"order":513},{"name":"Mo Gaffney","character":"Bo","img":null,"order":514},{"name":"Lulu","character":"Self","img":null,"order":514},{"name":"Helen Lederer","character":"Catriona","img":null,"order":518},{"name":"Tilly Blackwood","character":"Lady Candida De Denison-Bender","img":null,"order":624},{"name":"Llewella Gideon","character":"Nurse","img":null,"order":641}],"tv:106454":[{"name":"Tiffany Haddish","character":"Detective Danner","img":null,"order":0},{"name":"Sam Richardson","character":"Aniq","img":null,"order":1},{"name":"Zo\u00eb Chao","character":"Zoe","img":null,"order":2},{"name":"Ben Schwartz","character":"Yasper","img":null,"order":3},{"name":"Jamie Demetriou","character":"Walt","img":null,"order":7},{"name":"John Early","character":"Detective Culp","img":null,"order":8},{"name":"John Cho","character":"Ulysses","img":null,"order":10},{"name":"Paul Walter Hauser","character":"Travis","img":null,"order":11},{"name":"Ken Jeong","character":"Feng","img":null,"order":12},{"name":"Anna Konkle","character":"Hannah","img":null,"order":13},{"name":"Poppy Liu","character":"Grace","img":null,"order":14},{"name":"Elizabeth Perkins","character":"Isabel","img":null,"order":15},{"name":"Jack Whitehall","character":"Sebastian","img":null,"order":16},{"name":"Zach Woods","character":"Edgar","img":null,"order":17},{"name":"Vivian Wu","character":"Vivian","img":null,"order":18}],"tv:6127":[{"name":"Julia McKenzie","character":"Miss Marple","img":null,"order":0},{"name":"Geraldine McEwan","character":"Miss Marple","img":null,"order":1},{"name":"Emilia Fox","character":"Joanna Burton","img":null,"order":2},{"name":"Sylvia Syms","character":"Lavinia Pinkerton","img":null,"order":500},{"name":"Russell Tovey","character":"PC Terence Reed","img":null,"order":501},{"name":"Hugo Speer","character":"James Abbot","img":null,"order":502},{"name":"Shirley Henderson","character":"Honoria Waynflete","img":null,"order":503},{"name":"Margo Stilley","character":"Bridget Conway","img":null,"order":504},{"name":"Steve Pemberton","character":"Henry Wake","img":null,"order":505},{"name":"Benedict Cumberbatch","character":"Luke Fitzwilliam","img":null,"order":506},{"name":"Anna Chancellor","character":"Lydia Horton","img":null,"order":507},{"name":"Jemma Redgrave","character":"Jessie Humbleby","img":null,"order":508},{"name":"Lyndsey Marshal","character":"Amy Gibbs","img":null,"order":509},{"name":"David Haig","character":"Major Hugh Horton","img":null,"order":510},{"name":"Joanna Lumley","character":"Dolly Bantry","img":null,"order":679}],"tv:153723":[{"name":"Geffri Maya","character":"Simone Hicks","img":null,"order":0},{"name":"Peyton Alex Smith","character":"Damon Sims","img":null,"order":1},{"name":"Kelly Jenrette","character":"Amara Patterson","img":null,"order":2},{"name":"Cory Hardrict","character":"Marcus Turner","img":null,"order":3},{"name":"Sylvester Powell","character":"Jessie 'JR' Raymond","img":null,"order":4},{"name":"Camille Hyde","character":"Thea Mays","img":null,"order":5},{"name":"Mitchell Edwards","character":"Cam Watkins","img":null,"order":6},{"name":"Netta Walker","character":"Keisha McCalla","img":null,"order":7},{"name":"Rhoyle Ivy King","character":"Nathaniel Hardin","img":null,"order":8},{"name":"Leonard Roberts","character":"Zeke Allen","img":null,"order":505},{"name":"Derek Rivera","character":"Santiago Reyes","img":null,"order":510},{"name":"Joe Holt","character":"Jesse","img":null,"order":529},{"name":"Heather Lynn Harris","character":"Gabrielle","img":null,"order":554},{"name":"Martin Bobb-Semple","character":"Orlando 'Lando' Johnson","img":null,"order":555},{"name":"Taylor Polidore","character":"Tootie","img":null,"order":579}],"tv:116450":[{"name":"Sarah Jessica Parker","character":"Carrie Bradshaw","img":null,"order":0},{"name":"Cynthia Nixon","character":"Miranda Hobbes","img":null,"order":1},{"name":"Kristin Davis","character":"Charlotte York","img":null,"order":2},{"name":"Sara Ramirez","character":"Che Diaz","img":null,"order":3},{"name":"Cathy Ang","character":"Lily Goldenblatt","img":null,"order":4},{"name":"Mario Cantone","character":"Anthony Marentino","img":null,"order":5},{"name":"Sarita Choudhury","character":"Seema Patel","img":null,"order":6},{"name":"Evan Handler","character":"Harry Goldenblatt","img":"yU0OXeZ0K1ppe1o9SpHiAMHy1rc.jpg","order":7},{"name":"Christopher Jackson","character":"Herbert Wexley","img":null,"order":8},{"name":"Nicole Ari Parker","character":"Lisa Todd Wexley","img":null,"order":9},{"name":"Alexa Swinton","character":"Rose 'Rock' Goldenblatt","img":null,"order":10},{"name":"Karen Pittman","character":"Dr. Nya Wallace","img":null,"order":11},{"name":"Niall Cunningham","character":"Brady Hobbes","img":null,"order":12},{"name":"Alexander Bello","character":"Henry Wexley","img":null,"order":515},{"name":"Sebastiano Pigazzi","character":"Giuseppe","img":null,"order":620}],"tv:70785":[{"name":"Amybeth McNulty","character":"Anne Shirley","img":null,"order":0},{"name":"Geraldine James","character":"Marilla Cuthbert","img":null,"order":1},{"name":"R.H. Thomson","character":"Matthew Cuthbert","img":null,"order":2},{"name":"Dalila Bela","character":"Diana Barry","img":null,"order":3},{"name":"Lucas Jade Zumann","character":"Gilbert Blythe","img":null,"order":4},{"name":"Corrine Koslo","character":"Rachel Lynde","img":null,"order":5},{"name":"Jonathan Holmes","character":"Mr. William Barry","img":null,"order":539},{"name":"Helen Johns","character":"Mrs. Eliza Barry","img":null,"order":543},{"name":"Lia Pappas-Kemps","character":"Jane Andrews","img":null,"order":567},{"name":"Kyla Matthews","character":"Ruby Gillis","img":null,"order":569},{"name":"Miranda McKeon","character":"Josie Pye","img":null,"order":570},{"name":"Jacob Horsley","character":"Charlie Sloane","img":null,"order":572},{"name":"Jacob Ursomarzo","character":"Moody Spurgeon","img":null,"order":573},{"name":"Dalmar Abuzeid","character":"Sebastian 'Bash' Lacroix","img":null,"order":574},{"name":"Aymeric Jett Montaz","character":"Jerry Baynard","img":null,"order":579}],"tv:39859":[{"name":"Ashley Rickards","character":"Jenna Hamilton","img":null,"order":0},{"name":"Beau Mirchoff","character":"Matty McKibben","img":null,"order":1},{"name":"Nikki DeLoach","character":"Lacey Hamilton","img":null,"order":2},{"name":"Jillian Rose Reed","character":"Tamara Kaplan","img":null,"order":4},{"name":"Brett Davern","character":"Jake Rosati","img":null,"order":5},{"name":"Molly Tarlov","character":"Sadie Saxton","img":null,"order":6},{"name":"Desi Lydic","character":"Valerie Marks","img":null,"order":7},{"name":"Jessica Lu","character":"Ming Huang","img":null,"order":8},{"name":"Greer Grammer","character":"Lissa Miller","img":null,"order":9},{"name":"Meaghan Jette Martin","character":"Julie #2","img":null,"order":10},{"name":"Wesam Keesh","character":"Kyle Cohen","img":null,"order":522},{"name":"Barret Swatek","character":"Ally Saxton","img":null,"order":617},{"name":"Monty Geer","character":"Cole","img":null,"order":642},{"name":"Evan Crooks","character":"Theo","img":null,"order":643},{"name":"Niko Pepaj","character":"Sergio","img":null,"order":694}],"tv:100412":[{"name":"Sophie Grace","character":"Kristy Thomas","img":null,"order":0},{"name":"Momona Tamada","character":"Claudia Kishi","img":null,"order":1},{"name":"Shay Rudolph","character":"Stacey McGill","img":null,"order":2},{"name":"Malia Baker","character":"Mary Anne Spier","img":null,"order":3},{"name":"Alicia Silverstone","character":"Elizabeth Thomas-Brewer","img":null,"order":500},{"name":"Mark Feuerstein","character":"Watson Brewer","img":null,"order":501},{"name":"Takayo Fischer","character":"Mimi Yamamoto","img":null,"order":502},{"name":"Benjamin Goas","character":"David Michael Thomas","img":null,"order":505},{"name":"Sophia Reid-Gantzert","character":"Karen Brewer","img":null,"order":509},{"name":"Marc Evan Jackson","character":"Richard Spier","img":null,"order":515},{"name":"Xochitl Gomez","character":"Dawn Schafer","img":null,"order":536},{"name":"Jessica Elaina Eason","character":"Sharon Porter","img":null,"order":537},{"name":"Vivian Watson","character":"Mallory Pike","img":null,"order":562},{"name":"Anais Lee","character":"Jessi Ramsey","img":null,"order":577},{"name":"Kyndra Sanchez","character":"Dawn Schafer","img":null,"order":587}],"tv:62704":[{"name":"Dwayne Johnson","character":"Spencer Strassmore","img":null,"order":0},{"name":"Rob Corddry","character":"Joe Krutel","img":null,"order":1},{"name":"John David Washington","character":"Ricky Jerret","img":null,"order":2},{"name":"Omar Benson Miller","character":"Charles Greane","img":null,"order":3},{"name":"Troy Garity","character":"Jason Antolotti","img":null,"order":4},{"name":"London Brown","character":"Reggie","img":null,"order":5},{"name":"Donovan W. Carter","character":"Vernon Littlefield","img":null,"order":6},{"name":"Arielle Kebbel","character":"Tracy Legette","img":null,"order":7},{"name":"Jazmyn Simon","character":"Julie Greane","img":null,"order":9},{"name":"Brittany S. Hall","character":"Amber","img":null,"order":10},{"name":"Dul\u00e9 Hill","character":"Larry Siefert","img":null,"order":501},{"name":"Richard Schiff","character":"Brett Anderson","img":null,"order":503},{"name":"Jay Glazer","character":"Jay Glazer","img":null,"order":647},{"name":"Carl McDowell","character":"TTD","img":null,"order":673},{"name":"Robert Wisdom","character":"Dennis","img":null,"order":674}],"tv:3216":[{"name":"Robert Blake","character":"Det. Tony Baretta","img":null,"order":0},{"name":"Dana Elcar","character":"Inspector Shiller","img":null,"order":1},{"name":"Sharon Cintron","character":"","img":null,"order":2},{"name":"Burt Young","character":"Solomon","img":null,"order":368},{"name":"Michael D. Roberts","character":"","img":null,"order":500},{"name":"Val Avery","character":"","img":null,"order":501},{"name":"Joshua Shelley","character":"","img":null,"order":502},{"name":"Timothy Carey","character":"","img":null,"order":554},{"name":"Sharon Cintron","character":"","img":null,"order":565},{"name":"Art Metrano","character":"","img":null,"order":649},{"name":"Tom Ewell","character":"","img":null,"order":667},{"name":"Strother Martin","character":"","img":null,"order":678},{"name":"Ron Thompson","character":"","img":null,"order":689},{"name":"Elisha Cook Jr.","character":"","img":null,"order":700},{"name":"Angelo Rossitto","character":"Little Moe (uncredited)","img":null,"order":708}],"tv:197373":[{"name":"Kaley Cuoco","character":"Ava Bartlett","img":null,"order":0},{"name":"Chris Messina","character":"Nathan Bartlett","img":null,"order":1},{"name":"Tom Bateman","character":"Matt Pierce","img":null,"order":2},{"name":"Priscilla Quintana","character":"Ruby Gale","img":null,"order":3},{"name":"Liana Liberato","character":"Tory Thompson","img":null,"order":4},{"name":"Li Jun Li","character":"Michelle","img":null,"order":502},{"name":"Annabelle Dexter-Jones","character":"Serena","img":null,"order":503},{"name":"Aisha Alfa","character":"Carolyn","img":null,"order":504},{"name":"Alex Alomar Akpobome","character":"Ryan","img":null,"order":505},{"name":"June Diane Raphael","character":"Romy Lipinski","img":null,"order":506},{"name":"Brandon Keener","character":"Paul","img":null,"order":507},{"name":"Miles Mussenden","character":"Detective Quincy Burrell","img":null,"order":508},{"name":"Jessica St. Clair","character":"Rochelle Lipinski","img":null,"order":519},{"name":"Sebastian Quinn","character":"Carlos","img":null,"order":521},{"name":"Melissa Fumero","character":"Drew","img":null,"order":559}],"tv:250504":[{"name":"Claire Danes","character":"Agatha 'Aggie' Wiggs","img":null,"order":0},{"name":"Matthew Rhys","character":"Nile Jarvis","img":null,"order":1},{"name":"Brittany Snow","character":"Nina Jarvis","img":null,"order":2},{"name":"Natalie Morales","character":"Shelley Morris","img":null,"order":3},{"name":"Jonathan Banks","character":"Martin Jarvis","img":null,"order":500},{"name":"Tim Guinee","character":"Rick 'Wrecking Ball' Jarvis","img":null,"order":501},{"name":"David Lyons","character":"Brian Abbott","img":null,"order":502},{"name":"Bubba Weiler","character":"Teddy Fenig","img":null,"order":503},{"name":"Deirdre O'Connell","character":"Carol McGiddish","img":null,"order":504},{"name":"Leonard Gerome","character":"Cooper Morris-Wiggs","img":null,"order":508},{"name":"Hettienne Park","character":"Erika Breton","img":null,"order":514},{"name":"Aleyse Shannon","character":"Olivia Benitez","img":null,"order":515},{"name":"Julie Ann Emery","character":"Lila Jarvis","img":null,"order":519},{"name":"Amir Arison","character":"Frank Breton","img":null,"order":520},{"name":"Peter Grosz","character":"Elijah Garrity","img":null,"order":526}],"tv:80587":[{"name":"Chris Houghton","character":"Cricket Green (voice)","img":null,"order":0},{"name":"Marieve Herington","character":"Tilly Green (voice)","img":null,"order":1},{"name":"Bob Joles","character":"Bill Green (voice)","img":null,"order":2},{"name":"Artemis Pebdani","character":"Alice 'Gramma' Green (voice)","img":null,"order":3},{"name":"Zeno Robinson","character":"Remy (voice)","img":null,"order":4},{"name":"Wendi McLendon-Covey","character":"Nancy Green (voice)","img":null,"order":5},{"name":"Raven-Symon\u00e9","character":"Maria Media (voice)","img":null,"order":503},{"name":"Anna Akana","character":"Gloria (voice)","img":null,"order":512},{"name":"Danny Trejo","character":"Vasquez (voice)","img":null,"order":513},{"name":"Andy Daly","character":"Officer Keys (voice)","img":null,"order":514},{"name":"Paul Scheer","character":"Chip Whistler (voice)","img":null,"order":516},{"name":"Vladimir Caama\u00f1o","character":"Juan Pablo (voice)","img":null,"order":517},{"name":"Jenna Ortega","character":"Gabriella Espinosa (voice)","img":null,"order":521},{"name":"Paul F. Tompkins","character":"Tooth Fairy (voice)","img":null,"order":523},{"name":"Shane Houghton","character":"Driver Construction Worker (voice)","img":null,"order":541}],"tv:69630":[{"name":"Katie Stevens","character":"Jane Sloan","img":null,"order":0},{"name":"Aisha Dee","character":"Kat Edison","img":null,"order":1},{"name":"Meghann Fahy","character":"Sutton Brady","img":null,"order":2},{"name":"Melora Hardin","character":"Jacqueline Carlyle","img":null,"order":3},{"name":"Stephen Conrad Moore","character":"Oliver Grayson","img":null,"order":4},{"name":"Matt Ward","character":"Alex Crawford","img":null,"order":5},{"name":"Sam Page","character":"Richard 'Dick' Mott","img":null,"order":6},{"name":"Nikohl Boosheri","character":"Aden 'Adena' El Amin","img":null,"order":7},{"name":"Luca James Lee","character":"Ben Chau","img":null,"order":625},{"name":"Alexis Floyd","character":"Tia Clayton","img":null,"order":699},{"name":"Peter Vack","character":"Patrick Duchand","img":null,"order":711},{"name":"Adam Capriolo","character":"Andrew Morrison","img":null,"order":856},{"name":"Stephanie Costa","character":"Sage Aiello","img":null,"order":857},{"name":"Dan Jeannotte","character":"Ryan Decker","img":null,"order":859},{"name":"Gildart Jackson","character":"Ian Carlyle","img":null,"order":860}],"tv:92621":[{"name":"Joseph Gilgun","character":"Vincent \"Vinnie\" O'Neill","img":null,"order":0},{"name":"Michelle Keegan","character":"Erin Croft","img":null,"order":1},{"name":"Steve Evets","character":"Jim","img":null,"order":2},{"name":"Damien Molony","character":"Dylan Golding","img":null,"order":3},{"name":"Bronagh Gallagher","character":"Carol Dennings","img":null,"order":4},{"name":"Tom Hanson","character":"Leslie \"Cardi\" Titt","img":null,"order":5},{"name":"Aaron Heffernan","character":"Ashley \"Ash\" Dennings","img":null,"order":6},{"name":"Joanna Higson","character":"Mary \"Sugar\" Jones","img":null,"order":7},{"name":"Parth Thakerar","character":"Jehan \"JJ\" Jovani","img":null,"order":8},{"name":"Ryan Sampson","character":"Thomas \"Tommo\" Schaffner","img":null,"order":9},{"name":"Dominic West","character":"Dr. Chris Coxley","img":null,"order":10},{"name":"Ramon Tikaram","character":"Terence McCann","img":null,"order":11},{"name":"Tadhg Murphy","character":"Gary Cullen","img":null,"order":14},{"name":"Neil Ashton","character":"David \"Davey\" MacDonagh","img":null,"order":16},{"name":"Jude Riordan","character":"Tyler Croft","img":null,"order":501}],"movie:4982":[{"name":"Denzel Washington","character":"Frank Lucas","img":null,"order":0},{"name":"Russell Crowe","character":"Richie Roberts","img":null,"order":1},{"name":"Josh Brolin","character":"Detective Trupo","img":null,"order":2},{"name":"Chiwetel Ejiofor","character":"Huey Lucas","img":null,"order":3},{"name":"Cuba Gooding Jr.","character":"Nicky Barnes","img":null,"order":4},{"name":"Lymari Nadal","character":"Eva","img":null,"order":5},{"name":"Ted Levine","character":"Lou Toback","img":null,"order":6},{"name":"Armand Assante","character":"Dominic Cattano","img":null,"order":7},{"name":"John Ortiz","character":"Javier J. Rivera","img":null,"order":8},{"name":"John Hawkes","character":"Freddie Spearman","img":null,"order":9},{"name":"RZA","character":"Moses Jones","img":null,"order":10},{"name":"Yul Vazquez","character":"Alfonso Abruzzo","img":null,"order":11},{"name":"Ruby Dee","character":"Mama Lucas","img":null,"order":12},{"name":"Idris Elba","character":"Tango","img":null,"order":13},{"name":"Carla Gugino","character":"Laurie Roberts","img":null,"order":14}],"movie:703":[{"name":"Diane Keaton","character":"Annie Hall","img":null,"order":0},{"name":"Woody Allen","character":"Alvy Singer","img":null,"order":1},{"name":"Tony Roberts","character":"Rob","img":null,"order":2},{"name":"Carol Kane","character":"Allison","img":null,"order":3},{"name":"Paul Simon","character":"Tony Lacey","img":null,"order":4},{"name":"Shelley Duvall","character":"Pam","img":null,"order":5},{"name":"Janet Margolin","character":"Robin","img":null,"order":6},{"name":"Colleen Dewhurst","character":"Mrs. Hall","img":null,"order":7},{"name":"Christopher Walken","character":"Duane Hall","img":null,"order":8},{"name":"Donald Symington","character":"Dad Hall","img":null,"order":9},{"name":"Helen Ludlam","character":"Grammy Hall","img":null,"order":10},{"name":"Mordecai Lawner","character":"Alvy's Dad","img":null,"order":11},{"name":"Joan Neuman","character":"Alvy's Mom","img":null,"order":12},{"name":"Jonathan Munk","character":"Alvy - Age 9","img":null,"order":13},{"name":"Ruth Volner","character":"Alvy's Aunt","img":null,"order":14}],"movie:28":[{"name":"Martin Sheen","character":"Captain Benjamin Willard","img":null,"order":0},{"name":"Marlon Brando","character":"Colonel Walter Kurtz","img":null,"order":1},{"name":"Frederic Forrest","character":"Jay 'Chef' Hicks","img":null,"order":2},{"name":"Albert Hall","character":"Chief Phillips","img":null,"order":3},{"name":"Laurence Fishburne","character":"Tyrone 'Clean' Miller","img":null,"order":4},{"name":"Sam Bottoms","character":"Lance B. Johnson","img":null,"order":5},{"name":"Robert Duvall","character":"Lieutenant Colonel Bill Kilgore","img":null,"order":6},{"name":"G.D. Spradlin","character":"General Corman","img":null,"order":7},{"name":"Harrison Ford","character":"Colonel","img":null,"order":8},{"name":"Jerry Ziesmer","character":"Jerry, Civilian","img":null,"order":9},{"name":"Scott Glenn","character":"Lieutenant Richard M. Colby","img":null,"order":10},{"name":"Kerry Rossall","character":"Mike from San Diego","img":null,"order":11},{"name":"James Keane","character":"Kilgore's Gunner","img":null,"order":12},{"name":"Tom Mason","character":"Supply Sergeant","img":null,"order":13},{"name":"Cynthia Wood","character":"Playmate of the Year","img":null,"order":14}],"movie:329865":[{"name":"Amy Adams","character":"Louise Banks","img":null,"order":0},{"name":"Jeremy Renner","character":"Ian Donnelly","img":null,"order":1},{"name":"Forest Whitaker","character":"Colonel Weber","img":null,"order":2},{"name":"Michael Stuhlbarg","character":"Agent Halpern","img":null,"order":3},{"name":"Mark O'Brien","character":"Captain Marks","img":null,"order":4},{"name":"Tzi Ma","character":"General Shang","img":null,"order":5},{"name":"Abigail Pniowsky","character":"8-Year-Old Hannah","img":null,"order":6},{"name":"Julia Scarlett Dan","character":"12-Year-Old Hannah","img":null,"order":7},{"name":"Jadyn Malone","character":"6-Year-Old Hannah","img":null,"order":8},{"name":"Frank Schorpion","character":"Dr. Kettler","img":null,"order":9},{"name":"Lucas Chartier-Dessert","character":"Private Lasky","img":null,"order":10},{"name":"Christian Jadah","character":"Private Combs","img":null,"order":11},{"name":"Lucy Van Oldenbarneveld","character":"CNAC Anchor","img":null,"order":12},{"name":"Andrew Shaver","character":"Environmental Tech","img":null,"order":13},{"name":"Pat Kiely","character":"Environmental Tech","img":null,"order":14}],"movie:980431":[{"name":"Eric Nam","character":"Aang (voice)","img":null,"order":0},{"name":"Dave Bautista","character":"Tagah (voice)","img":null,"order":1},{"name":"Jessica Matten","character":"Katara (voice)","img":null,"order":2},{"name":"Rom\u00e1n Zaragoza","character":"Sokka (voice)","img":null,"order":3},{"name":"Steven Yeun","character":"Zuko (voice)","img":null,"order":4},{"name":"Dionne Quan","character":"Toph (voice)","img":null,"order":5},{"name":"Dee Bradley Baker","character":"Appa / Momo (voice)","img":null,"order":6},{"name":"Freida Pinto","character":"Avatar Sonam (voice)","img":null,"order":7},{"name":"Ke Huy Quan","character":"Avatar Xian (voice)","img":null,"order":8},{"name":"Taika Waititi","character":"Gorillavark (voice)","img":null,"order":9},{"name":"Geraldine Viswanathan","character":"Kallik (voice)","img":null,"order":10},{"name":"Peta Sergeant","character":"Jie (voice)","img":null,"order":11},{"name":"Jonathan Ohye","character":"Yuddah (voice)","img":null,"order":12},{"name":"Vic Chao","character":"Hong (voice)","img":null,"order":13},{"name":"Fran\u00e7ois Chau","character":"Monk Gyatso (voice)","img":null,"order":14}],"movie:424694":[{"name":"Rami Malek","character":"Freddie Mercury","img":null,"order":0},{"name":"Gwilym Lee","character":"Brian May","img":null,"order":1},{"name":"Ben Hardy","character":"Roger Taylor","img":null,"order":2},{"name":"Joseph Mazzello","character":"John Deacon","img":null,"order":3},{"name":"Lucy Boynton","character":"Mary Austin","img":null,"order":4},{"name":"Aidan Gillen","character":"John Reid","img":null,"order":5},{"name":"Allen Leech","character":"Paul Prenter","img":null,"order":6},{"name":"Tom Hollander","character":"Jim Beach","img":null,"order":7},{"name":"Mike Myers","character":"Ray Foster","img":null,"order":8},{"name":"Aaron McCusker","character":"Jim Hutton","img":null,"order":9},{"name":"Meneka Das","character":"Jer Bulsara","img":null,"order":10},{"name":"Ace Bhatti","character":"Bomi Bulsara","img":null,"order":11},{"name":"Priya Blackburn","character":"Kashmira Bulsara","img":null,"order":12},{"name":"Max Bennett","character":"David","img":null,"order":13},{"name":"Dermot Murphy","character":"Bob Geldof","img":null,"order":14}],"movie:508883":[{"name":"Soma Santoki","character":"Mahito Maki (voice)","img":null,"order":0},{"name":"Masaki Suda","character":"Gray Heron (voice)","img":null,"order":1},{"name":"Ko Shibasaki","character":"Kiriko (voice)","img":null,"order":2},{"name":"Aimyon","character":"Lady Himi (voice)","img":null,"order":3},{"name":"Yoshino Kimura","character":"Natsuko (voice)","img":null,"order":4},{"name":"Takuya Kimura","character":"Shoichi Maki (voice)","img":null,"order":5},{"name":"Keiko Takeshita","character":"Izumi (voice)","img":null,"order":6},{"name":"Jun Fubuki","character":"Utako (voice)","img":null,"order":7},{"name":"Sawako Agawa","character":"Eriko (voice)","img":null,"order":8},{"name":"Karen Takizawa","character":"Warawara (voice)","img":null,"order":9},{"name":"Shinobu Otake","character":"Aiko (voice)","img":null,"order":10},{"name":"Jun Kunimura","character":"The Parakeet King (voice)","img":null,"order":11},{"name":"Kaoru Kobayashi","character":"Noble Pelican (voice)","img":null,"order":12},{"name":"Sh\u014dhei Hino","character":"Granduncle (voice)","img":null,"order":13}],"movie:197":[{"name":"Mel Gibson","character":"William Wallace","img":null,"order":0},{"name":"Catherine McCormack","character":"Murron MacClannough","img":null,"order":1},{"name":"Sophie Marceau","character":"Princess Isabelle","img":null,"order":2},{"name":"Patrick McGoohan","character":"King Edward","img":null,"order":3},{"name":"Angus Macfadyen","character":"Robert the Bruce","img":null,"order":4},{"name":"Brendan Gleeson","character":"Hamish Campbell","img":null,"order":5},{"name":"James Robinson","character":"Young William Wallace","img":null,"order":6},{"name":"James Cosmo","character":"Campbell","img":null,"order":7},{"name":"Sean McGinley","character":"MacClannough","img":null,"order":8},{"name":"Gerda Stevenson","character":"Mother MacClannough","img":null,"order":9},{"name":"Mhairi Calvey","character":"Young Murron MacClannough","img":null,"order":10},{"name":"Jeanne Marine","character":"Nicolette","img":null,"order":11},{"name":"Sean Lawlor","character":"Malcolm Wallace","img":null,"order":12},{"name":"Sandy Nelson","character":"John Wallace","img":null,"order":13},{"name":"Alan Tall","character":"Elder Stewart","img":null,"order":14}],"movie:2108":[{"name":"Emilio Estevez","character":"Andrew Clark","img":null,"order":0},{"name":"Judd Nelson","character":"John Bender","img":null,"order":1},{"name":"Molly Ringwald","character":"Claire Standish","img":null,"order":2},{"name":"Anthony Michael Hall","character":"Brian Johnson","img":null,"order":3},{"name":"Ally Sheedy","character":"Allison Reynolds","img":null,"order":4},{"name":"Paul Gleason","character":"Richard Vernon","img":null,"order":5},{"name":"John Kapelos","character":"Carl","img":null,"order":6},{"name":"Perry Crawford","character":"Allison's Father","img":null,"order":7},{"name":"Mary Christian","character":"Brian's Sister","img":null,"order":8},{"name":"Ron Dean","character":"Andy's Father","img":null,"order":9},{"name":"Tim Gamble","character":"Claire's Father","img":null,"order":10},{"name":"Fran Gargano","character":"Allison's Mom","img":null,"order":11},{"name":"Mercedes Hall","character":"Brian's Mom","img":null,"order":12},{"name":"John Hughes","character":"Brian\u2019s Father (uncredited)","img":null,"order":13},{"name":"Jonathan Chapin","character":"Janitor (uncredited)","img":null,"order":14}],"movie:10515":[{"name":"Keiko Yokozawa","character":"Sheeta (voice)","img":null,"order":0},{"name":"Mayumi Tanaka","character":"Pazu (voice)","img":null,"order":1},{"name":"Minori Terada","character":"Muska (voice)","img":null,"order":2},{"name":"Kotoe Hatsui","character":"Dola (voice)","img":null,"order":3},{"name":"Fujio Tokita","character":"Uncle Pomme (voice)","img":null,"order":4},{"name":"Ichiro Nagai","character":"Shogun Mouro (voice)","img":null,"order":5},{"name":"Hiroshi Ito","character":"Oyakata (voice)","img":null,"order":6},{"name":"Machiko Washio","character":"Okami (voice)","img":null,"order":7},{"name":"Takuz\u014d Kamiyama","character":"Charles / Shalulu (voice)","img":null,"order":8},{"name":"Yoshito Yasuhara","character":"Louis (voice)","img":null,"order":9},{"name":"Sukekiyo Kameyama","character":"Henri (voice)","img":null,"order":10},{"name":"Eken Mine","character":"Underling (voice)","img":null,"order":11},{"name":"TARAKO","character":"Madge (voice)","img":null,"order":12},{"name":"Masashi Sugawara","character":"Underling (voice)","img":null,"order":13},{"name":"Reiko Suzuki","character":"Sheeta's Grandmother (voice)","img":null,"order":14}],"movie:15":[{"name":"Orson Welles","character":"Charles Foster Kane","img":null,"order":0},{"name":"Joseph Cotten","character":"Jedediah Leland","img":null,"order":1},{"name":"Dorothy Comingore","character":"Susan Alexander Kane","img":null,"order":2},{"name":"Ray Collins","character":"Jim W. Gettys","img":null,"order":3},{"name":"George Coulouris","character":"Walter Parks Thatcher","img":null,"order":4},{"name":"Agnes Moorehead","character":"Mary Kane","img":null,"order":5},{"name":"Paul Stewart","character":"Raymond","img":null,"order":6},{"name":"Ruth Warrick","character":"Emily Norton Kane","img":null,"order":7},{"name":"Erskine Sanford","character":"Herbert Carter","img":null,"order":8},{"name":"William Alland","character":"Jerry Thompson/Narrator","img":null,"order":9},{"name":"Everett Sloane","character":"Mr. Bernstein","img":null,"order":10},{"name":"Fortunio Bonanova","character":"Signor Matiste","img":null,"order":11},{"name":"Gus Schilling","character":"John","img":null,"order":12},{"name":"Philip Van Zandt","character":"Mr. Rawlston","img":null,"order":13},{"name":"Georgia Backus","character":"Bertha Anderson","img":null,"order":14}],"movie:974576":[{"name":"Ralph Fiennes","character":"Lawrence","img":null,"order":0},{"name":"Stanley Tucci","character":"Bellini","img":null,"order":1},{"name":"John Lithgow","character":"Tremblay","img":null,"order":2},{"name":"Isabella Rossellini","character":"Sister Agnes","img":null,"order":3},{"name":"Lucian Msamati","character":"Adeyemi","img":null,"order":4},{"name":"Carlos Diehz","character":"Benitez","img":null,"order":5},{"name":"Sergio Castellitto","character":"Tedesco","img":null,"order":6},{"name":"Br\u00edan F. O'Byrne","character":"O'Malley","img":null,"order":7},{"name":"Merab Ninidze","character":"Sabbadin","img":null,"order":8},{"name":"Thomas Loibl","character":"Mandorff","img":null,"order":9},{"name":"Jacek Koman","character":"Wozniak","img":null,"order":10},{"name":"Bruno Novelli","character":"Dead Pope","img":null,"order":11},{"name":"Rony Kramer","character":"Mendoza","img":null,"order":12},{"name":"Valerio Da Silva","character":"Father Haas","img":null,"order":13},{"name":"Joseph Mydell","character":"Nakitanda","img":null,"order":14}],"movie:567797":[{"name":"Eric Bana","character":"Aaron Falk","img":null,"order":0},{"name":"Genevieve O'Reilly","character":"Gretchen Schoner","img":"4eOoW1PNBZfxRW0g8RmYH8bL6mq.jpg","order":1},{"name":"Keir O'Donnell","character":"Greg Raco","img":null,"order":2},{"name":"John Polson","character":"Scott Whitlam","img":null,"order":3},{"name":"Matt Nable","character":"Grant Dow","img":null,"order":4},{"name":"Eddie Baroo","character":"McMurdo","img":null,"order":5},{"name":"Martin Dingle Wall","character":"Luke Hadler","img":null,"order":6},{"name":"Julia Blake","character":"Barb Hadler","img":null,"order":7},{"name":"Bruce Spence","character":"Gerry Hadler","img":null,"order":8},{"name":"BeBe Bettencourt","character":"Ellie Deacon","img":null,"order":9},{"name":"Jeremy Lindsay Taylor","character":"Erik Falk","img":null,"order":10},{"name":"Miranda Tapsell","character":"Rita Raco","img":null,"order":11},{"name":"Renee Lim","character":"Sandra Whitlam","img":null,"order":12},{"name":"James Frecheville","character":"Sullivan","img":null,"order":13},{"name":"William Zappa","character":"Mal Deacon","img":null,"order":14}],"movie:37735":[{"name":"Emma Stone","character":"Olive","img":null,"order":0},{"name":"Penn Badgley","character":"Woodchuck Todd","img":null,"order":1},{"name":"Amanda Bynes","character":"Marianne","img":null,"order":2},{"name":"Dan Byrd","character":"Brandon","img":null,"order":3},{"name":"Thomas Haden Church","character":"Mr. Griffith","img":null,"order":4},{"name":"Patricia Clarkson","character":"Rosemary","img":null,"order":5},{"name":"Cam Gigandet","character":"Micah","img":null,"order":6},{"name":"Lisa Kudrow","character":"Mrs. Griffith","img":null,"order":7},{"name":"Malcolm McDowell","character":"Principal Gibbons","img":null,"order":8},{"name":"Aly Michalka","character":"Rhiannon","img":null,"order":9},{"name":"Stanley Tucci","character":"Dill","img":null,"order":10},{"name":"Fred Armisen","character":"Pastor","img":null,"order":11},{"name":"Juliette Goglia","character":"Eighth Grade Olive","img":null,"order":12},{"name":"Jake Sandvig","character":"Anson","img":null,"order":13},{"name":"Morgan Rusler","character":"Mr. Abernathy","img":null,"order":14}],"movie:559969":[{"name":"Aaron Paul","character":"Jesse","img":null,"order":0},{"name":"Jesse Plemons","character":"Todd","img":null,"order":1},{"name":"Charles Baker","character":"Skinny Pete","img":null,"order":2},{"name":"Matt Jones","character":"Badger","img":null,"order":3},{"name":"Scott MacArthur","character":"Neil","img":null,"order":4},{"name":"Larry Hankin","character":"Old Joe","img":null,"order":5},{"name":"Scott Shepherd","character":"Casey","img":null,"order":6},{"name":"Tom Bower","character":"Lou","img":null,"order":7},{"name":"Robert Forster","character":"Ed","img":null,"order":8},{"name":"Jonathan Banks","character":"Mike","img":null,"order":9},{"name":"Bryan Cranston","character":"Walt","img":null,"order":10},{"name":"Krysten Ritter","character":"Jane","img":null,"order":11},{"name":"Kevin Rankin","character":"Kenny","img":null,"order":12},{"name":"Tess Harper","character":"Mrs. Pinkman","img":null,"order":13},{"name":"Michael Bofshever","character":"Mr. Pinkman","img":null,"order":14}],"movie:715931":[{"name":"Will Smith","character":"Peter","img":null,"order":0},{"name":"Ben Foster","character":"Jim Fassel","img":null,"order":1},{"name":"Charmaine Bingwa","character":"Dodienne","img":null,"order":2},{"name":"Gilbert Owuor","character":"Gordon","img":null,"order":3},{"name":"Ronnie Gene Blevins","character":"Harrington","img":null,"order":4},{"name":"Aaron Moten","character":"Knowls","img":null,"order":5},{"name":"Jabbar Lewis","character":"Tomas","img":null,"order":6},{"name":"Michael Luwoye","character":"John","img":null,"order":7},{"name":"Steven Ogg","character":"Howard","img":null,"order":8},{"name":"Grant Harvey","character":"Leeds","img":null,"order":9},{"name":"Mustafa Shakir","character":"Lt. Andrew Cailloux","img":null,"order":10},{"name":"Paul Ben-Victor","character":"Major G. Halstead","img":null,"order":11},{"name":"David Denman","character":"General William Dwight","img":null,"order":12},{"name":"Jesse C. Boyd","character":"Mike Hurley","img":null,"order":13},{"name":"Imani Pullum","character":"Betsy","img":null,"order":14}],"movie:445629":[{"name":"Florence Pugh","character":"Saraya 'Paige' Knight","img":null,"order":0},{"name":"Lena Headey","character":"Julia 'Sweet Saraya' Knight","img":null,"order":1},{"name":"Nick Frost","character":"Patrick 'Rowdy Ricky' Knight","img":null,"order":2},{"name":"Vince Vaughn","character":"Hutch Morgan","img":null,"order":3},{"name":"Jack Lowden","character":"Zak 'Zodiac' Knight","img":null,"order":4},{"name":"Dwayne Johnson","character":"Dwayne 'The Rock' Johnson","img":null,"order":5},{"name":"Thea Trinidad","character":"AJ Lee","img":null,"order":6},{"name":"Aqueela Zoll","character":"Kirsten","img":null,"order":7},{"name":"Stephen Merchant","character":"Hugh","img":null,"order":8},{"name":"Julia Davis","character":"Daphne","img":null,"order":9},{"name":"James Burrows","character":"Roy Knight","img":null,"order":10},{"name":"Hannah Rae","character":"Courtney","img":null,"order":11},{"name":"Kim Matula","character":"Jeri-Lynn","img":null,"order":12},{"name":"Paul Wight","character":"Big Show","img":null,"order":13},{"name":"Stephen Farrelly","character":"Sheamus","img":null,"order":14}],"movie:832262":[{"name":"Eric Bana","character":"Aaron Falk","img":null,"order":0},{"name":"Anna Torv","character":"Alice","img":null,"order":1},{"name":"Deborra-Lee Furness","character":"Jill Bailey","img":null,"order":2},{"name":"Robin McLeavy","character":"Lauren","img":null,"order":3},{"name":"Sisi Stringer","character":"Beth","img":null,"order":4},{"name":"Lucy Ansell","character":"Bree","img":null,"order":5},{"name":"Jacqueline McKenzie","character":"Carmen Cooper","img":null,"order":6},{"name":"Jeremy Lindsay Taylor","character":"Erik Falk","img":null,"order":7},{"name":"Richard Roxburgh","character":"Daniel Bailey","img":null,"order":8},{"name":"Tony Briggs","character":"Ian Chase","img":null,"order":9},{"name":"Kenneth Radley","character":"Sergeant King","img":null,"order":10},{"name":"Ash Ricardo","character":"Jennifer Falk","img":null,"order":11},{"name":"Ingrid Torelli","character":"Margot","img":null,"order":12},{"name":"Matilda May Pawsey","character":"Rebecca","img":null,"order":13},{"name":"Archie Thomson","character":"Aaron Falk","img":null,"order":14}],"movie:316152":[{"name":"Matthew McConaughey","character":"Newton Knight","img":null,"order":0},{"name":"Gugu Mbatha-Raw","character":"Rachel","img":null,"order":1},{"name":"Mahershala Ali","character":"Moses Washington","img":null,"order":2},{"name":"Keri Russell","character":"Serena Knight","img":null,"order":3},{"name":"Jacob Lofland","character":"Daniel","img":null,"order":4},{"name":"Sean Bridgers","character":"Sumrall","img":null,"order":5},{"name":"Brad Carter","character":"Lieutenant Barbour","img":null,"order":6},{"name":"Jane McNeill","character":"Miss Ellie","img":null,"order":7},{"name":"Gary Grubbs","character":"Prosecuting Attorney","img":null,"order":8},{"name":"Christopher Berry","character":"Jasper","img":null,"order":9},{"name":"Joe Chrest","character":"Amos Deason","img":null,"order":10},{"name":"David Jensen","character":"Quitman","img":null,"order":11},{"name":"Kurt Krause","character":"Injured Soldier","img":null,"order":12},{"name":"Carlton Caudle","character":"Confederate Color Guard","img":null,"order":13},{"name":"Martin Bats Bradford","character":"Freedman 1","img":null,"order":14}],"movie:83389":[{"name":"Masami Nagasawa","character":"Umi Matsuzaki (voice)","img":null,"order":0},{"name":"Junichi Okada","character":"Shun Kazama / Yuichiro Sawamura (voice)","img":null,"order":1},{"name":"Keiko Takeshita","character":"Hana Matsuzaki (voice)","img":null,"order":2},{"name":"Yuriko Ishida","character":"Miki Hokuto (voice)","img":null,"order":3},{"name":"Rumi Hiiragi","character":"Sachiko Hirokoji (voice)","img":null,"order":4},{"name":"Jun Fubuki","character":"Ryoko Matsuzaki (voice)","img":null,"order":5},{"name":"Takashi Naito","character":"Yoshio Onodera (voice)","img":null,"order":6},{"name":"Shunsuke Kazama","character":"Shiro Mizunuma / Hiroshi Tachibana (voice)","img":null,"order":7},{"name":"Nao Omori","character":"Akio Kazama (voice)","img":null,"order":8},{"name":"Teruyuki Kagawa","character":"Tokumaru Board Chairman (voice)","img":null,"order":9},{"name":"Haruka Shiraishi","character":"Sora Matsuzaki (voice)","img":null,"order":10},{"name":"Tsubasa Kobayashi","character":"Riku Matsuzaki (voice)","img":null,"order":11},{"name":"Ayako Ito","character":"Secretary to the Tokumaru Board Chairman (voice)","img":null,"order":12},{"name":"Naoya Fujimaki","character":"Tokumaru Front Desk Receptionist (voice)","img":null,"order":13},{"name":"Toshimi Kanno","character":"Nobuko (voice)","img":null,"order":14}],"movie:9323":[{"name":"Atsuko Tanaka","character":"Motoko Kusanagi (voice)","img":null,"order":0},{"name":"Akio Otsuka","character":"Batou (voice)","img":null,"order":1},{"name":"Iemasa Kayumi","character":"Puppetmaster (voice)","img":null,"order":2},{"name":"Koichi Yamadera","character":"Togusa (voice)","img":"biov5jENp2XYfehkcdnRvi796Wi.jpg","order":3},{"name":"Yutaka Nakano","character":"Ishikawa (voice)","img":null,"order":4},{"name":"Tamio Ohki","character":"Aramaki (voice)","img":null,"order":5},{"name":"Tessyo Genda","character":"Director Nakamura (voice)","img":null,"order":6},{"name":"Masakazu Namaki","character":"Dr. Willis (voice)","img":null,"order":7},{"name":"Masato Yamanouchi","character":"Minister of Foreign Affairs (voice)","img":null,"order":8},{"name":"Shinji Ogawa","character":"Gaik\u00f4kan (voice)","img":null,"order":9},{"name":"Mitsuru Miyamoto","character":"Mizuho Daita (voice)","img":null,"order":10},{"name":"Kazuhiro Yamaji","character":"Garbage Collector A (voice)","img":null,"order":11},{"name":"Shigeru Chiba","character":"Garbage Collector B (voice)","img":"mF5YJxApTYKBVFJDrEC9IrJ5ooV.jpg","order":12},{"name":"Hiroshi Yanaka","character":"Coroner (voice)","img":null,"order":13},{"name":"Ginzo Matsuo","character":"Old Man (voice)","img":null,"order":14}],"movie:12140":[{"name":"Atsuko Tanaka","character":"Motoko Kusanagi (voice)","img":null,"order":0},{"name":"Akio Otsuka","character":"Batou (voice)","img":null,"order":1},{"name":"Koichi Yamadera","character":"Togusa (voice)","img":"biov5jENp2XYfehkcdnRvi796Wi.jpg","order":2},{"name":"Tamio Ohki","character":"Section 9 Department Chief Aramaki (voice)","img":null,"order":3},{"name":"Yutaka Nakano","character":"Ishikawa (voice)","img":null,"order":4},{"name":"Hiroaki Hirata","character":"Koga (voice)","img":null,"order":5},{"name":"Masaki Terasoma","character":"Azuma (voice)","img":null,"order":6},{"name":"Sumi Mutoh","character":"Mysterious Woman (voice)","img":null,"order":7},{"name":"Yuzuru Fujimoto","character":"Criminal (voice)","img":null,"order":8},{"name":"Sukekiyo Kameyama","character":"Rin (voice)","img":null,"order":9},{"name":"Ryuji Nakagi","character":"Wakabayashi (voice)","img":null,"order":10},{"name":"Fumihiko Tachiki","character":"Bodyguard (voice)","img":null,"order":11},{"name":"Minoru Hirano","character":"Cyborg Doctor (voice)","img":null,"order":12},{"name":"Katsunosuke Hori","character":"Chief of Forensics Division (voice)","img":null,"order":13},{"name":"Naoto Takenaka","character":"Kim (voice)","img":null,"order":14}],"movie:238":[{"name":"Marlon Brando","character":"Don Vito Corleone","img":null,"order":0},{"name":"Al Pacino","character":"Michael Corleone","img":null,"order":1},{"name":"James Caan","character":"Sonny Corleone","img":null,"order":2},{"name":"Robert Duvall","character":"Tom Hagen","img":null,"order":3},{"name":"Richard S. Castellano","character":"Clemenza","img":null,"order":4},{"name":"Diane Keaton","character":"Kay Adams","img":null,"order":5},{"name":"Talia Shire","character":"Connie Corleone Rizzi","img":null,"order":6},{"name":"Gianni Russo","character":"Carlo Rizzi","img":null,"order":7},{"name":"Sterling Hayden","character":"Captain McCluskey","img":null,"order":8},{"name":"John Marley","character":"Jack Woltz","img":null,"order":9},{"name":"Richard Conte","character":"Barzini","img":null,"order":10},{"name":"Al Lettieri","character":"Sollozzo","img":null,"order":11},{"name":"Abe Vigoda","character":"Sal Tessio","img":null,"order":12},{"name":"John Cazale","character":"Fredo Corleone","img":null,"order":13},{"name":"Rudy Bond","character":"Cuneo","img":null,"order":14}],"movie:240":[{"name":"Al Pacino","character":"Don Michael Corleone","img":null,"order":0},{"name":"Robert Duvall","character":"Tom Hagen","img":null,"order":1},{"name":"Diane Keaton","character":"Kay Corleone","img":null,"order":2},{"name":"Robert De Niro","character":"Vito Corleone","img":null,"order":3},{"name":"John Cazale","character":"Frederico 'Fredo' Corleone","img":null,"order":4},{"name":"Talia Shire","character":"Constanzia 'Connie' Corleone","img":null,"order":5},{"name":"Lee Strasberg","character":"Hyman Roth","img":null,"order":6},{"name":"Michael V. Gazzo","character":"Frank Pentangeli","img":null,"order":7},{"name":"G.D. Spradlin","character":"Senator Pat Geary","img":null,"order":8},{"name":"Richard Bright","character":"Al Neri","img":null,"order":9},{"name":"Gastone Moschin","character":"Don Fanucci","img":null,"order":10},{"name":"Tom Rosqui","character":"Rocco Lampone","img":null,"order":11},{"name":"Bruno Kirby","character":"Young Clemenza","img":null,"order":12},{"name":"Frank Sivero","character":"Genco Abbandando","img":null,"order":13},{"name":"Francesca De Sapio","character":"Young Mama Corleone","img":null,"order":14}],"movie:242":[{"name":"Al Pacino","character":"Michael Corleone","img":null,"order":0},{"name":"Diane Keaton","character":"Kay Adams","img":null,"order":1},{"name":"Talia Shire","character":"Connie Corleone Rizzi","img":null,"order":2},{"name":"Andy Garcia","character":"Vincent Mancini","img":null,"order":3},{"name":"Eli Wallach","character":"Don Altobello","img":null,"order":4},{"name":"Joe Mantegna","character":"Joey Zasa","img":null,"order":5},{"name":"George Hamilton","character":"B.J. Harrison","img":null,"order":6},{"name":"Bridget Fonda","character":"Grace Hamilton","img":null,"order":7},{"name":"Sofia Coppola","character":"Mary Corleone","img":null,"order":8},{"name":"Raf Vallone","character":"Cardinal Lamberto","img":null,"order":9},{"name":"Franc D'Ambrosio","character":"Anthony Corleone","img":null,"order":10},{"name":"Donal Donnelly","character":"Archbishop Gilday","img":null,"order":11},{"name":"Richard Bright","character":"Al Neri","img":null,"order":12},{"name":"Helmut Berger","character":"Frederick Keinszig","img":null,"order":13},{"name":"Don Novello","character":"Dominic Abbandando","img":null,"order":14}],"movie:5279":[{"name":"Maggie Smith","character":"Constance Trentham","img":null,"order":0},{"name":"Michael Gambon","character":"William McCordle","img":null,"order":1},{"name":"Kristin Scott Thomas","character":"Sylvia McCordle","img":null,"order":2},{"name":"Camilla Rutherford","character":"Isobel McCordle","img":null,"order":3},{"name":"Charles Dance","character":"Raymond Stockbridge","img":null,"order":4},{"name":"Geraldine Somerville","character":"Louisa Stockbridge","img":null,"order":5},{"name":"Tom Hollander","character":"Anthony Meredith","img":null,"order":6},{"name":"Natasha Wightman","character":"Lavinia Meredith","img":null,"order":7},{"name":"Jeremy Northam","character":"Ivor Novello","img":null,"order":8},{"name":"Bob Balaban","character":"Morris Weissman","img":null,"order":9},{"name":"James Wilby","character":"Freddie Nesbitt","img":null,"order":10},{"name":"Claudie Blakley","character":"Mabel Nesbitt","img":null,"order":11},{"name":"Laurence Fox","character":"Rupert Standish","img":null,"order":12},{"name":"Trent Ford","character":"Jeremy Blond","img":null,"order":13},{"name":"Ryan Phillippe","character":"Henry Denton","img":null,"order":14}],"movie:12477":[{"name":"Tsutomu Tatsumi","character":"Seita (voice)","img":null,"order":0},{"name":"Ayano Shiraishi","character":"Setsuko (voice)","img":null,"order":1},{"name":"Yoshiko Shinohara","character":"Mother (voice)","img":null,"order":2},{"name":"Akemi Yamaguchi","character":"Auntie (voice)","img":null,"order":3},{"name":"Masayo Sakai","character":"Woman who takes care of Setsuko (voice)","img":null,"order":4},{"name":"Kozo Hashida","character":"Obayashi Chairman (voice)","img":null,"order":5},{"name":"Kazumi Nozaki","character":"Cousin (voice)","img":null,"order":6},{"name":"Yoshio Matsuoka","character":"Gosaku (voice)","img":null,"order":7},{"name":"Masahiro Kanetake","character":"Aunt's House Guest (voice)","img":null,"order":8},{"name":"Kiyoshi Yanagawa","character":"Patrolman (voice)","img":null,"order":9},{"name":"Hajime Maki","character":"Man who arrests Seita (voice)","img":null,"order":10},{"name":"Atsuo Omotem","character":"Person in Bank (voice)","img":null,"order":11},{"name":"Teruhisa Harita","character":"Station Worker (voice)","img":null,"order":12},{"name":"Hiroshi Tanaka","character":"Person in Bank (voice)","img":null,"order":13},{"name":"Michio Denpo","character":"Station Worker / Doctor (voice)","img":null,"order":14}],"movie:64682":[{"name":"Leonardo DiCaprio","character":"Jay Gatsby","img":null,"order":0},{"name":"Tobey Maguire","character":"Nick Carraway","img":null,"order":1},{"name":"Carey Mulligan","character":"Daisy Buchanan","img":null,"order":2},{"name":"Joel Edgerton","character":"Tom Buchanan","img":null,"order":3},{"name":"Elizabeth Debicki","character":"Jordan Baker","img":null,"order":4},{"name":"Isla Fisher","character":"Myrtle Wilson","img":null,"order":5},{"name":"Jason Clarke","character":"George Wilson","img":null,"order":6},{"name":"Amitabh Bachchan","character":"Meyer Wolfsheim","img":null,"order":7},{"name":"Callan McAuliffe","character":"Teen Jay Gatsby","img":null,"order":8},{"name":"Adelaide Clemens","character":"Catherine","img":null,"order":9},{"name":"Steve Bisley","character":"Dan Cody","img":null,"order":10},{"name":"Richard Carter","character":"Herzog","img":null,"order":11},{"name":"Jack Thompson","character":"Dr. Walter Perkins","img":null,"order":12},{"name":"Vince Colosimo","character":"Michaelis","img":null,"order":13},{"name":"Max Cullen","character":"Owl Eyes","img":null,"order":14}],"movie:590":[{"name":"Nicole Kidman","character":"Virginia Woolf","img":null,"order":0},{"name":"Julianne Moore","character":"Laura Brown","img":null,"order":1},{"name":"Meryl Streep","character":"Clarissa Vaughan","img":null,"order":2},{"name":"Stephen Dillane","character":"Leonard Woolf","img":null,"order":3},{"name":"Jack Rovello","character":"Richie","img":null,"order":4},{"name":"Ed Harris","character":"Richard Brown","img":null,"order":5},{"name":"Miranda Richardson","character":"Vanessa Bell","img":null,"order":6},{"name":"John C. Reilly","character":"Dan Brown","img":null,"order":7},{"name":"Allison Janney","character":"Sally Lester","img":null,"order":8},{"name":"George Loftus","character":"Quentin Bell","img":null,"order":9},{"name":"Charley Ramm","character":"Julian Bell","img":null,"order":10},{"name":"Sophie Wyburd","character":"Angelica Bell","img":null,"order":11},{"name":"Lyndsey Marshal","character":"Lottie Hope","img":null,"order":12},{"name":"Linda Bassett","character":"Nelly Boxall","img":null,"order":13},{"name":"Christian Coulson","character":"Ralph Partridge","img":null,"order":14}],"movie:4935":[{"name":"Chieko Baisho","character":"Sophie (voice)","img":null,"order":0},{"name":"Takuya Kimura","character":"Howl (voice)","img":null,"order":1},{"name":"Akihiro Miwa","character":"Witch of the Waste (voice)","img":null,"order":2},{"name":"Tatsuya Gashuin","character":"Calcifer (voice)","img":null,"order":3},{"name":"Ryunosuke Kamiki","character":"Markl (voice)","img":null,"order":4},{"name":"Mitsunori Isaki","character":"Kosho (voice)","img":null,"order":5},{"name":"Yo Oizumi","character":"Prince Justin / Turnip Head (voice)","img":null,"order":6},{"name":"Akio Otsuka","character":"King of Ingary (voice)","img":null,"order":7},{"name":"Daijir\u014d Harada","character":"Heen (voice)","img":null,"order":8},{"name":"Haruko Kat\u014d","character":"Madame Suliman (voice)","img":null,"order":9},{"name":"Makoto Yasumura","character":"(voice)","img":null,"order":10},{"name":"Manabu Muraji","character":"(voice)","img":null,"order":11},{"name":"Keiko Tsukamoto","character":"(voice)","img":null,"order":12},{"name":"Tsuzuki Kayako","character":"(voice)","img":null,"order":13},{"name":"Seiji Sasaki","character":"(voice)","img":null,"order":14}],"movie:467909":[{"name":"Anthony Ramos","character":"Usnavi","img":null,"order":0},{"name":"Corey Hawkins","character":"Benny","img":null,"order":1},{"name":"Leslie Grace","character":"Nina Rosario","img":null,"order":2},{"name":"Melissa Barrera","character":"Vanessa","img":null,"order":3},{"name":"Olga Merediz","character":"Abuela Claudia","img":null,"order":4},{"name":"Daphne Rubin-Vega","character":"Daniela","img":null,"order":5},{"name":"Gregory D\u00edaz IV","character":"Sonny","img":null,"order":6},{"name":"Marc Anthony","character":"Gapo","img":null,"order":7},{"name":"Jimmy Smits","character":"Kevin Rosario","img":null,"order":8},{"name":"Stephanie Beatriz","character":"Carla","img":null,"order":9},{"name":"Dascha Polanco","character":"Cuca","img":null,"order":10},{"name":"Noah Catala","character":"Graffiti Pete","img":null,"order":11},{"name":"Lin-Manuel Miranda","character":"Pirag\u00fcero","img":null,"order":12},{"name":"Mateo G\u00f3mez","character":"Alejandro","img":null,"order":13},{"name":"Patrick Page","character":"Pike","img":null,"order":14}],"movie:19994":[{"name":"Megan Fox","character":"Jennifer","img":null,"order":0},{"name":"Amanda Seyfried","character":"Needy","img":null,"order":1},{"name":"Johnny Simmons","character":"Chip","img":null,"order":2},{"name":"Adam Brody","character":"Nikolai","img":null,"order":3},{"name":"Sal Cortez","character":"Chas","img":null,"order":4},{"name":"Ryan Levine","character":"Mick","img":null,"order":5},{"name":"Juan Riedinger","character":"Dirk","img":null,"order":6},{"name":"Colin Askey","character":"Keyboardist","img":null,"order":7},{"name":"Chris Pratt","character":"Roman Duda","img":null,"order":8},{"name":"Juno Rinaldi","character":"Officer Warzak","img":null,"order":9},{"name":"Kyle Gallner","character":"Colin Gray","img":null,"order":10},{"name":"Josh Emerson","character":"Jonas Kozelle","img":null,"order":11},{"name":"J.K. Simmons","character":"Mr. Wroblewski","img":null,"order":12},{"name":"Amy Sedaris","character":"Needy's Mom","img":null,"order":13},{"name":"Cynthia Stevenson","character":"Chip's Mom","img":null,"order":14}],"movie:16859":[{"name":"Minami Takayama","character":"Kiki / Ursula (voice)","img":null,"order":0},{"name":"Rei Sakuma","character":"Jiji (voice)","img":null,"order":1},{"name":"Kappei Yamaguchi","character":"Tombo (voice)","img":null,"order":2},{"name":"Keiko Toda","character":"Osono (voice)","img":null,"order":3},{"name":"Mieko Nobusawa","character":"Kokiri (voice)","img":null,"order":4},{"name":"Koichi Miura","character":"Okino (voice)","img":null,"order":5},{"name":"Haruko Kat\u014d","character":"Old Lady (voice)","img":null,"order":6},{"name":"Hiroko Seki","character":"Bertha (voice)","img":null,"order":7},{"name":"Yuriko Fuchizaki","character":"Ketto (voice)","img":null,"order":8},{"name":"Koichi Yamadera","character":"Bakery Worker / Policeman (voice)","img":"biov5jENp2XYfehkcdnRvi796Wi.jpg","order":9},{"name":"Kikuko Inoue","character":"Maki (voice)","img":null,"order":10},{"name":"Yuko Kobayashi","character":"Young Senpai Witch (voice)","img":null,"order":11},{"name":"Mika Doi","character":"Ketto's Mother (voice)","img":null,"order":12},{"name":"Takaya Hashi","character":"Ketto's Father (voice)","img":null,"order":13},{"name":"Chika Sakamoto","character":"Baby (voice)","img":null,"order":14}],"movie:9361":[{"name":"Daniel Day-Lewis","character":"Hawkeye","img":null,"order":0},{"name":"Madeleine Stowe","character":"Cora","img":null,"order":1},{"name":"Jodhi May","character":"Alice","img":null,"order":2},{"name":"Russell Means","character":"Chingachgook","img":null,"order":3},{"name":"Wes Studi","character":"Magua","img":null,"order":4},{"name":"Eric Schweig","character":"Uncas","img":null,"order":5},{"name":"Steven Waddington","character":"Heyward","img":null,"order":6},{"name":"Maurice Ro\u00ebves","character":"Colonel Munro","img":null,"order":7},{"name":"Pete Postlethwaite","character":"Captain Beams","img":null,"order":8},{"name":"Colm Meaney","character":"Major Ambrose","img":null,"order":9},{"name":"Patrice Ch\u00e9reau","character":"General Montcalm","img":null,"order":10},{"name":"Edward Blatchford","character":"Jack Winthrop","img":null,"order":11},{"name":"Terry Kinney","character":"John Cameron","img":null,"order":12},{"name":"Tracey Ellis","character":"Alexandra Cameron","img":null,"order":13},{"name":"Justin M. Rice","character":"James Cameron","img":null,"order":14}],"movie:616":[{"name":"Tom Cruise","character":"Nathan Algren","img":null,"order":0},{"name":"Ken Watanabe","character":"Moritsugu Katsumoto","img":null,"order":1},{"name":"Timothy Spall","character":"Simon Graham","img":null,"order":2},{"name":"Tony Goldwyn","character":"Col. Benjamin Bagley","img":null,"order":3},{"name":"Hiroyuki Sanada","character":"Ujio","img":null,"order":4},{"name":"Koyuki","character":"Taka","img":null,"order":5},{"name":"Shin Koyamada","character":"Nobutada","img":null,"order":6},{"name":"Billy Connolly","character":"Zebulon Gant","img":null,"order":7},{"name":"Togo Igawa","character":"General Hasegawa","img":null,"order":8},{"name":"Shichinosuke Nakamura","character":"Emperor Meiji","img":null,"order":9},{"name":"Masato Harada","character":"Omura","img":null,"order":10},{"name":"William Atherton","character":"Winchester Rep","img":null,"order":11},{"name":"Chad Lindberg","character":"Winchester Rep Assistant","img":null,"order":12},{"name":"Scott Wilson","character":"Ambassador Swanbeck","img":null,"order":13},{"name":"Ray Godshall Sr.","character":"Convention Hall Attendee","img":null,"order":14}],"movie:11774":[{"name":"Emily Browning","character":"Violet Baudelaire","img":null,"order":0},{"name":"Liam Aiken","character":"Klaus Baudelaire","img":null,"order":1},{"name":"Kara Hoffman","character":"Sunny Baudelaire","img":null,"order":2},{"name":"Shelby Hoffman","character":"Sunny Baudelaire","img":null,"order":3},{"name":"Jim Carrey","character":"Count Olaf / Dr. Stephano / Captain Sham","img":null,"order":4},{"name":"Jude Law","character":"Lemony Snicket","img":null,"order":5},{"name":"Timothy Spall","character":"Mr. Poe","img":null,"order":6},{"name":"Meryl Streep","character":"Josephine Anwhistle","img":null,"order":7},{"name":"Billy Connolly","character":"Dr. Montgomery Montgomery","img":null,"order":8},{"name":"Catherine O'Hara","character":"Justice Strauss","img":null,"order":9},{"name":"Jennifer Coolidge","character":"White-Faced Women","img":null,"order":10},{"name":"Jane Adams","character":"White-Faced Women","img":null,"order":11},{"name":"Craig Ferguson","character":"Person of Indeterminate Gender","img":null,"order":12},{"name":"Luis Guzm\u00e1n","character":"Bald Man","img":null,"order":13},{"name":"Jamie Harris","character":"Hook-Handed Man","img":null,"order":14}],"movie:503919":[{"name":"Robert Pattinson","character":"Thomas Howard","img":null,"order":0},{"name":"Willem Dafoe","character":"Thomas Wake","img":null,"order":1},{"name":"Valeriia Karaman","character":"Mermaid","img":null,"order":2},{"name":"Logan Hawkes","character":"Ephraim Winslow","img":null,"order":3},{"name":"Kyla Nicolle","character":"Woman on the Rocks","img":null,"order":4},{"name":"Shaun Clarke","character":"Departing Wickie","img":null,"order":5},{"name":"Pierre Richard","character":"Departing Assistant Wickie","img":null,"order":6},{"name":"Preston Hudson","character":"Tender Mate","img":null,"order":7},{"name":"Jeff Cruts","character":"Tender Mate","img":null,"order":8}],"movie:603":[{"name":"Keanu Reeves","character":"Neo","img":null,"order":0},{"name":"Laurence Fishburne","character":"Morpheus","img":null,"order":1},{"name":"Carrie-Anne Moss","character":"Trinity","img":null,"order":2},{"name":"Hugo Weaving","character":"Agent Smith","img":null,"order":3},{"name":"Gloria Foster","character":"Oracle","img":null,"order":4},{"name":"Joe Pantoliano","character":"Cypher","img":null,"order":5},{"name":"Marcus Chong","character":"Tank","img":null,"order":6},{"name":"Julian Arahanga","character":"Apoc","img":null,"order":7},{"name":"Matt Doran","character":"Mouse","img":null,"order":8},{"name":"Belinda McClory","character":"Switch","img":null,"order":9},{"name":"Anthony Ray Parker","character":"Dozer","img":null,"order":10},{"name":"Paul Goddard","character":"Agent Brown","img":null,"order":11},{"name":"Robert Taylor","character":"Agent Jones","img":null,"order":12},{"name":"David Aston","character":"Rhineheart","img":null,"order":13},{"name":"Marc Aden Gray","character":"Choi","img":null,"order":14}],"movie:604":[{"name":"Keanu Reeves","character":"Neo","img":null,"order":0},{"name":"Laurence Fishburne","character":"Morpheus","img":null,"order":1},{"name":"Carrie-Anne Moss","character":"Trinity","img":null,"order":2},{"name":"Hugo Weaving","character":"Agent Smith","img":null,"order":3},{"name":"Jada Pinkett Smith","character":"Niobe","img":null,"order":4},{"name":"Gloria Foster","character":"The Oracle","img":null,"order":5},{"name":"Harold Perrineau","character":"Link","img":null,"order":6},{"name":"Monica Bellucci","character":"Persephone","img":null,"order":7},{"name":"Harry Lennix","character":"Commander Lock","img":null,"order":8},{"name":"Lambert Wilson","character":"The Merovingian","img":null,"order":9},{"name":"Randall Duk Kim","character":"Keymaker","img":null,"order":10},{"name":"Nona Gaye","character":"Zee","img":null,"order":11},{"name":"Anthony Zerbe","character":"Councillor Hamann","img":null,"order":12},{"name":"Daniel Bernhardt","character":"Agent Johnson","img":null,"order":13},{"name":"Helmut Bakaitis","character":"The Architect","img":null,"order":14}],"movie:624860":[{"name":"Keanu Reeves","character":"Neo / Thomas Anderson","img":null,"order":0},{"name":"Carrie-Anne Moss","character":"Trinity / Tiffany","img":null,"order":1},{"name":"Yahya Abdul-Mateen II","character":"Morpheus / Agent Smith","img":null,"order":2},{"name":"Jonathan Groff","character":"Smith","img":null,"order":3},{"name":"Jessica Henwick","character":"Bugs","img":null,"order":4},{"name":"Neil Patrick Harris","character":"The Analyst","img":null,"order":5},{"name":"Jada Pinkett Smith","character":"Niobe","img":null,"order":6},{"name":"Priyanka Chopra Jonas","character":"Sati","img":null,"order":7},{"name":"Christina Ricci","character":"Gwyn de Vere","img":null,"order":8},{"name":"Lambert Wilson","character":"The Merovingian","img":null,"order":9},{"name":"Andrew Lewis Caldwell","character":"Jude","img":null,"order":10},{"name":"Toby Onwumere","character":"Sequoia","img":null,"order":11},{"name":"Max Riemelt","character":"Sheperd","img":null,"order":12},{"name":"Joshua Grothe","character":"FunktI\u00f8n","img":null,"order":13},{"name":"Brian J. Smith","character":"Berg","img":null,"order":14}],"movie:605":[{"name":"Keanu Reeves","character":"Neo","img":null,"order":0},{"name":"Laurence Fishburne","character":"Morpheus","img":null,"order":1},{"name":"Carrie-Anne Moss","character":"Trinity","img":null,"order":2},{"name":"Hugo Weaving","character":"Agent Smith","img":null,"order":3},{"name":"Jada Pinkett Smith","character":"Niobe","img":null,"order":4},{"name":"Mary Alice","character":"The Oracle","img":null,"order":5},{"name":"Harold Perrineau","character":"Link","img":null,"order":6},{"name":"Collin Chou","character":"Seraph","img":null,"order":7},{"name":"Harry Lennix","character":"Commander Lock","img":null,"order":8},{"name":"Ian Bliss","character":"Bane","img":null,"order":9},{"name":"Lambert Wilson","character":"Merovingian","img":null,"order":10},{"name":"Bruce Spence","character":"Trainman","img":null,"order":11},{"name":"Monica Bellucci","character":"Persephone","img":null,"order":12},{"name":"Nona Gaye","character":"Zee","img":null,"order":13},{"name":"Anthony Zerbe","character":"Councillor Hamann","img":null,"order":14}],"movie:1236153":[{"name":"Chris Pratt","character":"Chris Raven","img":null,"order":0},{"name":"Rebecca Ferguson","character":"Judge Maddox","img":null,"order":1},{"name":"Kali Reis","character":"Jacqueline 'Jaq' Diallo","img":null,"order":2},{"name":"Kylie Rogers","character":"Britt Raven","img":null,"order":3},{"name":"Chris Sullivan","character":"Rob Nelson","img":null,"order":4},{"name":"Jeff Pierre","character":"Patrick Burke","img":null,"order":5},{"name":"Annabelle Wallis","character":"Nicole Raven","img":null,"order":6},{"name":"Rafi Gavron","character":"Holt Charles","img":null,"order":7},{"name":"Kenneth Choi","character":"Ray Vale","img":null,"order":8},{"name":"Jamie McBride","character":"Dan Vogel","img":null,"order":9},{"name":"Ross Gosla","character":"David Webb","img":null,"order":10},{"name":"Mark Daneri","character":"Jeff Martin","img":null,"order":11},{"name":"Haydn Dalton","character":"Bill Peterson","img":null,"order":12},{"name":"Michael C. Mahon","character":"Booking Sergeant","img":null,"order":13},{"name":"Noah Fearnley","character":"Tattoed Sleazebag","img":null,"order":14}],"movie:1242332":[{"name":"Bob Odenkirk","character":"Ulysses Richardson","img":null,"order":0},{"name":"Henry Winkler","character":"Mayor Kibner","img":null,"order":1},{"name":"Lena Headey","character":"Moira","img":null,"order":2},{"name":"Billy MacLellan","character":"Deputy Mike Nelson","img":null,"order":3},{"name":"Ryan Allen","character":"Deputy Blaine Anderson","img":null,"order":4},{"name":"Reena Jolly","character":"Lori","img":null,"order":5},{"name":"Brendan Fletcher","character":"Keith","img":null,"order":6},{"name":"Peter Shinkoda","character":"Joe","img":null,"order":7},{"name":"Jess McLeod","character":"Alex","img":null,"order":8},{"name":"Alex MacMillan","character":"Kent","img":null,"order":9},{"name":"Dan De Jaeger","character":"Ernie","img":null,"order":10},{"name":"John C. MacDonald","character":"Kenny","img":null,"order":11},{"name":"David Lawrence Brown","character":"Dr. Walter Vilie","img":null,"order":12},{"name":"Derek Barnes","character":"James","img":null,"order":13},{"name":"Megan MacArton","character":"Mary Beth","img":null,"order":14}],"movie:454227":[{"name":"Chris Pine","character":"Robert Bruce, Earl of Carrick","img":null,"order":0},{"name":"Aaron Taylor-Johnson","character":"James Douglas, Lord of Douglas","img":null,"order":1},{"name":"Florence Pugh","character":"Elizabeth Burgh","img":null,"order":2},{"name":"Billy Howle","character":"Edward, Prince of Wales","img":null,"order":3},{"name":"Sam Spruell","character":"Aymer de Valence, Earl of Pembroke","img":null,"order":4},{"name":"Tony Curran","character":"Angus Og Macdonald, Lord of Islay","img":null,"order":5},{"name":"Callan Mulvey","character":"John III Comyn, Lord of Badenoch","img":null,"order":6},{"name":"James Cosmo","character":"Robert Bruce Senior","img":null,"order":7},{"name":"Stephen Dillane","character":"King Edward I of England","img":null,"order":8},{"name":"Steven Cree","character":"Sir Christopher Seton","img":null,"order":9},{"name":"Alastair Mackenzie","character":"John Strathbogie, Earl of Atholl","img":null,"order":10},{"name":"Chris Fulton","character":"Euan Bruce","img":null,"order":11},{"name":"Lorne MacFadyen","character":"Neil Bruce","img":null,"order":12},{"name":"Jack Greenlees","character":"Alexander Bruce","img":null,"order":13},{"name":"Rebecca Robin","character":"Queen Margaret of England","img":null,"order":14}],"movie:4547":[{"name":"Jodie Foster","character":"Meg Altman","img":null,"order":0},{"name":"Kristen Stewart","character":"Sarah Altman","img":null,"order":1},{"name":"Forest Whitaker","character":"Burnham","img":null,"order":2},{"name":"Dwight Yoakam","character":"Raoul","img":null,"order":3},{"name":"Jared Leto","character":"Junior","img":null,"order":4},{"name":"Patrick Bauchau","character":"Stephen Altman","img":null,"order":5},{"name":"Ann Magnuson","character":"Lydia Lynch","img":null,"order":6},{"name":"Ian Buchanan","character":"Evan Kurlander","img":null,"order":7},{"name":"Andrew Kevin Walker","character":"Sleepy Neighbor","img":null,"order":8},{"name":"Paul Schulze","character":"Officer Keeney","img":null,"order":9},{"name":"Mel Rodriguez","character":"Officer Morales","img":null,"order":10},{"name":"Richard Conant","character":"SWAT Cop","img":null,"order":11},{"name":"Paul Simon","character":"SWAT Cop","img":null,"order":12},{"name":"Victor Thrash","character":"SWAT Cop","img":null,"order":13},{"name":"Ken Turner","character":"SWAT Cop","img":null,"order":14}],"movie:2024":[{"name":"Mel Gibson","character":"Benjamin Martin","img":null,"order":0},{"name":"Heath Ledger","character":"Gabriel Martin","img":null,"order":1},{"name":"Joely Richardson","character":"Charlotte Selton","img":null,"order":2},{"name":"Jason Isaacs","character":"Col. William Tavington","img":null,"order":3},{"name":"Chris Cooper","character":"Col. Harry Burwell","img":null,"order":4},{"name":"Tch\u00e9ky Karyo","character":"Jean Villeneuve","img":null,"order":5},{"name":"Ren\u00e9 Auberjonois","character":"Reverend Oliver","img":null,"order":6},{"name":"Lisa Brenner","character":"Anne Howard","img":null,"order":7},{"name":"Tom Wilkinson","character":"Gen. Cornwallis","img":null,"order":8},{"name":"Donal Logue","character":"Dan Scott","img":null,"order":9},{"name":"Leon Rippy","character":"John Billings","img":null,"order":10},{"name":"Adam Baldwin","character":"Capt. Wilkins","img":"lfbyJWNWGbiYfU0Yr1tjn6Trd2V.jpg","order":11},{"name":"Jay Arlen Jones","character":"Occam","img":null,"order":12},{"name":"Joey D. Vieira","character":"Peter Howard","img":null,"order":13},{"name":"Gregory Smith","character":"Thomas Martin","img":null,"order":14}],"movie:875828":[{"name":"Cillian Murphy","character":"Tommy Shelby","img":null,"order":0},{"name":"Barry Keoghan","character":"Duke Shelby","img":null,"order":1},{"name":"Rebecca Ferguson","character":"Kaulo / Zelda","img":null,"order":2},{"name":"Tim Roth","character":"John Beckett","img":null,"order":3},{"name":"Stephen Graham","character":"Hayden Stagg","img":null,"order":4},{"name":"Sophie Rundle","character":"Ada Shelby","img":null,"order":5},{"name":"Ned Dennehy","character":"Charlie Strong","img":null,"order":6},{"name":"Packy Lee","character":"Johnny Dogs","img":null,"order":7},{"name":"Ian Peck","character":"Curly","img":null,"order":8},{"name":"Jay Lycurgo","character":"Elijah","img":null,"order":9},{"name":"Kasper Hilton-Hille","character":"Karl Thorne","img":null,"order":10},{"name":"Ruby Ashbourne Serkis","character":"Agnes Shelby","img":null,"order":11},{"name":"Bonnie Stott","character":"Ruby","img":null,"order":12},{"name":"Thomas Arnold","character":"Virgil","img":null,"order":13},{"name":"Sam Baker Jones","character":"Jake","img":null,"order":14}],"movie:12429":[{"name":"Yuria Kozuki","character":"Ponyo (voice)","img":null,"order":0},{"name":"Hiroki Doi","character":"Sosuke (voice)","img":null,"order":1},{"name":"George Tokoro","character":"Fujimoto (voice)","img":null,"order":2},{"name":"Tomoko Yamaguchi","character":"Risa (voice)","img":null,"order":3},{"name":"Yuki Amami","character":"Gran Mamare (voice)","img":null,"order":4},{"name":"Kazushige Nagashima","character":"Koichi (voice)","img":null,"order":5},{"name":"Akiko Yano","character":"Ponyo's Sister (voice)","img":null,"order":6},{"name":"Shin'ichi Hatori","character":"Anchorman (voice)","img":null,"order":7},{"name":"Tokie Hidari","character":"Kayo (voice)","img":null,"order":8},{"name":"Rumi Hiiragi","character":"Young Mother (voice)","img":null,"order":9},{"name":"Tomoko Naraoka","character":"Yoshie (voice)","img":null,"order":10},{"name":"Kazuko Yoshiyuki","character":"Toki (voice)","img":null,"order":11},{"name":"Eimi Hiraoka","character":"Kumiko (voice)","img":null,"order":12},{"name":"Nozomi \u014chashi","character":"Karen (voice)","img":null,"order":13},{"name":"Akiko Takeguchi","character":"Noriko (voice)","img":null,"order":14}],"movie:11621":[{"name":"Sh\u016bichir\u014d Moriyama","character":"Porco Rosso (voice)","img":null,"order":0},{"name":"Tokiko Kato","character":"Madame Gina (voice)","img":null,"order":1},{"name":"Bunshi Katsura VI","character":"Grandpa Piccolo (voice)","img":null,"order":2},{"name":"Tsunehiko Kamij\u00f4","character":"Mamma Aiuto Boss (voice)","img":null,"order":3},{"name":"Akemi Okamura","character":"Fio Piccolo (voice)","img":null,"order":4},{"name":"Akio Otsuka","character":"Donald Curtis (voice)","img":null,"order":5},{"name":"Hiroko Seki","character":"Granny (voice)","img":null,"order":6},{"name":"Masayuki Inagaki","character":"Major Ferrarin (voice)","img":null,"order":7},{"name":"Reiz\u014d Nomoto","character":"Mamma Aiuto Gang Member (voice)","img":null,"order":8},{"name":"Osamu Saka","character":"Mamma Aiuto Gang Member (voice)","img":null,"order":9},{"name":"Yu Shimaka","character":"Mamma Aiuto Gang Member (voice)","img":null,"order":10},{"name":"Yuzuru Fujimoto","character":"Mamma Aiuto Gang Member (voice)","img":null,"order":11},{"name":"Tatsuyuki Jinnai","character":"Mamma Aiuto Gang Member (voice)","img":null,"order":12},{"name":"Kazunori Arai","character":"Mamma Aiuto Gang Member (voice)","img":null,"order":13},{"name":"Nobuo Tanaka","character":"Mamma Aiuto Gang Member (voice)","img":null,"order":14}],"movie:128":[{"name":"Yoji Matsuda","character":"Ashitaka (voice)","img":null,"order":0},{"name":"Yuriko Ishida","character":"San / Kaya (voice)","img":null,"order":1},{"name":"Yuko Tanaka","character":"Eboshi Gozen (voice)","img":null,"order":2},{"name":"Kaoru Kobayashi","character":"Jikobo (voice)","img":null,"order":3},{"name":"Masahiko Nishimura","character":"Kouroku (voice)","img":null,"order":4},{"name":"Tsunehiko Kamij\u00f4","character":"Gonza (voice)","img":null,"order":5},{"name":"Akihiro Miwa","character":"Mrs. Moro (voice)","img":null,"order":6},{"name":"Mitsuko Mori","character":"Mrs. Hii (voice)","img":null,"order":7},{"name":"Hisaya Morishige","character":"Chief of Affairs (voice)","img":null,"order":8},{"name":"Sumi Shimamoto","character":"Toki (voice)","img":null,"order":9},{"name":"Tetsu Watanabe","character":"Wild Dog (voice)","img":null,"order":10},{"name":"Makoto Sat\u014d","character":"Tatari God (voice)","img":null,"order":11},{"name":"Akira Nagoya","character":"Cowherd (voice)","img":null,"order":12},{"name":"Kei Iinuma","character":"Chief of the Sick (voice)","img":null,"order":13},{"name":"Tsuzuki Kayako","character":"Kiyo (voice)","img":null,"order":14}],"movie:146233":[{"name":"Hugh Jackman","character":"Keller Dover","img":null,"order":0},{"name":"Jake Gyllenhaal","character":"Detective Loki","img":null,"order":1},{"name":"Viola Davis","character":"Nancy Birch","img":null,"order":2},{"name":"Maria Bello","character":"Grace Dover","img":null,"order":3},{"name":"Terrence Howard","character":"Franklin Birch","img":null,"order":4},{"name":"Melissa Leo","character":"Holly Jones","img":null,"order":5},{"name":"Paul Dano","character":"Alex Jones","img":null,"order":6},{"name":"Dylan Minnette","character":"Ralph Dover","img":null,"order":7},{"name":"Zo\u00eb Soul","character":"Eliza Birch","img":null,"order":8},{"name":"Erin Gerasimovich","character":"Anna Dover","img":null,"order":9},{"name":"Kyla-Drew","character":"Joy Birch","img":null,"order":10},{"name":"Wayne Duvall","character":"Captain Richard O'Malley","img":null,"order":11},{"name":"Len Cariou","character":"Father Patrick Dunn","img":null,"order":12},{"name":"David Dastmalchian","character":"Bob Taylor","img":null,"order":13},{"name":"Brad James","character":"Officer Carter","img":null,"order":14}],"movie:6439":[{"name":"Frankie Muniz","character":"Stripes (voice)","img":null,"order":0},{"name":"Hayden Panettiere","character":"Channing Walsh","img":null,"order":1},{"name":"Bruce Greenwood","character":"Nolan Walsh","img":null,"order":2},{"name":"Mandy Moore","character":"Sandy (voice)","img":null,"order":3},{"name":"Wendie Malick","character":"Clara Dalrymple","img":null,"order":4},{"name":"M. Emmet Walsh","character":"Woodzie","img":null,"order":5},{"name":"Snoop Dogg","character":"Lightning (voice)","img":"rbxBK2m6fEq0oHeMoBUMVYkJEdJ.jpg","order":6},{"name":"Michael Clarke Duncan","character":"Clydesdale (voice)","img":null,"order":7},{"name":"Jeff Foxworthy","character":"Reggie (voice)","img":null,"order":8},{"name":"Whoopi Goldberg","character":"Franny (voice)","img":null,"order":9},{"name":"Steve Harvey","character":"Buzz (voice)","img":null,"order":10},{"name":"David Spade","character":"Scuzz (voice)","img":null,"order":11},{"name":"Dustin Hoffman","character":"Tucker (voice)","img":null,"order":12},{"name":"Joshua Jackson","character":"Trenton's Pride (voice)","img":null,"order":13},{"name":"Joe Pantoliano","character":"Goose (voice)","img":null,"order":14}],"movie:641":[{"name":"Ellen Burstyn","character":"Sara Goldfarb","img":null,"order":0},{"name":"Jared Leto","character":"Harry Goldfarb","img":null,"order":1},{"name":"Jennifer Connelly","character":"Marion Silver","img":null,"order":2},{"name":"Marlon Wayans","character":"Tyrone C. Love","img":null,"order":3},{"name":"Christopher McDonald","character":"Tappy Tibbons","img":null,"order":4},{"name":"Louise Lasser","character":"Ada","img":null,"order":5},{"name":"Marcia Jean Kurtz","character":"Rae","img":null,"order":6},{"name":"Janet Sarno","character":"Mrs. Pearlman","img":null,"order":7},{"name":"Suzanne Shepherd","character":"Mrs. Scarlini","img":null,"order":8},{"name":"Joanne Gordon","character":"Mrs. Ovadia","img":null,"order":9},{"name":"Charlotte Aronofsky","character":"Mrs. Miles","img":null,"order":10},{"name":"Mark Margolis","character":"Mr. Rabinowitz","img":null,"order":11},{"name":"Michael Kaycheck","character":"Donut Cop","img":null,"order":12},{"name":"Jack A. O'Connell","character":"Corn Dog Stand Boss","img":null,"order":13},{"name":"Chas Mastin","character":"Lyle Russel","img":null,"order":14}],"movie:330459":[{"name":"Felicity Jones","character":"Jyn Erso","img":null,"order":0},{"name":"Diego Luna","character":"Cassian Andor","img":"qwbjcyDN37OHiln4a6P4LP31o7F.jpg","order":1},{"name":"Alan Tudyk","character":"K-2SO","img":"jUuUbPuMGonFT5E2pcs4alfqaCN.jpg","order":2},{"name":"Donnie Yen","character":"Chirrut Imwe","img":null,"order":3},{"name":"Jiang Wen","character":"Baze Malbus","img":null,"order":4},{"name":"Ben Mendelsohn","character":"Orson Krennic","img":null,"order":5},{"name":"Guy Henry","character":"Governor Tarkin","img":null,"order":6},{"name":"Forest Whitaker","character":"Saw Gerrera","img":null,"order":7},{"name":"Riz Ahmed","character":"Bodhi Rook","img":null,"order":8},{"name":"Mads Mikkelsen","character":"Galen Erso","img":null,"order":9},{"name":"Jimmy Smits","character":"Bail Organa","img":null,"order":10},{"name":"Alistair Petrie","character":"General Draven","img":null,"order":11},{"name":"Genevieve O'Reilly","character":"Mon Mothma","img":"4eOoW1PNBZfxRW0g8RmYH8bL6mq.jpg","order":12},{"name":"Ben Daniels","character":"General Merrick","img":"phEIvq0Ay2KjI5ewmVvpQ1a29GJ.jpg","order":13},{"name":"Paul Kasey","character":"Admiral Raddus","img":null,"order":14}],"movie:13156":[{"name":"Michael Caine","character":"Garth","img":null,"order":0},{"name":"Robert Duvall","character":"Hub","img":null,"order":1},{"name":"Haley Joel Osment","character":"Walter","img":null,"order":2},{"name":"Josh Lucas","character":"Adult Walter","img":null,"order":3},{"name":"Kyra Sedgwick","character":"Mae","img":null,"order":4},{"name":"Christian Kane","character":"Young Hub","img":null,"order":5},{"name":"Nicky Katt","character":"Stan","img":null,"order":6},{"name":"Eric Balfour","character":"Sheik's Grandson","img":null,"order":7},{"name":"Kevin Haberer","character":"Young Garth","img":null,"order":8},{"name":"Emmanuelle Vaugier","character":"Jasmine","img":null,"order":9},{"name":"Mitchel Musso","character":"Boy","img":null,"order":10},{"name":"Marc Musso","character":"Boy","img":null,"order":11},{"name":"Michael O'Neill","character":"Ralph","img":null,"order":12},{"name":"Deirdre O'Connell","character":"Helen","img":null,"order":13},{"name":"Adam Ozturk","character":"The Sheik","img":null,"order":14}],"movie:412117":[{"name":"Patton Oswalt","character":"Max (voice)","img":null,"order":0},{"name":"Kevin Hart","character":"Snowball (voice)","img":null,"order":1},{"name":"Eric Stonestreet","character":"Duke (voice)","img":null,"order":2},{"name":"Jenny Slate","character":"Gidget (voice)","img":null,"order":3},{"name":"Tiffany Haddish","character":"Daisy (voice)","img":null,"order":4},{"name":"Lake Bell","character":"Chloe (voice)","img":null,"order":5},{"name":"Nick Kroll","character":"Sergei (voice)","img":null,"order":6},{"name":"Dana Carvey","character":"Pops (voice)","img":null,"order":7},{"name":"Ellie Kemper","character":"Katie (voice)","img":null,"order":8},{"name":"Harrison Ford","character":"Rooster (voice)","img":null,"order":9},{"name":"Chris Renaud","character":"Norman / Additional Voices (voice)","img":null,"order":10},{"name":"Bobby Moynihan","character":"Mel (voice)","img":null,"order":11},{"name":"Hannibal Buress","character":"Buddy (voice)","img":null,"order":12},{"name":"Pete Holmes","character":"Chuck (voice)","img":null,"order":13},{"name":"Henry Lynch","character":"Liam (voice)","img":null,"order":14}],"movie:1072876":[{"name":"Jake Johnson","character":"Tommy","img":null,"order":0},{"name":"Anna Kendrick","character":"Maddy","img":null,"order":1},{"name":"Natalie Morales","character":"Theresa","img":null,"order":2},{"name":"Andy Samberg","character":"Andy Samberg","img":null,"order":3},{"name":"Mary Holland","character":"Amy","img":null,"order":4},{"name":"Emily Hampshire","character":"Mary","img":null,"order":5},{"name":"Nancy Lenehan","character":"Laurie","img":null,"order":6},{"name":"Daryl J. Johnson","character":"Malcolm","img":null,"order":7},{"name":"Miriam Flynn","character":"Becky Ann","img":null,"order":8},{"name":"Boban Marjanovi\u0107","character":"7 Foot Man","img":null,"order":9},{"name":"Biff Wiff","character":"James / Walter","img":null,"order":10},{"name":"Eduardo Franco","character":"P.A. Ninja","img":null,"order":11},{"name":"Jeff Kober","character":"Cowboy","img":null,"order":12},{"name":"Christopher Lloyd","character":"Dennis Walcott","img":null,"order":13},{"name":"Theo Wilson","character":"Sinbad Guy","img":null,"order":14}],"movie:9040":[{"name":"Al Pacino","character":"Officer Frank Serpico","img":null,"order":0},{"name":"John Randolph","character":"Sidney Green","img":null,"order":1},{"name":"Jack Kehoe","character":"Tom Keough","img":null,"order":2},{"name":"Biff McGuire","character":"Captain McClain","img":null,"order":3},{"name":"Barbara Eda-Young","character":"Laurie","img":null,"order":4},{"name":"Cornelia Sharpe","character":"Leslie Lane","img":null,"order":5},{"name":"Tony Roberts","character":"Bob Blair","img":null,"order":6},{"name":"John Medici","character":"Pasquale","img":null,"order":7},{"name":"Allan Rich","character":"D. A. Tauber","img":null,"order":8},{"name":"Norman Ornellas","character":"Rubello","img":null,"order":9},{"name":"Edward Grover","character":"Lombardo","img":null,"order":10},{"name":"Albert Henderson","character":"Peluce","img":null,"order":11},{"name":"Hank Garrett","character":"Malone","img":null,"order":12},{"name":"Damien Leake","character":"Joey","img":null,"order":13},{"name":"Joseph Bova","character":"Potts","img":null,"order":14}],"movie:479":[{"name":"Samuel L. Jackson","character":"John Shaft","img":null,"order":0},{"name":"Vanessa Williams","character":"Carmen Vasquez","img":null,"order":1},{"name":"Jeffrey Wright","character":"Peoples Hernandez","img":null,"order":2},{"name":"Christian Bale","character":"Walter Wade, Jr.","img":null,"order":3},{"name":"Busta Rhymes","character":"Rasaan","img":null,"order":4},{"name":"Dan Hedaya","character":"Jack Roselli","img":null,"order":5},{"name":"Toni Collette","character":"Diane Palmieri","img":null,"order":6},{"name":"Richard Roundtree","character":"\"Uncle\" John Shaft","img":null,"order":7},{"name":"Ruben Santiago-Hudson","character":"Jimmy Groves","img":null,"order":8},{"name":"Josef Sommer","character":"Curt Flemming","img":null,"order":9},{"name":"Lynne Thigpen","character":"Carla Howard","img":null,"order":10},{"name":"Philip Bosco","character":"Walter Wade Senior","img":null,"order":11},{"name":"Pat Hingle","character":"Judge Dennis Bradford","img":null,"order":12},{"name":"Lee Tergesen","character":"Luger","img":null,"order":13},{"name":"Daniel von Bargen","character":"Lt Kearney","img":null,"order":14}],"movie:378064":[{"name":"Miyu Irino","character":"Shouya Ishida (voice)","img":null,"order":0},{"name":"Saori Hayami","character":"Shouko Nishimiya (voice)","img":null,"order":1},{"name":"Aoi Yuki","character":"Yuzuru Nishimiya (voice)","img":null,"order":2},{"name":"Kensho Ono","character":"Tomohiro Nagatsuka (voice)","img":null,"order":3},{"name":"Yuki Kaneko","character":"Naoka Ueno (voice)","img":null,"order":4},{"name":"Yui Ishikawa","character":"Miyoko Sahara (voice)","img":null,"order":5},{"name":"Megumi Han","character":"Miki Kawai (voice)","img":null,"order":6},{"name":"Toshiyuki Toyonaga","character":"Satoshi Mashiba (voice)","img":null,"order":7},{"name":"Mayu Matsuoka","character":"Shouya Ishida as an Elementary School Boy (voice)","img":null,"order":8},{"name":"Sachiko Kojima","character":"Kazuki Shimada as an Elementary School Boy (voice)","img":null,"order":9},{"name":"Hana Takeda","character":"Keisuke Hirose as an Elementary School Boy (voice)","img":null,"order":10},{"name":"Fuminori Komatsu","character":"Takeuchi-sensei (voice)","img":null,"order":11},{"name":"Ikuko Tani","character":"Ito Nishimiya (voice)","img":null,"order":12},{"name":"Erena Kamata","character":"Maria Ishida (voice)","img":null,"order":13},{"name":"Ayano Hamaguchi","character":"Shouya's Sister (voice)","img":null,"order":14}],"movie:14411":[{"name":"Brad Pitt","character":"Sinbad (voice)","img":null,"order":0},{"name":"Catherine Zeta-Jones","character":"Marina (voice)","img":null,"order":1},{"name":"Michelle Pfeiffer","character":"Eris (voice)","img":null,"order":2},{"name":"Joseph Fiennes","character":"Proteus (voice)","img":null,"order":3},{"name":"Dennis Haysbert","character":"Kale (voice)","img":null,"order":4},{"name":"Timothy West","character":"Dymas (voice)","img":null,"order":5},{"name":"Adriano Giannini","character":"Rat (voice)","img":null,"order":6},{"name":"Raman Hui Shing-Ngai","character":"Jin (voice)","img":null,"order":7},{"name":"Chung Chan","character":"Li (voice)","img":null,"order":8},{"name":"Jim Cummings","character":"Luca / Additional Voices (voice)","img":null,"order":9},{"name":"Conrad Vernon","character":"Jed (voice)","img":null,"order":10},{"name":"Andrew Birch","character":"Grum / Chum (voice)","img":null,"order":11},{"name":"Chris Miller","character":"Tower Guard (voice)","img":null,"order":12},{"name":"Frank Welker","character":"Spike (voice) (uncredited)","img":null,"order":13}],"movie:288036":[{"name":"Jason Sudeikis","character":"Jake Harper","img":null,"order":0},{"name":"Alison Brie","character":"Lainey Dalton","img":null,"order":1},{"name":"Adam Scott","character":"Matthew Sobvechik","img":null,"order":2},{"name":"Jason Mantzoukas","character":"Xander","img":null,"order":3},{"name":"Natasha Lyonne","character":"Kara","img":null,"order":4},{"name":"Adam Brody","character":"Sam","img":null,"order":5},{"name":"Amanda Peet","character":"Paula","img":null,"order":6},{"name":"Katherine Waterston","character":"Emma","img":null,"order":7},{"name":"Marc Blucas","character":"Chris Smith","img":null,"order":8},{"name":"Margarita Levieva","character":"Hannah","img":null,"order":9},{"name":"Andrea Savage","character":"Naomi","img":null,"order":10},{"name":"Daniella Pineda","character":"Danica","img":null,"order":11},{"name":"Remy Nozik","character":"George","img":null,"order":12},{"name":"Megan Guinan","character":"Alice","img":null,"order":13},{"name":"Anna Margaret Hollyman","character":"Renee","img":null,"order":14}],"movie:110415":[{"name":"Chris Evans","character":"Curtis","img":null,"order":0},{"name":"Song Kang-ho","character":"Namgoong Minsu","img":null,"order":1},{"name":"Ed Harris","character":"Wilford","img":null,"order":2},{"name":"John Hurt","character":"Gilliam","img":null,"order":3},{"name":"Tilda Swinton","character":"Mason","img":null,"order":4},{"name":"Jamie Bell","character":"Edgar","img":null,"order":5},{"name":"Octavia Spencer","character":"Tanya","img":null,"order":6},{"name":"Ewen Bremner","character":"Andrew","img":null,"order":7},{"name":"Ko Asung","character":"Yona","img":null,"order":8},{"name":"Alison Pill","character":"Teacher","img":null,"order":9},{"name":"Luke Pasqualino","character":"Grey","img":null,"order":10},{"name":"Vlad Ivanov","character":"Franco The Elder","img":null,"order":11},{"name":"Adnan Haskovi\u0107","character":"Franco The Younger","img":null,"order":12},{"name":"Emma Levie","character":"Claude","img":null,"order":13},{"name":"Steve Park","character":"Fuyu","img":null,"order":14}],"movie:969681":[{"name":"Tom Holland","character":"Peter Parker / Spider-Man","img":"xKBAaPIa1c7tzZD3Y0MhBLv4hPE.jpg","order":0},{"name":"Sadie Sink","character":"Jean Grey","img":null,"order":1},{"name":"Tramell Tillman","character":"William 'Bill' Metzger","img":null,"order":2},{"name":"Zendaya","character":"MJ","img":null,"order":3},{"name":"Jacob Batalon","character":"Ned Leeds","img":null,"order":4},{"name":"Jon Bernthal","character":"Frank Castle / Punisher","img":null,"order":5},{"name":"Mark Ruffalo","character":"Bruce Banner / Hulk","img":null,"order":6},{"name":"Florence Pugh","character":"Yelena Belova / Black Widow","img":null,"order":7},{"name":"Liza Col\u00f3n-Zayas","character":"Detective Jean DeWolff","img":null,"order":8},{"name":"Marisa Tomei","character":"May Parker","img":null,"order":9},{"name":"Naomi Watts","character":"E.V. (voice)","img":null,"order":10},{"name":"Michael Mando","character":"Mac Gargan / Scorpion","img":null,"order":11},{"name":"Keith David","character":"Narrator of Spider Video (voice)","img":null,"order":12},{"name":"Marvin Jones III","character":"Tombstone","img":null,"order":13},{"name":"Olivia Booth-Ford","character":"Sara","img":null,"order":14}],"movie:429617":[{"name":"Tom Holland","character":"Peter Parker / Spider-Man","img":"xKBAaPIa1c7tzZD3Y0MhBLv4hPE.jpg","order":0},{"name":"Jake Gyllenhaal","character":"Quentin Beck / Mysterio","img":null,"order":1},{"name":"Samuel L. Jackson","character":"Nick Fury","img":null,"order":2},{"name":"Marisa Tomei","character":"May Parker","img":null,"order":3},{"name":"Jon Favreau","character":"Happy Hogan","img":null,"order":4},{"name":"Zendaya","character":"MJ","img":null,"order":5},{"name":"Jacob Batalon","character":"Ned Leeds","img":null,"order":6},{"name":"Tony Revolori","character":"Flash Thompson","img":null,"order":7},{"name":"Angourie Rice","character":"Betty Brant","img":null,"order":8},{"name":"Remy Hii","character":"Brad Davis","img":null,"order":9},{"name":"Martin Starr","character":"Mr. Harrington","img":null,"order":10},{"name":"JB Smoove","character":"Mr. Dell","img":null,"order":11},{"name":"Jorge Lendeborg Jr.","character":"Jason Ionello","img":null,"order":12},{"name":"Cobie Smulders","character":"Maria Hill","img":null,"order":13},{"name":"Numan Acar","character":"Dimitri","img":null,"order":14}],"movie:315635":[{"name":"Tom Holland","character":"Peter Parker / Spider-Man","img":"xKBAaPIa1c7tzZD3Y0MhBLv4hPE.jpg","order":0},{"name":"Michael Keaton","character":"Adrian Toomes / Vulture","img":null,"order":1},{"name":"Robert Downey Jr.","character":"Tony Stark / Iron Man","img":null,"order":2},{"name":"Marisa Tomei","character":"May Parker","img":null,"order":3},{"name":"Jon Favreau","character":"Happy Hogan","img":null,"order":4},{"name":"Gwyneth Paltrow","character":"Pepper Potts","img":null,"order":5},{"name":"Zendaya","character":"Michelle","img":null,"order":6},{"name":"Donald Glover","character":"Aaron Davis","img":null,"order":7},{"name":"Jacob Batalon","character":"Ned","img":null,"order":8},{"name":"Laura Harrier","character":"Liz","img":null,"order":9},{"name":"Tony Revolori","character":"Flash","img":null,"order":10},{"name":"Bokeem Woodbine","character":"Herman Schultz / Shocker #2","img":null,"order":11},{"name":"Tyne Daly","character":"Anne Marie Hoag","img":null,"order":12},{"name":"Abraham Attah","character":"Abraham","img":null,"order":13},{"name":"Hannibal Buress","character":"Coach Wilson","img":null,"order":14}],"movie:634649":[{"name":"Tom Holland","character":"Peter Parker / Spider-Man","img":"xKBAaPIa1c7tzZD3Y0MhBLv4hPE.jpg","order":0},{"name":"Zendaya","character":"MJ","img":null,"order":1},{"name":"Benedict Cumberbatch","character":"Doctor Strange","img":null,"order":2},{"name":"Jacob Batalon","character":"Ned Leeds","img":null,"order":3},{"name":"Jon Favreau","character":"Happy Hogan","img":null,"order":4},{"name":"Jamie Foxx","character":"Max Dillon / Electro","img":null,"order":5},{"name":"Willem Dafoe","character":"Norman Osborn / Green Goblin","img":null,"order":6},{"name":"Alfred Molina","character":"Dr. Otto Octavius / Doc Ock","img":null,"order":7},{"name":"Benedict Wong","character":"Wong","img":null,"order":8},{"name":"Tony Revolori","character":"Flash Thompson","img":null,"order":9},{"name":"Marisa Tomei","character":"May Parker","img":null,"order":10},{"name":"Andrew Garfield","character":"Peter Parker / Spider-Man","img":null,"order":11},{"name":"Tobey Maguire","character":"Peter Parker / Spider-Man","img":null,"order":12},{"name":"Angourie Rice","character":"Betty Brant","img":null,"order":13},{"name":"Arian Moayed","character":"Agent Cleary","img":null,"order":14}],"movie:129":[{"name":"Rumi Hiiragi","character":"Chihiro (voice)","img":null,"order":0},{"name":"Miyu Irino","character":"Haku (voice)","img":null,"order":1},{"name":"Mari Natsuki","character":"Yubaba / Zeniba (voice)","img":null,"order":2},{"name":"Takashi Naito","character":"Father (voice)","img":null,"order":3},{"name":"Yasuko Sawaguchi","character":"Mother (voice)","img":null,"order":4},{"name":"Tatsuya Gashuin","character":"Aogaeru (voice)","img":null,"order":5},{"name":"Ryunosuke Kamiki","character":"Boh (voice)","img":null,"order":6},{"name":"Yumi Tamai","character":"Rin (voice)","img":null,"order":7},{"name":"Yo Oizumi","character":"Bandai Frog (voice)","img":null,"order":8},{"name":"Koba Hayashi","character":"River Spirit (voice)","img":null,"order":9},{"name":"Tsunehiko Kamij\u00f4","character":"Chichiyaku (voice)","img":null,"order":10},{"name":"Takehiko Ono","character":"Aniyaku (voice)","img":null,"order":11},{"name":"Bunta Sugawara","character":"Kamaji (voice)","img":null,"order":12},{"name":"Shigeru Wakita","character":"(voice)","img":null,"order":13},{"name":"Shirou Saitou","character":"(voice)","img":null,"order":14}],"movie:933260":[{"name":"Demi Moore","character":"Elisabeth","img":null,"order":0},{"name":"Margaret Qualley","character":"Sue","img":null,"order":1},{"name":"Dennis Quaid","character":"Harvey","img":null,"order":2},{"name":"Edward Hamilton-Clark","character":"Fred","img":null,"order":3},{"name":"Gore Abrams","character":"Oliver","img":null,"order":4},{"name":"Oscar Lesage","character":"Troy","img":null,"order":5},{"name":"Christian Erickson","character":"Man at Diner","img":null,"order":6},{"name":"Robin Greer","character":"Male Nurse","img":null,"order":7},{"name":"Tom Morton","character":"Doctor","img":null,"order":8},{"name":"Hugo Diego Garcia","character":"Diego - Boyfriend","img":null,"order":9},{"name":"Daniel Knight","character":"Casting Director","img":null,"order":10},{"name":"Jonathon Carley","character":"Assistant Casting Director","img":null,"order":11},{"name":"Jiselle Henderkott","character":"Girl Auditioning / New Year's Eve Show Dancer","img":null,"order":12},{"name":"Akil Wingate","character":"TV Host","img":null,"order":13},{"name":"Vincent Colombe","character":"Man in Suit #1 - Harvey's Office","img":null,"order":14}],"movie:149871":[{"name":"Aki Asakura","character":"The Princess Kaguya (voice)","img":null,"order":0},{"name":"Takeo Chii","character":"Sanuki no Miyatsuko (voice)","img":null,"order":1},{"name":"Nobuko Miyamoto","character":"\u014cna / Narrator (voice)","img":null,"order":2},{"name":"Kengo Kora","character":"Sutemaru (voice)","img":null,"order":3},{"name":"Atsuko Takahata","character":"Lady Sagami (voice)","img":null,"order":4},{"name":"Tomoko Tabata","character":"Menowarawa (voice)","img":null,"order":5},{"name":"Shinosuke Tatekawa","character":"Lord Inbe no Akita (voice)","img":null,"order":6},{"name":"Takaya Kamikawa","character":"Prince Ishitsukuri (voice)","img":null,"order":7},{"name":"Hikaru Ij\u016bin","character":"Minister of the Right Abe (voice)","img":null,"order":8},{"name":"Ryudo Uzaki","character":"Grand Counselor Otomo (voice)","img":null,"order":9},{"name":"Shichinosuke Nakamura","character":"The Emperor (voice)","img":null,"order":10},{"name":"Isao Hashizume","character":"Prince Kuramochi (voice)","img":null,"order":11},{"name":"Tatsuya Nakadai","character":"Charcoal-maker Old Man (voice)","img":null,"order":12},{"name":"Yukiji Asaoka","character":"Kita no kata (voice)","img":null,"order":13},{"name":"Tamaki Kojo","character":"Middle Counselor Isonokami (voice)","img":null,"order":14}],"movie:103":[{"name":"Robert De Niro","character":"Travis Bickle","img":null,"order":0},{"name":"Jodie Foster","character":"Iris","img":null,"order":1},{"name":"Cybill Shepherd","character":"Betsy","img":null,"order":2},{"name":"Harvey Keitel","character":"Sport","img":null,"order":3},{"name":"Peter Boyle","character":"Wizard","img":null,"order":4},{"name":"Leonard Harris","character":"Charles Palantine","img":null,"order":5},{"name":"Albert Brooks","character":"Tom","img":null,"order":6},{"name":"Diahnne Abbott","character":"Concession Girl","img":null,"order":7},{"name":"Frank Adu","character":"Angry Black Man","img":null,"order":8},{"name":"Victor Argo","character":"Melio","img":null,"order":9},{"name":"Gino Ardito","character":"Policeman at Rally","img":null,"order":10},{"name":"Garth Avery","character":"Iris' Friend","img":null,"order":11},{"name":"Harry Cohn","character":"Cabbie in Bellmore","img":null,"order":12},{"name":"Cooper Cunningham","character":"Hooker in Cab","img":null,"order":13},{"name":"Brenda Dickson","character":"Soap Opera Woman","img":null,"order":14}],"movie:497828":[{"name":"Harris Dickinson","character":"Carl","img":null,"order":0},{"name":"Charlbi Dean","character":"Yaya","img":null,"order":1},{"name":"Dolly de Leon","character":"Abigail","img":null,"order":2},{"name":"Woody Harrelson","character":"The Captain","img":null,"order":3},{"name":"Zlatko Buri\u0107","character":"Dimitry","img":null,"order":4},{"name":"Vicki Berlin","character":"Paula","img":null,"order":5},{"name":"Henrik Dorsin","character":"Jarmo","img":null,"order":6},{"name":"Iris Berben","character":"Therese","img":null,"order":7},{"name":"Jean-Christophe Folly","character":"Nelson","img":null,"order":8},{"name":"Amanda Walker","character":"Clementine","img":null,"order":9},{"name":"Oliver Ford Davies","character":"Winston","img":null,"order":10},{"name":"Sunnyi Melles","character":"Vera","img":null,"order":11},{"name":"Thobias Thorwid","character":"Lewis","img":null,"order":12},{"name":"Jiannis Moustos","character":"Driver","img":null,"order":13},{"name":"Timoleon Gketsos","character":"Handsome Crewman","img":null,"order":14}],"movie:868":[{"name":"Presley Chweneyagae","character":"Tsotsi","img":null,"order":0},{"name":"Jerry Mofokeng","character":"Morris","img":null,"order":1},{"name":"Terry Pheto","character":"Miriam","img":null,"order":2},{"name":"Zenzo Ngqobe","character":"Butcher","img":null,"order":3},{"name":"Zola","character":"Fela","img":null,"order":4},{"name":"Rapulana Seiphemo","character":"John Dube","img":null,"order":5},{"name":"Nambitha Mpumlwana","character":"Pumla Dube","img":null,"order":6},{"name":"Percy Matsemela","character":"Sergeant Zuma","img":null,"order":7},{"name":"Thembi Nyandeni","character":"Soekie","img":null,"order":8},{"name":"Owen Sejake","character":"Gumboot Dlamini","img":null,"order":9},{"name":"Israel Makoe","character":"Tsotsi's father","img":null,"order":10},{"name":"Sindi Khambule","character":"Tsotsi's Mother","img":null,"order":11},{"name":"Benny Moshe","character":"Young Tsotsi","img":null,"order":12},{"name":"Mothusi Magano","character":"Boston","img":null,"order":13},{"name":"Kenneth Nkosi","character":"Aap","img":null,"order":14}],"movie:1517772":[{"name":"Lauryn Licari","character":"Self","img":null,"order":0},{"name":"Owen McKenny","character":"Self","img":null,"order":1},{"name":"Sophie Weber","character":"Self","img":null,"order":2},{"name":"Macy Johnston","character":"Self","img":null,"order":3},{"name":"Shawn Licari","character":"Self","img":null,"order":4},{"name":"Kendra Licari","character":"Self","img":null,"order":5},{"name":"Dave McKenny","character":"Self","img":null,"order":6},{"name":"Jill McKenny","character":"Self","img":null,"order":7},{"name":"Bill Chillman","character":"Self - The Superintendent","img":null,"order":8},{"name":"Dan Boyer","character":"Self - Principal","img":null,"order":9},{"name":"Khloe Wilson","character":"Self","img":null,"order":10},{"name":"Craig Wilson","character":"Self","img":null,"order":11},{"name":"Tami Wilson","character":"Self","img":null,"order":12},{"name":"Korin Weber","character":"Self","img":null,"order":13},{"name":"Bradley Peter","character":"Self - FBI Liason","img":null,"order":14}],"movie:977942":[{"name":"Andrew Garfield","character":"Ploughman","img":null,"order":0},{"name":"Jamie Bell","character":"John Ball","img":null,"order":1},{"name":"Stephen Dillane","character":"Archbishop Sudbury","img":null,"order":2},{"name":"Woody Norman","character":"King Richard II","img":null,"order":3},{"name":"Tom Hollander","character":"Treasurer Hales","img":null,"order":4},{"name":"Cosmo Jarvis","character":"Wat Tyler","img":null,"order":5},{"name":"Thomasin McKenzie","character":"Alyce","img":null,"order":6},{"name":"Jonny Lee Miller","character":"Mayor Walworth","img":null,"order":7},{"name":"Katherine Waterston","character":"Countess","img":null,"order":8},{"name":"Stanley Townsend","character":"Chief Justice Bealknap","img":null,"order":9},{"name":"Sky Yang","character":"Scribe","img":null,"order":10},{"name":"D\u00f3nal Finn","character":"Village Priest","img":null,"order":11},{"name":"Gerald Tyler","character":"Lord Beauchamp","img":null,"order":12},{"name":"Simon Kunz","character":"Sir John Newton","img":null,"order":13},{"name":"Mark Redhead","character":"Lord Peterborough","img":null,"order":14}],"movie:429197":[{"name":"Christian Bale","character":"Dick Cheney","img":null,"order":0},{"name":"Amy Adams","character":"Lynne Cheney","img":null,"order":1},{"name":"Steve Carell","character":"Donald Rumsfeld","img":null,"order":2},{"name":"Sam Rockwell","character":"George W. Bush","img":null,"order":3},{"name":"Alison Pill","character":"Mary Cheney","img":null,"order":4},{"name":"Eddie Marsan","character":"Paul Wolfowitz","img":null,"order":5},{"name":"Justin Kirk","character":"Scooter Libby","img":null,"order":6},{"name":"LisaGay Hamilton","character":"Condoleezza Rice","img":null,"order":7},{"name":"Jesse Plemons","character":"Kurt","img":null,"order":8},{"name":"Bill Camp","character":"Gerald Ford","img":null,"order":9},{"name":"Don McManus","character":"David Addington","img":null,"order":10},{"name":"Lily Rabe","character":"Liz Cheney","img":null,"order":11},{"name":"Shea Whigham","character":"Wayne Vincent","img":null,"order":12},{"name":"Stephen Adly Guirgis","character":"George Tenet","img":null,"order":13},{"name":"Tyler Perry","character":"Colin Powell","img":null,"order":14}],"movie:81":[{"name":"Sumi Shimamoto","character":"Nausica\u00e4 (voice)","img":null,"order":0},{"name":"Ichiro Nagai","character":"Mito (voice)","img":null,"order":1},{"name":"Gor\u014d Naya","character":"Yupa (voice)","img":null,"order":2},{"name":"Yoji Matsuda","character":"Asbel (voice)","img":null,"order":3},{"name":"Yoshiko Sakakibara","character":"Kushana (voice)","img":null,"order":4},{"name":"Iemasa Kayumi","character":"Kurotowa (voice)","img":null,"order":5},{"name":"Hisako Kyoda","character":"Obaba (voice)","img":null,"order":6},{"name":"Mahito Tsujimura","character":"Jihl (voice)","img":null,"order":7},{"name":"Mina Tominaga","character":"Lastel (voice)","img":null,"order":8},{"name":"Kohei Miyauchi","character":"Gol (voice)","img":null,"order":9},{"name":"Joji Yanami","character":"Gikkuri (voice)","img":null,"order":10},{"name":"Minoru Yada","character":"Niga (voice)","img":null,"order":11},{"name":"Rihoko Yoshida","character":"Teto / Girl C (voice)","img":null,"order":12},{"name":"Tetsuo Mizutori","character":"Commando (voice)","img":null,"order":13},{"name":"Masako Sugaya","character":"Girl A (voice)","img":null,"order":14}],"movie:1198984":[{"name":"Daisy Ridley","character":"Ava","img":null,"order":0},{"name":"Brenton Thwaites","character":"Clay","img":null,"order":1},{"name":"Mark Coles Smith","character":"Riley","img":null,"order":2},{"name":"Matt Whelan","character":"Mitch","img":null,"order":3},{"name":"Chloe Hurst","character":"Katie Harris","img":null,"order":4},{"name":"Kym Jackson","character":"Lt. Wilkie","img":null,"order":5},{"name":"Holly Hargreaves","character":"Mrs. Smith","img":null,"order":6},{"name":"Deanna Cooney","character":"Bianca","img":null,"order":7},{"name":"Elijah Williams","character":"Greg","img":null,"order":8},{"name":"Salme Geransar","character":"Private Clarkson","img":null,"order":9},{"name":"Luke Jai McIntosh","character":"Cadet","img":null,"order":10},{"name":"Joel Jackson","character":"Anxious Bus Volunteer","img":null,"order":11},{"name":"Dan Paris","character":"Captain Vance","img":null,"order":12},{"name":"Nicola Bartlett","character":"Woman on Plane","img":null,"order":13},{"name":"Kim Fleming","character":"Briefing Colonel","img":null,"order":14}],"movie:953371":[{"name":"Benjamin O'Keefe","character":"Self - Activist & Public Speaker","img":null,"order":0},{"name":"Bobby Blanski","character":"Self - Former A&F Model","img":null,"order":1},{"name":"Ryan Daharsh","character":"Self - Former A&F Model","img":null,"order":2},{"name":"Jennifer Sheahan","character":"Self - Former Store Employee","img":null,"order":3},{"name":"Dr. Anthony Ocampo","character":"Self - Former Store Employee","img":null,"order":4},{"name":"Sapna Maheshwari","character":"Self - Business Reporter, NY Times","img":null,"order":5},{"name":"Alan Karo","character":"Self - Fashion Marketing & Advertising Executive","img":null,"order":6},{"name":"Treva Lindsey","character":"Self - Professor of History, Ohio State University","img":null,"order":7},{"name":"Carla Barrientos","character":"Self - Former Store Employee","img":null,"order":8},{"name":"Robin Givhan","character":"Self - Senior Critic-at-Large, Washington Post","img":null,"order":9},{"name":"Jose Sanchez","character":"Self - Former A&F Recruiter","img":null,"order":10},{"name":"Moe Tkacik","character":"Self - Journalist","img":null,"order":11},{"name":"Mike Jeffries","character":"Self - Former A&F CEO (archive footage)","img":null,"order":12},{"name":"Charles Martin","character":"Self - Former A&F Design VP","img":null,"order":13},{"name":"Cindy Smith-Maglione","character":"Self - Former A&F VP of Merchandising","img":null,"order":14}],"movie:1184918":[{"name":"Lupita Nyong'o","character":"Roz / Rummage (voice)","img":null,"order":0},{"name":"Pedro Pascal","character":"Fink (voice)","img":null,"order":1},{"name":"Kit Connor","character":"Brightbill (voice)","img":null,"order":2},{"name":"Bill Nighy","character":"Longneck (voice)","img":null,"order":3},{"name":"Stephanie Hsu","character":"Vontra (voice)","img":null,"order":4},{"name":"Matt Berry","character":"Paddler (voice)","img":null,"order":5},{"name":"Ving Rhames","character":"Thunderbolt (voice)","img":null,"order":6},{"name":"Mark Hamill","character":"Thorn (voice)","img":null,"order":7},{"name":"Catherine O'Hara","character":"Pinktail (voice)","img":null,"order":8},{"name":"Boone Storm","character":"Baby Brightbill (voice)","img":null,"order":9},{"name":"Alexandra Novelle","character":"Snowdown (voice)","img":null,"order":10},{"name":"Raphael Alejandro","character":"Peck (voice)","img":null,"order":11},{"name":"Paul-Mik\u00e9l Williams","character":"Feather (voice)","img":null,"order":12},{"name":"Eddie Park","character":"Honkington (voice)","img":null,"order":13},{"name":"Dee Bradley Baker","character":"Raccoons / Squirrels / Badgers (voice)","img":null,"order":14}],"movie:149870":[{"name":"Hideaki Anno","character":"Jiro Horikoshi (voice)","img":null,"order":0},{"name":"Hidetoshi Nishijima","character":"Honj\u00f4 (voice)","img":null,"order":1},{"name":"Miori Takimoto","character":"Naoko Satomi (voice)","img":null,"order":2},{"name":"Masahiko Nishimura","character":"Kurokawa (voice)","img":null,"order":3},{"name":"Stephen Alpert","character":"Castorp (voice)","img":null,"order":4},{"name":"Mansai Nomura","character":"Giovanni Battista (voice)","img":null,"order":5},{"name":"Jun Kunimura","character":"Hattori (voice)","img":null,"order":6},{"name":"Mirai Shida","character":"Kayo Horikoshi (voice)","img":null,"order":7},{"name":"Shinobu Otake","character":"Mrs. Kurokawa (voice)","img":null,"order":8},{"name":"Morio Kazama","character":"Satomi (voice)","img":null,"order":9},{"name":"Keiko Takeshita","character":"Jir\u00f4's mother (voice)","img":null,"order":10},{"name":"Sascha","character":"German Security Guard / Flight Engineer (voice)","img":null,"order":11},{"name":"Kaichi Kaburagi","character":"Young Jiro Horikoshi (voice)","img":null,"order":12},{"name":"Maki Shinta","character":"Young Kayo Horikoshi (voice)","img":null,"order":13},{"name":"Berndt Otto","character":"(voice)","img":null,"order":14}],"movie:666243":[{"name":"Theo James","character":"Vesemir (voice)","img":null,"order":0},{"name":"Mary McDonnell","character":"Lady Zerbst (voice)","img":null,"order":1},{"name":"Lara Pulver","character":"Tetra Gilcrest (voice)","img":null,"order":2},{"name":"Graham McTavish","character":"Deglan (voice)","img":null,"order":3},{"name":"Tom Canton","character":"Filavandrel (voice)","img":null,"order":4},{"name":"David Errigo Jr.","character":"Young Vesemir / Young Remus (voice)","img":null,"order":5},{"name":"Jennifer Hale","character":"Illyana / Noblewoman (voice)","img":null,"order":6},{"name":"Kari Wahlgren","character":"Kitsu / Boy (voice)","img":null,"order":7},{"name":"Matthew Yang King","character":"Luka / Skogar (voice)","img":null,"order":8},{"name":"Darryl Kurylo","character":"Sven / Nobleman (voice)","img":null,"order":9},{"name":"Keith Ferguson","character":"Lord Carlisle / Reidrich (voice)","img":null,"order":10},{"name":"Jennie Kwan","character":"Young Luka (voice)","img":null,"order":11},{"name":"AJ LoCascio","character":"Young Sven (voice)","img":null,"order":12},{"name":"Michaela Dietz","character":"Tomas (voice)","img":null,"order":13},{"name":"Harry Hissrich","character":"Young Boy (Geralt) (voice)","img":null,"order":14}],"movie:1203329":[{"name":"Doug Cockle","character":"Geralt (voice)","img":null,"order":0},{"name":"Joey Batey","character":"Jaskier / Young Jaskier (voice)","img":null,"order":1},{"name":"Christina Wren","character":"Essi (voice)","img":null,"order":2},{"name":"Emily Carey","character":"Sh'eenaz (voice)","img":null,"order":3},{"name":"Camrus Johnson","character":"Agloval (voice)","img":null,"order":4},{"name":"Simon Templeman","character":"King Usveldt (voice)","img":null,"order":5},{"name":"Mallory Jansen","character":"Melusina / Princess Nispen (voice)","img":null,"order":6},{"name":"Anya Chalotra","character":"Yennefer (voice)","img":null,"order":7},{"name":"Ray Chase","character":"Zelest (voice)","img":null,"order":8},{"name":"Cynthia Kaye McWilliams","character":"Queen Dahut (voice)","img":null,"order":9},{"name":"Ramon Tikaram","character":"King Basim (voice)","img":null,"order":10},{"name":"Kari Wahlgren","character":"Zmarra / Vodnik Assassin (voice)","img":null,"order":11},{"name":"Stephen Fu","character":"Deroua (voice)","img":null,"order":12},{"name":"George Ackles","character":"Drouhard (voice)","img":null,"order":13},{"name":"Brian George","character":"Barkeep / Herald (voice)","img":null,"order":14}],"tv:260458":[{"name":"Malik Elassal","character":"Samir Rahman","img":null,"order":0},{"name":"Lucy Freyer","character":"Billie Schaeffer","img":null,"order":1},{"name":"Jack Innanen","character":"Paul Baker","img":null,"order":2},{"name":"Amita Rao","character":"Issa Damani","img":null,"order":3},{"name":"Owen Thiele","character":"Anton Evans","img":null,"order":4},{"name":"Julia Fox","character":"Julia Fox","img":null,"order":500},{"name":"Rachel Marsh","character":"Carly","img":null,"order":502},{"name":"Charlie Cox","character":"Mr. Teacher","img":null,"order":521},{"name":"Julian Manjerico","character":"Zack-Carlos","img":null,"order":576},{"name":"Martha Girvin","character":"Serena","img":null,"order":582},{"name":"Andrea Pavlovic","character":"Lily","img":null,"order":585},{"name":"Meghan Swaby","character":"Mary","img":null,"order":587},{"name":"Liam Cullagh","character":"OJ","img":null,"order":594},{"name":"Will Sennett","character":"Roach","img":null,"order":595},{"name":"Joey Dardano","character":"Schnee","img":null,"order":596}],"tv:64513":[{"name":"Sterling K. Brown","character":"Christopher Darden","img":null,"order":0},{"name":"Kenneth Choi","character":"Lance Ito","img":null,"order":3},{"name":"Beanie Feldstein","character":"Monica Lewinsky","img":null,"order":5},{"name":"Christian Clemenson","character":"Bill Hodgman","img":null,"order":7},{"name":"Sarah Paulson","character":"Linda Tripp","img":null,"order":9},{"name":"Cuba Gooding Jr.","character":"O. J. Simpson","img":null,"order":9},{"name":"Margo Martindale","character":"Lucianne Goldberg","img":null,"order":11},{"name":"Bruce Greenwood","character":"Gil Garcetti","img":null,"order":12},{"name":"Edie Falco","character":"Hillary Clinton","img":null,"order":13},{"name":"Nathan Lane","character":"F. Lee Bailey","img":null,"order":14},{"name":"Clive Owen","character":"Bill Clinton","img":null,"order":15},{"name":"David Schwimmer","character":"Robert Kardashian","img":null,"order":18},{"name":"John Travolta","character":"Robert Shapiro","img":null,"order":19},{"name":"Courtney B. Vance","character":"Johnnie Cochran","img":null,"order":20},{"name":"Annaleigh Ashford","character":"Elizabeth Cote","img":null,"order":398}],"tv:215866":[{"name":"Taylor Kitsch","character":"Isaac Reed","img":null,"order":0},{"name":"Betty Gilpin","character":"Sara Rowell","img":null,"order":1},{"name":"Dane DeHaan","character":"Jacob Pratt","img":null,"order":2},{"name":"Saura Lightfoot-Leon","character":"Abish Pratt","img":null,"order":3},{"name":"Derek Hinkey","character":"Red Feather","img":null,"order":4},{"name":"Joe Tippett","character":"James Wolsey","img":null,"order":5},{"name":"Jai Courtney","character":"Virgil Cutter","img":null,"order":6},{"name":"Preston Mota","character":"Devin Rowell","img":null,"order":7},{"name":"Shawnee Pourier","character":"Two Moons","img":null,"order":8},{"name":"Shea Whigham","character":"Jim Bridger","img":null,"order":9},{"name":"Kyle Davis","character":"Tilly","img":null,"order":500},{"name":"Tokala Black Elk","character":"Buffalo Run","img":null,"order":501},{"name":"Nick Hargrove","character":"Cottrell","img":null,"order":502},{"name":"Alex Fine","character":"Gant","img":null,"order":504},{"name":"Andrew P. Logan","character":"Lucas","img":null,"order":506}],"tv:4589":[{"name":"Jason Bateman","character":"Michael Bluth","img":null,"order":0},{"name":"Portia de Rossi","character":"Lindsay Bluth F\u00fcnke","img":null,"order":1},{"name":"Will Arnett","character":"Gob Bluth","img":null,"order":2},{"name":"Michael Cera","character":"George Michael Bluth","img":null,"order":3},{"name":"Alia Shawkat","character":"Maeby F\u00fcnke","img":null,"order":4},{"name":"Tony Hale","character":"Byron 'Buster' Bluth","img":null,"order":5},{"name":"David Cross","character":"Tobias F\u00fcnke","img":null,"order":6},{"name":"Jeffrey Tambor","character":"George Bluth Sr.","img":null,"order":7},{"name":"Jessica Walter","character":"Lucille Bluth","img":null,"order":8},{"name":"Ron Howard","character":"Narrator","img":null,"order":9},{"name":"Justin Grant Wade","character":"Steve Holt","img":null,"order":508},{"name":"Liza Minnelli","character":"Lucille Austero","img":null,"order":520},{"name":"Mae Whitman","character":"Ann Veal","img":null,"order":533},{"name":"John Beard","character":"John Beard","img":null,"order":609},{"name":"Henry Winkler","character":"Barry Zuckerkorn","img":null,"order":635}],"tv:246":[{"name":"Zach Tyler Eisen","character":"Aang (voice)","img":null,"order":0},{"name":"Mae Whitman","character":"Katara (voice)","img":null,"order":1},{"name":"Jack De Sena","character":"Sokka (voice)","img":null,"order":2},{"name":"Dante Basco","character":"Zuko (voice)","img":null,"order":3},{"name":"Mako","character":"Iroh (voice)","img":null,"order":4},{"name":"Dee Bradley Baker","character":"Appa / Momo (voice)","img":null,"order":7},{"name":"Cricket Leigh","character":"Mai (voice)","img":null,"order":9},{"name":"Olivia Hack","character":"Ty Lee (voice)","img":null,"order":10},{"name":"Michaela Jill Murphy","character":"Toph Beifong (voice)","img":null,"order":173},{"name":"Andr\u00e9 Sogliuzzo","character":"Captain (voice)","img":null,"order":378},{"name":"Grey DeLisle","character":"Azula / Additional Voices (voice)","img":null,"order":491},{"name":"Greg Baldwin","character":"Iroh / Combustion Man (voice)","img":null,"order":545},{"name":"Mark Hamill","character":"Ozai (voice)","img":null,"order":581},{"name":"Jennie Kwan","character":"Suki (voice)","img":null,"order":624},{"name":"Jim Meskimen","character":"Avatar Kuruk (voice)","img":null,"order":657}],"tv:124428":[{"name":"Jeroen Kijk in de Vegte","character":"Voice-over","img":null,"order":0},{"name":"Martijn Bolhoeve","character":"Self - Owner of a B&B in Hua Hin, Thailand","img":null,"order":2},{"name":"Joy van Swieten","character":"Self - Owner of a B&B in Malaga, Spain","img":null,"order":3},{"name":"Marian Trott","character":"Self - Owner of a B&B in Budens, Portugal","img":null,"order":4},{"name":"Bram Eijgenhuijsen","character":"Self - Owner of a B&B in Norrhult, Sweden","img":null,"order":5},{"name":"Debbie de Jong","character":"Self - Owner of a B&B in Sant Andreu del Terri, Spain","img":null,"order":6},{"name":"Leendert Wijland","character":"Self - Owner of a B&B in La Roche-En-Ardenne, Belgium","img":null,"order":7},{"name":"Petri Damen","character":"Self - Owner of a B&B in Monclar, France","img":null,"order":8},{"name":"Walter Spangenberg","character":"Self - Owner of a B&B in Troche, France","img":null,"order":9},{"name":"Iris Villas","character":"Self - Owner of a B&B in Sparto, Greece","img":null,"order":13},{"name":"Robert Jan van Rheenen","character":"Self - Owner of a B&B in Dornoch, Scotland","img":null,"order":14},{"name":"Arno van Wilpe","character":"Self - Owner of a B&B in Payzac, France","img":null,"order":15},{"name":"Mike Verkoelen","character":"Self - Owner of a B&B in Ma\u00e7anet de la Selva, Spain","img":null,"order":16},{"name":"Paul Visser","character":"Self - Partner of Debbie de Jong","img":null,"order":48},{"name":"Dani Zijlstra","character":"Self - Partner of Joy van Swieten","img":null,"order":49}],"tv:136315":[{"name":"Jeremy Allen White","character":"Carmen 'Carmy' Berzatto","img":null,"order":0},{"name":"Ebon Moss-Bachrach","character":"Richard 'Richie' Jerimovich","img":null,"order":1},{"name":"Ayo Edebiri","character":"Sydney Adamu","img":null,"order":2},{"name":"Lionel Boyce","character":"Marcus Brooks","img":null,"order":3},{"name":"Abby Elliott","character":"Natalie 'Sugar' Berzatto","img":null,"order":4},{"name":"Matty Matheson","character":"Neil Fak","img":null,"order":5},{"name":"Liza Col\u00f3n-Zayas","character":"Bettina 'Tina' Marrero","img":null,"order":6},{"name":"Edwin Lee Gibson","character":"Ebraheim","img":null,"order":8},{"name":"Oliver Platt","character":"Uncle Jimmy 'Cicero' Kalinowski","img":null,"order":500},{"name":"Christopher Zucchero","character":"Chi-Chi","img":null,"order":505},{"name":"Chris Witaske","character":"Pete Katinsky","img":null,"order":513},{"name":"Corey Hendrix","character":"Gary 'Sweeps' Woods","img":null,"order":536},{"name":"Jose M. Cervantes","character":"Angel","img":null,"order":537},{"name":"Richard Esteras","character":"Manny","img":null,"order":538},{"name":"Ricky Staffieri","character":"Theodore Fak","img":null,"order":548}],"tv:74313":[{"name":"David Attenborough","character":"Self - Presenter","img":null,"order":0}],"tv:93290":[{"name":"Martin Freeman","character":"Paul Worsley","img":null,"order":0},{"name":"Daisy Haggard","character":"Ally Grant","img":null,"order":1},{"name":"Eve Prenelle","character":"Ava Worsley","img":null,"order":4},{"name":"Alex Eastwood","character":"Luke Worsley","img":null,"order":5},{"name":"Oscar Kennedy","character":"Luke Worsley","img":null,"order":6},{"name":"Zo\u00eb Athena","character":"Ava Worsley","img":null,"order":7},{"name":"George Wakeman","character":"Luke Worsley","img":null,"order":345},{"name":"Jayda Eyles","character":"Young Ava","img":null,"order":346},{"name":"Tim Steed","character":"Carl","img":null,"order":501},{"name":"Patrick Baladi","character":"Darren","img":null,"order":504},{"name":"Alun Armstrong","character":"Jim","img":null,"order":514},{"name":"Joanna Bacon","character":"Jackie","img":null,"order":515},{"name":"Stella Gonet","character":"Leah","img":null,"order":516},{"name":"Hugh Quarshie","character":"Alex","img":null,"order":519},{"name":"Aliyah Sesay","character":"Grace","img":null,"order":520}],"tv:87108":[{"name":"Jared Harris","character":"Valery Legasov","img":null,"order":0},{"name":"Stellan Skarsg\u00e5rd","character":"Boris Shcherbina","img":"mW7xmtGV4y79kQGn0zkKVGDMAmw.jpg","order":1},{"name":"Emily Watson","character":"Ulana Khomyuk","img":null,"order":2},{"name":"Paul Ritter","character":"Anatoly Dyatlov","img":null,"order":3},{"name":"Jessie Buckley","character":"Lyudmilla Ignatenko","img":null,"order":4},{"name":"Adam Nagaitis","character":"Vasily Ignatenko","img":null,"order":5},{"name":"Sam Troughton","character":"Alexandr Akimov","img":null,"order":6},{"name":"Robert Emms","character":"Leonid Toptunov","img":null,"order":7},{"name":"Con O'Neill","character":"Viktor Bryukhanov","img":null,"order":8},{"name":"Adrian Rawlins","character":"Nikolai Fomin","img":null,"order":9},{"name":"Alan Williams","character":"KGB Chairman Charkov","img":null,"order":10},{"name":"David Dencik","character":"Mikhail Gorbachev","img":null,"order":11},{"name":"Mark Lewis Jones","character":"General Pikalov","img":null,"order":12},{"name":"Ralph Ineson","character":"General Tarakanov","img":null,"order":13},{"name":"Barry Keoghan","character":"Pavel","img":null,"order":14}],"tv:18347":[{"name":"Joel McHale","character":"Jeff Winger","img":null,"order":0},{"name":"Gillian Jacobs","character":"Britta Perry","img":null,"order":1},{"name":"Alison Brie","character":"Annie Edison","img":null,"order":2},{"name":"Donald Glover","character":"Troy Barnes","img":null,"order":3},{"name":"Yvette Nicole Brown","character":"Shirley Bennett","img":null,"order":4},{"name":"Jim Rash","character":"Dean Craig Pelton","img":null,"order":5},{"name":"Danny Pudi","character":"Abed Nadir","img":null,"order":6},{"name":"Chevy Chase","character":"Pierce Hawthorne","img":null,"order":7},{"name":"Ken Jeong","character":"Ben Chang","img":null,"order":10},{"name":"John Oliver","character":"Professor Ian Duncan","img":null,"order":154},{"name":"Charley Koontz","character":"Neil","img":null,"order":1003},{"name":"Danielle Kaplowitz","character":"Vicki","img":null,"order":1034},{"name":"Erik Charles Nielsen","character":"Garrett Lambert","img":null,"order":1035},{"name":"Dino Stamatopoulos","character":"Star-Burns","img":null,"order":1046},{"name":"Richard Erdman","character":"Leonard","img":null,"order":1049}],"tv:289517":[{"name":"Lee Jae-wook","character":"Byeon Hyeok-jun","img":null,"order":0},{"name":"Go Min-si","character":"Eum Yeon-joo","img":null,"order":1},{"name":"Kim Min-ha","character":"Byeon Ji-yoon","img":null,"order":2},{"name":"Lee Hee-jun","character":"Head of Spider Staffing Agency","img":null,"order":3},{"name":"Choi Hyun-wook","character":"Choi Hee-dong","img":null,"order":500},{"name":"Yoon Kyung-ho","character":"Jo Jun-seop","img":null,"order":501},{"name":"Cho Hyun-chul","character":"Cinephile","img":null,"order":502},{"name":"Yoon Byung-hee","character":"Theater Manager","img":null,"order":503},{"name":"Seo Jae-hee","character":"Director of Yangcheon Bio Clinic","img":null,"order":504},{"name":"Ko Chang-seok","character":"Foreman","img":null,"order":505},{"name":"Kim Si-eun","character":"Woman on the bridge","img":null,"order":506}],"tv:110695":[{"name":"Michael Keaton","character":"Samuel Finnix","img":null,"order":0},{"name":"Peter Sarsgaard","character":"Rick Mountcastle","img":null,"order":1},{"name":"Michael Stuhlbarg","character":"Richard Sackler","img":null,"order":2},{"name":"Will Poulter","character":"Billy Cutler","img":null,"order":3},{"name":"John Hoogenakker","character":"Randy Ramseyer","img":null,"order":4},{"name":"Kaitlyn Dever","character":"Betsy Mallum","img":null,"order":5},{"name":"Rosario Dawson","character":"Bridget Meyer","img":null,"order":6},{"name":"Will Chase","character":"Michael Friedman","img":null,"order":7},{"name":"Cleopatra Coleman","character":"Grace Pell","img":null,"order":8},{"name":"Parker Sack","character":"Dirk","img":null,"order":9},{"name":"Mare Winningham","character":"Diane Mallum","img":null,"order":504},{"name":"Ian Unterman","character":"Jonathan Sackler","img":null,"order":505},{"name":"Jake McDorman","character":"John Brownlee","img":null,"order":506},{"name":"Phillipa Soo","character":"Amber Collins","img":null,"order":507},{"name":"Brendan Patrick Connor","character":"Howard Udell","img":null,"order":508}],"tv:90228":[{"name":"Emily Watson","character":"Mother Superior Valya Harkonnen","img":null,"order":0},{"name":"Olivia Williams","character":"Reverend Mother Tula Harkonnen","img":null,"order":1},{"name":"Travis Fimmel","character":"Desmond Hart","img":null,"order":2},{"name":"Jodhi May","character":"Empress Natalya Arat Corrino","img":null,"order":3},{"name":"Sarah-Sofie Boussnina","character":"Princess Ynez Corrino","img":null,"order":4},{"name":"Chloe Lea","character":"Sister Lila","img":null,"order":5},{"name":"Chris Mason","character":"Keiran Atreides","img":null,"order":6},{"name":"Shalom Brune-Franklin","character":"Mikaela","img":null,"order":7},{"name":"Jade Anouka","character":"Sister Theodosia","img":null,"order":9},{"name":"Edward Davis","character":"Harrow Harkonnen","img":null,"order":10},{"name":"Josh Heuston","character":"Constantine Corrino","img":null,"order":11},{"name":"Faoileann Cunningham","character":"Sister Jen","img":null,"order":12},{"name":"Aoife Hinds","character":"Sister Emeline","img":null,"order":13},{"name":"Indira Varma","character":"","img":null,"order":14},{"name":"Ashley Walters","character":"","img":null,"order":15}],"tv:258165":[{"name":"Florence Pugh","character":"Cathy Ames","img":null,"order":0},{"name":"Christopher Abbott","character":"Adam Trask","img":null,"order":1},{"name":"Mike Faist","character":"Charles Trask","img":null,"order":2},{"name":"Joseph Zada","character":"Caleb Trask","img":null,"order":253},{"name":"Robert Morgan","character":"Sheriff Quinn","img":null,"order":500},{"name":"Christopher Sommers","character":"Mr. Bacon","img":null,"order":501},{"name":"Brad McMurray","character":"Joe Valery","img":null,"order":502},{"name":"Vaughan Reilly","character":"Adolescent Cathy","img":null,"order":503},{"name":"Hoon Lee","character":"Lee","img":null,"order":505},{"name":"Ciar\u00e1n Hinds","character":"Samuel Hamilton","img":null,"order":506},{"name":"Tandi Wright","character":"Mrs. Garb","img":null,"order":507},{"name":"Anita Hegh","character":"Liza Hamilton","img":null,"order":508},{"name":"Oscar Redding","character":"Rabbit Holman","img":null,"order":509},{"name":"Amelia Reynolds","character":"Ethel","img":null,"order":510},{"name":"Jonah Wren Phillips","character":"Young Aron Trask","img":null,"order":512}],"tv:106379":[{"name":"Ella Purnell","character":"Lucy MacLean","img":null,"order":0},{"name":"Aaron Moten","character":"Maximus","img":null,"order":1},{"name":"Mois\u00e9s Arias","character":"Norm MacLean","img":null,"order":2},{"name":"Frances Turner","character":"Barb Howard","img":null,"order":3},{"name":"Kyle MacLachlan","character":"Overseer Hank MacLean","img":null,"order":4},{"name":"Walton Goggins","character":"The Ghoul / Cooper Howard","img":null,"order":5},{"name":"Leslie Uggams","character":"Betty Pearson","img":null,"order":501},{"name":"Michael Cristofer","character":"Elder Cleric Quintus","img":null,"order":502},{"name":"Johnny Pemberton","character":"Thaddeus","img":null,"order":503},{"name":"Zach Cherry","character":"Woody Thomas","img":null,"order":506},{"name":"Annabel O'Hagan","character":"Stephanie Harper","img":null,"order":507},{"name":"Dave Register","character":"Chet","img":null,"order":508},{"name":"Rodrigo Luzzi","character":"Reg McPhee","img":null,"order":509},{"name":"Leer Leary","character":"Davey","img":null,"order":515},{"name":"Amir Carr","character":"Young Maximus","img":null,"order":527}],"tv:2207":[{"name":"John Cleese","character":"Basil Fawlty","img":null,"order":0},{"name":"Prunella Scales","character":"Sybil Fawlty","img":null,"order":1},{"name":"Connie Booth","character":"Polly Sherman","img":null,"order":2},{"name":"Andrew Sachs","character":"Manuel","img":null,"order":3},{"name":"Ballard Berkeley","character":"Major Gowen","img":null,"order":4},{"name":"Brian Hall","character":"Terry the Chef","img":null,"order":5},{"name":"Gilly Flower","character":"Miss Tibbs","img":null,"order":6},{"name":"Renee Roberts","character":"Miss Gatsby","img":null,"order":7},{"name":"Michael Gwynn","character":"Lord Melbury","img":null,"order":500},{"name":"Diana King","character":"Mrs. Rachel Lloyd","img":null,"order":501},{"name":"David Kelly","character":"O'Reilly","img":null,"order":502},{"name":"Brenda Cowling","character":"Sister","img":null,"order":503},{"name":"Elizabeth Benson","character":"Mrs. Heath","img":null,"order":540},{"name":"Terence Conoley","character":"Mr. Johnston","img":null,"order":548},{"name":"George Lee","character":"Delivery Man","img":null,"order":565}],"tv:290720":[{"name":"Jose Coronado","character":"Basil","img":null,"order":0},{"name":"Mar\u00eda Valverde","character":"Adela Figueora","img":null,"order":1},{"name":"Marti\u00f1o Rivas","character":"Fox\u00e1","img":null,"order":2},{"name":"Maribel Verd\u00fa","character":"Raquel Auslander","img":null,"order":3},{"name":"Gonzalo de Castro","character":"Fonseca","img":null,"order":4},{"name":"Cristina Kovani","character":"Vicky","img":null,"order":5},{"name":"Jos\u00e9 Condessa","character":"Toni","img":null,"order":6},{"name":"Pep\u00f3n Nieto","character":"Seruya","img":null,"order":7},{"name":"Daniel Holgu\u00edn","character":"Gerard","img":null,"order":8},{"name":"Michael Stobbe","character":"Se\u00f1or Klemmer","img":null,"order":9},{"name":"Yaiza Guimar\u00e9","character":"Se\u00f1ora Klemmer","img":null,"order":10},{"name":"Almudena Salort","character":"Catalina","img":null,"order":11},{"name":"Paloma Bloyd","character":"Elisa","img":null,"order":12}],"tv:243316":[{"name":"Tina Fey","character":"Kate","img":null,"order":0},{"name":"Steve Carell","character":"Nick Pagano","img":null,"order":1},{"name":"Colman Domingo","character":"Danny","img":null,"order":2},{"name":"Erika Henningsen","character":"Ginny","img":null,"order":3},{"name":"Kerri Kenney","character":"Anne Pagano","img":null,"order":4},{"name":"Will Forte","character":"Jack","img":null,"order":5},{"name":"Marco Calvani","character":"Claude","img":null,"order":6},{"name":"Julia Lester","character":"Lila","img":null,"order":502},{"name":"Alan Alda","character":"Don","img":null,"order":503},{"name":"Ashlyn Maddox","character":"Beth","img":null,"order":509},{"name":"Eric Elizaga","character":"Desk Clerk","img":null,"order":513},{"name":"Toby Huss","character":"Terry","img":null,"order":519},{"name":"Blake Russell","character":"State Trooper","img":null,"order":528},{"name":"Steven Pasquale","character":"Mark Brett","img":null,"order":531},{"name":"Brielle Pribbernow","character":"Boardwalk Kid","img":null,"order":532}],"tv:65345":[{"name":"Justice Smith","character":"Ezekiel Figuero","img":null,"order":0},{"name":"Shameik Moore","character":"Shaolin Fantastic","img":null,"order":1},{"name":"Herizen F. Guardiola","character":"Mylene Cruz","img":null,"order":2},{"name":"Skylan Brooks","character":"Ra-Ra Kipling","img":null,"order":3},{"name":"Jaden Smith","character":"Marcus 'Dizzee' Kipling","img":null,"order":4},{"name":"Tremaine Brown Jr.","character":"Boo-Boo Kipling","img":null,"order":5},{"name":"Yahya Abdul-Mateen II","character":"Clarence 'Cadillac' Caldwell","img":null,"order":6},{"name":"Jimmy Smits","character":"Francisco 'Papa Fuerte' Cruz","img":null,"order":7},{"name":"Stefan\u00e9e Martin","character":"Yolanda Kipling","img":null,"order":8},{"name":"Shyrley Rodriguez","character":"Regina Diaz","img":null,"order":9},{"name":"Mamoudou Athie","character":"Grandmaster Flash","img":null,"order":10},{"name":"Giancarlo Esposito","character":"Pastor Ramon Cruz","img":null,"order":11},{"name":"Ron Cephas Jones","character":"Winston Kipling","img":null,"order":505},{"name":"RayJonaldy Rodriguez","character":"Silent Carlito","img":null,"order":506},{"name":"Zuri Reed","character":"Zulu Girl Yvonne","img":null,"order":508}],"tv:204304":[{"name":"Michael Pollan","character":"Self","img":null,"order":0},{"name":"Ann Shulgin","character":"Self - Author, Mentor & Guide Therapist","img":null,"order":500},{"name":"Alexander Shulgin","character":"Self - Chemist, Author & Professor (archive footage)","img":null,"order":501},{"name":"Rick Doblin","character":"Self - Founder, MAPS","img":null,"order":502},{"name":"Paul F. Daley","character":"Self","img":null,"order":503}],"tv:96488":[{"name":"Peter Sarsgaard","character":"David Russell","img":null,"order":0},{"name":"Kyle Gallner","character":"Eric Fisher","img":null,"order":1},{"name":"David Strathairn","character":"Henry Fisher","img":null,"order":2},{"name":"Kodi Smit-McPhee","character":"Chris Keller","img":null,"order":3},{"name":"Jersey Johnston","character":"Debbie Russell","img":null,"order":533},{"name":"Frank Whaley","character":"Detective James Connor","img":null,"order":577},{"name":"Ellen Humphreys","character":"Sharon Russell","img":null,"order":578},{"name":"Melinda McGraw","character":"Faith Turner","img":null,"order":579},{"name":"Chad L. Coleman","character":"Mr. Franklin","img":null,"order":604},{"name":"Joanna Going","character":"Mrs. Fisher","img":null,"order":621},{"name":"Ebon Moss-Bachrach","character":"Trey Carano","img":null,"order":622},{"name":"Autry Haydon-Wilson","character":"Young Amy Harlow","img":null,"order":623},{"name":"Vincent D'Onofrio","character":"Sgt. Ian Lynch","img":null,"order":624},{"name":"Andre Royo","character":"Charlie Shannon","img":null,"order":625},{"name":"Barbie Robertson","character":"Debbie Russell","img":null,"order":636}],"tv:2710":[{"name":"Charlie Day","character":"Charlie Kelly","img":null,"order":0},{"name":"Glenn Howerton","character":"Dennis Reynolds","img":null,"order":1},{"name":"Rob Mac","character":"Ronald 'Mac' McDonald","img":null,"order":2},{"name":"Kaitlin Olson","character":"Dee Reynolds","img":null,"order":3},{"name":"Danny DeVito","character":"Frank Reynolds","img":null,"order":4},{"name":"Artemis Pebdani","character":"Artemis","img":null,"order":6},{"name":"Mary Elizabeth Ellis","character":"Waitress","img":null,"order":7},{"name":"David Hornsby","character":"Matthew 'Rickety Cricket' Mara","img":null,"order":8},{"name":"Lynne Marie Stewart","character":"Charlie's Mom","img":null,"order":216},{"name":"Sandy Martin","character":"Mac's Mom","img":null,"order":847},{"name":"Jimmi Simpson","character":"Liam McPoyle","img":null,"order":1009},{"name":"Nate Mooney","character":"Ryan McPoyle","img":null,"order":1011},{"name":"Andrew Friedman","character":"Uncle Jack","img":null,"order":1140},{"name":"Lance Barber","character":"Bill Ponderosa","img":null,"order":1201},{"name":"Gregory Scott Cummins","character":"Luther","img":null,"order":1230}],"tv:224372":[{"name":"Peter Claffey","character":"Ser Duncan 'Dunk' the Tall","img":null,"order":0},{"name":"Dexter Sol Ansell","character":"Egg","img":null,"order":1},{"name":"Ross Anderson","character":"Ser Humfrey Hardyng","img":null,"order":500},{"name":"Daniel Monks","character":"Ser Manfred Dondarrion","img":null,"order":504},{"name":"Tom Vaughan-Lawlor","character":"Plummer","img":null,"order":505},{"name":"Sam Spruell","character":"Prince Maekar Targaryen","img":null,"order":510},{"name":"Finn Bennett","character":"Prince Aerion 'Brightflame' Targaryen","img":null,"order":511},{"name":"Bertie Carvel","character":"Prince Baelor 'Breakspear' Targaryen","img":null,"order":512},{"name":"Danny Webb","character":"Ser Arlan of Pennytree","img":null,"order":513},{"name":"Tanzyn Crawford","character":"Tanselle","img":null,"order":514},{"name":"Henry Ashton","character":"Prince Daeron 'the Drunken' Targaryen","img":null,"order":517},{"name":"Edward Ashley","character":"Ser Steffon Fossoway","img":null,"order":518},{"name":"Cara Harris","character":"Lady Gwin","img":null,"order":527},{"name":"Shaun Thomas","character":"Ser Raymun 'The Reluctant' Fossoway","img":null,"order":532},{"name":"Daniel Ings","character":"Ser Lyonel 'The Laughing Storm' Baratheon","img":null,"order":543}],"tv:157741":[{"name":"Billy Bob Thornton","character":"Tommy Norris","img":null,"order":0},{"name":"Demi Moore","character":"Cami Miller","img":null,"order":1},{"name":"Andy Garcia","character":"Gallino","img":null,"order":2},{"name":"Ali Larter","character":"Angela Norris","img":null,"order":3},{"name":"Jacob Lofland","character":"Cooper Norris","img":null,"order":4},{"name":"Michelle Randolph","character":"Ainsley Norris","img":null,"order":5},{"name":"Paulina Ch\u00e1vez","character":"Ariana Medina","img":null,"order":6},{"name":"Kayla Wallace","character":"Rebecca Falcone","img":null,"order":7},{"name":"Mark Collie","character":"Sheriff Walt Joeberg","img":null,"order":8},{"name":"James Jordan","character":"Dale Bradley","img":null,"order":9},{"name":"Jon Hamm","character":"Monty Miller","img":null,"order":10},{"name":"Sam Elliott","character":"Thomas 'T.L.' Norris","img":null,"order":11},{"name":"Colm Feore","character":"Nathan","img":null,"order":501},{"name":"Mustafa Speaks","character":"Boss","img":null,"order":531},{"name":"Caleb Martin","character":"Ben 'BR' Reynolds","img":null,"order":611}],"tv:100088":[{"name":"Bella Ramsey","character":"Ellie Williams","img":null,"order":1},{"name":"Gabriel Luna","character":"Tommy Miller","img":null,"order":2},{"name":"Isabela Merced","character":"Dina","img":null,"order":3},{"name":"Young Mazino","character":"Jesse","img":null,"order":4},{"name":"Pedro Pascal","character":"Joel Miller","img":null,"order":164},{"name":"Anna Torv","character":"Tess","img":null,"order":523},{"name":"Lamar Johnson","character":"Henry Burrell","img":null,"order":565},{"name":"Rutina Wesley","character":"Maria","img":null,"order":567},{"name":"Samuel Hoeksema","character":"Museum Clicker","img":null,"order":571},{"name":"Kaitlyn Dever","character":"Abby Anderson","img":null,"order":576},{"name":"Danny Ramirez","character":"Manny Alvarez","img":null,"order":577},{"name":"Tati Gabrielle","character":"Nora","img":null,"order":578},{"name":"Spencer Lord","character":"Owen","img":null,"order":579},{"name":"Robert John Burke","character":"Seth","img":null,"order":580},{"name":"Ariela Barer","character":"Mel","img":null,"order":581}],"tv:252193":[{"name":"Junichi Okada","character":"Shujiro Saga","img":null,"order":0},{"name":"Yumia Fujisaki","character":"Futaba Katsuki","img":null,"order":1},{"name":"Kaya Kiyohara","character":"Iroha Kinugasa","img":null,"order":2},{"name":"Masahiro Higashide","character":"Kyojin Tsuge","img":null,"order":3},{"name":"Taichi Saotome","character":"Shikura Adashino","img":null,"order":4},{"name":"Yuya Endo","character":"Sansuke Gion","img":null,"order":5},{"name":"Hideaki Ito","character":"Bukotsu Kanjiya","img":null,"order":6},{"name":"Hiroshi Abe","character":"Okabe Gentosai","img":null,"order":7},{"name":"Gaku Hamada","character":"Kawaji Toshiyoshi","img":null,"order":501},{"name":"Arata Iura","character":"Okubo Toshimichi","img":null,"order":502},{"name":"Yuya Matsuura","character":"Kouemon Tachikawa","img":null,"order":504},{"name":"Nobi Nakanishi","character":"Narrator","img":null,"order":505},{"name":"Tetsushi Tanaka","character":"Hisoka Maejima","img":null,"order":506},{"name":"Kairi Jo","character":"Shinjiro Sayama","img":null,"order":514},{"name":"Taiiku Okazaki","character":"Keage Jinroku","img":null,"order":519}],"tv:270572":[{"name":"Winston Sawyers","character":"Ralph","img":null,"order":0},{"name":"David McKenna","character":"Nicholas \"Piggy\"","img":null,"order":1},{"name":"Lox Pratt","character":"Jack Merridew","img":null,"order":2},{"name":"Ike Talbut","character":"Simon Camborne","img":null,"order":3},{"name":"Thomas Connor","character":"Roger","img":null,"order":4},{"name":"Cornelius Brandreth","character":"Maurice","img":null,"order":5},{"name":"Cassius Flemyng","character":"Eric","img":null,"order":6},{"name":"Noah Flemyng","character":"Sam","img":null,"order":7},{"name":"Harrison Metcalf","character":"Frankie","img":null,"order":8},{"name":"Rafael de Belligny","character":"Robert","img":null,"order":9},{"name":"Felix Tandon","character":"William","img":null,"order":10},{"name":"Beau Thompson","character":"Philip","img":null,"order":11},{"name":"Kai Wilson","character":"Jasper","img":null,"order":12},{"name":"Freddie Lee-Grey","character":"Percival","img":null,"order":13},{"name":"Tom Page-Turner","character":"Bill","img":null,"order":14}],"tv:84773":[{"name":"Morfydd Clark","character":"Galadriel","img":null,"order":1},{"name":"Charlie Vickers","character":"Halbrand","img":null,"order":1},{"name":"Robert Aramayo","character":"Elrond","img":null,"order":3},{"name":"Charles Edwards","character":"Lord Celebrimbor","img":null,"order":4},{"name":"Owain Arthur","character":"Durin IV","img":null,"order":5},{"name":"Sophia Nomvete","character":"Disa","img":null,"order":6},{"name":"Daniel Weyman","character":"The Stranger","img":null,"order":7},{"name":"Markella Kavenagh","character":"Elanor 'Nori' Brandyfoot","img":null,"order":8},{"name":"Megan Richards","character":"Poppy Proudfellow","img":null,"order":9},{"name":"Ismael Cruz Cordova","character":"Arondir","img":null,"order":10},{"name":"Lloyd Owen","character":"Elendil","img":null,"order":11},{"name":"Benjamin Walker","character":"High King Gil-Galad","img":null,"order":16},{"name":"Cynthia Addai-Robinson","character":"M\u00edriel","img":null,"order":17},{"name":"Trystan Gravelle","character":"Pharaz\u00f4n","img":null,"order":18},{"name":"Ema Horvath","character":"E\u00e4rien","img":null,"order":19}],"tv:73411":[{"name":"Emma Stone","character":"Annie Landsberg","img":null,"order":0},{"name":"Jonah Hill","character":"Owen Milgrim","img":null,"order":1},{"name":"Sonoya Mizuno","character":"Dr. Azumi Fujita","img":null,"order":2},{"name":"Justin Theroux","character":"Dr. James K. Mantleray","img":null,"order":8},{"name":"Sally Field","character":"Dr. Greta Mantleray","img":null,"order":9},{"name":"Aaralyn Anderson","character":"Australia","img":null,"order":347},{"name":"James Monroe Iglehart","character":"Carl","img":null,"order":539},{"name":"Dai Ishiguro","character":"Head Control Tech","img":null,"order":540},{"name":"Gabriel Byrne","character":"Porter Milgrim","img":null,"order":546},{"name":"Kathleen Choe","character":"3","img":null,"order":547},{"name":"Allyce Beasley","character":"11","img":null,"order":550},{"name":"Stephen Hill","character":"7","img":null,"order":553},{"name":"Danny Hoch","character":"5","img":null,"order":556},{"name":"Billy Magnussen","character":"Jed Milgrim","img":null,"order":559},{"name":"Sejal Shah","character":"First Medical Tech","img":null,"order":562}],"tv:212333":[{"name":"Luca Marinelli","character":"Benito Mussolini","img":null,"order":0},{"name":"Francesco Russo","character":"Cesare Rossi","img":null,"order":1},{"name":"Barbara Chichiarelli","character":"Margherita Sarfatti","img":null,"order":2},{"name":"Benedetta Cimatti","character":"Rachele Mussolini","img":null,"order":3},{"name":"Gaetano Bruno","character":"Giacomo Matteotti","img":null,"order":4},{"name":"Maurizio Lombardi","character":"Emilio De Bono","img":null,"order":5},{"name":"Gianmarco Vettori","character":"Dino Grandi","img":null,"order":6},{"name":"Massimo De Lorenzo","character":"Alfredo Rocco","img":null,"order":7},{"name":"Fulvio Falzarano","character":"Giovanni Giolitti","img":null,"order":8},{"name":"Amedeo Gull\u00e0","character":"Augusto Malacria","img":null,"order":9},{"name":"Federico Majorana","character":"Amerigo Dumini","img":null,"order":500},{"name":"Lorenzo Zurzolo","character":"Italo Balbo","img":null,"order":501},{"name":"Vincenzo Nemolato","character":"King Vittorio Emanuele III","img":null,"order":502},{"name":"Giovanni Alfieri","character":"Amleto Poverno","img":null,"order":503},{"name":"Gabriele Falsetta","character":"Roberto Farinacci","img":null,"order":504}],"tv:61859":[{"name":"Tom Hiddleston","character":"Jonathan Pine","img":null,"order":0},{"name":"Hugh Laurie","character":"Richard Roper","img":null,"order":1},{"name":"Diego Calva","character":"Teddy Dos Santos","img":null,"order":2},{"name":"Camila Morrone","character":"Roxana Bola\u00f1os","img":null,"order":3},{"name":"Douglas Hodge","character":"Rex Mayhew","img":null,"order":5},{"name":"Elizabeth Debicki","character":"Jed Marshall","img":null,"order":6},{"name":"Paul Chahidi","character":"Basil Karapetian","img":null,"order":7},{"name":"David Harewood","character":"Joel Steadman","img":null,"order":8},{"name":"Hayley Squires","character":"Sally Price-Jones","img":null,"order":9},{"name":"Tom Hollander","character":"Lance Corkoran","img":null,"order":14},{"name":"Olivia Colman","character":"Angela Burr","img":"lawJUdbLb1YW0niaYjiVGR9IBG8.jpg","order":19},{"name":"Michael Nardone","character":"Frisky","img":null,"order":20},{"name":"Alistair Petrie","character":"Sandy Langbourne","img":null,"order":21},{"name":"Aure Atika","character":"","img":null,"order":281},{"name":"Noah Jupe","character":"","img":null,"order":380}],"tv:111110":[{"name":"I\u00f1aki Godoy","character":"Monkey D. Luffy","img":null,"order":0},{"name":"Emily Rudd","character":"Nami","img":null,"order":1},{"name":"Mackenyu","character":"Roronoa Zoro","img":null,"order":2},{"name":"Jacob Gibson","character":"Usopp","img":null,"order":3},{"name":"Vincent Regan","character":"Vice-Admiral Garp","img":null,"order":4},{"name":"Jeff Ward","character":"Buggy the Clown","img":null,"order":5},{"name":"Taz Skylar","character":"Sanji","img":null,"order":6},{"name":"Charithra Chandran","character":"Miss Wednesday / Nefertari Vivi","img":null,"order":7},{"name":"Mikaela Hoover","character":"Tony Tony Chopper (voice)","img":null,"order":8},{"name":"Morgan Davies","character":"Koby","img":null,"order":10},{"name":"Lera Abova","character":"Miss All Sunday","img":null,"order":11},{"name":"Armand Aucamp","character":"Bogard","img":null,"order":510},{"name":"McKinley Belcher III","character":"Captain Arlong","img":null,"order":546},{"name":"Jandre le Roux","character":"Kuroobi","img":null,"order":551},{"name":"Aidan Scott","character":"Helmeppo","img":null,"order":592}],"tv:61617":[{"name":"Elijah Wood","character":"Wirt (voice)","img":null,"order":0},{"name":"Collin Dean","character":"Gregory (voice)","img":null,"order":1},{"name":"Melanie Lynskey","character":"Beatrice (voice)","img":null,"order":2},{"name":"Samuel Ramey","character":"The Beast (voice)","img":null,"order":3},{"name":"Christopher Lloyd","character":"The Woodsman (voice)","img":null,"order":4},{"name":"Janet Klein","character":"Miss Langtree (voice)","img":null,"order":500},{"name":"Deborah Voigt","character":"Queen of the Clouds / Additional Voices (voice)","img":null,"order":501},{"name":"Chris Isaak","character":"Enoch / Additional Voices (voice)","img":null,"order":503},{"name":"John Cleese","character":"Adelaide (voice)","img":null,"order":510},{"name":"Sam Marin","character":"Old Man Langtree (voice)","img":null,"order":512},{"name":"Emily Brundige","character":"Sara / Additional Voices (voice)","img":null,"order":519},{"name":"Fred Stoller","character":"Fred The Horse (voice)","img":null,"order":521},{"name":"Cole Sanchez","character":"Jason Funderberker / Additional Voices (voice)","img":null,"order":521},{"name":"Shirley Jones","character":"Beatrice's Mother / Additional Voices (voice)","img":null,"order":523},{"name":"Jack Jones","character":"Gregory's Frog (voice)","img":null,"order":526}],"tv:60574":[{"name":"Cillian Murphy","character":"Thomas Shelby","img":null,"order":0},{"name":"Paul Anderson","character":"Arthur Shelby","img":null,"order":1},{"name":"Joe Cole","character":"John Shelby","img":null,"order":2},{"name":"Helen McCrory","character":"Polly Gray","img":null,"order":4},{"name":"Sophie Rundle","character":"Ada Shelby","img":null,"order":6},{"name":"Natasha O'Keeffe","character":"Lizzie Stark","img":null,"order":12},{"name":"Finn Cole","character":"Michael Gray","img":null,"order":14},{"name":"Ian Peck","character":"Curly","img":null,"order":15},{"name":"Tom Hardy","character":"Alfie Solomons","img":"d81K0RH8UX7tZj49tZaQhZ9ewH.jpg","order":16},{"name":"Ned Dennehy","character":"Charlie Strong","img":null,"order":17},{"name":"Harry Kirton","character":"Finn Shelby","img":null,"order":87},{"name":"Kate Phillips","character":"Linda Shelby","img":null,"order":104},{"name":"Annabelle Wallis","character":"Grace Shelby","img":null,"order":181},{"name":"Aimee-Ffion Edwards","character":"Esme Shelby","img":null,"order":606},{"name":"Packy Lee","character":"Johnny Dogs","img":null,"order":621}],"tv:250307":[{"name":"Noah Wyle","character":"Dr. Michael 'Robby' Robinavitch","img":null,"order":0},{"name":"Patrick Ball","character":"Dr. Frank Langdon","img":null,"order":2},{"name":"Katherine LaNasa","character":"Charge Nurse Dana Evans","img":null,"order":3},{"name":"Supriya Ganesh","character":"Dr. Samira Mohan","img":null,"order":4},{"name":"Fiona Dourif","character":"Dr. Cassie McKay","img":null,"order":5},{"name":"Taylor Dearden","character":"Dr. Melissa King","img":null,"order":6},{"name":"Isa Briones","character":"Dr. Trinity Santos","img":null,"order":7},{"name":"Gerran Howell","character":"Student Doctor Dennis Whitaker","img":null,"order":8},{"name":"Shabana Azeez","character":"Student Doctor Victoria Javadi","img":null,"order":9},{"name":"Alexandra Metz","character":"Dr. Yolanda Garcia","img":null,"order":508},{"name":"Ned Brower","character":"Nurse Jesse","img":null,"order":523},{"name":"Amielynn Abellera","character":"Nurse Perlah Alawi","img":null,"order":707},{"name":"Jalen Thomas Brooks","character":"Nurse Mateo Diaz","img":null,"order":708},{"name":"Brandon Mendez Homer","character":"Nurse Donald 'Donnie' Donahue","img":null,"order":709},{"name":"Kristin Villanueva","character":"Nurse Princess","img":null,"order":710}],"tv:225171":[{"name":"Rhea Seehorn","character":"Carol Sturka","img":null,"order":0},{"name":"Karolina Wydra","character":"Zosia","img":null,"order":1},{"name":"Carlos-Manuel Vesga","character":"Manousos Oviedo","img":null,"order":2},{"name":"Miriam Shor","character":"Helen","img":null,"order":500},{"name":"Samba Schutte","character":"Mr. Diabat\u00e9","img":null,"order":524},{"name":"Menik Gooneratne","character":"Laxmi","img":null,"order":525},{"name":"Darinka Arones","character":"Kusimayu","img":null,"order":526},{"name":"Anna Mhairi","character":"Vesper","img":null,"order":527},{"name":"Max Reeves","character":"Margaux","img":null,"order":528},{"name":"Olivia Rouyre","character":"Genevieve","img":null,"order":529},{"name":"Rayaan Kamal Khan","character":"Ravi","img":null,"order":532},{"name":"Elena Est\u00e9r","character":"T'ika","img":null,"order":537},{"name":"Soledad Campos","character":"Maternal Other","img":null,"order":559},{"name":"Jennifer Esquivel","character":"Sisa","img":null,"order":581},{"name":"Patrick Fabian","character":"Others' Phone Answering Service (voice) (uncredited)","img":null,"order":591}],"tv:94028":[{"name":"Andrew Scott","character":"Tom Ripley","img":"4F8XpjyQCvtuu21WFm5d8RF5Rl.jpg","order":0},{"name":"Dakota Fanning","character":"Marge Sherwood","img":null,"order":1},{"name":"Johnny Flynn","character":"Dickie Greenleaf","img":null,"order":2},{"name":"Kenneth Lonergan","character":"Herbert Greenleaf","img":null,"order":500},{"name":"Francesca Romana Bergamo","character":"Ermelinda","img":null,"order":511},{"name":"Pasquale Esposito","character":"Pucci","img":null,"order":512},{"name":"Arcangelo Iannace","character":"Giulio","img":null,"order":513},{"name":"Vittorio Viviani","character":"Matteo","img":null,"order":514},{"name":"Eliot Sumner","character":"Frederick 'Freddie' Miles","img":null,"order":515},{"name":"Bokeem Woodbine","character":"Alvin MacCarron","img":null,"order":516},{"name":"Renato Solpietro","character":"Carlo","img":null,"order":519},{"name":"Francesco Formichetti","character":"Sanremo Desk Clerk","img":null,"order":523},{"name":"Margherita Buy","character":"Sig. Silvana Buffi","img":null,"order":540},{"name":"Maurizio Lombardi","character":"Inspector Pietro Ravini","img":null,"order":544},{"name":"Gennaro Arena","character":"Naples Kid","img":null,"order":578}],"tv:245256":[{"name":"Lola Petticrew","character":"Dolours Price","img":null,"order":0},{"name":"Maxine Peake","character":"Older Dolours Price","img":null,"order":1},{"name":"Hazel Doupe","character":"Marian Price","img":null,"order":2},{"name":"Anthony Boyle","character":"Brendan Hughes","img":null,"order":3},{"name":"Tom Vaughan-Lawlor","character":"Older Brendan Hughes","img":null,"order":4},{"name":"Josh Finan","character":"Gerry Adams","img":null,"order":5},{"name":"Rory Kinnear","character":"Frank Kitson","img":null,"order":6},{"name":"Seamus O'Hara","character":"Mackers","img":null,"order":500},{"name":"Helen Behan","character":"Older Marian Price","img":null,"order":501},{"name":"Michael Colgan","character":"Older Gerry Adams","img":null,"order":502},{"name":"Conor O'Donnell","character":"Eamon","img":null,"order":503},{"name":"James Doherty","character":"Chief Inspector","img":null,"order":504},{"name":"Bill Jones","character":"Ian","img":null,"order":505},{"name":"Shannon Watson","character":"Make-up Artist","img":null,"order":506},{"name":"Emma Canning","character":"Kathleen","img":null,"order":508}],"tv:95396":[{"name":"Adam Scott","character":"Mark Scout","img":null,"order":0},{"name":"Britt Lower","character":"Helly Riggs","img":null,"order":1},{"name":"Tramell Tillman","character":"Seth Milchick","img":null,"order":2},{"name":"Zach Cherry","character":"Dylan George","img":null,"order":3},{"name":"Patricia Arquette","character":"Harmony Cobel","img":null,"order":4},{"name":"Jen Tullock","character":"Devon Scout-Hale","img":null,"order":5},{"name":"Michael Chernus","character":"Ricken Hale","img":null,"order":6},{"name":"Dichen Lachman","character":"Ms. Casey","img":null,"order":7},{"name":"Sarah Bock","character":"Eustice Huang","img":null,"order":8},{"name":"John Turturro","character":"Irving Bailiff","img":null,"order":9},{"name":"Christopher Walken","character":"Burt Goodman","img":null,"order":10},{"name":"Sydney Cole Alexander","character":"Natalie Kalen","img":null,"order":515},{"name":"Karen Aldridge","character":"Asal Regabhi","img":null,"order":522},{"name":"Michael Cumpsty","character":"Doug Graner","img":null,"order":529},{"name":"Claudia Robinson","character":"Felicia","img":null,"order":537}],"tv:1274":[{"name":"Peter Krause","character":"Nate Fisher","img":null,"order":0},{"name":"Michael C. Hall","character":"David Fisher","img":null,"order":1},{"name":"Frances Conroy","character":"Ruth Fisher","img":null,"order":2},{"name":"Lauren Ambrose","character":"Claire Fisher","img":null,"order":3},{"name":"Freddy Rodr\u00edguez","character":"Federico Diaz","img":null,"order":4},{"name":"Mathew St. Patrick","character":"Keith Charles","img":null,"order":5},{"name":"Rachel Griffiths","character":"Brenda Chenowith","img":null,"order":6},{"name":"Ben Foster","character":"Russell Corwin","img":null,"order":7},{"name":"Justina Machado","character":"Vanessa Diaz","img":null,"order":8},{"name":"Jeremy Sisto","character":"Billy Chenowith","img":null,"order":10},{"name":"Richard Jenkins","character":"Nathaniel Fisher","img":null,"order":11},{"name":"Lili Taylor","character":"Lisa Kimmel Fisher","img":null,"order":12},{"name":"James Cromwell","character":"George Sibley","img":null,"order":13},{"name":"Bronwyn Tosh","character":"Maya Fisher","img":null,"order":559},{"name":"Brenna Tosh","character":"Maya Fisher","img":null,"order":608}],"tv:86942":[{"name":"Tom Waes","character":"Bob Lemmens","img":null,"order":0},{"name":"Frank Lammers","character":"Ferry Bouman","img":null,"order":1},{"name":"Raymond Thiry","character":"John Zwart","img":null,"order":2},{"name":"Elise Schaap","character":"Danielle Bouman","img":null,"order":4},{"name":"Anna Drijver","character":"Kim De Rooij","img":null,"order":5},{"name":"Jeroen Van der Ven","character":"Patrick Dierickx","img":null,"order":6},{"name":"Ruth Becquart","character":"Nathalie Geudens","img":null,"order":7},{"name":"Manou Kersting","character":"Nick Janssens","img":null,"order":8},{"name":"Wim Willaert","character":"Laurent Berger","img":null,"order":10},{"name":"Sebastien Dewaele","character":"Jean-Pierre Berger","img":null,"order":11},{"name":"Emma Verlinden","character":"Polly","img":null,"order":187},{"name":"Katrien De Ruysscher","character":"Liesbeth Mertens","img":null,"order":553},{"name":"Robbie Cleiren","character":"Marc Gevers","img":null,"order":554},{"name":"Lieke van den Broek","character":"Sonja Van Kamp","img":null,"order":562},{"name":"Celest Henri Cornelis","character":"Jackson Geudens","img":null,"order":582}],"tv:86248":[{"name":"Robbie Amell","character":"Nathan Brown","img":null,"order":0},{"name":"Andy Allo","character":"Nora Antony","img":null,"order":1},{"name":"Allegra Edwards","character":"Ingrid Kannerman","img":null,"order":2},{"name":"Zainab Johnson","character":"Aleesha","img":null,"order":3},{"name":"Kevin Bigley","character":"Luke","img":null,"order":4},{"name":"Owen Daniels","character":"A.I. Guy","img":null,"order":5},{"name":"Andrea Rosen","character":"Lucy Slack","img":null,"order":6},{"name":"Josh Banday","character":"Ivan Spelich","img":null,"order":8},{"name":"Mackenzie Cardwell","character":"Tinsley","img":null,"order":9},{"name":"Andy Thompson","character":"Lionel Winter","img":null,"order":600},{"name":"Barclay Hope","character":"Oliver Kannerman","img":null,"order":607},{"name":"William B. Davis","character":"David Choak","img":null,"order":624},{"name":"Chloe Coleman","character":"Nevaeh","img":null,"order":632},{"name":"Yvetta Fisher","character":"Batia","img":null,"order":633},{"name":"Jessica Tuck","character":"Viv","img":null,"order":651}],"tv:210664":[{"name":"Stephen Graham","character":"Matthew Collins","img":null,"order":0},{"name":"Jason Flemyng","character":"Nick","img":null,"order":1},{"name":"Leanne Best","character":"Alison","img":null,"order":2},{"name":"Andrew Ellis","character":"Robbie","img":null,"order":3},{"name":"Chris Coghill","character":"Chris","img":null,"order":4},{"name":"Dean-Charles Chapman","character":"Jack","img":null,"order":5},{"name":"Sami Amber","character":"Billy","img":null,"order":6},{"name":"Steven Miller","character":"Young Lenny","img":null,"order":500}],"tv:270476":[{"name":"Matthew Rhys","character":"Mayor Tom Loftis","img":null,"order":0},{"name":"Kate O'Flynn","character":"Patricia Moyer","img":null,"order":1},{"name":"Kevin Carroll","character":"Sheriff Bechir Clemmons","img":null,"order":2},{"name":"Dale Dickey","character":"Rosemary","img":null,"order":3},{"name":"Kingston Rumi Southwick","character":"Evan Loftis","img":null,"order":4},{"name":"Stephen Root","character":"Wyck Crawford","img":null,"order":5},{"name":"Neil Casey","character":"Kurt","img":null,"order":500},{"name":"Jeff Hiller","character":"Dale","img":null,"order":501},{"name":"K Callan","character":"Ruth","img":null,"order":503},{"name":"Kylie McNeill","character":"Kelly","img":null,"order":504},{"name":"Beck Nolan","character":"PJ","img":null,"order":505},{"name":"Charles Van Flaherty","character":"Gil","img":null,"order":506},{"name":"Nancy Lenehan","character":"Gerrie","img":null,"order":508},{"name":"Christian Clemenson","character":"Dr. Morgan","img":null,"order":509},{"name":"Sipiwe Moyo","character":"Chelle","img":null,"order":529}],"tv:71912":[{"name":"Henry Cavill","character":"Geralt of Rivia","img":null,"order":0},{"name":"Anya Chalotra","character":"Yennefer of Vengerberg","img":null,"order":2},{"name":"Freya Allan","character":"Princess Cirilla 'Ciri' of Cintra","img":null,"order":3},{"name":"Joey Batey","character":"Jaskier / The Sandpiper","img":null,"order":4},{"name":"Eamon Farren","character":"Cahir","img":null,"order":5},{"name":"Mim\u00ee M. Khayisa","character":"Fringilla Vigo","img":null,"order":6},{"name":"Anna Shaffer","character":"Triss Merigold","img":null,"order":7},{"name":"Mahesh Jadu","character":"Vilgefortz of Roggeveen","img":null,"order":8},{"name":"Cassie Clare","character":"Philippa Eilhart","img":null,"order":11},{"name":"MyAnna Buring","character":"Tissaia De Vries","img":null,"order":14},{"name":"Mecia Simson","character":"Francesca Findabair","img":null,"order":18},{"name":"Bart Edwards","character":"The White Flame","img":null,"order":345},{"name":"Wilson Mbomio","character":"Dara","img":null,"order":500},{"name":"Therica Wilson-Read","character":"Sabrina","img":null,"order":542},{"name":"Royce Pierreson","character":"Istredd","img":null,"order":671}],"movie:76203":[{"name":"Chiwetel Ejiofor","character":"Solomon Northup","img":null,"order":0},{"name":"Michael Fassbender","character":"Edwin Epps","img":null,"order":1},{"name":"Lupita Nyong'o","character":"Patsey","img":null,"order":2},{"name":"Benedict Cumberbatch","character":"William Ford","img":null,"order":3},{"name":"Paul Dano","character":"John Tibeats","img":null,"order":4},{"name":"Sarah Paulson","character":"Mary Epps","img":null,"order":5},{"name":"Adepero Oduye","character":"Eliza","img":null,"order":6},{"name":"Scoot McNairy","character":"Brown","img":null,"order":7},{"name":"Paul Giamatti","character":"Theophilus Freeman","img":null,"order":8},{"name":"Brad Pitt","character":"Samuel Bass","img":null,"order":9},{"name":"Michael Kenneth Williams","character":"Robert","img":null,"order":10},{"name":"Alfre Woodard","character":"Mistress Shaw","img":null,"order":11},{"name":"Chris Chalk","character":"Clemens","img":null,"order":12},{"name":"Taran Killam","character":"Hamilton","img":null,"order":13},{"name":"Bill Camp","character":"Radburn","img":null,"order":14}],"movie:13654":[{"name":"Barry Bostwick","character":"Thunderbolt (voice)","img":null,"order":0},{"name":"Jason Alexander","character":"Lightning (voice)","img":null,"order":1},{"name":"Martin Short","character":"Lars (voice)","img":null,"order":2},{"name":"Bobby Lockwood","character":"Patch (voice)","img":null,"order":3},{"name":"Susanne Blakeslee","character":"Cruella (voice)","img":null,"order":4},{"name":"Samuel West","character":"Pongo (voice)","img":null,"order":5},{"name":"Maurice LaMarche","character":"Horace (voice)","img":null,"order":6},{"name":"Jeff Bennett","character":"Jasper (voice)","img":null,"order":7},{"name":"Jodi Benson","character":"Anita (voice)","img":null,"order":8},{"name":"Tim Bentinck","character":"Roger (voice)","img":null,"order":9},{"name":"Kath Soucie","character":"Perdita (voice)","img":null,"order":10},{"name":"Mary MacLeod","character":"Nanny (voice)","img":null,"order":11},{"name":"Michael Lerner","character":"Producer (voice)","img":null,"order":12},{"name":"Kathryn Beaumont","character":"Additional Voices (voice)","img":null,"order":13},{"name":"Nicolette Little","character":"Additional Voices (voice)","img":null,"order":14}],"movie:974453":[{"name":"Liam Neeson","character":"Thug","img":null,"order":0},{"name":"Ron Perlman","character":"Charlie Conner","img":null,"order":1},{"name":"Yolonda Ross","character":"Woman","img":null,"order":2},{"name":"Frankie Shaw","character":"Daisy","img":null,"order":3},{"name":"Daniel Diemer","character":"Kyle Conner","img":null,"order":4},{"name":"Jimmy Gonzales","character":"Diego Machado","img":null,"order":5},{"name":"Josh Drennen","character":"Thug's Dad","img":null,"order":6},{"name":"Deanna Tarraza","character":"Araceli","img":null,"order":7},{"name":"Terrence Pulliam","character":"Dre","img":null,"order":8},{"name":"Omar Moustafa Ghonim","character":"Armando","img":null,"order":9},{"name":"Tom Kemp","character":"Burt","img":null,"order":10},{"name":"Ryan Homchick","character":"Dr. Gruber","img":null,"order":11},{"name":"William Xifaras","character":"Kiko","img":null,"order":12},{"name":"Kris Eivers","character":"Priest","img":null,"order":13},{"name":"Ryan Caraway","character":"George","img":null,"order":14}],"movie:446791":[{"name":"Michelle Williams","character":"Gail Harris","img":null,"order":0},{"name":"Mark Wahlberg","character":"Fletcher Chase","img":null,"order":1},{"name":"Christopher Plummer","character":"Jean Paul Getty","img":null,"order":2},{"name":"Charlie Plummer","character":"John Paul Getty III","img":null,"order":3},{"name":"Romain Duris","character":"Cinquanta","img":null,"order":4},{"name":"Timothy Hutton","character":"Oswald Hinge","img":null,"order":5},{"name":"Charlie Shotwell","character":"John Paul Getty III (Age 7)","img":null,"order":6},{"name":"Andrew Buchan","character":"John Paul Getty II","img":null,"order":7},{"name":"Marco Leonardi","character":"Saverio Mammoliti","img":null,"order":8},{"name":"Giuseppe Bonifati","character":"Giovanni Iacovoni","img":null,"order":9},{"name":"Nicolas Vaporidis","character":"Il Tamia 'Chipmunk'","img":null,"order":10},{"name":"Andrea Piedimonte Bodini","character":"Corvo","img":null,"order":11},{"name":"Guglielmo Favilla","character":"Piccolino","img":null,"order":12},{"name":"Adele Tirante","character":"Maria","img":null,"order":13},{"name":"Stacy Martin","character":"Nancy","img":null,"order":14}],"movie:1013601":[{"name":"Robert De Niro","character":"Vito Genovese / Frank Costello","img":null,"order":0},{"name":"Debra Messing","character":"Bobbie Costello","img":null,"order":1},{"name":"Cosmo Jarvis","character":"Vincent Gigante","img":null,"order":2},{"name":"Kathrine Narducci","character":"Anna Genovese","img":null,"order":3},{"name":"Michael Rispoli","character":"Albert Anastasia","img":null,"order":4},{"name":"Michael Adler","character":"Senator Tobey","img":null,"order":5},{"name":"Ed Amatrudo","character":"Rudolph Halley","img":null,"order":6},{"name":"Joe Bacino","character":"Joe Profaci","img":null,"order":7},{"name":"Anthony J. Gallo","character":"Tommy Lucchese","img":null,"order":8},{"name":"Wallace Langham","character":"Senator Estes Kefauver","img":null,"order":9},{"name":"Louis Mustillo","character":"Joe Bonanno","img":null,"order":10},{"name":"Frank Piccirillo","character":"Richie Boiardo","img":null,"order":11},{"name":"Matt Servitto","character":"George Wolf","img":null,"order":12},{"name":"Robert Uricola","character":"Tony Bender","img":null,"order":13},{"name":"Belmont Cameli","character":"Frankie Boy","img":null,"order":14}],"movie:416153":[{"name":"James Franco","character":"Lemmy (voice)","img":null,"order":0},{"name":"Jeremy Renner","character":"Swifty (voice)","img":null,"order":1},{"name":"Alec Baldwin","character":"PB (voice)","img":null,"order":2},{"name":"Anjelica Huston","character":"Magda (voice)","img":null,"order":3},{"name":"Michael Madsen","character":"Duke (voice)","img":null,"order":4},{"name":"Heidi Klum","character":"Jade / Bertha (voice)","img":null,"order":5},{"name":"John Cleese","character":"Otto von Walrus (voice)","img":null,"order":6},{"name":"Omar Sy","character":"Leopold (voice)","img":null,"order":7},{"name":"Laurie Holden","character":"Dakota (voice)","img":null,"order":8},{"name":"Donny Falsetti","character":"Dusty (voice)","img":null,"order":9},{"name":"Susan Spano","character":"Mary (voice)","img":null,"order":10},{"name":"Jason Deline","character":"Naz Narwahl (voice)","img":null,"order":11},{"name":"Aaron Woodley","character":"Puffin Leader (voice)","img":null,"order":12},{"name":"Lucie Hager","character":"Countdown Inka (voice)","img":null,"order":13},{"name":"Lillian Moloy","character":"Young Jade","img":null,"order":14}],"movie:1607":[{"name":"Robert De Niro","character":"Lorenzo Anello","img":null,"order":0},{"name":"Chazz Palminteri","character":"Sonny LoSpecchio","img":null,"order":1},{"name":"Lillo Brancato","character":"Calogero 'C' Anello (Age 17)","img":null,"order":2},{"name":"Francis Capra","character":"Calogero 'C' Anello (Age 9)","img":null,"order":3},{"name":"Taral Hicks","character":"Jane Williams","img":null,"order":4},{"name":"Kathrine Narducci","character":"Rosina Anello","img":null,"order":5},{"name":"Clem Caserta","character":"Jimmy Whispers","img":null,"order":6},{"name":"Alfred Sauchelli, Jr.","character":"Bobby Bars","img":null,"order":7},{"name":"Frank Pietrangolare","character":"Danny K.O.","img":null,"order":8},{"name":"Joe Pesci","character":"Carmine","img":null,"order":9},{"name":"Robert D'Andrea","character":"Tony Toupee","img":null,"order":10},{"name":"Eddie Montanaro","character":"Eddie Mush","img":null,"order":11},{"name":"Fred Fischer","character":"JoJo 'The Whale'","img":null,"order":12},{"name":"Dave Salerno","character":"Frankie Coffeecake","img":null,"order":13},{"name":"Joseph D'Onofrio","character":"Slick (Age 17)","img":null,"order":14}],"movie:1380417":[{"name":"Yahya Abdul-Mateen II","character":"Wayne Strider","img":null,"order":0},{"name":"Mark Wahlberg","character":"Gregory Scarpa","img":null,"order":1},{"name":"Nicole Beharie","character":"Allison Strider","img":null,"order":2},{"name":"Josh Lucas","character":"Special Agent Everett Rhodes","img":null,"order":3},{"name":"David Strathairn","character":"Special Agent Roy K. Moore","img":null,"order":4},{"name":"Giancarlo Esposito","character":"Vernon Dahmer","img":null,"order":5},{"name":"Ethan Embry","character":"Sam Bowers","img":null,"order":6},{"name":"LisaGay Hamilton","character":"Ms. Rebecca Brewer","img":null,"order":7},{"name":"LaChanze","character":"Hattie Dahmer","img":null,"order":8},{"name":"Biko Eisen-Martin","character":"Special Agent Archie Crawford","img":null,"order":9},{"name":"Will Brittain","character":"Billy Roy Pitts","img":null,"order":10},{"name":"DeVere Jehl","character":"Edgar Ray Killen","img":null,"order":11},{"name":"Jody Thompson","character":"Lawrence Byrd","img":null,"order":12},{"name":"Zach Lane","character":"Howard Styles","img":null,"order":13},{"name":"Lee Perkins","character":"Sheriff Mitchell","img":null,"order":14}],"movie:271110":[{"name":"Chris Evans","character":"Steve Rogers / Captain America","img":null,"order":0},{"name":"Robert Downey Jr.","character":"Tony Stark / Iron Man","img":null,"order":1},{"name":"Scarlett Johansson","character":"Natasha Romanoff / Black Widow","img":null,"order":2},{"name":"Sebastian Stan","character":"Bucky Barnes / Winter Soldier","img":null,"order":3},{"name":"Anthony Mackie","character":"Sam Wilson / Falcon","img":null,"order":4},{"name":"Don Cheadle","character":"Lieutenant James Rhodes / War Machine","img":null,"order":5},{"name":"Jeremy Renner","character":"Clint Barton / Hawkeye","img":null,"order":6},{"name":"Chadwick Boseman","character":"T'Challa / Black Panther","img":null,"order":7},{"name":"Paul Bettany","character":"Vision","img":null,"order":8},{"name":"Elizabeth Olsen","character":"Wanda Maximoff / Scarlet Witch","img":null,"order":9},{"name":"Paul Rudd","character":"Scott Lang / Ant-Man","img":null,"order":10},{"name":"Emily VanCamp","character":"Sharon Carter","img":null,"order":11},{"name":"Tom Holland","character":"Peter Parker / Spider-Man","img":"xKBAaPIa1c7tzZD3Y0MhBLv4hPE.jpg","order":12},{"name":"Daniel Br\u00fchl","character":"Zemo","img":null,"order":13},{"name":"Frank Grillo","character":"Brock Rumlow / Crossbones","img":null,"order":14}],"movie:1771":[{"name":"Chris Evans","character":"Captain America / Steve Rogers","img":null,"order":0},{"name":"Hayley Atwell","character":"Peggy Carter","img":null,"order":1},{"name":"Sebastian Stan","character":"James Buchanan 'Bucky' Barnes","img":null,"order":2},{"name":"Tommy Lee Jones","character":"Colonel Chester Phillips","img":null,"order":3},{"name":"Hugo Weaving","character":"Johann Schmidt / Red Skull","img":null,"order":4},{"name":"Dominic Cooper","character":"Howard Stark","img":null,"order":5},{"name":"Richard Armitage","character":"Heinz Kruger","img":null,"order":6},{"name":"Stanley Tucci","character":"Dr. Abraham Erskine","img":null,"order":7},{"name":"Samuel L. Jackson","character":"Nick Fury","img":null,"order":8},{"name":"Toby Jones","character":"Dr. Arnim Zola","img":null,"order":9},{"name":"Neal McDonough","character":"Timothy 'Dum Dum' Dugan","img":null,"order":10},{"name":"Derek Luke","character":"Gabe Jones","img":null,"order":11},{"name":"Kenneth Choi","character":"Jim Morita","img":null,"order":12},{"name":"JJ Feild","character":"James Montgomery Falsworth","img":null,"order":13},{"name":"Bruno Ricci","character":"Jacques Dernier","img":null,"order":14}],"movie:100402":[{"name":"Chris Evans","character":"Steve Rogers / Captain America","img":null,"order":0},{"name":"Samuel L. Jackson","character":"Nick Fury","img":null,"order":1},{"name":"Scarlett Johansson","character":"Natasha Romanoff / Black Widow","img":null,"order":2},{"name":"Robert Redford","character":"Alexander Pierce","img":null,"order":3},{"name":"Sebastian Stan","character":"Bucky Barnes / Winter Soldier","img":null,"order":4},{"name":"Anthony Mackie","character":"Sam Wilson / Falcon","img":null,"order":5},{"name":"Cobie Smulders","character":"Maria Hill","img":null,"order":6},{"name":"Frank Grillo","character":"Brock Rumlow","img":null,"order":7},{"name":"Maximiliano Hern\u00e1ndez","character":"Jasper Sitwell","img":null,"order":8},{"name":"Emily VanCamp","character":"Kate / Agent 13","img":null,"order":9},{"name":"Hayley Atwell","character":"Peggy Carter","img":null,"order":10},{"name":"Toby Jones","character":"Dr. Arnim Zola","img":null,"order":11},{"name":"Stan Lee","character":"Smithsonian Guard","img":null,"order":12},{"name":"Callan Mulvey","character":"Jack Rollins","img":null,"order":13},{"name":"Jenny Agutter","character":"Councilwoman Hawley","img":null,"order":14}],"tv:60948":[{"name":"Aaron Stanford","character":"James Cole","img":null,"order":0},{"name":"Amanda Schull","character":"Cassandra Railly","img":null,"order":1},{"name":"Kirk Acevedo","character":"Jos\u00e9 Ramse","img":null,"order":2},{"name":"Barbara Sukowa","character":"Katarina Jones","img":null,"order":3},{"name":"Emily Hampshire","character":"Jennifer Goines","img":null,"order":4},{"name":"Todd Stashwick","character":"Deacon","img":null,"order":6},{"name":"Noah Bean","character":"Aaron Marker","img":null,"order":7},{"name":"Alisen Richmond-Peck","character":"Striking Woman","img":null,"order":366},{"name":"Scottie Thompson","character":"Vivian Rutledge","img":null,"order":519},{"name":"Peter DaCunha","character":"Samuel Ramse","img":null,"order":760},{"name":"Murray Furrow","character":"Lasky","img":null,"order":797},{"name":"Andrew Gillies","character":"Julian Adler","img":null,"order":800},{"name":"Tom Noonan","character":"Pallid Man","img":null,"order":805},{"name":"Demore Barnes","character":"Marcus Whitley","img":null,"order":806},{"name":"Brooke Williams","character":"Hannah Jones","img":null,"order":807}],"tv:202097":[{"name":"Jill Hennessy","character":"Lynn Harmon","img":null,"order":501},{"name":"Oakes Fegley","character":"Devin Harmon","img":null,"order":502},{"name":"Robert Wisdom","character":"Mitch Becker","img":null,"order":503},{"name":"J. Harrison Ghee","character":"Kevin Milstead","img":null,"order":504},{"name":"Evan Marsh","character":"Alex Harmon","img":null,"order":505},{"name":"Sonny McKnight","character":"Young Devin (Age 3)","img":null,"order":506},{"name":"Coleby Wojtak-Hissong","character":"Young Devin (Age 5)","img":null,"order":507},{"name":"Athan Giazitzidis","character":"Young Alex","img":null,"order":508},{"name":"Ali Momen","character":"Principal Sam Canosa","img":null,"order":509},{"name":"Tara Rosling","character":"Dr. Laurie Grotstein","img":null,"order":510},{"name":"Deklon Roberts","character":"Captain Kramer","img":null,"order":511},{"name":"Seema Doad","character":"Uniformed Officer","img":null,"order":512},{"name":"Michael Chiklis","character":"Dr. Scott Harmon","img":null,"order":596},{"name":"Daniel Maslany","character":"Public Defender Eli Demille","img":null,"order":633},{"name":"Matthew James Thomas","character":"Alexei","img":null,"order":639}],"tv:79410":[{"name":"Ricky Gervais","character":"Tony","img":null,"order":0},{"name":"Tom Basden","character":"Matt","img":null,"order":1},{"name":"Kerry Godliman","character":"Lisa","img":null,"order":2},{"name":"David Bradley","character":"Ray","img":null,"order":3},{"name":"Joe Wilkinson","character":"Pat","img":null,"order":4},{"name":"Roisin Conaty","character":"Daphne \"Roxy\"","img":null,"order":6},{"name":"Tony Way","character":"Lenny","img":null,"order":7},{"name":"Diane Morgan","character":"Kath","img":null,"order":9},{"name":"Ashley Jensen","character":"Emma","img":null,"order":11},{"name":"Tommy Finnegan","character":"George","img":null,"order":12},{"name":"Jo Hartley","character":"June","img":null,"order":13},{"name":"Penelope Wilton","character":"Anne","img":null,"order":14},{"name":"David Earl","character":"Brian","img":null,"order":15},{"name":"Ethan Lawrence","character":"James","img":null,"order":16},{"name":"Michelle Greenidge","character":"Valerie","img":null,"order":17}],"tv:7406":[{"name":"Lynda Bellingham","character":"Helen Herriot","img":null,"order":0},{"name":"Robert Hardy","character":"Siegfried Farnon","img":null,"order":1},{"name":"Christopher Timothy","character":"James Herriot","img":null,"order":2},{"name":"Carol Drinkwater","character":"Helen Herriot","img":null,"order":3},{"name":"Mary Hignett","character":"Mrs. Hall","img":null,"order":4},{"name":"Margaretta Scott","character":"Mrs. Pumphrey","img":null,"order":7},{"name":"Teddy Turner","character":"Hodgekin","img":null,"order":8},{"name":"Peter Davison","character":"Tristan Farnon","img":null,"order":10},{"name":"John McGlynn","character":"Calum Buchanan","img":null,"order":11},{"name":"Jean Heywood","character":"Mrs Alton","img":null,"order":12},{"name":"Peter Martin","character":"Handshaw","img":null,"order":839},{"name":"Oliver Wilson","character":"Jimmy Herriot","img":null,"order":928},{"name":"Rebecca Smith","character":"Rosie Herriot","img":null,"order":929},{"name":"John Sharp","character":"Mr. Biggins","img":null,"order":952},{"name":"Andrea Gibb","character":"Deidre Buchanan","img":null,"order":971}],"tv:67117":[{"name":"Katy Mixon","character":"Katie Otto","img":null,"order":0},{"name":"Diedrich Bader","character":"Greg Otto","img":null,"order":1},{"name":"Meg Donnelly","character":"Taylor Otto","img":null,"order":2},{"name":"Giselle Eisenberg","character":"Anna-Kat Otto","img":null,"order":3},{"name":"Julia Butters","character":"Anna-Kat Otto","img":null,"order":4},{"name":"Daniel DiMaggio","character":"Oliver Otto","img":null,"order":5},{"name":"Carly Hughes","character":"Angela","img":null,"order":6},{"name":"Ali Wong","character":"Doris","img":null,"order":7},{"name":"Jerry Lambert","character":"Principal Ablin","img":null,"order":515},{"name":"Logan Pepper","character":"Cooper Bradford","img":null,"order":518},{"name":"Peyton Meyer","character":"Trip Windsor","img":null,"order":519},{"name":"Wendie Malick","character":"Kathryn","img":null,"order":532},{"name":"Carly Craig","character":"Tara","img":null,"order":541},{"name":"Evan O'Toole","character":"Franklin","img":null,"order":565},{"name":"Julie Meyer","character":"Maria","img":null,"order":634}],"tv:47054":[{"name":"Mark Addy","character":"Hercules","img":null,"order":0},{"name":"Jack Donnelly","character":"Jason","img":null,"order":1},{"name":"Robert Emms","character":"Pythagoras","img":null,"order":2},{"name":"Aiysha Hart","character":"Ariadne","img":null,"order":3},{"name":"Sarah Parish","character":"Pasiphae","img":null,"order":4},{"name":"Juliet Stevenson","character":"The Oracle","img":null,"order":5},{"name":"Jemima Rooper","character":"Medusa","img":null,"order":6},{"name":"Amy Manson","character":"Medea","img":null,"order":7},{"name":"Alexander Siddig","character":"Minos","img":null,"order":8},{"name":"Vincent Regan","character":"Dion","img":null,"order":9},{"name":"Lorcan Cranitch","character":"Cilix","img":null,"order":10},{"name":"Robert Lindsay","character":"Daedalus","img":null,"order":11},{"name":"Anya Taylor-Joy","character":"Cassandra","img":null,"order":514},{"name":"Stuart Davidson","character":"Lysis's Masseur","img":null,"order":516},{"name":"Kate Davies-Speak","character":"Atlantean","img":null,"order":533}],"tv:62481":[{"name":"Bobby Flay","character":"Self","img":null,"order":0},{"name":"Giada De Laurentiis","character":"Self","img":null,"order":475},{"name":"Michael Symon","character":"Self","img":null,"order":493},{"name":"Scott Conant","character":"Self - Guest Judge","img":null,"order":503},{"name":"Alex Guarnaschelli","character":"Self - Guest Judge","img":null,"order":512},{"name":"Eddie Jackson","character":"Self","img":null,"order":512},{"name":"Sunny Anderson","character":"Self","img":null,"order":515},{"name":"Jet Tila","character":"Self","img":null,"order":524},{"name":"Anne Burrell","character":"Self","img":null,"order":526},{"name":"Ted Allen","character":"Self","img":null,"order":534},{"name":"Simon Majumdar","character":"Self","img":null,"order":536},{"name":"Geoffrey Zakarian","character":"Self","img":null,"order":537},{"name":"Michael Voltaggio","character":"Self","img":null,"order":549},{"name":"Amanda Freitag","character":"Self","img":null,"order":569},{"name":"Katie Lee Biegel","character":"Self","img":null,"order":645}],"tv:1849":[{"name":"Robert Guillaume","character":"Benson DuBois","img":null,"order":0},{"name":"James Noble","character":"Eugene Gatling","img":null,"order":1},{"name":"Inga Swenson","character":"Gretchen Kraus","img":null,"order":2},{"name":"Caroline McWilliams","character":"Marcy Hill","img":null,"order":3},{"name":"Missy Gold","character":"Katie Gatling","img":null,"order":4},{"name":"Ethan Phillips","character":"Pete Downey","img":null,"order":5},{"name":"Didi Conn","character":"Denise Stevens","img":null,"order":6},{"name":"Lewis J. Stadlen","character":"John Taylor","img":null,"order":7},{"name":"Ren\u00e9 Auberjonois","character":"Clayton Endicott III","img":null,"order":8},{"name":"Billie Bird","character":"Rose Cassidy","img":null,"order":9},{"name":"Beverly Todd","character":"Casey Turner","img":null,"order":507},{"name":"Ren\u00e9 Enr\u00edquez","character":"","img":null,"order":509},{"name":"Norman Bartold","character":"","img":null,"order":513},{"name":"G.W. Bailey","character":"","img":null,"order":539},{"name":"Jerry Seinfeld","character":"Frankie","img":null,"order":552}],"tv:249039":[{"name":"Jason Bateman","character":"Vince Friedken","img":null,"order":0},{"name":"Jude Law","character":"Jake Friedken","img":null,"order":1},{"name":"Chris Coy","character":"Babbitt","img":null,"order":2},{"name":"Troy Kotsur","character":"Joe Mancuso","img":null,"order":3},{"name":"Abbey Lee","character":"Anna","img":null,"order":4},{"name":"Odessa Young","character":"Gen","img":null,"order":5},{"name":"Robin de Jes\u00fas","character":"Tony","img":null,"order":6},{"name":"Cleopatra Coleman","character":"Estelle","img":null,"order":500},{"name":"\u1e62\u1ecdp\u1eb9\u0301 D\u00ecr\u00eds\u00f9","character":"Wes","img":null,"order":501},{"name":"Amaka Okafor","character":"Roxie","img":null,"order":502},{"name":"Dagmara Dominczyk","character":"Val","img":null,"order":503},{"name":"Don Harvey","character":"Matt","img":null,"order":504},{"name":"Gus Birney","character":"Mel Whitney","img":null,"order":505},{"name":"Forrest Weber","character":"Junior","img":null,"order":506},{"name":"Michael Cash","character":"Hunter","img":null,"order":510}],"tv:61381":[{"name":"Anthony Anderson","character":"Andre Johnson","img":null,"order":0},{"name":"Tracee Ellis Ross","character":"Rainbow Johnson","img":null,"order":1},{"name":"Yara Shahidi","character":"Zoey Johnson","img":null,"order":2},{"name":"Marcus Scribner","character":"Junior Johnson","img":null,"order":3},{"name":"Marsai Martin","character":"Diane Johnson","img":null,"order":4},{"name":"Miles Brown","character":"Jack Johnson","img":null,"order":5},{"name":"Jenifer Lewis","character":"Ruby Johnson","img":null,"order":6},{"name":"Deon Cole","character":"Charlie Telphy","img":null,"order":7},{"name":"Peter MacKenzie","character":"Leslie Stevens","img":null,"order":8},{"name":"Jeff Meacham","character":"Josh Oppenhol","img":null,"order":9},{"name":"Katlyn Nichol","character":"Olivia Lockhart","img":null,"order":10},{"name":"Laurence Fishburne","character":"Pops Johnson","img":null,"order":500},{"name":"Nelson Franklin","character":"Connor Stevens","img":null,"order":534},{"name":"Berlin Gross","character":"Devante Johnson","img":null,"order":562},{"name":"August Gross","character":"Devante Johnson","img":null,"order":563}],"tv:1396":[{"name":"Bryan Cranston","character":"Walter White","img":null,"order":0},{"name":"Aaron Paul","character":"Jesse Pinkman","img":null,"order":1},{"name":"Anna Gunn","character":"Skyler White","img":null,"order":2},{"name":"RJ Mitte","character":"Walter White Jr.","img":null,"order":3},{"name":"Dean Norris","character":"Hank Schrader","img":null,"order":4},{"name":"Betsy Brandt","character":"Marie Schrader","img":null,"order":5},{"name":"Giancarlo Esposito","character":"Gustavo Fring","img":null,"order":6},{"name":"Bob Odenkirk","character":"Saul Goodman","img":null,"order":7},{"name":"Steven Michael Quezada","character":"Steven Gomez","img":null,"order":8},{"name":"Jonathan Banks","character":"Mike Ehrmantraut","img":null,"order":9},{"name":"Christopher Cousins","character":"Ted Beneke","img":null,"order":604},{"name":"Michael Shamus Wiles","character":"ASAC George Merkert","img":null,"order":606},{"name":"Lavell Crawford","character":"Huell","img":null,"order":637},{"name":"Charles Baker","character":"Skinny Pete","img":null,"order":656},{"name":"Matt Jones","character":"Badger","img":null,"order":658}],"tv:93166":[{"name":"Holliday Grainger","character":"DI Rachel Carey","img":null,"order":0},{"name":"Callum Turner","character":"Shaun Emery","img":null,"order":1},{"name":"Ben Miles","character":"Commander Danny Hart","img":null,"order":2},{"name":"Lia Williams","character":"DSU Gemma Garland","img":null,"order":3},{"name":"Ron Perlman","character":"Frank Napier","img":null,"order":4},{"name":"Laura Haddock","character":"Hannah Roberts","img":null,"order":5},{"name":"Barry Ward","character":"Charlie Hall","img":null,"order":6},{"name":"Ginny Holder","character":"DC / DI Nadia Latif","img":null,"order":7},{"name":"Cavan Clerkin","character":"DS Patrick Flynn","img":null,"order":8},{"name":"Indira Varma","character":"Khadija Khan","img":null,"order":10},{"name":"Tessa Wong","character":"DC Chloe Tan","img":null,"order":12},{"name":"Andy Nyman","character":"Rowan Gill","img":null,"order":13},{"name":"Daisy Waterstone","character":"Abigail Carey","img":null,"order":15},{"name":"Nigel Lindsay","character":"DSI / DSU Tom Kendricks","img":null,"order":106},{"name":"Paapa Essiedu","character":"Isaac Turner","img":null,"order":160}]},"availableArt":["assets/cast/26R1LBiSOkkP08saSsz6LmVTY16.jpg","assets/cast/2tFblvjBRUYwcVT1V1i79Ppd6wG.jpg","assets/cast/320qW5yEbxpmyxQ3evmClJbtKag.jpg","assets/cast/4F8XpjyQCvtuu21WFm5d8RF5Rl.jpg","assets/cast/4eOoW1PNBZfxRW0g8RmYH8bL6mq.jpg","assets/cast/4gyHyg6FJ1oFczOm5pmMkdEEo2J.jpg","assets/cast/5L0kzTVITmOQdQ94NhicKM8ecCX.jpg","assets/cast/5VbadQsYCKdiJysP01LJFPmYB8A.jpg","assets/cast/5eIRHUbNOH7ibXa0IfUfFvj5I5L.jpg","assets/cast/5hn7WeXi6wguwxTPTSmcmzHAs9.jpg","assets/cast/6hmUs5Q1EETapn32yKNt3c3i1sn.jpg","assets/cast/7LVYeFTgUpjYDrxnU7Wm5ZQUX9t.jpg","assets/cast/7MHdNxS1gRnx0ZQGPrfS5eRjMe3.jpg","assets/cast/7Y96dAfg0HcFrcLjlD5eD9N0uj4.jpg","assets/cast/8v3lDa0QCJlDtOx9flqlMNIA3uf.jpg","assets/cast/9pxaoasotR1pdCXYSfN1pkm0geO.jpg","assets/cast/aHQk6XxqyJinfvrVRADEIg31nOk.jpg","assets/cast/aW6vCxkUZtwb6iH2Wf88Uq0XNVv.jpg","assets/cast/bLgmpSFgEiSFNshqmdl3EIZkRZm.jpg","assets/cast/biov5jENp2XYfehkcdnRvi796Wi.jpg","assets/cast/d81K0RH8UX7tZj49tZaQhZ9ewH.jpg","assets/cast/fD8GuLHOOSSAG98VjwN3NhAmqAJ.jpg","assets/cast/fbWWkhe7GVSlhBljY89rJd01OnA.jpg","assets/cast/fdFVatb9pt5dfT9tDK8tikKWafS.jpg","assets/cast/fjJc03z1cOlMxLZbC1qihsq3C37.jpg","assets/cast/hOntuFYAMwn9QaXT0AwLCPU79ce.jpg","assets/cast/hrTwOPWek7nkPJgFhh6inFu7vSS.jpg","assets/cast/hzu7OqTmNCT1xdCdmwBSf2yJvaO.jpg","assets/cast/iQLEawIP2593yrGy22GzLSpnfgl.jpg","assets/cast/j0GXzLelO1dOzI7d1WVIwQXhLz8.jpg","assets/cast/jUuUbPuMGonFT5E2pcs4alfqaCN.jpg","assets/cast/lF06PTNYrlXeJ3DYApvOcPXJr87.jpg","assets/cast/lUhVV9u5VGMCNdCAVhGzg8GFSdo.jpg","assets/cast/lawJUdbLb1YW0niaYjiVGR9IBG8.jpg","assets/cast/lfbyJWNWGbiYfU0Yr1tjn6Trd2V.jpg","assets/cast/lpEs9yDsMQlsbm0RVsvDIleCXcI.jpg","assets/cast/mF5YJxApTYKBVFJDrEC9IrJ5ooV.jpg","assets/cast/mW7xmtGV4y79kQGn0zkKVGDMAmw.jpg","assets/cast/mk5vpYLmER7asqOhEwVKXD3P3YE.jpg","assets/cast/mnKYU7HIsuRkx3YjdRO5FLXCdX5.jpg","assets/cast/msqe5A1q5YUUND0RhzB9dw1AEAO.jpg","assets/cast/nAxS5l9vb1C8ltCp9yAa0f6lazu.jpg","assets/cast/nmgOd3X2Xn3jIp9OLCRJzLExRWN.jpg","assets/cast/o7splcOIVAdbHnwohJOUAvXWVlk.jpg","assets/cast/oMHMLhoV3ry8aOfdvs1kxyowhNq.jpg","assets/cast/omWXGbeRR32MWuYhpnLOSqJ6xkO.jpg","assets/cast/pVwR9hsEiHzVTo9giCjR3vIfa8W.jpg","assets/cast/phEIvq0Ay2KjI5ewmVvpQ1a29GJ.jpg","assets/cast/qP5vQMIbLNvSsSFkEMLZRVt4ZW5.jpg","assets/cast/qiWu2gphSg64igYl3Nx0hEIOQF3.jpg","assets/cast/qwbjcyDN37OHiln4a6P4LP31o7F.jpg","assets/cast/r6s7dkYZUJpd00C7p9itT6Gp8XB.jpg","assets/cast/rDTxblyw0AwU5oSeksgP7GwMyrF.jpg","assets/cast/rIqcwewXdkrDTMTwX56QPa7MNTC.jpg","assets/cast/rLzduQpYJnZJKkXv3jRc7MDHwoI.jpg","assets/cast/rbxBK2m6fEq0oHeMoBUMVYkJEdJ.jpg","assets/cast/sG6CWLdENCtzukNFkFYyPxqyRmS.jpg","assets/cast/sh4ANkKoIHgmHtkKQbJvF8y0Fjq.jpg","assets/cast/tqFHQiJUycuob3ytvilaAWDlbQ8.jpg","assets/cast/uL8yfcm0dCFYFQt052kczVQVvac.jpg","assets/cast/ub2IuMWFNQGYghHTPq0lpmn2Ue0.jpg","assets/cast/ukDNHgvEgdU95eaw6pngiAPucv4.jpg","assets/cast/v4sRxLpRpHEd9vbk43wm9VL0v0K.jpg","assets/cast/wWcby4ARYqr8d92ZdjyQcfaiJBW.jpg","assets/cast/waayy0jpNbxLDQ8JO1uRDng8SLF.jpg","assets/cast/x0lmqgkmphvOCSTHk2oxsaxA9Ra.jpg","assets/cast/xKBAaPIa1c7tzZD3Y0MhBLv4hPE.jpg","assets/cast/xV9UrP8kZdxOiTI2efDFhp3t4iA.jpg","assets/cast/xm2pYFBggiXSkFBV2I612i3fDd3.jpg","assets/cast/xx3As5SWcE8vYOKZgtjDjqmT3jc.jpg","assets/cast/yU0OXeZ0K1ppe1o9SpHiAMHy1rc.jpg","assets/cast/yWZILsr2mBQBCG1LOcCYYshIbts.jpg","assets/cast/yxGsAKnrJZs2g6q4unNgkfm7NAA.jpg","assets/cast/ztxDIZvuPreUixO6ZPxwkCg2UlO.jpg","assets/episode-all-hands-on-deck-class-1-a-2021-s5e1-backdrop.jpg","assets/episode-all-hands-on-deck-class-1-a-2021-s5e1-poster.jpg","assets/episode-announcement-2022-s1e7-backdrop.jpg","assets/episode-announcement-2022-s1e7-poster.jpg","assets/episode-episode-1-2025-s1e1-backdrop.jpg","assets/episode-episode-1-2025-s1e1-poster.jpg","assets/episode-episode-2-2016-s1e2-backdrop.jpg","assets/episode-episode-2-2016-s1e2-poster.jpg","assets/episode-erickerhead-2026-s9e6-backdrop.jpg","assets/episode-erickerhead-2026-s9e6-poster.jpg","assets/episode-girls-interrupted-2007-s1e7-backdrop.jpg","assets/episode-girls-interrupted-2007-s1e7-poster.jpg","assets/episode-i-heart-ny-2002-s4e18-backdrop.jpg","assets/episode-i-heart-ny-2002-s4e18-poster.jpg","assets/episode-new-day-2021-s1e2-backdrop.jpg","assets/episode-new-day-2021-s1e2-poster.jpg","assets/episode-no-honeymoon-2020-s1e8-backdrop.jpg","assets/episode-no-honeymoon-2020-s1e8-poster.jpg","assets/episode-notapusy-2005-s3e4-backdrop.jpg","assets/episode-notapusy-2005-s3e4-poster.jpg","assets/episode-one-of-us-2024-s1e7-backdrop.jpg","assets/episode-one-of-us-2024-s1e7-poster.jpg","assets/episode-remembrance-day-2026-s2e1-backdrop.jpg","assets/episode-remembrance-day-2026-s2e1-poster.jpg","assets/episode-sex-and-the-country-2001-s4e9-backdrop.jpg","assets/episode-sex-and-the-country-2001-s4e9-poster.jpg","assets/episode-south-american-biker-gangs-2026-s29e1-backdrop.jpg","assets/episode-south-american-biker-gangs-2026-s29e1-poster.jpg","assets/episode-the-breakup-part-2-2001-s1e17-backdrop.jpg","assets/episode-the-breakup-part-2-2001-s1e17-poster.jpg","assets/episode-the-man-who-would-be-vogue-2018-s2e1-backdrop.jpg","assets/episode-the-man-who-would-be-vogue-2018-s2e1-poster.jpg","assets/episode-the-uncomfortability-2010-s1e2-backdrop.jpg","assets/episode-the-uncomfortability-2010-s1e2-poster.jpg","assets/episode-three-monsters-1992-s1e6-backdrop.jpg","assets/episode-three-monsters-1992-s1e6-poster.jpg","assets/episode-tomorrow-2024-s3e1-backdrop.jpg","assets/episode-tomorrow-2024-s3e1-poster.jpg","assets/episode-what-more-do-i-need-2008-s5e7-backdrop.jpg","assets/episode-what-more-do-i-need-2008-s5e7-poster.jpg","assets/movie-101-dalmatians-ii-patch-s-london-adventure-2003-backdrop.jpg","assets/movie-101-dalmatians-ii-patch-s-london-adventure-2003-poster.jpg","assets/movie-a-bronx-tale-1993-backdrop.jpg","assets/movie-a-bronx-tale-1993-poster.jpg","assets/movie-a-silent-voice-the-movie-2016-backdrop.jpg","assets/movie-a-silent-voice-the-movie-2016-poster.jpg","assets/movie-absolution-2024-backdrop.jpg","assets/movie-absolution-2024-poster.jpg","assets/movie-across-the-hall-2009-backdrop.jpg","assets/movie-across-the-hall-2009-poster.jpg","assets/movie-addicted-2014-backdrop.jpg","assets/movie-addicted-2014-poster.jpg","assets/movie-adulterers-2016-backdrop.jpg","assets/movie-adulterers-2016-poster.jpg","assets/movie-all-the-money-in-the-world-2017-backdrop.jpg","assets/movie-all-the-money-in-the-world-2017-poster.jpg","assets/movie-alone-with-her-2006-backdrop.jpg","assets/movie-alone-with-her-2006-poster.jpg","assets/movie-american-gangster-2007-backdrop.jpg","assets/movie-american-gangster-2007-poster.jpg","assets/movie-annie-hall-1977-backdrop.jpg","assets/movie-annie-hall-1977-poster.jpg","assets/movie-ant-man-and-the-wasp-quantumania-2023-backdrop.jpg","assets/movie-ant-man-and-the-wasp-quantumania-2023-poster.jpg","assets/movie-appleseed-alpha-2014-backdrop.jpg","assets/movie-appleseed-alpha-2014-poster.jpg","assets/movie-aquaman-and-the-lost-kingdom-2023-backdrop.jpg","assets/movie-aquaman-and-the-lost-kingdom-2023-poster.jpg","assets/movie-arctic-dogs-2019-backdrop.jpg","assets/movie-arctic-dogs-2019-poster.jpg","assets/movie-arrival-2016-backdrop.jpg","assets/movie-arrival-2016-poster.jpg","assets/movie-avatar-aang-the-last-airbender-2026-backdrop.jpg","assets/movie-avatar-aang-the-last-airbender-2026-poster.jpg","assets/movie-avengers-age-of-ultron-2015-backdrop.jpg","assets/movie-avengers-age-of-ultron-2015-poster.jpg","assets/movie-avengers-confidential-black-widow-punisher-2014-backdrop.jpg","assets/movie-avengers-confidential-black-widow-punisher-2014-poster.jpg","assets/movie-avengers-infinity-war-2018-backdrop.jpg","assets/movie-avengers-infinity-war-2018-poster.jpg","assets/movie-awake-2007-backdrop.jpg","assets/movie-awake-2007-poster.jpg","assets/movie-batman-ninja-2018-backdrop.jpg","assets/movie-batman-ninja-2018-poster.jpg","assets/movie-batman-the-dark-knight-returns-part-1-2012-backdrop.jpg","assets/movie-batman-the-dark-knight-returns-part-1-2012-poster.jpg","assets/movie-batman-v-superman-dawn-of-justice-2016-backdrop.jpg","assets/movie-batman-v-superman-dawn-of-justice-2016-poster.jpg","assets/movie-black-adam-2022-backdrop.jpg","assets/movie-black-adam-2022-poster.jpg","assets/movie-black-bear-2020-backdrop.jpg","assets/movie-black-bear-2020-poster.jpg","assets/movie-black-panther-2018-backdrop.jpg","assets/movie-black-panther-2018-poster.jpg","assets/movie-black-panther-wakanda-forever-2022-backdrop.jpg","assets/movie-black-panther-wakanda-forever-2022-poster.jpg","assets/movie-blame-2017-backdrop.jpg","assets/movie-blame-2017-poster.jpg","assets/movie-bleach-the-movie-fade-to-black-2008-backdrop.jpg","assets/movie-bleach-the-movie-fade-to-black-2008-poster.jpg","assets/movie-butterfly-on-a-wheel-2007-backdrop.jpg","assets/movie-butterfly-on-a-wheel-2007-poster.jpg","assets/movie-captain-america-civil-war-2016-backdrop.jpg","assets/movie-captain-america-civil-war-2016-poster.jpg","assets/movie-careful-what-you-wish-for-2015-backdrop.jpg","assets/movie-careful-what-you-wish-for-2015-poster.jpg","assets/movie-castle-in-the-sky-1986-backdrop.jpg","assets/movie-castle-in-the-sky-1986-poster.jpg","assets/movie-chicago-2002-backdrop.jpg","assets/movie-chicago-2002-poster.jpg","assets/movie-citizen-kane-1941-backdrop.jpg","assets/movie-citizen-kane-1941-poster.jpg","assets/movie-conclave-2024-backdrop.jpg","assets/movie-conclave-2024-poster.jpg","assets/movie-crime-of-passion-1956-backdrop.jpg","assets/movie-crime-of-passion-1956-poster.jpg","assets/movie-deep-water-2022-backdrop.jpg","assets/movie-deep-water-2022-poster.jpg","assets/movie-derailed-2005-backdrop.jpg","assets/movie-derailed-2005-poster.jpg","assets/movie-dream-house-2011-backdrop.jpg","assets/movie-dream-house-2011-poster.jpg","assets/movie-dream-lover-1994-backdrop.jpg","assets/movie-dream-lover-1994-poster.jpg","assets/movie-drop-2025-backdrop.jpg","assets/movie-drop-2025-poster.jpg","assets/movie-eastern-promises-2007-backdrop.jpg","assets/movie-eastern-promises-2007-poster.jpg","assets/movie-el-camino-a-breaking-bad-movie-2019-backdrop.jpg","assets/movie-el-camino-a-breaking-bad-movie-2019-poster.jpg","assets/movie-fatale-2020-backdrop.jpg","assets/movie-fatale-2020-poster.jpg","assets/movie-force-of-nature-the-dry-2-2024-backdrop.jpg","assets/movie-force-of-nature-the-dry-2-2024-poster.jpg","assets/movie-ghost-in-the-shell-1995-backdrop.jpg","assets/movie-ghost-in-the-shell-1995-poster.jpg","assets/movie-ghost-in-the-shell-2-innocence-2004-backdrop.jpg","assets/movie-ghost-in-the-shell-2-innocence-2004-poster.jpg","assets/movie-gosford-park-2001-backdrop.jpg","assets/movie-gosford-park-2001-poster.jpg","assets/movie-howl-s-moving-castle-2004-backdrop.jpg","assets/movie-howl-s-moving-castle-2004-poster.jpg","assets/movie-in-the-heights-2021-backdrop.jpg","assets/movie-in-the-heights-2021-poster.jpg","assets/movie-kiki-s-delivery-service-1989-backdrop.jpg","assets/movie-kiki-s-delivery-service-1989-poster.jpg","assets/movie-lemony-snicket-s-a-series-of-unfortunate-events-2004-backdrop.jpg","assets/movie-lemony-snicket-s-a-series-of-unfortunate-events-2004-poster.jpg","assets/movie-locke-2014-backdrop.jpg","assets/movie-locke-2014-poster.jpg","assets/movie-memories-1995-backdrop.jpg","assets/movie-memories-1995-poster.jpg","assets/movie-night-is-short-walk-on-girl-2017-backdrop.jpg","assets/movie-night-is-short-walk-on-girl-2017-poster.jpg","assets/movie-panic-room-2002-backdrop.jpg","assets/movie-panic-room-2002-poster.jpg","assets/movie-ponyo-2008-backdrop.jpg","assets/movie-ponyo-2008-poster.jpg","assets/movie-porco-rosso-1992-backdrop.jpg","assets/movie-porco-rosso-1992-poster.jpg","assets/movie-princess-mononoke-1997-backdrop.jpg","assets/movie-princess-mononoke-1997-poster.jpg","assets/movie-prisoners-2013-backdrop.jpg","assets/movie-prisoners-2013-poster.jpg","assets/movie-racing-stripes-2005-backdrop.jpg","assets/movie-racing-stripes-2005-poster.jpg","assets/movie-secondhand-lions-2003-backdrop.jpg","assets/movie-secondhand-lions-2003-poster.jpg","assets/movie-shaft-2000-backdrop.jpg","assets/movie-shaft-2000-poster.jpg","assets/movie-sinbad-legend-of-the-seven-seas-2003-backdrop.jpg","assets/movie-sinbad-legend-of-the-seven-seas-2003-poster.jpg","assets/movie-sleeping-with-other-people-2015-backdrop.jpg","assets/movie-sleeping-with-other-people-2015-poster.jpg","assets/movie-snowpiercer-2013-backdrop.jpg","assets/movie-snowpiercer-2013-poster.jpg","assets/movie-spider-man-brand-new-day-2026-backdrop.jpg","assets/movie-spider-man-brand-new-day-2026-poster.jpg","assets/movie-spider-man-far-from-home-2019-backdrop.jpg","assets/movie-spider-man-far-from-home-2019-poster.jpg","assets/movie-spider-man-homecoming-2017-backdrop.jpg","assets/movie-spider-man-homecoming-2017-poster.jpg","assets/movie-spider-man-no-way-home-2021-backdrop.jpg","assets/movie-spider-man-no-way-home-2021-poster.jpg","assets/movie-spirited-away-2001-backdrop.jpg","assets/movie-spirited-away-2001-poster.jpg","assets/movie-the-alto-knights-2025-backdrop.jpg","assets/movie-the-alto-knights-2025-poster.jpg","assets/movie-the-amazing-spider-man-2-2014-backdrop.jpg","assets/movie-the-amazing-spider-man-2-2014-poster.jpg","assets/movie-the-amazing-spider-man-2012-backdrop.jpg","assets/movie-the-amazing-spider-man-2012-poster.jpg","assets/movie-the-animatrix-2003-backdrop.jpg","assets/movie-the-animatrix-2003-poster.jpg","assets/movie-the-avengers-2012-backdrop.jpg","assets/movie-the-avengers-2012-poster.jpg","assets/movie-the-boy-and-the-heron-2023-backdrop.jpg","assets/movie-the-boy-and-the-heron-2023-poster.jpg","assets/movie-the-breakfast-club-1985-backdrop.jpg","assets/movie-the-breakfast-club-1985-poster.jpg","assets/movie-the-dry-2021-backdrop.jpg","assets/movie-the-dry-2021-poster.jpg","assets/movie-the-girl-who-leapt-through-time-2006-backdrop.jpg","assets/movie-the-girl-who-leapt-through-time-2006-poster.jpg","assets/movie-the-godfather-1972-backdrop.jpg","assets/movie-the-godfather-1972-poster.jpg","assets/movie-the-godfather-part-iii-1990-backdrop.jpg","assets/movie-the-godfather-part-iii-1990-poster.jpg","assets/movie-the-great-gatsby-2013-backdrop.jpg","assets/movie-the-great-gatsby-2013-poster.jpg","assets/movie-the-killing-of-a-sacred-deer-2017-backdrop.jpg","assets/movie-the-killing-of-a-sacred-deer-2017-poster.jpg","assets/movie-the-lighthouse-2019-backdrop.jpg","assets/movie-the-lighthouse-2019-poster.jpg","assets/movie-the-secret-life-of-pets-2-2019-backdrop.jpg","assets/movie-the-secret-life-of-pets-2-2019-poster.jpg","assets/movie-the-tale-of-the-princess-kaguya-2013-backdrop.jpg","assets/movie-the-tale-of-the-princess-kaguya-2013-poster.jpg","assets/movie-the-wild-robot-2024-backdrop.jpg","assets/movie-the-wild-robot-2024-poster.jpg","assets/movie-the-witcher-nightmare-of-the-wolf-2021-backdrop.jpg","assets/movie-the-witcher-nightmare-of-the-wolf-2021-poster.jpg","assets/movie-the-witcher-sirens-of-the-deep-2025-backdrop.jpg","assets/movie-the-witcher-sirens-of-the-deep-2025-poster.jpg","assets/movie-thelma-louise-1991-backdrop.jpg","assets/movie-thelma-louise-1991-poster.jpg","assets/movie-triangle-of-sadness-2022-backdrop.jpg","assets/movie-triangle-of-sadness-2022-poster.jpg","assets/movie-vice-2018-backdrop.jpg","assets/movie-vice-2018-poster.jpg","assets/tv-12-monkeys-2015-backdrop.jpg","assets/tv-12-monkeys-2015-poster.jpg","assets/tv-56-days-2026-backdrop.jpg","assets/tv-56-days-2026-poster.jpg","assets/tv-a-knight-of-the-seven-kingdoms-2026-backdrop.jpg","assets/tv-a-knight-of-the-seven-kingdoms-2026-poster.jpg","assets/tv-absolutely-fabulous-1992-backdrop.jpg","assets/tv-absolutely-fabulous-1992-poster.jpg","assets/tv-accel-world-2012-backdrop.jpg","assets/tv-accel-world-2012-poster.jpg","assets/tv-accused-2023-backdrop.jpg","assets/tv-accused-2023-poster.jpg","assets/tv-after-life-2019-backdrop.jpg","assets/tv-after-life-2019-poster.jpg","assets/tv-agatha-christie-s-marple-2004-backdrop.jpg","assets/tv-agatha-christie-s-marple-2004-poster.jpg","assets/tv-ahsoka-2023-backdrop.jpg","assets/tv-ahsoka-2023-poster.jpg","assets/tv-all-creatures-great-and-small-1978-backdrop.jpg","assets/tv-all-creatures-great-and-small-1978-poster.jpg","assets/tv-all-creatures-great-small-2020-backdrop.jpg","assets/tv-all-creatures-great-small-2020-poster.jpg","assets/tv-american-dad-2005-backdrop.jpg","assets/tv-american-dad-2005-poster.jpg","assets/tv-american-gods-2017-backdrop.jpg","assets/tv-american-gods-2017-poster.jpg","assets/tv-american-housewife-2016-backdrop.jpg","assets/tv-american-housewife-2016-poster.jpg","assets/tv-american-primeval-2025-backdrop.jpg","assets/tv-american-primeval-2025-poster.jpg","assets/tv-and-just-like-that-2021-backdrop.jpg","assets/tv-and-just-like-that-2021-poster.jpg","assets/tv-andor-2022-backdrop.jpg","assets/tv-andor-2022-poster.jpg","assets/tv-andromeda-2000-backdrop.jpg","assets/tv-andromeda-2000-poster.jpg","assets/tv-anne-with-an-e-2017-backdrop.jpg","assets/tv-anne-with-an-e-2017-poster.jpg","assets/tv-aqua-teen-hunger-force-2000-backdrop.jpg","assets/tv-aqua-teen-hunger-force-2000-poster.jpg","assets/tv-arifureta-from-commonplace-to-world-s-strongest-2019-backdrop.jpg","assets/tv-arifureta-from-commonplace-to-world-s-strongest-2019-poster.jpg","assets/tv-arrow-2012-backdrop.jpg","assets/tv-arrow-2012-poster.jpg","assets/tv-atlantis-2013-backdrop.jpg","assets/tv-atlantis-2013-poster.jpg","assets/tv-avatar-seven-havens-2026-backdrop.jpg","assets/tv-avatar-seven-havens-2026-poster.jpg","assets/tv-avatar-the-last-airbender-2005-backdrop.jpg","assets/tv-avatar-the-last-airbender-2005-poster.jpg","assets/tv-avatar-the-last-airbender-2024-backdrop.jpg","assets/tv-avatar-the-last-airbender-2024-poster.jpg","assets/tv-awkward-2011-backdrop.jpg","assets/tv-awkward-2011-poster.jpg","assets/tv-back-to-life-2019-backdrop.jpg","assets/tv-back-to-life-2019-poster.jpg","assets/tv-ballers-2015-backdrop.jpg","assets/tv-ballers-2015-poster.jpg","assets/tv-baretta-1975-backdrop.jpg","assets/tv-baretta-1975-poster.jpg","assets/tv-based-on-a-true-story-2023-backdrop.jpg","assets/tv-based-on-a-true-story-2023-poster.jpg","assets/tv-batman-the-brave-and-the-bold-2008-backdrop.jpg","assets/tv-batman-the-brave-and-the-bold-2008-poster.jpg","assets/tv-batwoman-2019-backdrop.jpg","assets/tv-batwoman-2019-poster.jpg","assets/tv-big-boys-2022-backdrop.jpg","assets/tv-big-boys-2022-poster.jpg","assets/tv-big-city-greens-2018-backdrop.jpg","assets/tv-big-city-greens-2018-poster.jpg","assets/tv-big-mouth-2017-backdrop.jpg","assets/tv-big-mouth-2017-poster.jpg","assets/tv-black-lightning-2018-backdrop.jpg","assets/tv-black-lightning-2018-poster.jpg","assets/tv-black-rabbit-2025-backdrop.jpg","assets/tv-black-rabbit-2025-poster.jpg","assets/tv-bleach-2004-backdrop.jpg","assets/tv-bleach-2004-poster.jpg","assets/tv-brassic-2019-backdrop.jpg","assets/tv-brassic-2019-poster.jpg","assets/tv-breaking-bad-2008-backdrop.jpg","assets/tv-breaking-bad-2008-poster.jpg","assets/tv-californication-2007-backdrop.jpg","assets/tv-californication-2007-poster.jpg","assets/tv-chainsaw-man-2022-backdrop.jpg","assets/tv-chainsaw-man-2022-poster.jpg","assets/tv-cyberpunk-edgerunners-2022-backdrop.jpg","assets/tv-cyberpunk-edgerunners-2022-poster.jpg","assets/tv-derry-girls-2018-backdrop.jpg","assets/tv-derry-girls-2018-poster.jpg","assets/tv-desperate-housewives-2004-backdrop.jpg","assets/tv-desperate-housewives-2004-poster.jpg","assets/tv-dune-prophecy-2024-backdrop.jpg","assets/tv-dune-prophecy-2024-poster.jpg","assets/tv-etoile-2025-backdrop.jpg","assets/tv-etoile-2025-poster.jpg","assets/tv-fallout-2024-backdrop.jpg","assets/tv-fallout-2024-poster.jpg","assets/tv-firefly-2002-backdrop.jpg","assets/tv-firefly-2002-poster.jpg","assets/tv-fleabag-2016-backdrop.jpg","assets/tv-fleabag-2016-poster.jpg","assets/tv-gilmore-girls-2000-backdrop.jpg","assets/tv-gilmore-girls-2000-poster.jpg","assets/tv-high-potential-2024-backdrop.jpg","assets/tv-high-potential-2024-poster.jpg","assets/tv-invincible-2021-backdrop.jpg","assets/tv-invincible-2021-poster.jpg","assets/tv-last-samurai-standing-2025-backdrop.jpg","assets/tv-last-samurai-standing-2025-poster.jpg","assets/tv-lord-of-the-flies-2026-backdrop.jpg","assets/tv-lord-of-the-flies-2026-poster.jpg","assets/tv-love-death-robots-2019-backdrop.jpg","assets/tv-love-death-robots-2019-poster.jpg","assets/tv-maniac-2018-backdrop.jpg","assets/tv-maniac-2018-poster.jpg","assets/tv-neon-genesis-evangelion-1995-backdrop.jpg","assets/tv-neon-genesis-evangelion-1995-poster.jpg","assets/tv-norsemen-2016-backdrop.jpg","assets/tv-norsemen-2016-poster.jpg","assets/tv-one-piece-2023-backdrop.jpg","assets/tv-one-piece-2023-poster.jpg","assets/tv-only-murders-in-the-building-2021-backdrop.jpg","assets/tv-only-murders-in-the-building-2021-poster.jpg","assets/tv-over-the-garden-wall-2014-backdrop.jpg","assets/tv-over-the-garden-wall-2014-poster.jpg","assets/tv-pluribus-2025-backdrop.jpg","assets/tv-pluribus-2025-poster.jpg","assets/tv-rick-and-morty-2013-backdrop.jpg","assets/tv-rick-and-morty-2013-poster.jpg","assets/tv-secret-level-2024-backdrop.jpg","assets/tv-secret-level-2024-poster.jpg","assets/tv-severance-2022-backdrop.jpg","assets/tv-severance-2022-poster.jpg","assets/tv-sex-and-the-city-1998-backdrop.jpg","assets/tv-sex-and-the-city-1998-poster.jpg","assets/tv-the-100-2014-backdrop.jpg","assets/tv-the-100-2014-poster.jpg","assets/tv-the-abbott-and-costello-show-1952-backdrop.jpg","assets/tv-the-abbott-and-costello-show-1952-poster.jpg","assets/tv-the-afterparty-2022-backdrop.jpg","assets/tv-the-afterparty-2022-poster.jpg","assets/tv-the-bear-2022-backdrop.jpg","assets/tv-the-bear-2022-poster.jpg","assets/tv-the-beast-in-me-2025-backdrop.jpg","assets/tv-the-beast-in-me-2025-poster.jpg","assets/tv-the-bold-type-2017-backdrop.jpg","assets/tv-the-bold-type-2017-poster.jpg","assets/tv-the-boys-2019-backdrop.jpg","assets/tv-the-boys-2019-poster.jpg","assets/tv-the-capture-2019-backdrop.jpg","assets/tv-the-capture-2019-poster.jpg","assets/tv-the-durrells-2016-backdrop.jpg","assets/tv-the-durrells-2016-poster.jpg","assets/tv-the-final-problem-2026-backdrop.jpg","assets/tv-the-final-problem-2026-poster.jpg","assets/tv-the-legend-of-korra-2012-backdrop.jpg","assets/tv-the-legend-of-korra-2012-poster.jpg","assets/tv-the-lord-of-the-rings-the-rings-of-power-2022-backdrop.jpg","assets/tv-the-lord-of-the-rings-the-rings-of-power-2022-poster.jpg","assets/tv-the-night-manager-2016-backdrop.jpg","assets/tv-the-night-manager-2016-poster.jpg","assets/tv-the-tatami-galaxy-2010-backdrop.jpg","assets/tv-the-tatami-galaxy-2010-poster.jpg","assets/tv-the-white-lotus-2021-backdrop.jpg","assets/tv-the-white-lotus-2021-poster.jpg","assets/tv-the-witcher-2019-backdrop.jpg","assets/tv-the-witcher-2019-poster.jpg","assets/tv-upload-2020-backdrop.jpg","assets/tv-upload-2020-poster.jpg","assets/tv-widow-s-bay-2026-backdrop.jpg","assets/tv-widow-s-bay-2026-poster.jpg","assets/tv-yellowjackets-2021-backdrop.jpg","assets/tv-yellowjackets-2021-poster.jpg"]} diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/26R1LBiSOkkP08saSsz6LmVTY16.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/26R1LBiSOkkP08saSsz6LmVTY16.jpg new file mode 100644 index 00000000..12a79fbe Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/cast/26R1LBiSOkkP08saSsz6LmVTY16.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/2tFblvjBRUYwcVT1V1i79Ppd6wG.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/2tFblvjBRUYwcVT1V1i79Ppd6wG.jpg new file mode 100644 index 00000000..51c11420 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/cast/2tFblvjBRUYwcVT1V1i79Ppd6wG.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/320qW5yEbxpmyxQ3evmClJbtKag.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/320qW5yEbxpmyxQ3evmClJbtKag.jpg new file mode 100644 index 00000000..0c5b151f Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/cast/320qW5yEbxpmyxQ3evmClJbtKag.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/4F8XpjyQCvtuu21WFm5d8RF5Rl.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/4F8XpjyQCvtuu21WFm5d8RF5Rl.jpg new file mode 100644 index 00000000..a639becf Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/cast/4F8XpjyQCvtuu21WFm5d8RF5Rl.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/4eOoW1PNBZfxRW0g8RmYH8bL6mq.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/4eOoW1PNBZfxRW0g8RmYH8bL6mq.jpg new file mode 100644 index 00000000..f0a4e83a Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/cast/4eOoW1PNBZfxRW0g8RmYH8bL6mq.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/4gyHyg6FJ1oFczOm5pmMkdEEo2J.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/4gyHyg6FJ1oFczOm5pmMkdEEo2J.jpg new file mode 100644 index 00000000..b28de566 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/cast/4gyHyg6FJ1oFczOm5pmMkdEEo2J.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/5L0kzTVITmOQdQ94NhicKM8ecCX.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/5L0kzTVITmOQdQ94NhicKM8ecCX.jpg new file mode 100644 index 00000000..35a785df Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/cast/5L0kzTVITmOQdQ94NhicKM8ecCX.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/5VbadQsYCKdiJysP01LJFPmYB8A.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/5VbadQsYCKdiJysP01LJFPmYB8A.jpg new file mode 100644 index 00000000..41618a2e Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/cast/5VbadQsYCKdiJysP01LJFPmYB8A.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/5eIRHUbNOH7ibXa0IfUfFvj5I5L.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/5eIRHUbNOH7ibXa0IfUfFvj5I5L.jpg new file mode 100644 index 00000000..5c6e94de Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/cast/5eIRHUbNOH7ibXa0IfUfFvj5I5L.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/5hn7WeXi6wguwxTPTSmcmzHAs9.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/5hn7WeXi6wguwxTPTSmcmzHAs9.jpg new file mode 100644 index 00000000..1b59f62f Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/cast/5hn7WeXi6wguwxTPTSmcmzHAs9.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/6hmUs5Q1EETapn32yKNt3c3i1sn.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/6hmUs5Q1EETapn32yKNt3c3i1sn.jpg new file mode 100644 index 00000000..21dc4e89 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/cast/6hmUs5Q1EETapn32yKNt3c3i1sn.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/7LVYeFTgUpjYDrxnU7Wm5ZQUX9t.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/7LVYeFTgUpjYDrxnU7Wm5ZQUX9t.jpg new file mode 100644 index 00000000..dde817a1 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/cast/7LVYeFTgUpjYDrxnU7Wm5ZQUX9t.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/7MHdNxS1gRnx0ZQGPrfS5eRjMe3.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/7MHdNxS1gRnx0ZQGPrfS5eRjMe3.jpg new file mode 100644 index 00000000..5094cb73 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/cast/7MHdNxS1gRnx0ZQGPrfS5eRjMe3.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/7Y96dAfg0HcFrcLjlD5eD9N0uj4.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/7Y96dAfg0HcFrcLjlD5eD9N0uj4.jpg new file mode 100644 index 00000000..1f88cd10 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/cast/7Y96dAfg0HcFrcLjlD5eD9N0uj4.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/8v3lDa0QCJlDtOx9flqlMNIA3uf.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/8v3lDa0QCJlDtOx9flqlMNIA3uf.jpg new file mode 100644 index 00000000..5d5930b7 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/cast/8v3lDa0QCJlDtOx9flqlMNIA3uf.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/9pxaoasotR1pdCXYSfN1pkm0geO.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/9pxaoasotR1pdCXYSfN1pkm0geO.jpg new file mode 100644 index 00000000..6a483975 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/cast/9pxaoasotR1pdCXYSfN1pkm0geO.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/aHQk6XxqyJinfvrVRADEIg31nOk.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/aHQk6XxqyJinfvrVRADEIg31nOk.jpg new file mode 100644 index 00000000..a1df3481 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/cast/aHQk6XxqyJinfvrVRADEIg31nOk.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/aW6vCxkUZtwb6iH2Wf88Uq0XNVv.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/aW6vCxkUZtwb6iH2Wf88Uq0XNVv.jpg new file mode 100644 index 00000000..50197dbe Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/cast/aW6vCxkUZtwb6iH2Wf88Uq0XNVv.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/bLgmpSFgEiSFNshqmdl3EIZkRZm.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/bLgmpSFgEiSFNshqmdl3EIZkRZm.jpg new file mode 100644 index 00000000..5aa08a58 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/cast/bLgmpSFgEiSFNshqmdl3EIZkRZm.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/biov5jENp2XYfehkcdnRvi796Wi.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/biov5jENp2XYfehkcdnRvi796Wi.jpg new file mode 100644 index 00000000..07813572 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/cast/biov5jENp2XYfehkcdnRvi796Wi.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/d81K0RH8UX7tZj49tZaQhZ9ewH.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/d81K0RH8UX7tZj49tZaQhZ9ewH.jpg new file mode 100644 index 00000000..77d5fe66 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/cast/d81K0RH8UX7tZj49tZaQhZ9ewH.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/fD8GuLHOOSSAG98VjwN3NhAmqAJ.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/fD8GuLHOOSSAG98VjwN3NhAmqAJ.jpg new file mode 100644 index 00000000..3c66e9a4 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/cast/fD8GuLHOOSSAG98VjwN3NhAmqAJ.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/fbWWkhe7GVSlhBljY89rJd01OnA.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/fbWWkhe7GVSlhBljY89rJd01OnA.jpg new file mode 100644 index 00000000..15a25098 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/cast/fbWWkhe7GVSlhBljY89rJd01OnA.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/fdFVatb9pt5dfT9tDK8tikKWafS.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/fdFVatb9pt5dfT9tDK8tikKWafS.jpg new file mode 100644 index 00000000..60531535 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/cast/fdFVatb9pt5dfT9tDK8tikKWafS.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/fjJc03z1cOlMxLZbC1qihsq3C37.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/fjJc03z1cOlMxLZbC1qihsq3C37.jpg new file mode 100644 index 00000000..989ce70d Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/cast/fjJc03z1cOlMxLZbC1qihsq3C37.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/hOntuFYAMwn9QaXT0AwLCPU79ce.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/hOntuFYAMwn9QaXT0AwLCPU79ce.jpg new file mode 100644 index 00000000..783d7043 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/cast/hOntuFYAMwn9QaXT0AwLCPU79ce.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/hrTwOPWek7nkPJgFhh6inFu7vSS.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/hrTwOPWek7nkPJgFhh6inFu7vSS.jpg new file mode 100644 index 00000000..5f9dc239 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/cast/hrTwOPWek7nkPJgFhh6inFu7vSS.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/hzu7OqTmNCT1xdCdmwBSf2yJvaO.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/hzu7OqTmNCT1xdCdmwBSf2yJvaO.jpg new file mode 100644 index 00000000..2b3d2015 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/cast/hzu7OqTmNCT1xdCdmwBSf2yJvaO.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/iQLEawIP2593yrGy22GzLSpnfgl.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/iQLEawIP2593yrGy22GzLSpnfgl.jpg new file mode 100644 index 00000000..5965e06d Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/cast/iQLEawIP2593yrGy22GzLSpnfgl.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/j0GXzLelO1dOzI7d1WVIwQXhLz8.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/j0GXzLelO1dOzI7d1WVIwQXhLz8.jpg new file mode 100644 index 00000000..0921d96c Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/cast/j0GXzLelO1dOzI7d1WVIwQXhLz8.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/jUuUbPuMGonFT5E2pcs4alfqaCN.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/jUuUbPuMGonFT5E2pcs4alfqaCN.jpg new file mode 100644 index 00000000..7e03f82b Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/cast/jUuUbPuMGonFT5E2pcs4alfqaCN.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/lF06PTNYrlXeJ3DYApvOcPXJr87.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/lF06PTNYrlXeJ3DYApvOcPXJr87.jpg new file mode 100644 index 00000000..01f41d25 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/cast/lF06PTNYrlXeJ3DYApvOcPXJr87.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/lUhVV9u5VGMCNdCAVhGzg8GFSdo.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/lUhVV9u5VGMCNdCAVhGzg8GFSdo.jpg new file mode 100644 index 00000000..8baff3f2 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/cast/lUhVV9u5VGMCNdCAVhGzg8GFSdo.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/lawJUdbLb1YW0niaYjiVGR9IBG8.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/lawJUdbLb1YW0niaYjiVGR9IBG8.jpg new file mode 100644 index 00000000..11d18b7a Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/cast/lawJUdbLb1YW0niaYjiVGR9IBG8.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/lfbyJWNWGbiYfU0Yr1tjn6Trd2V.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/lfbyJWNWGbiYfU0Yr1tjn6Trd2V.jpg new file mode 100644 index 00000000..64a37106 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/cast/lfbyJWNWGbiYfU0Yr1tjn6Trd2V.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/lpEs9yDsMQlsbm0RVsvDIleCXcI.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/lpEs9yDsMQlsbm0RVsvDIleCXcI.jpg new file mode 100644 index 00000000..5ff2d6a1 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/cast/lpEs9yDsMQlsbm0RVsvDIleCXcI.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/mF5YJxApTYKBVFJDrEC9IrJ5ooV.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/mF5YJxApTYKBVFJDrEC9IrJ5ooV.jpg new file mode 100644 index 00000000..2a4445ce Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/cast/mF5YJxApTYKBVFJDrEC9IrJ5ooV.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/mW7xmtGV4y79kQGn0zkKVGDMAmw.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/mW7xmtGV4y79kQGn0zkKVGDMAmw.jpg new file mode 100644 index 00000000..21dd5ffe Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/cast/mW7xmtGV4y79kQGn0zkKVGDMAmw.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/mk5vpYLmER7asqOhEwVKXD3P3YE.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/mk5vpYLmER7asqOhEwVKXD3P3YE.jpg new file mode 100644 index 00000000..b4189cd0 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/cast/mk5vpYLmER7asqOhEwVKXD3P3YE.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/mnKYU7HIsuRkx3YjdRO5FLXCdX5.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/mnKYU7HIsuRkx3YjdRO5FLXCdX5.jpg new file mode 100644 index 00000000..40aee939 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/cast/mnKYU7HIsuRkx3YjdRO5FLXCdX5.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/msqe5A1q5YUUND0RhzB9dw1AEAO.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/msqe5A1q5YUUND0RhzB9dw1AEAO.jpg new file mode 100644 index 00000000..afc0e401 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/cast/msqe5A1q5YUUND0RhzB9dw1AEAO.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/nAxS5l9vb1C8ltCp9yAa0f6lazu.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/nAxS5l9vb1C8ltCp9yAa0f6lazu.jpg new file mode 100644 index 00000000..2e179e76 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/cast/nAxS5l9vb1C8ltCp9yAa0f6lazu.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/nmgOd3X2Xn3jIp9OLCRJzLExRWN.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/nmgOd3X2Xn3jIp9OLCRJzLExRWN.jpg new file mode 100644 index 00000000..5a261d60 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/cast/nmgOd3X2Xn3jIp9OLCRJzLExRWN.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/o7splcOIVAdbHnwohJOUAvXWVlk.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/o7splcOIVAdbHnwohJOUAvXWVlk.jpg new file mode 100644 index 00000000..4cdadf08 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/cast/o7splcOIVAdbHnwohJOUAvXWVlk.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/oMHMLhoV3ry8aOfdvs1kxyowhNq.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/oMHMLhoV3ry8aOfdvs1kxyowhNq.jpg new file mode 100644 index 00000000..99b5bcd6 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/cast/oMHMLhoV3ry8aOfdvs1kxyowhNq.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/omWXGbeRR32MWuYhpnLOSqJ6xkO.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/omWXGbeRR32MWuYhpnLOSqJ6xkO.jpg new file mode 100644 index 00000000..1ee336bd Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/cast/omWXGbeRR32MWuYhpnLOSqJ6xkO.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/pVwR9hsEiHzVTo9giCjR3vIfa8W.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/pVwR9hsEiHzVTo9giCjR3vIfa8W.jpg new file mode 100644 index 00000000..4dbe126b Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/cast/pVwR9hsEiHzVTo9giCjR3vIfa8W.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/phEIvq0Ay2KjI5ewmVvpQ1a29GJ.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/phEIvq0Ay2KjI5ewmVvpQ1a29GJ.jpg new file mode 100644 index 00000000..b673a5b2 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/cast/phEIvq0Ay2KjI5ewmVvpQ1a29GJ.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/qP5vQMIbLNvSsSFkEMLZRVt4ZW5.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/qP5vQMIbLNvSsSFkEMLZRVt4ZW5.jpg new file mode 100644 index 00000000..ed34c316 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/cast/qP5vQMIbLNvSsSFkEMLZRVt4ZW5.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/qiWu2gphSg64igYl3Nx0hEIOQF3.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/qiWu2gphSg64igYl3Nx0hEIOQF3.jpg new file mode 100644 index 00000000..e5fd3f84 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/cast/qiWu2gphSg64igYl3Nx0hEIOQF3.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/qwbjcyDN37OHiln4a6P4LP31o7F.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/qwbjcyDN37OHiln4a6P4LP31o7F.jpg new file mode 100644 index 00000000..4132aac4 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/cast/qwbjcyDN37OHiln4a6P4LP31o7F.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/r6s7dkYZUJpd00C7p9itT6Gp8XB.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/r6s7dkYZUJpd00C7p9itT6Gp8XB.jpg new file mode 100644 index 00000000..8ae18ac0 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/cast/r6s7dkYZUJpd00C7p9itT6Gp8XB.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/rDTxblyw0AwU5oSeksgP7GwMyrF.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/rDTxblyw0AwU5oSeksgP7GwMyrF.jpg new file mode 100644 index 00000000..8d6c9a83 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/cast/rDTxblyw0AwU5oSeksgP7GwMyrF.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/rIqcwewXdkrDTMTwX56QPa7MNTC.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/rIqcwewXdkrDTMTwX56QPa7MNTC.jpg new file mode 100644 index 00000000..0b985c84 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/cast/rIqcwewXdkrDTMTwX56QPa7MNTC.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/rLzduQpYJnZJKkXv3jRc7MDHwoI.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/rLzduQpYJnZJKkXv3jRc7MDHwoI.jpg new file mode 100644 index 00000000..7377c541 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/cast/rLzduQpYJnZJKkXv3jRc7MDHwoI.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/rbxBK2m6fEq0oHeMoBUMVYkJEdJ.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/rbxBK2m6fEq0oHeMoBUMVYkJEdJ.jpg new file mode 100644 index 00000000..f23bdc49 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/cast/rbxBK2m6fEq0oHeMoBUMVYkJEdJ.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/sG6CWLdENCtzukNFkFYyPxqyRmS.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/sG6CWLdENCtzukNFkFYyPxqyRmS.jpg new file mode 100644 index 00000000..755a5276 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/cast/sG6CWLdENCtzukNFkFYyPxqyRmS.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/sh4ANkKoIHgmHtkKQbJvF8y0Fjq.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/sh4ANkKoIHgmHtkKQbJvF8y0Fjq.jpg new file mode 100644 index 00000000..c1a9d397 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/cast/sh4ANkKoIHgmHtkKQbJvF8y0Fjq.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/tqFHQiJUycuob3ytvilaAWDlbQ8.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/tqFHQiJUycuob3ytvilaAWDlbQ8.jpg new file mode 100644 index 00000000..5b2de159 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/cast/tqFHQiJUycuob3ytvilaAWDlbQ8.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/uL8yfcm0dCFYFQt052kczVQVvac.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/uL8yfcm0dCFYFQt052kczVQVvac.jpg new file mode 100644 index 00000000..4f1e2f21 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/cast/uL8yfcm0dCFYFQt052kczVQVvac.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/ub2IuMWFNQGYghHTPq0lpmn2Ue0.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/ub2IuMWFNQGYghHTPq0lpmn2Ue0.jpg new file mode 100644 index 00000000..725372ad Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/cast/ub2IuMWFNQGYghHTPq0lpmn2Ue0.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/ukDNHgvEgdU95eaw6pngiAPucv4.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/ukDNHgvEgdU95eaw6pngiAPucv4.jpg new file mode 100644 index 00000000..7ed17793 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/cast/ukDNHgvEgdU95eaw6pngiAPucv4.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/v4sRxLpRpHEd9vbk43wm9VL0v0K.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/v4sRxLpRpHEd9vbk43wm9VL0v0K.jpg new file mode 100644 index 00000000..2118c505 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/cast/v4sRxLpRpHEd9vbk43wm9VL0v0K.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/wWcby4ARYqr8d92ZdjyQcfaiJBW.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/wWcby4ARYqr8d92ZdjyQcfaiJBW.jpg new file mode 100644 index 00000000..3a609d1e Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/cast/wWcby4ARYqr8d92ZdjyQcfaiJBW.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/waayy0jpNbxLDQ8JO1uRDng8SLF.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/waayy0jpNbxLDQ8JO1uRDng8SLF.jpg new file mode 100644 index 00000000..b650362b Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/cast/waayy0jpNbxLDQ8JO1uRDng8SLF.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/x0lmqgkmphvOCSTHk2oxsaxA9Ra.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/x0lmqgkmphvOCSTHk2oxsaxA9Ra.jpg new file mode 100644 index 00000000..3d3f33ee Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/cast/x0lmqgkmphvOCSTHk2oxsaxA9Ra.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/xKBAaPIa1c7tzZD3Y0MhBLv4hPE.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/xKBAaPIa1c7tzZD3Y0MhBLv4hPE.jpg new file mode 100644 index 00000000..fba488ba Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/cast/xKBAaPIa1c7tzZD3Y0MhBLv4hPE.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/xV9UrP8kZdxOiTI2efDFhp3t4iA.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/xV9UrP8kZdxOiTI2efDFhp3t4iA.jpg new file mode 100644 index 00000000..eb015896 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/cast/xV9UrP8kZdxOiTI2efDFhp3t4iA.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/xm2pYFBggiXSkFBV2I612i3fDd3.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/xm2pYFBggiXSkFBV2I612i3fDd3.jpg new file mode 100644 index 00000000..bc482f04 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/cast/xm2pYFBggiXSkFBV2I612i3fDd3.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/xx3As5SWcE8vYOKZgtjDjqmT3jc.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/xx3As5SWcE8vYOKZgtjDjqmT3jc.jpg new file mode 100644 index 00000000..dd70f23a Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/cast/xx3As5SWcE8vYOKZgtjDjqmT3jc.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/yU0OXeZ0K1ppe1o9SpHiAMHy1rc.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/yU0OXeZ0K1ppe1o9SpHiAMHy1rc.jpg new file mode 100644 index 00000000..dd0796e7 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/cast/yU0OXeZ0K1ppe1o9SpHiAMHy1rc.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/yWZILsr2mBQBCG1LOcCYYshIbts.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/yWZILsr2mBQBCG1LOcCYYshIbts.jpg new file mode 100644 index 00000000..04d476bb Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/cast/yWZILsr2mBQBCG1LOcCYYshIbts.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/yxGsAKnrJZs2g6q4unNgkfm7NAA.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/yxGsAKnrJZs2g6q4unNgkfm7NAA.jpg new file mode 100644 index 00000000..6e7a492b Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/cast/yxGsAKnrJZs2g6q4unNgkfm7NAA.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/ztxDIZvuPreUixO6ZPxwkCg2UlO.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/ztxDIZvuPreUixO6ZPxwkCg2UlO.jpg new file mode 100644 index 00000000..6883cc09 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/cast/ztxDIZvuPreUixO6ZPxwkCg2UlO.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/episode-all-hands-on-deck-class-1-a-2021-s5e1-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/episode-all-hands-on-deck-class-1-a-2021-s5e1-backdrop.jpg new file mode 100644 index 00000000..f5b2f94c Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/episode-all-hands-on-deck-class-1-a-2021-s5e1-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/episode-all-hands-on-deck-class-1-a-2021-s5e1-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/episode-all-hands-on-deck-class-1-a-2021-s5e1-poster.jpg new file mode 100644 index 00000000..cf571b93 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/episode-all-hands-on-deck-class-1-a-2021-s5e1-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/episode-announcement-2022-s1e7-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/episode-announcement-2022-s1e7-backdrop.jpg new file mode 100644 index 00000000..a874a0c0 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/episode-announcement-2022-s1e7-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/episode-announcement-2022-s1e7-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/episode-announcement-2022-s1e7-poster.jpg new file mode 100644 index 00000000..df5dc498 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/episode-announcement-2022-s1e7-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/episode-episode-1-2025-s1e1-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/episode-episode-1-2025-s1e1-backdrop.jpg new file mode 100644 index 00000000..f2519b04 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/episode-episode-1-2025-s1e1-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/episode-episode-1-2025-s1e1-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/episode-episode-1-2025-s1e1-poster.jpg new file mode 100644 index 00000000..77febd79 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/episode-episode-1-2025-s1e1-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/episode-episode-2-2016-s1e2-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/episode-episode-2-2016-s1e2-backdrop.jpg new file mode 100644 index 00000000..3f8bdeec Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/episode-episode-2-2016-s1e2-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/episode-episode-2-2016-s1e2-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/episode-episode-2-2016-s1e2-poster.jpg new file mode 100644 index 00000000..d29448bb Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/episode-episode-2-2016-s1e2-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/episode-erickerhead-2026-s9e6-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/episode-erickerhead-2026-s9e6-backdrop.jpg new file mode 100644 index 00000000..1c4c0ffc Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/episode-erickerhead-2026-s9e6-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/episode-erickerhead-2026-s9e6-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/episode-erickerhead-2026-s9e6-poster.jpg new file mode 100644 index 00000000..b95c4890 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/episode-erickerhead-2026-s9e6-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/episode-girls-interrupted-2007-s1e7-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/episode-girls-interrupted-2007-s1e7-backdrop.jpg new file mode 100644 index 00000000..ffff979b Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/episode-girls-interrupted-2007-s1e7-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/episode-girls-interrupted-2007-s1e7-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/episode-girls-interrupted-2007-s1e7-poster.jpg new file mode 100644 index 00000000..38428e76 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/episode-girls-interrupted-2007-s1e7-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/episode-i-heart-ny-2002-s4e18-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/episode-i-heart-ny-2002-s4e18-backdrop.jpg new file mode 100644 index 00000000..57beacc1 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/episode-i-heart-ny-2002-s4e18-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/episode-i-heart-ny-2002-s4e18-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/episode-i-heart-ny-2002-s4e18-poster.jpg new file mode 100644 index 00000000..e546cc6d Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/episode-i-heart-ny-2002-s4e18-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/episode-new-day-2021-s1e2-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/episode-new-day-2021-s1e2-backdrop.jpg new file mode 100644 index 00000000..1f747323 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/episode-new-day-2021-s1e2-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/episode-new-day-2021-s1e2-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/episode-new-day-2021-s1e2-poster.jpg new file mode 100644 index 00000000..2f23d1fa Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/episode-new-day-2021-s1e2-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/episode-no-honeymoon-2020-s1e8-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/episode-no-honeymoon-2020-s1e8-backdrop.jpg new file mode 100644 index 00000000..9d95ba77 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/episode-no-honeymoon-2020-s1e8-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/episode-no-honeymoon-2020-s1e8-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/episode-no-honeymoon-2020-s1e8-poster.jpg new file mode 100644 index 00000000..49c61208 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/episode-no-honeymoon-2020-s1e8-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/episode-notapusy-2005-s3e4-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/episode-notapusy-2005-s3e4-backdrop.jpg new file mode 100644 index 00000000..ecb7e8cd Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/episode-notapusy-2005-s3e4-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/episode-notapusy-2005-s3e4-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/episode-notapusy-2005-s3e4-poster.jpg new file mode 100644 index 00000000..02b0dd43 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/episode-notapusy-2005-s3e4-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/episode-one-of-us-2024-s1e7-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/episode-one-of-us-2024-s1e7-backdrop.jpg new file mode 100644 index 00000000..66bb4d00 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/episode-one-of-us-2024-s1e7-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/episode-one-of-us-2024-s1e7-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/episode-one-of-us-2024-s1e7-poster.jpg new file mode 100644 index 00000000..00a07ca7 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/episode-one-of-us-2024-s1e7-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/episode-remembrance-day-2026-s2e1-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/episode-remembrance-day-2026-s2e1-backdrop.jpg new file mode 100644 index 00000000..23a242da Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/episode-remembrance-day-2026-s2e1-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/episode-remembrance-day-2026-s2e1-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/episode-remembrance-day-2026-s2e1-poster.jpg new file mode 100644 index 00000000..930943b0 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/episode-remembrance-day-2026-s2e1-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/episode-sex-and-the-country-2001-s4e9-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/episode-sex-and-the-country-2001-s4e9-backdrop.jpg new file mode 100644 index 00000000..57beacc1 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/episode-sex-and-the-country-2001-s4e9-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/episode-sex-and-the-country-2001-s4e9-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/episode-sex-and-the-country-2001-s4e9-poster.jpg new file mode 100644 index 00000000..e546cc6d Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/episode-sex-and-the-country-2001-s4e9-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/episode-south-american-biker-gangs-2026-s29e1-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/episode-south-american-biker-gangs-2026-s29e1-backdrop.jpg new file mode 100644 index 00000000..cd0aec39 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/episode-south-american-biker-gangs-2026-s29e1-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/episode-south-american-biker-gangs-2026-s29e1-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/episode-south-american-biker-gangs-2026-s29e1-poster.jpg new file mode 100644 index 00000000..d8568d54 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/episode-south-american-biker-gangs-2026-s29e1-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/episode-the-breakup-part-2-2001-s1e17-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/episode-the-breakup-part-2-2001-s1e17-backdrop.jpg new file mode 100644 index 00000000..b42ba953 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/episode-the-breakup-part-2-2001-s1e17-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/episode-the-breakup-part-2-2001-s1e17-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/episode-the-breakup-part-2-2001-s1e17-poster.jpg new file mode 100644 index 00000000..23b668cb Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/episode-the-breakup-part-2-2001-s1e17-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/episode-the-man-who-would-be-vogue-2018-s2e1-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/episode-the-man-who-would-be-vogue-2018-s2e1-backdrop.jpg new file mode 100644 index 00000000..84944d39 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/episode-the-man-who-would-be-vogue-2018-s2e1-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/episode-the-man-who-would-be-vogue-2018-s2e1-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/episode-the-man-who-would-be-vogue-2018-s2e1-poster.jpg new file mode 100644 index 00000000..4086bf17 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/episode-the-man-who-would-be-vogue-2018-s2e1-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/episode-the-uncomfortability-2010-s1e2-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/episode-the-uncomfortability-2010-s1e2-backdrop.jpg new file mode 100644 index 00000000..bc22e11a Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/episode-the-uncomfortability-2010-s1e2-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/episode-the-uncomfortability-2010-s1e2-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/episode-the-uncomfortability-2010-s1e2-poster.jpg new file mode 100644 index 00000000..ae305043 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/episode-the-uncomfortability-2010-s1e2-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/episode-three-monsters-1992-s1e6-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/episode-three-monsters-1992-s1e6-backdrop.jpg new file mode 100644 index 00000000..ba65ca2d Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/episode-three-monsters-1992-s1e6-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/episode-three-monsters-1992-s1e6-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/episode-three-monsters-1992-s1e6-poster.jpg new file mode 100644 index 00000000..f5843766 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/episode-three-monsters-1992-s1e6-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/episode-tomorrow-2024-s3e1-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/episode-tomorrow-2024-s3e1-backdrop.jpg new file mode 100644 index 00000000..9c9cca57 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/episode-tomorrow-2024-s3e1-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/episode-tomorrow-2024-s3e1-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/episode-tomorrow-2024-s3e1-poster.jpg new file mode 100644 index 00000000..bbf0ab28 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/episode-tomorrow-2024-s3e1-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/episode-what-more-do-i-need-2008-s5e7-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/episode-what-more-do-i-need-2008-s5e7-backdrop.jpg new file mode 100644 index 00000000..40ec3001 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/episode-what-more-do-i-need-2008-s5e7-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/episode-what-more-do-i-need-2008-s5e7-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/episode-what-more-do-i-need-2008-s5e7-poster.jpg new file mode 100644 index 00000000..13f0f7b3 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/episode-what-more-do-i-need-2008-s5e7-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-101-dalmatians-ii-patch-s-london-adventure-2003-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-101-dalmatians-ii-patch-s-london-adventure-2003-backdrop.jpg new file mode 100644 index 00000000..f0d6f752 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-101-dalmatians-ii-patch-s-london-adventure-2003-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-101-dalmatians-ii-patch-s-london-adventure-2003-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-101-dalmatians-ii-patch-s-london-adventure-2003-poster.jpg new file mode 100644 index 00000000..b9b72894 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-101-dalmatians-ii-patch-s-london-adventure-2003-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-a-bronx-tale-1993-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-a-bronx-tale-1993-backdrop.jpg new file mode 100644 index 00000000..15c11ff7 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-a-bronx-tale-1993-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-a-bronx-tale-1993-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-a-bronx-tale-1993-poster.jpg new file mode 100644 index 00000000..f7444271 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-a-bronx-tale-1993-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-a-silent-voice-the-movie-2016-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-a-silent-voice-the-movie-2016-backdrop.jpg new file mode 100644 index 00000000..b386b136 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-a-silent-voice-the-movie-2016-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-a-silent-voice-the-movie-2016-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-a-silent-voice-the-movie-2016-poster.jpg new file mode 100644 index 00000000..580b22f4 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-a-silent-voice-the-movie-2016-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-absolution-2024-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-absolution-2024-backdrop.jpg new file mode 100644 index 00000000..eb28037c Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-absolution-2024-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-absolution-2024-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-absolution-2024-poster.jpg new file mode 100644 index 00000000..3ce49112 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-absolution-2024-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-across-the-hall-2009-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-across-the-hall-2009-backdrop.jpg new file mode 100644 index 00000000..6c1ee8ac Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-across-the-hall-2009-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-across-the-hall-2009-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-across-the-hall-2009-poster.jpg new file mode 100644 index 00000000..52362b4e Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-across-the-hall-2009-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-addicted-2014-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-addicted-2014-backdrop.jpg new file mode 100644 index 00000000..5c016976 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-addicted-2014-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-addicted-2014-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-addicted-2014-poster.jpg new file mode 100644 index 00000000..e9a03c14 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-addicted-2014-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-adulterers-2016-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-adulterers-2016-backdrop.jpg new file mode 100644 index 00000000..331391ed Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-adulterers-2016-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-adulterers-2016-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-adulterers-2016-poster.jpg new file mode 100644 index 00000000..8b65745b Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-adulterers-2016-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-all-the-money-in-the-world-2017-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-all-the-money-in-the-world-2017-backdrop.jpg new file mode 100644 index 00000000..ac4b828a Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-all-the-money-in-the-world-2017-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-all-the-money-in-the-world-2017-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-all-the-money-in-the-world-2017-poster.jpg new file mode 100644 index 00000000..8beb2f7f Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-all-the-money-in-the-world-2017-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-alone-with-her-2006-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-alone-with-her-2006-backdrop.jpg new file mode 100644 index 00000000..ddf8f1dc Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-alone-with-her-2006-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-alone-with-her-2006-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-alone-with-her-2006-poster.jpg new file mode 100644 index 00000000..7ce6fa96 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-alone-with-her-2006-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-american-gangster-2007-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-american-gangster-2007-backdrop.jpg new file mode 100644 index 00000000..cb5c5de7 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-american-gangster-2007-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-american-gangster-2007-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-american-gangster-2007-poster.jpg new file mode 100644 index 00000000..0f41d299 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-american-gangster-2007-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-annie-hall-1977-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-annie-hall-1977-backdrop.jpg new file mode 100644 index 00000000..16d0b27d Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-annie-hall-1977-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-annie-hall-1977-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-annie-hall-1977-poster.jpg new file mode 100644 index 00000000..9023a691 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-annie-hall-1977-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-ant-man-and-the-wasp-quantumania-2023-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-ant-man-and-the-wasp-quantumania-2023-backdrop.jpg new file mode 100644 index 00000000..2a99846d Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-ant-man-and-the-wasp-quantumania-2023-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-ant-man-and-the-wasp-quantumania-2023-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-ant-man-and-the-wasp-quantumania-2023-poster.jpg new file mode 100644 index 00000000..030addf0 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-ant-man-and-the-wasp-quantumania-2023-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-appleseed-alpha-2014-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-appleseed-alpha-2014-backdrop.jpg new file mode 100644 index 00000000..82579b86 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-appleseed-alpha-2014-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-appleseed-alpha-2014-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-appleseed-alpha-2014-poster.jpg new file mode 100644 index 00000000..62bd0afa Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-appleseed-alpha-2014-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-aquaman-and-the-lost-kingdom-2023-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-aquaman-and-the-lost-kingdom-2023-backdrop.jpg new file mode 100644 index 00000000..6693cb6d Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-aquaman-and-the-lost-kingdom-2023-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-aquaman-and-the-lost-kingdom-2023-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-aquaman-and-the-lost-kingdom-2023-poster.jpg new file mode 100644 index 00000000..4550bdaa Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-aquaman-and-the-lost-kingdom-2023-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-arctic-dogs-2019-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-arctic-dogs-2019-backdrop.jpg new file mode 100644 index 00000000..1794c99d Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-arctic-dogs-2019-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-arctic-dogs-2019-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-arctic-dogs-2019-poster.jpg new file mode 100644 index 00000000..07552443 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-arctic-dogs-2019-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-arrival-2016-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-arrival-2016-backdrop.jpg new file mode 100644 index 00000000..605724e7 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-arrival-2016-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-arrival-2016-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-arrival-2016-poster.jpg new file mode 100644 index 00000000..fe224a15 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-arrival-2016-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-avatar-aang-the-last-airbender-2026-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-avatar-aang-the-last-airbender-2026-backdrop.jpg new file mode 100644 index 00000000..d3b12fe0 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-avatar-aang-the-last-airbender-2026-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-avatar-aang-the-last-airbender-2026-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-avatar-aang-the-last-airbender-2026-poster.jpg new file mode 100644 index 00000000..f57f1052 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-avatar-aang-the-last-airbender-2026-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-avengers-age-of-ultron-2015-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-avengers-age-of-ultron-2015-backdrop.jpg new file mode 100644 index 00000000..f739d620 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-avengers-age-of-ultron-2015-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-avengers-age-of-ultron-2015-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-avengers-age-of-ultron-2015-poster.jpg new file mode 100644 index 00000000..300840ff Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-avengers-age-of-ultron-2015-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-avengers-confidential-black-widow-punisher-2014-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-avengers-confidential-black-widow-punisher-2014-backdrop.jpg new file mode 100644 index 00000000..8163931a Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-avengers-confidential-black-widow-punisher-2014-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-avengers-confidential-black-widow-punisher-2014-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-avengers-confidential-black-widow-punisher-2014-poster.jpg new file mode 100644 index 00000000..b096fff2 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-avengers-confidential-black-widow-punisher-2014-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-avengers-infinity-war-2018-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-avengers-infinity-war-2018-backdrop.jpg new file mode 100644 index 00000000..90dc4672 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-avengers-infinity-war-2018-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-avengers-infinity-war-2018-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-avengers-infinity-war-2018-poster.jpg new file mode 100644 index 00000000..57df9298 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-avengers-infinity-war-2018-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-awake-2007-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-awake-2007-backdrop.jpg new file mode 100644 index 00000000..6ddd7f59 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-awake-2007-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-awake-2007-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-awake-2007-poster.jpg new file mode 100644 index 00000000..32595897 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-awake-2007-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-batman-ninja-2018-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-batman-ninja-2018-backdrop.jpg new file mode 100644 index 00000000..9b538d9f Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-batman-ninja-2018-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-batman-ninja-2018-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-batman-ninja-2018-poster.jpg new file mode 100644 index 00000000..7f59127b Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-batman-ninja-2018-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-batman-the-dark-knight-returns-part-1-2012-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-batman-the-dark-knight-returns-part-1-2012-backdrop.jpg new file mode 100644 index 00000000..9d385801 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-batman-the-dark-knight-returns-part-1-2012-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-batman-the-dark-knight-returns-part-1-2012-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-batman-the-dark-knight-returns-part-1-2012-poster.jpg new file mode 100644 index 00000000..ce5623fe Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-batman-the-dark-knight-returns-part-1-2012-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-batman-v-superman-dawn-of-justice-2016-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-batman-v-superman-dawn-of-justice-2016-backdrop.jpg new file mode 100644 index 00000000..b4fa63b9 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-batman-v-superman-dawn-of-justice-2016-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-batman-v-superman-dawn-of-justice-2016-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-batman-v-superman-dawn-of-justice-2016-poster.jpg new file mode 100644 index 00000000..f8115170 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-batman-v-superman-dawn-of-justice-2016-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-black-adam-2022-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-black-adam-2022-backdrop.jpg new file mode 100644 index 00000000..3232fb22 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-black-adam-2022-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-black-adam-2022-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-black-adam-2022-poster.jpg new file mode 100644 index 00000000..dde008b5 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-black-adam-2022-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-black-bear-2020-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-black-bear-2020-backdrop.jpg new file mode 100644 index 00000000..4b0b262f Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-black-bear-2020-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-black-bear-2020-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-black-bear-2020-poster.jpg new file mode 100644 index 00000000..61c8860f Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-black-bear-2020-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-black-panther-2018-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-black-panther-2018-backdrop.jpg new file mode 100644 index 00000000..f0b8de8a Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-black-panther-2018-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-black-panther-2018-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-black-panther-2018-poster.jpg new file mode 100644 index 00000000..6fdaa3d2 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-black-panther-2018-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-black-panther-wakanda-forever-2022-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-black-panther-wakanda-forever-2022-backdrop.jpg new file mode 100644 index 00000000..ac7ad532 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-black-panther-wakanda-forever-2022-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-black-panther-wakanda-forever-2022-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-black-panther-wakanda-forever-2022-poster.jpg new file mode 100644 index 00000000..d0219a75 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-black-panther-wakanda-forever-2022-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-blame-2017-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-blame-2017-backdrop.jpg new file mode 100644 index 00000000..f39cf5ce Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-blame-2017-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-blame-2017-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-blame-2017-poster.jpg new file mode 100644 index 00000000..8d50255f Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-blame-2017-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-bleach-the-movie-fade-to-black-2008-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-bleach-the-movie-fade-to-black-2008-backdrop.jpg new file mode 100644 index 00000000..f426d0ce Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-bleach-the-movie-fade-to-black-2008-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-bleach-the-movie-fade-to-black-2008-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-bleach-the-movie-fade-to-black-2008-poster.jpg new file mode 100644 index 00000000..d3619fbd Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-bleach-the-movie-fade-to-black-2008-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-butterfly-on-a-wheel-2007-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-butterfly-on-a-wheel-2007-backdrop.jpg new file mode 100644 index 00000000..e49fe1d5 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-butterfly-on-a-wheel-2007-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-butterfly-on-a-wheel-2007-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-butterfly-on-a-wheel-2007-poster.jpg new file mode 100644 index 00000000..b54cc5c7 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-butterfly-on-a-wheel-2007-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-captain-america-civil-war-2016-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-captain-america-civil-war-2016-backdrop.jpg new file mode 100644 index 00000000..5700c4e7 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-captain-america-civil-war-2016-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-captain-america-civil-war-2016-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-captain-america-civil-war-2016-poster.jpg new file mode 100644 index 00000000..426142ef Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-captain-america-civil-war-2016-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-careful-what-you-wish-for-2015-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-careful-what-you-wish-for-2015-backdrop.jpg new file mode 100644 index 00000000..988e20d5 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-careful-what-you-wish-for-2015-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-careful-what-you-wish-for-2015-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-careful-what-you-wish-for-2015-poster.jpg new file mode 100644 index 00000000..b58ce600 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-careful-what-you-wish-for-2015-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-castle-in-the-sky-1986-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-castle-in-the-sky-1986-backdrop.jpg new file mode 100644 index 00000000..f785c549 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-castle-in-the-sky-1986-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-castle-in-the-sky-1986-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-castle-in-the-sky-1986-poster.jpg new file mode 100644 index 00000000..a6a99fee Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-castle-in-the-sky-1986-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-chicago-2002-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-chicago-2002-backdrop.jpg new file mode 100644 index 00000000..6b3d02a3 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-chicago-2002-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-chicago-2002-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-chicago-2002-poster.jpg new file mode 100644 index 00000000..1def9414 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-chicago-2002-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-citizen-kane-1941-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-citizen-kane-1941-backdrop.jpg new file mode 100644 index 00000000..d41a10ef Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-citizen-kane-1941-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-citizen-kane-1941-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-citizen-kane-1941-poster.jpg new file mode 100644 index 00000000..f26414a7 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-citizen-kane-1941-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-conclave-2024-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-conclave-2024-backdrop.jpg new file mode 100644 index 00000000..db0a7fcd Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-conclave-2024-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-conclave-2024-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-conclave-2024-poster.jpg new file mode 100644 index 00000000..c6aecefa Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-conclave-2024-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-crime-of-passion-1956-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-crime-of-passion-1956-backdrop.jpg new file mode 100644 index 00000000..6c70a452 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-crime-of-passion-1956-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-crime-of-passion-1956-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-crime-of-passion-1956-poster.jpg new file mode 100644 index 00000000..4acfa871 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-crime-of-passion-1956-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-deep-water-2022-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-deep-water-2022-backdrop.jpg new file mode 100644 index 00000000..bec98992 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-deep-water-2022-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-deep-water-2022-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-deep-water-2022-poster.jpg new file mode 100644 index 00000000..018d4f4b Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-deep-water-2022-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-derailed-2005-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-derailed-2005-backdrop.jpg new file mode 100644 index 00000000..e958449b Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-derailed-2005-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-derailed-2005-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-derailed-2005-poster.jpg new file mode 100644 index 00000000..d93f3124 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-derailed-2005-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-dream-house-2011-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-dream-house-2011-backdrop.jpg new file mode 100644 index 00000000..29ecc103 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-dream-house-2011-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-dream-house-2011-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-dream-house-2011-poster.jpg new file mode 100644 index 00000000..d3444637 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-dream-house-2011-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-dream-lover-1994-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-dream-lover-1994-backdrop.jpg new file mode 100644 index 00000000..7f5a31ac Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-dream-lover-1994-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-dream-lover-1994-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-dream-lover-1994-poster.jpg new file mode 100644 index 00000000..3b563cf9 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-dream-lover-1994-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-drop-2025-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-drop-2025-backdrop.jpg new file mode 100644 index 00000000..ee642530 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-drop-2025-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-drop-2025-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-drop-2025-poster.jpg new file mode 100644 index 00000000..4cf7d66b Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-drop-2025-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-eastern-promises-2007-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-eastern-promises-2007-backdrop.jpg new file mode 100644 index 00000000..9096a0fa Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-eastern-promises-2007-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-eastern-promises-2007-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-eastern-promises-2007-poster.jpg new file mode 100644 index 00000000..01a26f3b Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-eastern-promises-2007-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-el-camino-a-breaking-bad-movie-2019-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-el-camino-a-breaking-bad-movie-2019-backdrop.jpg new file mode 100644 index 00000000..8edc02a6 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-el-camino-a-breaking-bad-movie-2019-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-el-camino-a-breaking-bad-movie-2019-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-el-camino-a-breaking-bad-movie-2019-poster.jpg new file mode 100644 index 00000000..7a62df45 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-el-camino-a-breaking-bad-movie-2019-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-fatale-2020-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-fatale-2020-backdrop.jpg new file mode 100644 index 00000000..b13c33c6 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-fatale-2020-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-fatale-2020-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-fatale-2020-poster.jpg new file mode 100644 index 00000000..dda9d622 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-fatale-2020-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-force-of-nature-the-dry-2-2024-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-force-of-nature-the-dry-2-2024-backdrop.jpg new file mode 100644 index 00000000..3735ee63 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-force-of-nature-the-dry-2-2024-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-force-of-nature-the-dry-2-2024-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-force-of-nature-the-dry-2-2024-poster.jpg new file mode 100644 index 00000000..93ba0d71 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-force-of-nature-the-dry-2-2024-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-ghost-in-the-shell-1995-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-ghost-in-the-shell-1995-backdrop.jpg new file mode 100644 index 00000000..716b8a87 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-ghost-in-the-shell-1995-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-ghost-in-the-shell-1995-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-ghost-in-the-shell-1995-poster.jpg new file mode 100644 index 00000000..58d221ba Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-ghost-in-the-shell-1995-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-ghost-in-the-shell-2-innocence-2004-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-ghost-in-the-shell-2-innocence-2004-backdrop.jpg new file mode 100644 index 00000000..4a9931ed Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-ghost-in-the-shell-2-innocence-2004-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-ghost-in-the-shell-2-innocence-2004-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-ghost-in-the-shell-2-innocence-2004-poster.jpg new file mode 100644 index 00000000..ae262147 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-ghost-in-the-shell-2-innocence-2004-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-gosford-park-2001-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-gosford-park-2001-backdrop.jpg new file mode 100644 index 00000000..42c4cea6 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-gosford-park-2001-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-gosford-park-2001-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-gosford-park-2001-poster.jpg new file mode 100644 index 00000000..6a6aa651 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-gosford-park-2001-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-howl-s-moving-castle-2004-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-howl-s-moving-castle-2004-backdrop.jpg new file mode 100644 index 00000000..0aeef2ba Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-howl-s-moving-castle-2004-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-howl-s-moving-castle-2004-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-howl-s-moving-castle-2004-poster.jpg new file mode 100644 index 00000000..042a6c80 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-howl-s-moving-castle-2004-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-in-the-heights-2021-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-in-the-heights-2021-backdrop.jpg new file mode 100644 index 00000000..7cd4ee24 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-in-the-heights-2021-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-in-the-heights-2021-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-in-the-heights-2021-poster.jpg new file mode 100644 index 00000000..e05bfdb0 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-in-the-heights-2021-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-kiki-s-delivery-service-1989-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-kiki-s-delivery-service-1989-backdrop.jpg new file mode 100644 index 00000000..2a108f0c Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-kiki-s-delivery-service-1989-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-kiki-s-delivery-service-1989-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-kiki-s-delivery-service-1989-poster.jpg new file mode 100644 index 00000000..80a59b1e Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-kiki-s-delivery-service-1989-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-lemony-snicket-s-a-series-of-unfortunate-events-2004-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-lemony-snicket-s-a-series-of-unfortunate-events-2004-backdrop.jpg new file mode 100644 index 00000000..38203b77 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-lemony-snicket-s-a-series-of-unfortunate-events-2004-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-lemony-snicket-s-a-series-of-unfortunate-events-2004-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-lemony-snicket-s-a-series-of-unfortunate-events-2004-poster.jpg new file mode 100644 index 00000000..4c78fee9 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-lemony-snicket-s-a-series-of-unfortunate-events-2004-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-locke-2014-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-locke-2014-backdrop.jpg new file mode 100644 index 00000000..a24e64ab Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-locke-2014-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-locke-2014-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-locke-2014-poster.jpg new file mode 100644 index 00000000..94ebe97d Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-locke-2014-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-memories-1995-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-memories-1995-backdrop.jpg new file mode 100644 index 00000000..2fcaeaad Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-memories-1995-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-memories-1995-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-memories-1995-poster.jpg new file mode 100644 index 00000000..f3725b42 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-memories-1995-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-night-is-short-walk-on-girl-2017-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-night-is-short-walk-on-girl-2017-backdrop.jpg new file mode 100644 index 00000000..da38be48 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-night-is-short-walk-on-girl-2017-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-night-is-short-walk-on-girl-2017-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-night-is-short-walk-on-girl-2017-poster.jpg new file mode 100644 index 00000000..c6fa6a26 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-night-is-short-walk-on-girl-2017-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-panic-room-2002-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-panic-room-2002-backdrop.jpg new file mode 100644 index 00000000..ec877751 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-panic-room-2002-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-panic-room-2002-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-panic-room-2002-poster.jpg new file mode 100644 index 00000000..0b2a0d30 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-panic-room-2002-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-ponyo-2008-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-ponyo-2008-backdrop.jpg new file mode 100644 index 00000000..8aa4ecbd Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-ponyo-2008-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-ponyo-2008-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-ponyo-2008-poster.jpg new file mode 100644 index 00000000..f548e831 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-ponyo-2008-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-porco-rosso-1992-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-porco-rosso-1992-backdrop.jpg new file mode 100644 index 00000000..63d8112e Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-porco-rosso-1992-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-porco-rosso-1992-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-porco-rosso-1992-poster.jpg new file mode 100644 index 00000000..4a6a2bd1 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-porco-rosso-1992-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-princess-mononoke-1997-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-princess-mononoke-1997-backdrop.jpg new file mode 100644 index 00000000..9486d2cf Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-princess-mononoke-1997-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-princess-mononoke-1997-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-princess-mononoke-1997-poster.jpg new file mode 100644 index 00000000..21dd9359 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-princess-mononoke-1997-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-prisoners-2013-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-prisoners-2013-backdrop.jpg new file mode 100644 index 00000000..724008c5 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-prisoners-2013-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-prisoners-2013-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-prisoners-2013-poster.jpg new file mode 100644 index 00000000..411b149b Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-prisoners-2013-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-racing-stripes-2005-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-racing-stripes-2005-backdrop.jpg new file mode 100644 index 00000000..1d332cec Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-racing-stripes-2005-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-racing-stripes-2005-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-racing-stripes-2005-poster.jpg new file mode 100644 index 00000000..cc2ef2ce Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-racing-stripes-2005-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-secondhand-lions-2003-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-secondhand-lions-2003-backdrop.jpg new file mode 100644 index 00000000..7ea43937 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-secondhand-lions-2003-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-secondhand-lions-2003-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-secondhand-lions-2003-poster.jpg new file mode 100644 index 00000000..33408fa0 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-secondhand-lions-2003-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-shaft-2000-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-shaft-2000-backdrop.jpg new file mode 100644 index 00000000..13f14f93 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-shaft-2000-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-shaft-2000-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-shaft-2000-poster.jpg new file mode 100644 index 00000000..6b2b79ce Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-shaft-2000-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-sinbad-legend-of-the-seven-seas-2003-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-sinbad-legend-of-the-seven-seas-2003-backdrop.jpg new file mode 100644 index 00000000..d04685fe Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-sinbad-legend-of-the-seven-seas-2003-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-sinbad-legend-of-the-seven-seas-2003-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-sinbad-legend-of-the-seven-seas-2003-poster.jpg new file mode 100644 index 00000000..428bb38b Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-sinbad-legend-of-the-seven-seas-2003-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-sleeping-with-other-people-2015-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-sleeping-with-other-people-2015-backdrop.jpg new file mode 100644 index 00000000..df5bb046 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-sleeping-with-other-people-2015-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-sleeping-with-other-people-2015-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-sleeping-with-other-people-2015-poster.jpg new file mode 100644 index 00000000..a5ffff1f Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-sleeping-with-other-people-2015-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-snowpiercer-2013-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-snowpiercer-2013-backdrop.jpg new file mode 100644 index 00000000..c1281a9c Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-snowpiercer-2013-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-snowpiercer-2013-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-snowpiercer-2013-poster.jpg new file mode 100644 index 00000000..be1873fd Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-snowpiercer-2013-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-spider-man-brand-new-day-2026-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-spider-man-brand-new-day-2026-backdrop.jpg new file mode 100644 index 00000000..2fdaadb4 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-spider-man-brand-new-day-2026-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-spider-man-brand-new-day-2026-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-spider-man-brand-new-day-2026-poster.jpg new file mode 100644 index 00000000..51fa155f Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-spider-man-brand-new-day-2026-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-spider-man-far-from-home-2019-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-spider-man-far-from-home-2019-backdrop.jpg new file mode 100644 index 00000000..d09f655a Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-spider-man-far-from-home-2019-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-spider-man-far-from-home-2019-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-spider-man-far-from-home-2019-poster.jpg new file mode 100644 index 00000000..28521997 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-spider-man-far-from-home-2019-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-spider-man-homecoming-2017-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-spider-man-homecoming-2017-backdrop.jpg new file mode 100644 index 00000000..6088fadf Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-spider-man-homecoming-2017-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-spider-man-homecoming-2017-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-spider-man-homecoming-2017-poster.jpg new file mode 100644 index 00000000..97fc9c05 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-spider-man-homecoming-2017-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-spider-man-no-way-home-2021-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-spider-man-no-way-home-2021-backdrop.jpg new file mode 100644 index 00000000..a76afec9 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-spider-man-no-way-home-2021-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-spider-man-no-way-home-2021-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-spider-man-no-way-home-2021-poster.jpg new file mode 100644 index 00000000..bc92a4cd Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-spider-man-no-way-home-2021-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-spirited-away-2001-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-spirited-away-2001-backdrop.jpg new file mode 100644 index 00000000..af91c123 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-spirited-away-2001-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-spirited-away-2001-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-spirited-away-2001-poster.jpg new file mode 100644 index 00000000..5a79da62 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-spirited-away-2001-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-the-alto-knights-2025-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-the-alto-knights-2025-backdrop.jpg new file mode 100644 index 00000000..ea619c14 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-the-alto-knights-2025-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-the-alto-knights-2025-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-the-alto-knights-2025-poster.jpg new file mode 100644 index 00000000..bb009cbf Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-the-alto-knights-2025-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-the-amazing-spider-man-2-2014-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-the-amazing-spider-man-2-2014-backdrop.jpg new file mode 100644 index 00000000..7c5878b7 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-the-amazing-spider-man-2-2014-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-the-amazing-spider-man-2-2014-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-the-amazing-spider-man-2-2014-poster.jpg new file mode 100644 index 00000000..4de2711f Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-the-amazing-spider-man-2-2014-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-the-amazing-spider-man-2012-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-the-amazing-spider-man-2012-backdrop.jpg new file mode 100644 index 00000000..75ae9b64 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-the-amazing-spider-man-2012-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-the-amazing-spider-man-2012-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-the-amazing-spider-man-2012-poster.jpg new file mode 100644 index 00000000..c1aec876 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-the-amazing-spider-man-2012-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-the-animatrix-2003-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-the-animatrix-2003-backdrop.jpg new file mode 100644 index 00000000..7d67ccb0 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-the-animatrix-2003-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-the-animatrix-2003-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-the-animatrix-2003-poster.jpg new file mode 100644 index 00000000..65553d47 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-the-animatrix-2003-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-the-avengers-2012-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-the-avengers-2012-backdrop.jpg new file mode 100644 index 00000000..83636f25 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-the-avengers-2012-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-the-avengers-2012-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-the-avengers-2012-poster.jpg new file mode 100644 index 00000000..be87067c Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-the-avengers-2012-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-the-boy-and-the-heron-2023-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-the-boy-and-the-heron-2023-backdrop.jpg new file mode 100644 index 00000000..a19dc77b Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-the-boy-and-the-heron-2023-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-the-boy-and-the-heron-2023-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-the-boy-and-the-heron-2023-poster.jpg new file mode 100644 index 00000000..094df64e Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-the-boy-and-the-heron-2023-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-the-breakfast-club-1985-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-the-breakfast-club-1985-backdrop.jpg new file mode 100644 index 00000000..9f979cd8 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-the-breakfast-club-1985-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-the-breakfast-club-1985-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-the-breakfast-club-1985-poster.jpg new file mode 100644 index 00000000..d1823120 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-the-breakfast-club-1985-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-the-dry-2021-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-the-dry-2021-backdrop.jpg new file mode 100644 index 00000000..2f016498 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-the-dry-2021-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-the-dry-2021-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-the-dry-2021-poster.jpg new file mode 100644 index 00000000..a0379f2f Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-the-dry-2021-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-the-girl-who-leapt-through-time-2006-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-the-girl-who-leapt-through-time-2006-backdrop.jpg new file mode 100644 index 00000000..6e920467 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-the-girl-who-leapt-through-time-2006-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-the-girl-who-leapt-through-time-2006-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-the-girl-who-leapt-through-time-2006-poster.jpg new file mode 100644 index 00000000..ded6cd2a Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-the-girl-who-leapt-through-time-2006-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-the-godfather-1972-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-the-godfather-1972-backdrop.jpg new file mode 100644 index 00000000..8fbb8f2a Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-the-godfather-1972-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-the-godfather-1972-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-the-godfather-1972-poster.jpg new file mode 100644 index 00000000..94111d9b Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-the-godfather-1972-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-the-godfather-part-iii-1990-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-the-godfather-part-iii-1990-backdrop.jpg new file mode 100644 index 00000000..d2080487 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-the-godfather-part-iii-1990-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-the-godfather-part-iii-1990-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-the-godfather-part-iii-1990-poster.jpg new file mode 100644 index 00000000..abbe5e99 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-the-godfather-part-iii-1990-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-the-great-gatsby-2013-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-the-great-gatsby-2013-backdrop.jpg new file mode 100644 index 00000000..7a4f8a32 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-the-great-gatsby-2013-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-the-great-gatsby-2013-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-the-great-gatsby-2013-poster.jpg new file mode 100644 index 00000000..e949fb3e Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-the-great-gatsby-2013-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-the-killing-of-a-sacred-deer-2017-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-the-killing-of-a-sacred-deer-2017-backdrop.jpg new file mode 100644 index 00000000..dd755ee8 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-the-killing-of-a-sacred-deer-2017-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-the-killing-of-a-sacred-deer-2017-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-the-killing-of-a-sacred-deer-2017-poster.jpg new file mode 100644 index 00000000..83464d4c Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-the-killing-of-a-sacred-deer-2017-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-the-lighthouse-2019-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-the-lighthouse-2019-backdrop.jpg new file mode 100644 index 00000000..862c064a Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-the-lighthouse-2019-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-the-lighthouse-2019-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-the-lighthouse-2019-poster.jpg new file mode 100644 index 00000000..15f26533 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-the-lighthouse-2019-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-the-secret-life-of-pets-2-2019-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-the-secret-life-of-pets-2-2019-backdrop.jpg new file mode 100644 index 00000000..698f4ad2 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-the-secret-life-of-pets-2-2019-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-the-secret-life-of-pets-2-2019-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-the-secret-life-of-pets-2-2019-poster.jpg new file mode 100644 index 00000000..fe50c2f8 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-the-secret-life-of-pets-2-2019-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-the-tale-of-the-princess-kaguya-2013-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-the-tale-of-the-princess-kaguya-2013-backdrop.jpg new file mode 100644 index 00000000..b9157b91 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-the-tale-of-the-princess-kaguya-2013-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-the-tale-of-the-princess-kaguya-2013-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-the-tale-of-the-princess-kaguya-2013-poster.jpg new file mode 100644 index 00000000..882621cb Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-the-tale-of-the-princess-kaguya-2013-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-the-wild-robot-2024-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-the-wild-robot-2024-backdrop.jpg new file mode 100644 index 00000000..bb48460b Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-the-wild-robot-2024-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-the-wild-robot-2024-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-the-wild-robot-2024-poster.jpg new file mode 100644 index 00000000..0894754d Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-the-wild-robot-2024-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-the-witcher-nightmare-of-the-wolf-2021-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-the-witcher-nightmare-of-the-wolf-2021-backdrop.jpg new file mode 100644 index 00000000..cb5bfb28 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-the-witcher-nightmare-of-the-wolf-2021-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-the-witcher-nightmare-of-the-wolf-2021-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-the-witcher-nightmare-of-the-wolf-2021-poster.jpg new file mode 100644 index 00000000..6db33db1 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-the-witcher-nightmare-of-the-wolf-2021-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-the-witcher-sirens-of-the-deep-2025-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-the-witcher-sirens-of-the-deep-2025-backdrop.jpg new file mode 100644 index 00000000..aad8ea2c Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-the-witcher-sirens-of-the-deep-2025-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-the-witcher-sirens-of-the-deep-2025-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-the-witcher-sirens-of-the-deep-2025-poster.jpg new file mode 100644 index 00000000..8a54bc7f Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-the-witcher-sirens-of-the-deep-2025-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-thelma-louise-1991-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-thelma-louise-1991-backdrop.jpg new file mode 100644 index 00000000..a35327a9 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-thelma-louise-1991-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-thelma-louise-1991-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-thelma-louise-1991-poster.jpg new file mode 100644 index 00000000..5a4ff0ab Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-thelma-louise-1991-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-triangle-of-sadness-2022-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-triangle-of-sadness-2022-backdrop.jpg new file mode 100644 index 00000000..7c9b445d Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-triangle-of-sadness-2022-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-triangle-of-sadness-2022-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-triangle-of-sadness-2022-poster.jpg new file mode 100644 index 00000000..aee092c6 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-triangle-of-sadness-2022-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-vice-2018-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-vice-2018-backdrop.jpg new file mode 100644 index 00000000..2918db3a Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-vice-2018-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-vice-2018-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-vice-2018-poster.jpg new file mode 100644 index 00000000..76855126 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/movie-vice-2018-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-12-monkeys-2015-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-12-monkeys-2015-backdrop.jpg new file mode 100644 index 00000000..3d2825f5 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-12-monkeys-2015-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-12-monkeys-2015-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-12-monkeys-2015-poster.jpg new file mode 100644 index 00000000..96e3596e Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-12-monkeys-2015-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-56-days-2026-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-56-days-2026-backdrop.jpg new file mode 100644 index 00000000..dcda667a Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-56-days-2026-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-56-days-2026-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-56-days-2026-poster.jpg new file mode 100644 index 00000000..64ca587b Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-56-days-2026-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-a-knight-of-the-seven-kingdoms-2026-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-a-knight-of-the-seven-kingdoms-2026-backdrop.jpg new file mode 100644 index 00000000..ce12fc71 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-a-knight-of-the-seven-kingdoms-2026-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-a-knight-of-the-seven-kingdoms-2026-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-a-knight-of-the-seven-kingdoms-2026-poster.jpg new file mode 100644 index 00000000..79f1351c Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-a-knight-of-the-seven-kingdoms-2026-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-absolutely-fabulous-1992-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-absolutely-fabulous-1992-backdrop.jpg new file mode 100644 index 00000000..2df9722d Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-absolutely-fabulous-1992-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-absolutely-fabulous-1992-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-absolutely-fabulous-1992-poster.jpg new file mode 100644 index 00000000..afd371a9 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-absolutely-fabulous-1992-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-accel-world-2012-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-accel-world-2012-backdrop.jpg new file mode 100644 index 00000000..1f5f6bdc Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-accel-world-2012-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-accel-world-2012-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-accel-world-2012-poster.jpg new file mode 100644 index 00000000..207b1dd1 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-accel-world-2012-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-accused-2023-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-accused-2023-backdrop.jpg new file mode 100644 index 00000000..d41c6397 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-accused-2023-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-accused-2023-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-accused-2023-poster.jpg new file mode 100644 index 00000000..74af010a Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-accused-2023-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-after-life-2019-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-after-life-2019-backdrop.jpg new file mode 100644 index 00000000..eaf9a4fa Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-after-life-2019-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-after-life-2019-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-after-life-2019-poster.jpg new file mode 100644 index 00000000..4fae4e33 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-after-life-2019-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-agatha-christie-s-marple-2004-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-agatha-christie-s-marple-2004-backdrop.jpg new file mode 100644 index 00000000..0c52f2e6 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-agatha-christie-s-marple-2004-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-agatha-christie-s-marple-2004-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-agatha-christie-s-marple-2004-poster.jpg new file mode 100644 index 00000000..ea7dda99 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-agatha-christie-s-marple-2004-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-ahsoka-2023-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-ahsoka-2023-backdrop.jpg new file mode 100644 index 00000000..7343fb32 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-ahsoka-2023-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-ahsoka-2023-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-ahsoka-2023-poster.jpg new file mode 100644 index 00000000..a29d6cb4 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-ahsoka-2023-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-all-creatures-great-and-small-1978-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-all-creatures-great-and-small-1978-backdrop.jpg new file mode 100644 index 00000000..909ae1ba Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-all-creatures-great-and-small-1978-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-all-creatures-great-and-small-1978-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-all-creatures-great-and-small-1978-poster.jpg new file mode 100644 index 00000000..5b1666e6 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-all-creatures-great-and-small-1978-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-all-creatures-great-small-2020-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-all-creatures-great-small-2020-backdrop.jpg new file mode 100644 index 00000000..d2bcc88d Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-all-creatures-great-small-2020-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-all-creatures-great-small-2020-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-all-creatures-great-small-2020-poster.jpg new file mode 100644 index 00000000..b506f990 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-all-creatures-great-small-2020-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-american-dad-2005-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-american-dad-2005-backdrop.jpg new file mode 100644 index 00000000..75a1948b Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-american-dad-2005-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-american-dad-2005-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-american-dad-2005-poster.jpg new file mode 100644 index 00000000..1f9e239a Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-american-dad-2005-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-american-gods-2017-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-american-gods-2017-backdrop.jpg new file mode 100644 index 00000000..4817c690 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-american-gods-2017-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-american-gods-2017-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-american-gods-2017-poster.jpg new file mode 100644 index 00000000..c02c46da Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-american-gods-2017-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-american-housewife-2016-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-american-housewife-2016-backdrop.jpg new file mode 100644 index 00000000..bf758ae0 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-american-housewife-2016-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-american-housewife-2016-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-american-housewife-2016-poster.jpg new file mode 100644 index 00000000..5f92bf05 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-american-housewife-2016-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-american-primeval-2025-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-american-primeval-2025-backdrop.jpg new file mode 100644 index 00000000..c3bfe5e1 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-american-primeval-2025-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-american-primeval-2025-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-american-primeval-2025-poster.jpg new file mode 100644 index 00000000..7f10e6aa Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-american-primeval-2025-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-and-just-like-that-2021-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-and-just-like-that-2021-backdrop.jpg new file mode 100644 index 00000000..59c981e5 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-and-just-like-that-2021-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-and-just-like-that-2021-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-and-just-like-that-2021-poster.jpg new file mode 100644 index 00000000..9904c74b Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-and-just-like-that-2021-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-andor-2022-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-andor-2022-backdrop.jpg new file mode 100644 index 00000000..a874a0c0 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-andor-2022-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-andor-2022-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-andor-2022-poster.jpg new file mode 100644 index 00000000..df5dc498 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-andor-2022-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-andromeda-2000-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-andromeda-2000-backdrop.jpg new file mode 100644 index 00000000..199249d7 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-andromeda-2000-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-andromeda-2000-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-andromeda-2000-poster.jpg new file mode 100644 index 00000000..67b766b0 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-andromeda-2000-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-anne-with-an-e-2017-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-anne-with-an-e-2017-backdrop.jpg new file mode 100644 index 00000000..15e325c9 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-anne-with-an-e-2017-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-anne-with-an-e-2017-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-anne-with-an-e-2017-poster.jpg new file mode 100644 index 00000000..55d443b4 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-anne-with-an-e-2017-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-aqua-teen-hunger-force-2000-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-aqua-teen-hunger-force-2000-backdrop.jpg new file mode 100644 index 00000000..d0e37959 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-aqua-teen-hunger-force-2000-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-aqua-teen-hunger-force-2000-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-aqua-teen-hunger-force-2000-poster.jpg new file mode 100644 index 00000000..e0c97321 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-aqua-teen-hunger-force-2000-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-arifureta-from-commonplace-to-world-s-strongest-2019-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-arifureta-from-commonplace-to-world-s-strongest-2019-backdrop.jpg new file mode 100644 index 00000000..9300e20f Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-arifureta-from-commonplace-to-world-s-strongest-2019-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-arifureta-from-commonplace-to-world-s-strongest-2019-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-arifureta-from-commonplace-to-world-s-strongest-2019-poster.jpg new file mode 100644 index 00000000..6a8f9a24 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-arifureta-from-commonplace-to-world-s-strongest-2019-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-arrow-2012-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-arrow-2012-backdrop.jpg new file mode 100644 index 00000000..b32f5aca Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-arrow-2012-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-arrow-2012-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-arrow-2012-poster.jpg new file mode 100644 index 00000000..adee3bb7 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-arrow-2012-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-atlantis-2013-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-atlantis-2013-backdrop.jpg new file mode 100644 index 00000000..39ecc0d6 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-atlantis-2013-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-atlantis-2013-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-atlantis-2013-poster.jpg new file mode 100644 index 00000000..5cd5f098 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-atlantis-2013-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-avatar-seven-havens-2026-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-avatar-seven-havens-2026-backdrop.jpg new file mode 100644 index 00000000..dacfd59e Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-avatar-seven-havens-2026-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-avatar-seven-havens-2026-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-avatar-seven-havens-2026-poster.jpg new file mode 100644 index 00000000..61f459c6 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-avatar-seven-havens-2026-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-avatar-the-last-airbender-2005-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-avatar-the-last-airbender-2005-backdrop.jpg new file mode 100644 index 00000000..f60120da Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-avatar-the-last-airbender-2005-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-avatar-the-last-airbender-2005-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-avatar-the-last-airbender-2005-poster.jpg new file mode 100644 index 00000000..6f99263a Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-avatar-the-last-airbender-2005-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-avatar-the-last-airbender-2024-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-avatar-the-last-airbender-2024-backdrop.jpg new file mode 100644 index 00000000..ad740218 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-avatar-the-last-airbender-2024-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-avatar-the-last-airbender-2024-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-avatar-the-last-airbender-2024-poster.jpg new file mode 100644 index 00000000..9d46c2ff Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-avatar-the-last-airbender-2024-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-awkward-2011-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-awkward-2011-backdrop.jpg new file mode 100644 index 00000000..1ca5e56c Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-awkward-2011-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-awkward-2011-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-awkward-2011-poster.jpg new file mode 100644 index 00000000..8da6e8e2 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-awkward-2011-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-back-to-life-2019-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-back-to-life-2019-backdrop.jpg new file mode 100644 index 00000000..7ffef681 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-back-to-life-2019-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-back-to-life-2019-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-back-to-life-2019-poster.jpg new file mode 100644 index 00000000..b00730a8 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-back-to-life-2019-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-ballers-2015-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-ballers-2015-backdrop.jpg new file mode 100644 index 00000000..c4685e84 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-ballers-2015-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-ballers-2015-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-ballers-2015-poster.jpg new file mode 100644 index 00000000..f1c18f69 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-ballers-2015-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-baretta-1975-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-baretta-1975-backdrop.jpg new file mode 100644 index 00000000..4d61dfbe Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-baretta-1975-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-baretta-1975-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-baretta-1975-poster.jpg new file mode 100644 index 00000000..ceef0c9d Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-baretta-1975-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-based-on-a-true-story-2023-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-based-on-a-true-story-2023-backdrop.jpg new file mode 100644 index 00000000..b59c0e58 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-based-on-a-true-story-2023-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-based-on-a-true-story-2023-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-based-on-a-true-story-2023-poster.jpg new file mode 100644 index 00000000..386d3d90 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-based-on-a-true-story-2023-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-batman-the-brave-and-the-bold-2008-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-batman-the-brave-and-the-bold-2008-backdrop.jpg new file mode 100644 index 00000000..69e567cb Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-batman-the-brave-and-the-bold-2008-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-batman-the-brave-and-the-bold-2008-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-batman-the-brave-and-the-bold-2008-poster.jpg new file mode 100644 index 00000000..a864f50c Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-batman-the-brave-and-the-bold-2008-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-batwoman-2019-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-batwoman-2019-backdrop.jpg new file mode 100644 index 00000000..d04a1a20 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-batwoman-2019-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-batwoman-2019-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-batwoman-2019-poster.jpg new file mode 100644 index 00000000..7ca86592 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-batwoman-2019-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-big-boys-2022-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-big-boys-2022-backdrop.jpg new file mode 100644 index 00000000..c7c91e2d Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-big-boys-2022-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-big-boys-2022-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-big-boys-2022-poster.jpg new file mode 100644 index 00000000..93e1e86a Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-big-boys-2022-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-big-city-greens-2018-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-big-city-greens-2018-backdrop.jpg new file mode 100644 index 00000000..381774e7 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-big-city-greens-2018-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-big-city-greens-2018-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-big-city-greens-2018-poster.jpg new file mode 100644 index 00000000..ef024946 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-big-city-greens-2018-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-big-mouth-2017-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-big-mouth-2017-backdrop.jpg new file mode 100644 index 00000000..760e0262 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-big-mouth-2017-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-big-mouth-2017-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-big-mouth-2017-poster.jpg new file mode 100644 index 00000000..4f535fec Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-big-mouth-2017-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-black-lightning-2018-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-black-lightning-2018-backdrop.jpg new file mode 100644 index 00000000..3a0854c9 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-black-lightning-2018-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-black-lightning-2018-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-black-lightning-2018-poster.jpg new file mode 100644 index 00000000..733f5cbc Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-black-lightning-2018-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-black-rabbit-2025-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-black-rabbit-2025-backdrop.jpg new file mode 100644 index 00000000..48df9d46 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-black-rabbit-2025-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-black-rabbit-2025-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-black-rabbit-2025-poster.jpg new file mode 100644 index 00000000..3ff9aeed Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-black-rabbit-2025-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-bleach-2004-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-bleach-2004-backdrop.jpg new file mode 100644 index 00000000..ef8eaaa3 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-bleach-2004-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-bleach-2004-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-bleach-2004-poster.jpg new file mode 100644 index 00000000..b233e033 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-bleach-2004-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-brassic-2019-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-brassic-2019-backdrop.jpg new file mode 100644 index 00000000..86d3d806 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-brassic-2019-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-brassic-2019-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-brassic-2019-poster.jpg new file mode 100644 index 00000000..73b77df2 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-brassic-2019-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-breaking-bad-2008-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-breaking-bad-2008-backdrop.jpg new file mode 100644 index 00000000..e4ac4ae3 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-breaking-bad-2008-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-breaking-bad-2008-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-breaking-bad-2008-poster.jpg new file mode 100644 index 00000000..c4f7217c Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-breaking-bad-2008-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-californication-2007-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-californication-2007-backdrop.jpg new file mode 100644 index 00000000..ffff979b Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-californication-2007-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-californication-2007-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-californication-2007-poster.jpg new file mode 100644 index 00000000..38428e76 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-californication-2007-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-chainsaw-man-2022-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-chainsaw-man-2022-backdrop.jpg new file mode 100644 index 00000000..f1fbee3e Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-chainsaw-man-2022-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-chainsaw-man-2022-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-chainsaw-man-2022-poster.jpg new file mode 100644 index 00000000..a3779772 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-chainsaw-man-2022-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-cyberpunk-edgerunners-2022-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-cyberpunk-edgerunners-2022-backdrop.jpg new file mode 100644 index 00000000..2920cf5a Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-cyberpunk-edgerunners-2022-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-cyberpunk-edgerunners-2022-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-cyberpunk-edgerunners-2022-poster.jpg new file mode 100644 index 00000000..8d338532 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-cyberpunk-edgerunners-2022-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-derry-girls-2018-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-derry-girls-2018-backdrop.jpg new file mode 100644 index 00000000..76fae98c Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-derry-girls-2018-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-derry-girls-2018-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-derry-girls-2018-poster.jpg new file mode 100644 index 00000000..56a8cd79 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-derry-girls-2018-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-desperate-housewives-2004-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-desperate-housewives-2004-backdrop.jpg new file mode 100644 index 00000000..40ec3001 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-desperate-housewives-2004-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-desperate-housewives-2004-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-desperate-housewives-2004-poster.jpg new file mode 100644 index 00000000..0c4c38bc Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-desperate-housewives-2004-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-dune-prophecy-2024-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-dune-prophecy-2024-backdrop.jpg new file mode 100644 index 00000000..5b940b9f Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-dune-prophecy-2024-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-dune-prophecy-2024-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-dune-prophecy-2024-poster.jpg new file mode 100644 index 00000000..eef2a4f3 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-dune-prophecy-2024-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-etoile-2025-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-etoile-2025-backdrop.jpg new file mode 100644 index 00000000..a8e9576c Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-etoile-2025-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-etoile-2025-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-etoile-2025-poster.jpg new file mode 100644 index 00000000..f078bc89 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-etoile-2025-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-fallout-2024-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-fallout-2024-backdrop.jpg new file mode 100644 index 00000000..c9d6a190 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-fallout-2024-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-fallout-2024-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-fallout-2024-poster.jpg new file mode 100644 index 00000000..5e87a07d Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-fallout-2024-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-firefly-2002-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-firefly-2002-backdrop.jpg new file mode 100644 index 00000000..fa3b7604 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-firefly-2002-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-firefly-2002-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-firefly-2002-poster.jpg new file mode 100644 index 00000000..9ad599b3 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-firefly-2002-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-fleabag-2016-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-fleabag-2016-backdrop.jpg new file mode 100644 index 00000000..e5a83462 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-fleabag-2016-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-fleabag-2016-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-fleabag-2016-poster.jpg new file mode 100644 index 00000000..bd3ccea5 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-fleabag-2016-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-gilmore-girls-2000-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-gilmore-girls-2000-backdrop.jpg new file mode 100644 index 00000000..b42ba953 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-gilmore-girls-2000-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-gilmore-girls-2000-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-gilmore-girls-2000-poster.jpg new file mode 100644 index 00000000..23b668cb Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-gilmore-girls-2000-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-high-potential-2024-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-high-potential-2024-backdrop.jpg new file mode 100644 index 00000000..66bb4d00 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-high-potential-2024-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-high-potential-2024-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-high-potential-2024-poster.jpg new file mode 100644 index 00000000..00a07ca7 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-high-potential-2024-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-invincible-2021-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-invincible-2021-backdrop.jpg new file mode 100644 index 00000000..e8120387 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-invincible-2021-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-invincible-2021-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-invincible-2021-poster.jpg new file mode 100644 index 00000000..c278e37e Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-invincible-2021-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-last-samurai-standing-2025-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-last-samurai-standing-2025-backdrop.jpg new file mode 100644 index 00000000..c85a210b Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-last-samurai-standing-2025-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-last-samurai-standing-2025-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-last-samurai-standing-2025-poster.jpg new file mode 100644 index 00000000..a9789909 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-last-samurai-standing-2025-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-lord-of-the-flies-2026-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-lord-of-the-flies-2026-backdrop.jpg new file mode 100644 index 00000000..1249a8f3 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-lord-of-the-flies-2026-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-lord-of-the-flies-2026-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-lord-of-the-flies-2026-poster.jpg new file mode 100644 index 00000000..b1229ed8 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-lord-of-the-flies-2026-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-love-death-robots-2019-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-love-death-robots-2019-backdrop.jpg new file mode 100644 index 00000000..3694bfc4 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-love-death-robots-2019-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-love-death-robots-2019-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-love-death-robots-2019-poster.jpg new file mode 100644 index 00000000..08449aa9 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-love-death-robots-2019-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-maniac-2018-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-maniac-2018-backdrop.jpg new file mode 100644 index 00000000..9d46de1d Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-maniac-2018-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-maniac-2018-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-maniac-2018-poster.jpg new file mode 100644 index 00000000..a1985217 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-maniac-2018-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-neon-genesis-evangelion-1995-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-neon-genesis-evangelion-1995-backdrop.jpg new file mode 100644 index 00000000..d80e17c1 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-neon-genesis-evangelion-1995-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-neon-genesis-evangelion-1995-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-neon-genesis-evangelion-1995-poster.jpg new file mode 100644 index 00000000..f86cd698 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-neon-genesis-evangelion-1995-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-norsemen-2016-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-norsemen-2016-backdrop.jpg new file mode 100644 index 00000000..58805e67 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-norsemen-2016-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-norsemen-2016-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-norsemen-2016-poster.jpg new file mode 100644 index 00000000..e7206b88 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-norsemen-2016-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-one-piece-2023-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-one-piece-2023-backdrop.jpg new file mode 100644 index 00000000..3fa4a282 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-one-piece-2023-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-one-piece-2023-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-one-piece-2023-poster.jpg new file mode 100644 index 00000000..286e6260 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-one-piece-2023-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-only-murders-in-the-building-2021-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-only-murders-in-the-building-2021-backdrop.jpg new file mode 100644 index 00000000..0b768d54 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-only-murders-in-the-building-2021-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-only-murders-in-the-building-2021-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-only-murders-in-the-building-2021-poster.jpg new file mode 100644 index 00000000..a6b147a4 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-only-murders-in-the-building-2021-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-over-the-garden-wall-2014-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-over-the-garden-wall-2014-backdrop.jpg new file mode 100644 index 00000000..43ac4428 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-over-the-garden-wall-2014-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-over-the-garden-wall-2014-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-over-the-garden-wall-2014-poster.jpg new file mode 100644 index 00000000..34b1ed84 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-over-the-garden-wall-2014-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-pluribus-2025-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-pluribus-2025-backdrop.jpg new file mode 100644 index 00000000..e64c0268 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-pluribus-2025-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-pluribus-2025-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-pluribus-2025-poster.jpg new file mode 100644 index 00000000..1ea4711b Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-pluribus-2025-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-rick-and-morty-2013-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-rick-and-morty-2013-backdrop.jpg new file mode 100644 index 00000000..1c4c0ffc Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-rick-and-morty-2013-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-rick-and-morty-2013-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-rick-and-morty-2013-poster.jpg new file mode 100644 index 00000000..b95c4890 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-rick-and-morty-2013-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-secret-level-2024-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-secret-level-2024-backdrop.jpg new file mode 100644 index 00000000..54d2a13e Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-secret-level-2024-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-secret-level-2024-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-secret-level-2024-poster.jpg new file mode 100644 index 00000000..1b6e67f6 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-secret-level-2024-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-severance-2022-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-severance-2022-backdrop.jpg new file mode 100644 index 00000000..a24c03bd Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-severance-2022-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-severance-2022-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-severance-2022-poster.jpg new file mode 100644 index 00000000..5a29d6f9 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-severance-2022-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-sex-and-the-city-1998-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-sex-and-the-city-1998-backdrop.jpg new file mode 100644 index 00000000..57beacc1 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-sex-and-the-city-1998-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-sex-and-the-city-1998-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-sex-and-the-city-1998-poster.jpg new file mode 100644 index 00000000..e546cc6d Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-sex-and-the-city-1998-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-the-100-2014-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-the-100-2014-backdrop.jpg new file mode 100644 index 00000000..8d41cb22 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-the-100-2014-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-the-100-2014-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-the-100-2014-poster.jpg new file mode 100644 index 00000000..4d254cb0 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-the-100-2014-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-the-abbott-and-costello-show-1952-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-the-abbott-and-costello-show-1952-backdrop.jpg new file mode 100644 index 00000000..53e1d593 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-the-abbott-and-costello-show-1952-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-the-abbott-and-costello-show-1952-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-the-abbott-and-costello-show-1952-poster.jpg new file mode 100644 index 00000000..7d6768ea Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-the-abbott-and-costello-show-1952-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-the-afterparty-2022-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-the-afterparty-2022-backdrop.jpg new file mode 100644 index 00000000..72ca19db Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-the-afterparty-2022-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-the-afterparty-2022-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-the-afterparty-2022-poster.jpg new file mode 100644 index 00000000..9beacde2 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-the-afterparty-2022-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-the-bear-2022-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-the-bear-2022-backdrop.jpg new file mode 100644 index 00000000..9c9cca57 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-the-bear-2022-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-the-bear-2022-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-the-bear-2022-poster.jpg new file mode 100644 index 00000000..bbf0ab28 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-the-bear-2022-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-the-beast-in-me-2025-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-the-beast-in-me-2025-backdrop.jpg new file mode 100644 index 00000000..e7727a79 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-the-beast-in-me-2025-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-the-beast-in-me-2025-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-the-beast-in-me-2025-poster.jpg new file mode 100644 index 00000000..c78c5cf0 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-the-beast-in-me-2025-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-the-bold-type-2017-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-the-bold-type-2017-backdrop.jpg new file mode 100644 index 00000000..dd84deab Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-the-bold-type-2017-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-the-bold-type-2017-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-the-bold-type-2017-poster.jpg new file mode 100644 index 00000000..f663eb8b Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-the-bold-type-2017-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-the-boys-2019-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-the-boys-2019-backdrop.jpg new file mode 100644 index 00000000..a834d9fb Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-the-boys-2019-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-the-boys-2019-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-the-boys-2019-poster.jpg new file mode 100644 index 00000000..f67ff16f Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-the-boys-2019-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-the-capture-2019-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-the-capture-2019-backdrop.jpg new file mode 100644 index 00000000..42256fc1 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-the-capture-2019-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-the-capture-2019-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-the-capture-2019-poster.jpg new file mode 100644 index 00000000..a4039011 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-the-capture-2019-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-the-durrells-2016-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-the-durrells-2016-backdrop.jpg new file mode 100644 index 00000000..3f8bdeec Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-the-durrells-2016-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-the-durrells-2016-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-the-durrells-2016-poster.jpg new file mode 100644 index 00000000..d29448bb Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-the-durrells-2016-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-the-final-problem-2026-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-the-final-problem-2026-backdrop.jpg new file mode 100644 index 00000000..61a310d8 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-the-final-problem-2026-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-the-final-problem-2026-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-the-final-problem-2026-poster.jpg new file mode 100644 index 00000000..f0332826 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-the-final-problem-2026-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-the-legend-of-korra-2012-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-the-legend-of-korra-2012-backdrop.jpg new file mode 100644 index 00000000..ab6086bd Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-the-legend-of-korra-2012-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-the-legend-of-korra-2012-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-the-legend-of-korra-2012-poster.jpg new file mode 100644 index 00000000..a7086d5e Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-the-legend-of-korra-2012-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-the-lord-of-the-rings-the-rings-of-power-2022-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-the-lord-of-the-rings-the-rings-of-power-2022-backdrop.jpg new file mode 100644 index 00000000..0b88fd4b Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-the-lord-of-the-rings-the-rings-of-power-2022-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-the-lord-of-the-rings-the-rings-of-power-2022-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-the-lord-of-the-rings-the-rings-of-power-2022-poster.jpg new file mode 100644 index 00000000..dc387fcd Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-the-lord-of-the-rings-the-rings-of-power-2022-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-the-night-manager-2016-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-the-night-manager-2016-backdrop.jpg new file mode 100644 index 00000000..327aa449 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-the-night-manager-2016-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-the-night-manager-2016-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-the-night-manager-2016-poster.jpg new file mode 100644 index 00000000..7ab2dbf5 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-the-night-manager-2016-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-the-tatami-galaxy-2010-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-the-tatami-galaxy-2010-backdrop.jpg new file mode 100644 index 00000000..a267b4cb Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-the-tatami-galaxy-2010-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-the-tatami-galaxy-2010-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-the-tatami-galaxy-2010-poster.jpg new file mode 100644 index 00000000..5324dc75 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-the-tatami-galaxy-2010-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-the-white-lotus-2021-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-the-white-lotus-2021-backdrop.jpg new file mode 100644 index 00000000..1f747323 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-the-white-lotus-2021-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-the-white-lotus-2021-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-the-white-lotus-2021-poster.jpg new file mode 100644 index 00000000..2f23d1fa Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-the-white-lotus-2021-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-the-witcher-2019-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-the-witcher-2019-backdrop.jpg new file mode 100644 index 00000000..acca834e Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-the-witcher-2019-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-the-witcher-2019-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-the-witcher-2019-poster.jpg new file mode 100644 index 00000000..f5eb8f59 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-the-witcher-2019-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-upload-2020-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-upload-2020-backdrop.jpg new file mode 100644 index 00000000..f350a49c Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-upload-2020-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-upload-2020-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-upload-2020-poster.jpg new file mode 100644 index 00000000..547d3b7a Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-upload-2020-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-widow-s-bay-2026-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-widow-s-bay-2026-backdrop.jpg new file mode 100644 index 00000000..03b1c335 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-widow-s-bay-2026-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-widow-s-bay-2026-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-widow-s-bay-2026-poster.jpg new file mode 100644 index 00000000..8c02a0d7 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-widow-s-bay-2026-poster.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-yellowjackets-2021-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-yellowjackets-2021-backdrop.jpg new file mode 100644 index 00000000..960c2d23 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-yellowjackets-2021-backdrop.jpg differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-yellowjackets-2021-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-yellowjackets-2021-poster.jpg new file mode 100644 index 00000000..c7e16a57 Binary files /dev/null and b/app/src/debug/assets/wolfskin-titles/assets/tv-yellowjackets-2021-poster.jpg differ diff --git a/app/src/debug/java/org/justinvisser/wolfskin/tv/DebugHome.kt b/app/src/debug/java/org/justinvisser/wolfskin/tv/DebugHome.kt new file mode 100644 index 00000000..8eb4a397 --- /dev/null +++ b/app/src/debug/java/org/justinvisser/wolfskin/tv/DebugHome.kt @@ -0,0 +1,9 @@ +package org.justinvisser.wolfskin.tv + +import android.content.Context +import org.justinvisser.wolfskin.design.HomeFixture +import org.justinvisser.wolfskin.design.HomeSnapshot + +/** Only debug builds may read the locked prototype household projection. */ +fun debugHomeSnapshot(context: Context, person: String): HomeSnapshot? = + if (ConductarrPreference.fixtures) HomeFixture(context).snapshot(person) else null diff --git a/app/src/debug/res/values/strings.xml b/app/src/debug/res/values/strings.xml index 606329c9..cb6c15c6 100644 --- a/app/src/debug/res/values/strings.xml +++ b/app/src/debug/res/values/strings.xml @@ -1,4 +1,4 @@ <?xml version="1.0" encoding="utf-8"?> <resources> - <string name="app_name" translatable="false">Wholphin (Debug)</string> + <string name="app_name" translatable="false">Wolfskin (Debug)</string> </resources> diff --git a/app/src/default/AndroidManifest.xml b/app/src/default/AndroidManifest.xml new file mode 100644 index 00000000..c59612c7 --- /dev/null +++ b/app/src/default/AndroidManifest.xml @@ -0,0 +1,9 @@ +<?xml version="1.0" encoding="utf-8"?> +<manifest xmlns:android="http://schemas.android.com/apk/res/android"> + <queries> + <intent> + <action android:name="android.intent.action.VIEW" /> + <data android:scheme="vnd.youtube" /> + </intent> + </queries> +</manifest> diff --git a/app/src/default/res/mipmap-anydpi-v26/ic_banner.xml b/app/src/default/res/mipmap-anydpi-v26/ic_banner.xml new file mode 100644 index 00000000..ec3f55e3 --- /dev/null +++ b/app/src/default/res/mipmap-anydpi-v26/ic_banner.xml @@ -0,0 +1,4 @@ +<?xml version="1.0" encoding="utf-8"?> +<bitmap xmlns:android="http://schemas.android.com/apk/res/android" + android:src="@mipmap/ic_banner_foreground" + android:gravity="fill" /> diff --git a/app/src/default/res/mipmap-hdpi/ic_launcher.webp b/app/src/default/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 00000000..4ac23262 Binary files /dev/null and b/app/src/default/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app/src/default/res/mipmap-hdpi/ic_launcher_foreground.webp b/app/src/default/res/mipmap-hdpi/ic_launcher_foreground.webp new file mode 100644 index 00000000..de1628cc Binary files /dev/null and b/app/src/default/res/mipmap-hdpi/ic_launcher_foreground.webp differ diff --git a/app/src/default/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/default/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 00000000..df5faba9 Binary files /dev/null and b/app/src/default/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app/src/default/res/mipmap-mdpi/ic_launcher.webp b/app/src/default/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 00000000..415cf194 Binary files /dev/null and b/app/src/default/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app/src/default/res/mipmap-mdpi/ic_launcher_foreground.webp b/app/src/default/res/mipmap-mdpi/ic_launcher_foreground.webp new file mode 100644 index 00000000..d9df942a Binary files /dev/null and b/app/src/default/res/mipmap-mdpi/ic_launcher_foreground.webp differ diff --git a/app/src/default/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/default/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 00000000..dea8ff64 Binary files /dev/null and b/app/src/default/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app/src/default/res/mipmap-xhdpi/ic_banner.png b/app/src/default/res/mipmap-xhdpi/ic_banner.png new file mode 100644 index 00000000..ee42b91e Binary files /dev/null and b/app/src/default/res/mipmap-xhdpi/ic_banner.png differ diff --git a/app/src/default/res/mipmap-xhdpi/ic_banner_foreground.png b/app/src/default/res/mipmap-xhdpi/ic_banner_foreground.png new file mode 100644 index 00000000..ee42b91e Binary files /dev/null and b/app/src/default/res/mipmap-xhdpi/ic_banner_foreground.png differ diff --git a/app/src/default/res/mipmap-xhdpi/ic_launcher.webp b/app/src/default/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 00000000..924b40eb Binary files /dev/null and b/app/src/default/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app/src/default/res/mipmap-xhdpi/ic_launcher_foreground.webp b/app/src/default/res/mipmap-xhdpi/ic_launcher_foreground.webp new file mode 100644 index 00000000..e218f8bd Binary files /dev/null and b/app/src/default/res/mipmap-xhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/default/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/default/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 00000000..40674eff Binary files /dev/null and b/app/src/default/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app/src/default/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/default/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 00000000..6f5be441 Binary files /dev/null and b/app/src/default/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/default/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/app/src/default/res/mipmap-xxhdpi/ic_launcher_foreground.webp new file mode 100644 index 00000000..8c1935c7 Binary files /dev/null and b/app/src/default/res/mipmap-xxhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/default/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/default/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 00000000..48a4e286 Binary files /dev/null and b/app/src/default/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app/src/default/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/default/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 00000000..1cf49fb6 Binary files /dev/null and b/app/src/default/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/default/res/mipmap-xxxhdpi/ic_launcher_foreground.webp b/app/src/default/res/mipmap-xxxhdpi/ic_launcher_foreground.webp new file mode 100644 index 00000000..afc41a8b Binary files /dev/null and b/app/src/default/res/mipmap-xxxhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/default/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/default/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 00000000..122a33e0 Binary files /dev/null and b/app/src/default/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app/src/default/res/values-hu/strings.xml b/app/src/default/res/values-hu/strings.xml new file mode 100644 index 00000000..fc653df6 --- /dev/null +++ b/app/src/default/res/values-hu/strings.xml @@ -0,0 +1,6 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <string name="display_preset_default">Wolfskin alapértelmezett beállítása</string> + <string name="display_preset_compact">Wolfskin kompakt elrendezés</string> + <string name="updated_toast">A Wolfskin frissült a %s verzióra</string> +</resources> diff --git a/app/src/main/res/values/ic_banner_background.xml b/app/src/default/res/values-pl/strings.xml similarity index 53% rename from app/src/main/res/values/ic_banner_background.xml rename to app/src/default/res/values-pl/strings.xml index e0e3446a..e334b3ba 100644 --- a/app/src/main/res/values/ic_banner_background.xml +++ b/app/src/default/res/values-pl/strings.xml @@ -1,4 +1,4 @@ <?xml version="1.0" encoding="utf-8"?> <resources> - <color name="ic_banner_background">#000B25</color> + <string name="about">O Wolfskin</string> </resources> diff --git a/app/src/default/res/values/strings.xml b/app/src/default/res/values/strings.xml new file mode 100644 index 00000000..c76d6792 --- /dev/null +++ b/app/src/default/res/values/strings.xml @@ -0,0 +1,11 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <string name="app_name" translatable="false">Wolfskin</string> + <string name="app_name_long" translatable="false">Wolfskin</string> + <string name="display_preset_default">Wolfskin Default</string> + <string name="display_preset_compact">Wolfskin Compact</string> + <string name="updated_toast">Wolfskin updated to %s</string> + <string name="wolfskin_conductarr_url">Conductarr server URL</string> + <string name="wolfskin_conductarr_url_summary">Advanced override. HTTPS required; blank restores the build default.</string> + <string name="wolfskin_fixtures">Wolfskin preview fixtures (debug only)</string> +</resources> diff --git a/app/src/firetv/AndroidManifest.xml b/app/src/firetv/AndroidManifest.xml new file mode 100644 index 00000000..8a762a28 --- /dev/null +++ b/app/src/firetv/AndroidManifest.xml @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="utf-8"?> +<manifest xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:tools="http://schemas.android.com/tools"> + + <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" tools:node="remove" /> + +</manifest> diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 51377cb8..aad5c5b5 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -4,6 +4,7 @@ android:installLocation="auto"> <uses-permission android:name="android.permission.INTERNET" /> + <uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" /> <uses-permission android:name="android.permission.RECORD_AUDIO" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" /> @@ -14,6 +15,7 @@ android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="28" /> <uses-permission android:name="com.android.providers.tv.permission.WRITE_EPG_DATA" /> + <uses-permission android:name="com.amazon.permission.media.session.voicecommandcontrol" /> <uses-feature android:name="android.hardware.touchscreen" @@ -51,13 +53,21 @@ android:name=".MainActivity" android:exported="true" android:launchMode="singleTask" - android:configChanges="keyboard|keyboardHidden|navigation|orientation|screenSize|screenLayout|smallestScreenSize"> + android:configChanges="keyboard|keyboardHidden|navigation|orientation|screenSize|screenLayout|smallestScreenSize|density"> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LEANBACK_LAUNCHER" /> </intent-filter> + <intent-filter> + <action android:name="android.intent.action.VIEW" /> + <action android:name="android.intent.action.SEARCH" /> + <action android:name="com.github.damontecres.wholphin.PLAYBACK" /> + + <category android:name="android.intent.category.DEFAULT" /> + <data android:scheme="wholphin" /> + </intent-filter> </activity> <provider @@ -72,7 +82,12 @@ <provider android:name="androidx.startup.InitializationProvider" android:authorities="${applicationId}.androidx-startup" - tools:node="remove" /> + tools:node="merge"> + <meta-data + android:name="androidx.work.WorkManagerInitializer" + android:value="androidx.startup" + tools:node="remove" /> + </provider> <service android:name=".WholphinDreamService" @@ -84,6 +99,15 @@ <category android:name="android.intent.category.DEFAULT" /> </intent-filter> </service> + + <service + android:name="androidx.appcompat.app.AppLocalesMetadataHolderService" + android:enabled="false" + android:exported="false"> + <meta-data + android:name="autoStoreLocales" + android:value="false" /> + </service> </application> </manifest> diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png index 87623e5e..dfc380e1 100644 Binary files a/app/src/main/ic_launcher-playstore.png and b/app/src/main/ic_launcher-playstore.png differ diff --git a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt index 1fa290d0..ec61583d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -6,8 +6,10 @@ import android.content.res.Configuration import android.os.Bundle import android.view.KeyEvent import android.view.WindowManager +import android.widget.Toast import androidx.activity.compose.setContent import androidx.activity.viewModels +import androidx.annotation.VisibleForTesting import androidx.appcompat.app.AppCompatActivity import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box @@ -27,6 +29,8 @@ import androidx.lifecycle.lifecycleScope import androidx.lifecycle.viewModelScope import androidx.navigation3.runtime.NavBackStack import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Surface import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.PlayerBackend @@ -35,23 +39,28 @@ import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.DatePlayedInvalidationService import com.github.damontecres.wholphin.services.DeviceProfileService import com.github.damontecres.wholphin.services.ImageUrlService +import com.github.damontecres.wholphin.services.IntentResult +import com.github.damontecres.wholphin.services.IntentService import com.github.damontecres.wholphin.services.LatestNextUpSchedulerService 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 import com.github.damontecres.wholphin.services.SuggestionsSchedulerService import com.github.damontecres.wholphin.services.UpdateChecker +import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.services.UserSwitchListener import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient import com.github.damontecres.wholphin.services.tvprovider.TvProviderSchedulerService 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.detail.series.SeasonEpisodeIds +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 @@ -60,25 +69,25 @@ import com.github.damontecres.wholphin.ui.theme.WholphinTheme import com.github.damontecres.wholphin.ui.util.ProvideLocalClock import com.github.damontecres.wholphin.util.DebugLogTree import com.github.damontecres.wholphin.util.ExceptionHandler +import com.github.damontecres.wholphin.util.WholphinDispatchers +import com.github.damontecres.wholphin.util.requestSerializersModule import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json import okhttp3.OkHttpClient -import org.jellyfin.sdk.model.api.BaseItemKind -import org.jellyfin.sdk.model.serializer.toUUIDOrNull import timber.log.Timber import javax.inject.Inject +import kotlin.time.Duration.Companion.milliseconds @AndroidEntryPoint class MainActivity : AppCompatActivity() { @@ -88,6 +97,9 @@ class MainActivity : AppCompatActivity() { @Inject lateinit var userPreferencesDataStore: DataStore<AppPreferences> + @Inject + lateinit var userPreferencesService: UserPreferencesService + @AuthOkHttpClient @Inject lateinit var okHttpClient: OkHttpClient @@ -107,6 +119,9 @@ class MainActivity : AppCompatActivity() { @Inject lateinit var imageUrlService: ImageUrlService + @Inject + lateinit var seerrRatingsService: SeerrRatingsService + @Inject lateinit var refreshRateService: RefreshRateService @@ -136,11 +151,16 @@ class MainActivity : AppCompatActivity() { @Inject lateinit var screensaverService: ScreensaverService + @Inject + lateinit var intentService: IntentService + private var signInAuto = true + private var playerBackend: PlayerBackend? = null private val json = Json { classDiscriminator = "_type" + serializersModule = requestSerializersModule } @OptIn(ExperimentalTvMaterial3Api::class) @@ -151,37 +171,51 @@ class MainActivity : AppCompatActivity() { lifecycle.addObserver(playbackLifecycleObserver) val backStackStr = savedInstanceState?.getString(KEY_BACK_STACK) - if (backStackStr != null) { + val restoredBackStack = + if (backStackStr != null) { + try { + json.decodeFromString<List<Destination>>(backStackStr) + } catch (ex: Exception) { + // Best-effort: a previously persisted back stack we can no longer decode + // must not crash startup; fall back to a fresh start destination. + Timber.w(ex, "Could not restore back stack; starting fresh") + null + } + } else { + null + } + if (restoredBackStack != null) { Timber.d("Restoring back stack") - var backStack = json.decodeFromString<List<Destination>>(backStackStr) - + var backStack = restoredBackStack if (!playExternalViewModel.launched.value) { val lastDest = backStack.lastOrNull() if (lastDest.isPlayback) { - Timber.v("Restoring back stack with playback") + Timber.v("Restoring back stack without playback") backStack = backStack.toMutableList().apply { removeAt(lastIndex) } } } navigationManager.backStack = NavBackStack(*backStack.toTypedArray()) } else { - val startDestination = intent?.let(::extractDestination) ?: Destination.Home() - navigationManager.backStack = NavBackStack(startDestination) + navigationManager.backStack = NavBackStack(Destination.Home()) } - viewModel.serverRepository.currentUser.observe(this) { user -> - if (user?.hasPin == true) { - window?.setFlags( - WindowManager.LayoutParams.FLAG_SECURE, - WindowManager.LayoutParams.FLAG_SECURE, - ) - } else { - window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) - } - } + viewModel.serverRepository.currentUserFlow + .onEach { user -> + withContext(WholphinDispatchers.Main) { + if (user?.hasPin == true) { + window.addFlags(WindowManager.LayoutParams.FLAG_SECURE) + } else { + window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) + } + } + }.catch { ex -> + Timber.e(ex, "Error with settings flag secure") + }.launchIn(lifecycleScope) + screensaverService.keepScreenOn .onEach { keepScreenOn -> Timber.v("keepScreenOn: %s", keepScreenOn) - withContext(Dispatchers.Main) { + withContext(WholphinDispatchers.Main) { if (keepScreenOn) { window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) } else { @@ -192,55 +226,66 @@ class MainActivity : AppCompatActivity() { Timber.e(ex, "Error with keepScreenOn") }.launchIn(lifecycleScope) - viewModel.appStart() + userPreferencesDataStore.data.collectLatestIn(lifecycleScope) { prefs -> + signInAuto = prefs.signInAutomatically + playerBackend = prefs.playbackPreferences.playerBackend + } + + viewModel.appStart(intent) setContent { - val appPreferences by userPreferencesDataStore.data.collectAsState(null) - if (appPreferences == null) { - // Show loading page if it is taking a while to get app preferences - var showLoading by remember { mutableStateOf(false) } - LaunchedEffect(Unit) { - delay(500) - Timber.i("Showing loading page") - showLoading = true - } - if (showLoading) { - Box( - modifier = - Modifier - .fillMaxSize() - .background(Color.Black), - ) { - LoadingPage() - } - } - } - appPreferences?.let { appPreferences -> - LaunchedEffect(appPreferences.signInAutomatically) { - signInAuto = appPreferences.signInAutomatically - } - CoilConfig( - prefs = appPreferences, - okHttpClient = okHttpClient, - debugLogging = false, - enableCache = true, - ) - LaunchedEffect(appPreferences.debugLogging) { - DebugLogTree.INSTANCE.enabled = appPreferences.debugLogging - } - CompositionLocalProvider(LocalImageUrlService provides imageUrlService) { - WholphinTheme( - true, - appThemeColors = appPreferences.interfacePreferences.appThemeColors, - ) { - ProvideLocalClock { - MainContent( - backStack = setupNavigationManager.backStack, - navigationManager = navigationManager, - appPreferences = appPreferences, - backdropService = backdropService, - screensaverService = screensaverService, - modifier = Modifier.fillMaxSize(), + MaterialTheme { + Surface(Modifier.fillMaxSize()) { + val userPreferences by userPreferencesService.flow.collectAsState(null) + if (userPreferences == null) { + // Show loading page if it is taking a while to get app preferences + var showLoading by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { + delay(500.milliseconds) + Timber.i("Showing loading page") + showLoading = true + } + if (showLoading) { + Box( + modifier = + Modifier + .fillMaxSize() + .background(Color.Black), + ) { + LoadingPage() + } + } + } else { + userPreferences?.let { userPreferences -> + val appPreferences = userPreferences.appPreferences + CoilConfig( + prefs = appPreferences, + okHttpClient = okHttpClient, + debugLogging = false, + enableCache = true, ) + LaunchedEffect(appPreferences.debugLogging) { + DebugLogTree.INSTANCE.enabled = appPreferences.debugLogging + } + CompositionLocalProvider( + LocalImageUrlService provides imageUrlService, + LocalSeerrRatingsService provides seerrRatingsService, + ) { + WholphinTheme( + true, + appThemeColors = appPreferences.interfacePreferences.appThemeColors, + ) { + ProvideLocalClock { + MainContent( + backStack = setupNavigationManager.backStack, + navigationManager = navigationManager, + userPreferences = userPreferences, + backdropService = backdropService, + screensaverService = screensaverService, + modifier = Modifier.fillMaxSize(), + ) + } + } + } } } } @@ -249,7 +294,7 @@ class MainActivity : AppCompatActivity() { } override fun dispatchKeyEvent(event: KeyEvent): Boolean { - if (screensaverService.state.value.show) { + if (screensaverService.state.value.run { show || showDim }) { screensaverService.stop(false) screensaverService.pulse() return true @@ -262,6 +307,7 @@ class MainActivity : AppCompatActivity() { override fun onResume() { super.onResume() Timber.d("onResume") + viewModel.appResume() lifecycleScope.launchDefault { screensaverService.pulse() } @@ -270,7 +316,7 @@ class MainActivity : AppCompatActivity() { override fun onRestart() { super.onRestart() Timber.d("onRestart") - viewModel.appStart() + viewModel.appStart(null) if (!playExternalViewModel.launched.value) { // If restarting during playback that is not external, go back a page val lastDest = navigationManager.backStack.lastOrNull() @@ -317,10 +363,14 @@ class MainActivity : AppCompatActivity() { override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) Timber.d("onSaveInstanceState") - val str = json.encodeToString(navigationManager.backStack.toList()) - outState.putString(KEY_BACK_STACK, str) - val playerBackend = - runBlocking { userPreferencesDataStore.data.firstOrNull() }?.playbackPreferences?.playerBackend + try { + val str = json.encodeToString(navigationManager.backStack.toList()) + outState.putString(KEY_BACK_STACK, str) + } catch (ex: Exception) { + // Best-effort: some destinations carry types we can't serialize; skip persisting + // the back stack rather than crashing in onSaveInstanceState. + Timber.w(ex, "Could not persist back stack; skipping") + } outState.putBoolean(KEY_EXTERNAL_PLAYER, playerBackend == PlayerBackend.EXTERNAL_PLAYER) } @@ -336,67 +386,34 @@ class MainActivity : AppCompatActivity() { override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) - Timber.d("onConfigurationChanged") + Timber.d("onConfigurationChanged: newConfig=%s", newConfig) } override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) Timber.v("onNewIntent") setIntent(intent) - extractDestination(intent)?.let { - navigationManager.replace(it) - } + viewModel.appStart(intent) } - private fun extractDestination(intent: Intent): Destination? = - intent.let { - val itemId = - it.getStringExtra(INTENT_ITEM_ID)?.toUUIDOrNull() - val type = - it.getStringExtra(INTENT_ITEM_TYPE)?.let(BaseItemKind::fromNameOrNull) - if (itemId != null && type != null) { - val seriesId = it.getStringExtra(INTENT_SERIES_ID)?.toUUIDOrNull() - val seasonId = it.getStringExtra(INTENT_SEASON_ID)?.toUUIDOrNull() - val episodeNumber = it.getIntExtra(INTENT_EPISODE_NUMBER, -1) - val seasonNumber = it.getIntExtra(INTENT_SEASON_NUMBER, -1) - if (seriesId != null && seasonId != null && episodeNumber >= 0 && seasonNumber >= 0) { - Destination.SeriesOverview( - itemId = seriesId, - type = BaseItemKind.SERIES, - seasonEpisode = - SeasonEpisodeIds( - seasonId = seasonId, - seasonNumber = seasonNumber, - episodeId = itemId, - episodeNumber = episodeNumber, - ), - ) - } else { - Destination.MediaItem(itemId, type) - } - } else { - null - } - } - fun changeDisplayMode(modeId: Int) { - lifecycleScope.launch(Dispatchers.Main + ExceptionHandler(autoToast = true)) { - val attrs = window.attributes - if (attrs.preferredDisplayModeId != modeId) { - Timber.d("Switch preferredDisplayModeId to %s", modeId) - window.attributes = attrs.apply { preferredDisplayModeId = modeId } + lifecycleScope.launch(WholphinDispatchers.Main + ExceptionHandler(autoToast = true)) { + try { + val attrs = window.attributes + if (attrs.preferredDisplayModeId != modeId) { + Timber.d("Switch preferredDisplayModeId to %s", modeId) + window.attributes = attrs.apply { preferredDisplayModeId = modeId } + } + } catch (ex: Exception) { + Timber.e(ex, "Error switching preferredDisplayModeId to %s", modeId) + Toast + .makeText(this@MainActivity, "Error changing display mode", Toast.LENGTH_SHORT) + .show() } } } companion object { - const val INTENT_ITEM_ID = "itemId" - const val INTENT_ITEM_TYPE = "itemType" - const val INTENT_SERIES_ID = "seriesId" - const val INTENT_EPISODE_NUMBER = "epNum" - const val INTENT_SEASON_NUMBER = "seaNum" - const val INTENT_SEASON_ID = "seaId" - private const val KEY_BACK_STACK = "backStack" private const val KEY_EXTERNAL_PLAYER = "extPlayer" @@ -412,65 +429,44 @@ class MainActivityViewModel @param:ApplicationContext private val context: Context, private val preferences: DataStore<AppPreferences>, val serverRepository: ServerRepository, - private val navigationManager: SetupNavigationManager, + private val setupNavigationManager: SetupNavigationManager, + private val navigationManager: NavigationManager, private val deviceProfileService: DeviceProfileService, private val backdropService: BackdropService, private val appUpgradeHandler: AppUpgradeHandler, + private val intentService: IntentService, ) : ViewModel() { - fun appStart() { + private val mutex = Mutex() + + fun appStart(intent: Intent?) { viewModelScope.launchDefault { - try { - val needUpgrade = appUpgradeHandler.needUpgrade() - if (needUpgrade) { + mutex.withLock { + try { + handleAppUpgrade() + } catch (ex: Exception) { + Timber.e(ex, "Error during app upgrade!") showToast( context, - context.getString( - R.string.updated_toast, - appUpgradeHandler.currentVersion.toString(), - ), + "Error during app upgrade: ${ex.localizedMessage}", ) - appUpgradeHandler.run() } - appUpgradeHandler.copySubfont(false) - val prefs = - preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance() - val userHasPin = serverRepository.currentUser.value?.hasPin == true - if (prefs.signInAutomatically && !userHasPin) { - val current = - serverRepository.restoreSession( - prefs.currentServerId?.toUUIDOrNull(), - prefs.currentUserId?.toUUIDOrNull(), - ) - if (current != null) { - if (current.user.hasPin) { - navigationManager.navigateTo(SetupDestination.UserList(current.server)) - } else { - // Restored - navigationManager.navigateTo(SetupDestination.AppContent(current)) - } - } else { - // Did not restore - navigationManager.navigateTo(SetupDestination.ServerList) - } - } else { - navigationManager.navigateTo(SetupDestination.Loading) - backdropService.clearBackdrop() - val currentServerId = prefs.currentServerId?.toUUIDOrNull() - if (currentServerId != null) { - val currentServer = - serverRepository.serverDao.getServer(currentServerId)?.server - if (currentServer != null) { - navigationManager.navigateTo(SetupDestination.UserList(currentServer)) - } else { - navigationManager.navigateTo(SetupDestination.ServerList) - } - } else { - navigationManager.navigateTo(SetupDestination.ServerList) + + try { + val result = handleIntent(intent) + if (result) { + Timber.i("appStart was handled by intent") + return@withLock } + } catch (ex: Exception) { + Timber.e(ex, "Error parsing intent") + } + + try { + handleAppStart() + } catch (ex: Exception) { + Timber.e(ex, "Error during appStart") + setupNavigationManager.navigateTo(SetupDestination.ServerList) } - } catch (ex: Exception) { - Timber.e(ex, "Error during appStart") - navigationManager.navigateTo(SetupDestination.ServerList) } } viewModelScope.launchDefault { @@ -478,6 +474,116 @@ class MainActivityViewModel deviceProfileService.mediaCodecCapabilitiesTest.supportsAVC() } } + + fun appResume() { + viewModelScope.launchDefault { + mutex.withLock { + try { + Timber.v("Updating userDto") + serverRepository.updateUserDto() + } catch (ex: Exception) { + Timber.w(ex, "Error during appResume") + } + } + } + } + + /** + * Handle an [Intent], navigating if needed + * + * @return true if the intent was handled, false if normal app start should proceed + */ + @VisibleForTesting + internal suspend fun handleIntent(intent: Intent?): Boolean { + val result = intent?.let { intentService.parseIntent(intent) } + when (result) { + is IntentResult.Error -> { + val current = serverRepository.current.value + val destination = + if (current != null) { + SetupDestination.UserList(current.server) + } else { + SetupDestination.ServerList + } + setupNavigationManager.navigateTo(destination) + Timber.e("Error parsing intent: %s", result.message) + showToast(context, result.message) + return true + } + + is IntentResult.Target -> { + val current = serverRepository.current.value + if (current != null) { + Timber.i("Received valid intent, switching to AppContent") + + if (result.addHomeToBackStack) { + navigationManager.reloadHome() + } else { + navigationManager.backStack.clear() + } + navigationManager.backStack.addAll(result.destinations) + + setupNavigationManager.navigateTo( + SetupDestination.AppContent(current), + ) + } else { + // This should never happen, but just reset app state if it does + setupNavigationManager.navigateTo(SetupDestination.ServerList) + Timber.e("Error parsing intent, no user is active") + showToast(context, "An error occurred parsing the intent") + } + return true + } + + IntentResult.NoOp, + null, + -> { + // Not handled + return false + } + } + } + + private suspend fun handleAppUpgrade() { + val needUpgrade = appUpgradeHandler.needUpgrade() + if (needUpgrade) { + showToast( + context, + context.getString( + R.string.updated_toast, + appUpgradeHandler.currentVersion.toString(), + ), + ) + appUpgradeHandler.run() + } + appUpgradeHandler.copySubfont(false) + } + + /** + * Handle starting the app including auto sign in and navigating to the right starting page + */ + @VisibleForTesting + internal suspend fun handleAppStart() { + val signInAutomatically = preferences.data.first().signInAutomatically + val currentUser = serverRepository.current.value + if (signInAutomatically && currentUser != null && !currentUser.user.isProtected) { + // Hot reload + Timber.v("Hot reload restoring state") + setupNavigationManager.navigateTo( + SetupDestination.AppContent(currentUser), + ) + } else { + val restoredSession = + if (signInAutomatically) { + Timber.v("Sign in automatically") + serverRepository.restoreLastSession() + } else { + serverRepository.getMostRecentServer() + } + Timber.d("Restore last session result=%s", restoredSession) + setupNavigationManager.navigateTo(restoredSession.destination) + } + } } private val Destination?.isPlayback: Boolean diff --git a/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt b/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt index 4de2ab2f..fcaa3165 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.size import androidx.compose.material3.CircularProgressIndicator import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -16,6 +17,7 @@ import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.unit.dp import androidx.lifecycle.Lifecycle @@ -26,27 +28,28 @@ import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator import androidx.navigation3.ui.NavDisplay import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Surface -import com.github.damontecres.wholphin.preferences.AppPreferences 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.ScreensaverService import com.github.damontecres.wholphin.services.SetupDestination import com.github.damontecres.wholphin.ui.components.AppScreensaver -import com.github.damontecres.wholphin.ui.nav.ApplicationContent +import org.justinvisser.wolfskin.tv.WolfskinApp import com.github.damontecres.wholphin.ui.setup.SwitchServerContent import com.github.damontecres.wholphin.ui.setup.SwitchUserContent +import com.github.damontecres.wholphin.ui.util.InterfaceCustomization +import com.github.damontecres.wholphin.ui.util.LocalInterfaceCustomization @Composable fun MainContent( backStack: MutableList<SetupDestination>, navigationManager: NavigationManager, - appPreferences: AppPreferences, + userPreferences: UserPreferences, backdropService: BackdropService, screensaverService: ScreensaverService, modifier: Modifier = Modifier, ) { - val preferences by rememberUpdatedState(UserPreferences(appPreferences)) + val preferences by rememberUpdatedState(userPreferences) Surface( modifier = modifier @@ -55,64 +58,22 @@ fun MainContent( ) { // val backStack = rememberNavBackStack(SetupDestination.Loading) // setupNavigationManager.backStack = backStack - NavDisplay( - backStack = backStack, - onBack = { backStack.removeLastOrNull() }, - entryDecorators = - listOf( - rememberSaveableStateHolderNavEntryDecorator(), - rememberViewModelStoreNavEntryDecorator(), - ), - entryProvider = { key -> - key as SetupDestination - NavEntry(key) { - when (key) { - SetupDestination.Loading -> { - Box( - modifier = Modifier.size(200.dp), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator( - color = MaterialTheme.colorScheme.border, - modifier = Modifier.align(Alignment.Center), - ) - } - } - - SetupDestination.ServerList -> { - SwitchServerContent(Modifier.fillMaxSize()) - } - - is SetupDestination.UserList -> { - SwitchUserContent( - server = key.server, - Modifier.fillMaxSize(), - ) - } - - is SetupDestination.AppContent -> { - LaunchedEffect(Unit) { - backdropService.clearBackdrop() - } - val current = key.current - var showContent by remember { - mutableStateOf(true) - } - LifecycleEventEffect(Lifecycle.Event.ON_STOP) { - if (!appPreferences.signInAutomatically) { - showContent = false - } - } - - if (showContent) { - ApplicationContent( - user = current.user, - server = current.server, - navigationManager = navigationManager, - preferences = preferences, - modifier = Modifier.fillMaxSize(), - ) - } else { + val interfaceCustomization = + remember(userPreferences.appPreferences) { InterfaceCustomization(userPreferences.appPreferences) } + CompositionLocalProvider(LocalInterfaceCustomization provides interfaceCustomization) { + NavDisplay( + backStack = backStack, + onBack = { backStack.removeLastOrNull() }, + entryDecorators = + listOf( + rememberSaveableStateHolderNavEntryDecorator(), + rememberViewModelStoreNavEntryDecorator(), + ), + entryProvider = { key -> + key as SetupDestination + NavEntry(key) { + when (key) { + SetupDestination.Loading -> { Box( modifier = Modifier.size(200.dp), contentAlignment = Alignment.Center, @@ -123,18 +84,80 @@ fun MainContent( ) } } + + SetupDestination.ServerList -> { + SwitchServerContent(Modifier.fillMaxSize()) + } + + is SetupDestination.UserList -> { + SwitchUserContent( + server = key.server, + Modifier.fillMaxSize(), + ) + } + + is SetupDestination.AppContent -> { + LaunchedEffect(Unit) { + backdropService.clearBackdrop() + } + val current = key.current + var showContent by remember { + mutableStateOf(true) + } + LifecycleEventEffect(Lifecycle.Event.ON_STOP) { + if (!userPreferences.appPreferences.signInAutomatically) { + showContent = false + } + } + + if (showContent) { + WolfskinApp( + user = current.user, + server = current.server, + navigationManager = navigationManager, + preferences = preferences, + modifier = Modifier.fillMaxSize(), + ) + } else { + Box( + modifier = Modifier.size(200.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator( + color = MaterialTheme.colorScheme.border, + modifier = Modifier.align(Alignment.Center), + ) + } + } + } } } + }, + ) + val screenSaverState by screensaverService.state.collectAsState() + if (screenSaverState.enabled || screenSaverState.enabledTemp) { + AnimatedVisibility( + visible = screenSaverState.show, + enter = ScreensaverService.enterAnimation, + exit = ScreensaverService.exitAnimation, + modifier = Modifier.fillMaxSize(), + ) { + AppScreensaver(userPreferences.appPreferences, Modifier.fillMaxSize()) } - }, - ) - val screenSaverState by screensaverService.state.collectAsState() - if (screenSaverState.enabled || screenSaverState.enabledTemp) { + } AnimatedVisibility( - screenSaverState.show, - Modifier.fillMaxSize(), + visible = screenSaverState.showDim || (screenSaverState.dimEnabled && screenSaverState.show), + enter = ScreensaverService.enterAnimation, + exit = ScreensaverService.exitAnimation, + modifier = Modifier.fillMaxSize(), ) { - AppScreensaver(appPreferences, Modifier.fillMaxSize()) + val alpha = + userPreferences.appPreferences.interfacePreferences.screensaverPreference.dimPercent / 100f + Box( + Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = alpha)), + ) } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt b/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt index ceb7264e..6ec9d4b4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt @@ -9,12 +9,13 @@ import androidx.compose.runtime.ExperimentalComposeRuntimeApi import androidx.hilt.work.HiltWorkerFactory import androidx.work.Configuration import dagger.hilt.android.HiltAndroidApp -import okhttp3.OkHttp import org.acra.ACRA import org.acra.ReportField import org.acra.config.dialog import org.acra.data.StringFormat import org.acra.ktx.initAcra +import org.jellyfin.sdk.Jellyfin +import org.jellyfin.sdk.model.ServerVersion import timber.log.Timber import javax.inject.Inject @@ -70,7 +71,6 @@ class WholphinApplication : // .build(), // ) } - OkHttp.initialize(this) initAcra { buildConfigClass = BuildConfig::class.java reportFormat = StringFormat.JSON @@ -120,5 +120,9 @@ class WholphinApplication : companion object { lateinit var instance: WholphinApplication private set + + val minimumServerVersion: ServerVersion = Jellyfin.minimumVersion + + val newMinimumServerVersion: ServerVersion = ServerVersion(12, 0, 0) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/WholphinDreamService.kt b/app/src/main/java/com/github/damontecres/wholphin/WholphinDreamService.kt index 502444e1..3bc17f3c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/WholphinDreamService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/WholphinDreamService.kt @@ -1,15 +1,17 @@ package com.github.damontecres.wholphin import android.service.dreams.DreamService +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.ComposeView import androidx.datastore.core.DataStore import androidx.lifecycle.Lifecycle @@ -20,6 +22,7 @@ import androidx.savedstate.SavedStateRegistry import androidx.savedstate.SavedStateRegistryController import androidx.savedstate.SavedStateRegistryOwner import androidx.savedstate.setViewTreeSavedStateRegistryOwner +import com.github.damontecres.wholphin.data.RestoredSession import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.services.ScreensaverService @@ -31,9 +34,7 @@ import com.github.damontecres.wholphin.ui.theme.WholphinTheme import com.github.damontecres.wholphin.ui.util.ProvideLocalClock import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.first import okhttp3.OkHttpClient -import org.jellyfin.sdk.model.serializer.toUUIDOrNull import timber.log.Timber import javax.inject.Inject import kotlin.time.Duration.Companion.milliseconds @@ -73,8 +74,10 @@ class WholphinDreamService : lifecycleRegistry.currentState = Lifecycle.State.CREATED lifecycleScope.launchDefault { if (serverRepository.current.value == null) { - val prefs = preferencesDataStore.data.first() - serverRepository.restoreSession(prefs.currentServerId.toUUIDOrNull(), prefs.currentUserId.toUUIDOrNull()) + val result = serverRepository.restoreLastSession() + if (result !is RestoredSession.Success) { + Timber.w("Could not restore user session") + } } } } @@ -88,7 +91,7 @@ class WholphinDreamService : setViewTreeLifecycleOwner(this@WholphinDreamService) setViewTreeSavedStateRegistryOwner(this@WholphinDreamService) setContent { - val user by serverRepository.currentUser.observeAsState() + val user by serverRepository.currentUserFlow.collectAsState(null) if (user != null) { var prefs by remember { mutableStateOf<AppPreferences?>(null) } LaunchedEffect(Unit) { @@ -105,13 +108,23 @@ class WholphinDreamService : ProvideLocalClock { val screensaverPrefs = prefs.interfacePreferences.screensaverPreference val currentItem by itemFlow.collectAsState(null) - AppScreensaverContent( - currentItem = currentItem, - showClock = screensaverPrefs.showClock, - duration = screensaverPrefs.duration.milliseconds, - animate = screensaverPrefs.animate, - modifier = Modifier.fillMaxSize(), - ) + Box(Modifier.fillMaxSize()) { + AppScreensaverContent( + currentItem = currentItem, + showClock = screensaverPrefs.showClock, + duration = screensaverPrefs.duration.milliseconds, + animate = screensaverPrefs.animate, + modifier = Modifier.fillMaxSize(), + ) + if (screensaverPrefs.dimEnabled) { + val alpha = screensaverPrefs.dimPercent / 100f + Box( + Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = alpha)), + ) + } + } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/WolfskinDefaults.kt b/app/src/main/java/com/github/damontecres/wholphin/WolfskinDefaults.kt new file mode 100644 index 00000000..aa023e97 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/WolfskinDefaults.kt @@ -0,0 +1,9 @@ +package com.github.damontecres.wholphin + +/** Wolfskin-specific defaults kept in one place to keep upstream diffs small. */ +object WolfskinDefaults { + const val SEERR_URL = "https://seerr.justinvisser.org" +} + +/** Returns [url] when it has content, otherwise the Wolfskin Seerr URL so the user can still edit it. */ +fun seerrUrlPrefill(url: String = ""): String = if (url.isBlank()) WolfskinDefaults.SEERR_URL else url diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt b/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt index b3f33bed..45af2d65 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt @@ -16,14 +16,24 @@ import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo import com.github.damontecres.wholphin.data.model.NavDrawerPinnedItem import com.github.damontecres.wholphin.data.model.PlaybackEffect import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice +import com.github.damontecres.wholphin.data.model.RememberedTab import com.github.damontecres.wholphin.data.model.SeerrServer import com.github.damontecres.wholphin.data.model.SeerrUser +import com.github.damontecres.wholphin.data.model.SeriesTrackChoice import com.github.damontecres.wholphin.ui.components.ViewOptions +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.json.Json import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.SortOrder import org.jellyfin.sdk.model.serializer.toUUID import timber.log.Timber +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter import java.util.UUID @Database( @@ -38,9 +48,10 @@ import java.util.UUID ItemTrackModification::class, SeerrServer::class, SeerrUser::class, - + RememberedTab::class, + SeriesTrackChoice::class, ], - version = 31, + version = 41, exportSchema = true, autoMigrations = [ AutoMigration(3, 4), @@ -55,6 +66,12 @@ import java.util.UUID AutoMigration(12, 20), AutoMigration(20, 30), AutoMigration(30, 31), + AutoMigration(31, 32), + AutoMigration(32, 33), + AutoMigration(33, 34), + AutoMigration(34, 35), + AutoMigration(35, 40), + AutoMigration(40, 41), ], ) @TypeConverters(Converters::class) @@ -72,9 +89,18 @@ abstract class AppDatabase : RoomDatabase() { abstract fun seerrServerDao(): SeerrServerDao abstract fun playbackEffectDao(): PlaybackEffectDao + + abstract fun rememberedTabDao(): RememberedTabDao + + abstract fun seriesTrackChoiceDao(): SeriesTrackChoiceDao } class Converters { + private val json = + Json { + ignoreUnknownKeys = true + } + @TypeConverter fun convertToString(id: UUID): String = id.toString().replace("-", "") @@ -94,28 +120,49 @@ class Converters { fun convertSortOrder(sort: String): SortOrder? = SortOrder.fromNameOrNull(sort) @TypeConverter - fun convertGetItemsFilter(filter: GetItemsFilter): String = Json.encodeToString(filter) + fun convertGetItemsFilter(filter: GetItemsFilter): String = json.encodeToString(filter) @TypeConverter fun convertGetItemsFilter(filter: String): GetItemsFilter = try { - Json.decodeFromString(filter) + json.decodeFromString(filter) } catch (ex: Exception) { Timber.e(ex, "Error parsing filter") GetItemsFilter() } @TypeConverter - fun convertViewOptions(viewOptions: ViewOptions?): String? = viewOptions?.let { Json.encodeToString(viewOptions) } + fun convertViewOptions(viewOptions: ViewOptions?): String? = viewOptions?.let { json.encodeToString(viewOptions) } @TypeConverter fun convertViewOptions(viewOptions: String?): ViewOptions? = try { - viewOptions?.let { Json.decodeFromString(viewOptions) } + viewOptions?.let { json.decodeFromString(viewOptions) } } catch (ex: Exception) { Timber.e(ex, "Error parsing view options") null } + + @TypeConverter + fun convertToString(dateTime: ZonedDateTime): String = DateTimeFormatter.ISO_ZONED_DATE_TIME.format(dateTime) + + @TypeConverter + fun convertToLocalDateTime(dateTime: String): ZonedDateTime = ZonedDateTime.parse(dateTime, DateTimeFormatter.ISO_ZONED_DATE_TIME) +} + +class ZonedDateTimeSerializer : KSerializer<ZonedDateTime> { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor(ZonedDateTime::class.qualifiedName!!, PrimitiveKind.STRING) + + override fun deserialize(decoder: Decoder): ZonedDateTime = + ZonedDateTime.parse(decoder.decodeString(), DateTimeFormatter.ISO_ZONED_DATE_TIME) + + override fun serialize( + encoder: Encoder, + value: ZonedDateTime, + ) { + encoder.encodeString(DateTimeFormatter.ISO_ZONED_DATE_TIME.format(value)) + } } object Migrations { diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/ExtrasItem.kt b/app/src/main/java/com/github/damontecres/wholphin/data/ExtrasItem.kt index ce73e6eb..490103b8 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/ExtrasItem.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/ExtrasItem.kt @@ -4,9 +4,16 @@ import androidx.annotation.PluralsRes import androidx.annotation.StringRes import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.ui.AspectRatio +import com.github.damontecres.wholphin.ui.components.ViewOptions import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.util.ResStringProvider +import com.github.damontecres.wholphin.util.GetItemsRequestHandler import org.jellyfin.sdk.model.UUID import org.jellyfin.sdk.model.api.ExtraType +import org.jellyfin.sdk.model.api.ItemSortBy +import org.jellyfin.sdk.model.api.SortOrder +import org.jellyfin.sdk.model.api.request.GetItemsRequest /** * Represents "extras" for media such as behind-the-scenes or deleted scenes @@ -20,6 +27,7 @@ sealed interface ExtrasItem { val subtitle: String? val isPlayed: Boolean val playedPercentage: Double + val aspectRatio: Float? /** * Represents multiple extras of the same type @@ -32,9 +40,26 @@ sealed interface ExtrasItem { override val title: String, override val subtitle: String, override val isPlayed: Boolean, + override val aspectRatio: Float?, ) : ExtrasItem { override val destination: Destination = - Destination.ItemGrid(null, type.stringRes, items.map { it.id }) + Destination.ItemGrid( + title = ResStringProvider(type.stringRes), + request = + GetItemsRequest( + ids = items.map { it.id }, + sortBy = listOf(ItemSortBy.SORT_NAME), + sortOrder = listOf(SortOrder.ASCENDING), + ), + requestHandler = GetItemsRequestHandler, + initialPosition = 0, + viewOptions = + ViewOptions( + columns = 3, + spacing = 24, + aspectRatio = AspectRatio.WIDE, + ), + ) override val playedPercentage get() = -1.0 @@ -59,6 +84,8 @@ sealed interface ExtrasItem { get() = item.played override val playedPercentage get() = item.data.userData?.playedPercentage ?: 0.0 + override val aspectRatio: Float? + get() = item.aspectRatio } } @@ -69,18 +96,18 @@ sealed interface ExtrasItem { val ExtraType.stringRes: Int get() = when (this) { - ExtraType.UNKNOWN -> R.string.other_extras - ExtraType.CLIP -> R.string.clips - ExtraType.TRAILER -> R.string.trailers - ExtraType.BEHIND_THE_SCENES -> R.string.behind_the_scenes - ExtraType.DELETED_SCENE -> R.string.deleted_scenes - ExtraType.INTERVIEW -> R.string.interviews - ExtraType.SCENE -> R.string.scenes - ExtraType.SAMPLE -> R.string.samples - ExtraType.THEME_SONG -> R.string.theme_songs - ExtraType.THEME_VIDEO -> R.string.theme_videos - ExtraType.FEATURETTE -> R.string.featurettes - ExtraType.SHORT -> R.string.shorts + ExtraType.UNKNOWN -> R.string.other_extras_title + ExtraType.CLIP -> R.string.clips_title + ExtraType.TRAILER -> R.string.trailers_title + ExtraType.BEHIND_THE_SCENES -> R.string.behind_the_scenes_title + ExtraType.DELETED_SCENE -> R.string.deleted_scenes_title + ExtraType.INTERVIEW -> R.string.interviews_title + ExtraType.SCENE -> R.string.scenes_title + ExtraType.SAMPLE -> R.string.samples_title + ExtraType.THEME_SONG -> R.string.theme_songs_title + ExtraType.THEME_VIDEO -> R.string.theme_videos_title + ExtraType.FEATURETTE -> R.string.featurettes_title + ExtraType.SHORT -> R.string.shorts_title } /** diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackRepository.kt index 3f8dc198..702ecfdb 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackRepository.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackRepository.kt @@ -3,11 +3,14 @@ package com.github.damontecres.wholphin.data import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.data.model.ItemTrackModification -import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice +import com.github.damontecres.wholphin.data.model.RelativeMediaStream +import com.github.damontecres.wholphin.data.model.SeriesTrackChoice +import com.github.damontecres.wholphin.data.model.SeriesTrackChoiceType import com.github.damontecres.wholphin.data.model.TrackIndex import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.services.StreamChoiceResult import com.github.damontecres.wholphin.services.StreamChoiceService -import kotlinx.coroutines.Dispatchers +import com.github.damontecres.wholphin.util.WholphinDispatchers import kotlinx.coroutines.withContext import org.jellyfin.sdk.model.api.MediaSourceInfo import org.jellyfin.sdk.model.api.MediaStream @@ -19,65 +22,105 @@ import javax.inject.Inject import javax.inject.Singleton import kotlin.time.Duration +/** + * Wrapper for [ItemPlaybackDao] to save [ItemPlayback] objects and also convert them into [ChosenStreams] + */ @Singleton class ItemPlaybackRepository @Inject constructor( val serverRepository: ServerRepository, val itemPlaybackDao: ItemPlaybackDao, - private val playbackLanguageChoiceDao: PlaybackLanguageChoiceDao, + private val seriesTrackChoiceDao: SeriesTrackChoiceDao, private val streamChoiceService: StreamChoiceService, ) { + /** + * Get the chosen source & stream for the given item + * + * This will be either the explicit choice from the user ([ItemPlayback]) or the default choices + * + * @see getChosenItemFromPlayback + */ suspend fun getSelectedTracks( itemId: UUID, item: BaseItem, prefs: UserPreferences, ): ChosenStreams? = - serverRepository.currentUser.value?.let { user -> + serverRepository.currentUser?.let { user -> val itemPlayback = itemPlaybackDao.getItem(user = user, itemId = itemId) - val plc = streamChoiceService.getPlaybackLanguageChoice(item.data) - Timber.v("For ${item.id}: itemPlayback=${itemPlayback != null}, plc=${plc != null}") - return getChosenItemFromPlayback(item, itemPlayback, plc, prefs) + return getChosenItemFromPlayback(item, itemPlayback, prefs) } + suspend fun getChosenItemFromPlayback( + item: BaseItem, + itemPlayback: ItemPlayback?, + prefs: UserPreferences, + ): ChosenStreams? { + val audioStc = + streamChoiceService.getSeriesTrackChoices( + item.data, + SeriesTrackChoiceType.AUDIO, + ) + val subtitleStc = + streamChoiceService.getSeriesTrackChoices( + item.data, + SeriesTrackChoiceType.SUBTITLE, + ) + return getChosenItemFromPlayback(item, itemPlayback, audioStc, subtitleStc, prefs) + } + + /** + * Get the chosen source & stream for the given item + * + * This will be either the explicit choice from the provided [ItemPlayback] & [SeriesTrackChoice] or the default choices if they are null + */ fun getChosenItemFromPlayback( item: BaseItem, itemPlayback: ItemPlayback?, - plc: PlaybackLanguageChoice?, + audioStc: List<SeriesTrackChoice>, + subtitleStc: List<SeriesTrackChoice>, prefs: UserPreferences, ): ChosenStreams? { + Timber.v( + "For %s: itemPlayback=%s, audioStc=%s, subtitleStc=%s", + item.id, + itemPlayback != null, + audioStc.isNotEmpty(), + subtitleStc.isNotEmpty(), + ) val source = item.data.mediaSources?.firstOrNull { it.id?.toUUIDOrNull() == itemPlayback?.sourceId } ?: streamChoiceService.chooseSource(item.data.mediaSources.orEmpty()) if (source != null) { - val audioStream = + val audioResult = streamChoiceService.chooseAudioStream( candidates = source.mediaStreams ?.filter { it.type == MediaStreamType.AUDIO } .orEmpty(), itemPlayback = itemPlayback, - playbackLanguageChoice = plc, + stc = audioStc, prefs = prefs, ) val subtitleStream = streamChoiceService.chooseSubtitleStream( - audioStreamLang = audioStream?.language, + audioStreamLang = audioResult.stream?.language, candidates = source.mediaStreams ?.filter { it.type == MediaStreamType.SUBTITLE } .orEmpty(), itemPlayback = itemPlayback, - playbackLanguageChoice = plc, + stc = subtitleStc, prefs = prefs, ) return ChosenStreams( itemPlayback = itemPlayback, - plc = plc, + audioStc = audioStc, + subtitleStc = subtitleStc, itemId = item.id, source = source, videoStream = source.mediaStreams?.firstOrNull { it.type == MediaStreamType.VIDEO }, - audioStream = audioStream, + audioStream = audioResult, subtitleStream = subtitleStream, subtitlesDisabled = itemPlayback?.subtitleIndex == TrackIndex.DISABLED, ) @@ -86,12 +129,15 @@ class ItemPlaybackRepository } } + /** + * Save the choice for a specific media source for an item + */ suspend fun savePlayVersion( itemId: UUID, sourceId: UUID, ): ItemPlayback? = - withContext(Dispatchers.IO) { - serverRepository.currentUser.value?.let { user -> + withContext(WholphinDispatchers.IO) { + serverRepository.currentUser?.let { user -> val itemPlayback = ItemPlayback( userId = user.rowId, @@ -103,6 +149,11 @@ class ItemPlaybackRepository } } + /** + * Save the chosen track (index & type) for an item + * + * Additionally, if the item is part of a TV Series, store the language as the preferred one for this series + */ suspend fun saveTrackSelection( item: BaseItem, itemPlayback: ItemPlayback?, @@ -133,26 +184,36 @@ class ItemPlaybackRepository Timber.v("Saving track selection %s", toSave) toSave = saveItemPlayback(toSave) val seriesId = item.data.seriesId - if (seriesId != null && (trackIndex >= 0 || trackIndex == TrackIndex.DISABLED)) { + if (seriesId != null || item.data.parentId != null) { if (type == MediaStreamType.AUDIO) { - val stream = source.mediaStreams?.first { it.index == trackIndex } + val stream = + RelativeMediaStream.from(trackIndex, source.mediaStreams.orEmpty()) if (stream?.language != null) { - streamChoiceService.updateAudio(item.data, stream.language!!) + streamChoiceService.saveSeriesTrackChoice( + item.data, + stream.type, + stream, + ) } } else if (type == MediaStreamType.SUBTITLE) { if (trackIndex == TrackIndex.DISABLED) { - streamChoiceService.updateSubtitles( + streamChoiceService.saveDisabledSeriesTrackChoice( item.data, - subtitleLang = null, - subtitlesDisabled = true, + MediaStreamType.SUBTITLE, + ) + } else if (trackIndex == TrackIndex.ONLY_FORCED) { + streamChoiceService.saveOnlyForcedSeriesTrackChoice( + item.data, + MediaStreamType.SUBTITLE, ) } else { - val stream = source.mediaStreams?.firstOrNull { it.index == trackIndex } + val stream = + RelativeMediaStream.from(trackIndex, source.mediaStreams.orEmpty()) if (stream?.language != null) { - streamChoiceService.updateSubtitles( + streamChoiceService.saveSeriesTrackChoice( item.data, - stream.language!!, - subtitlesDisabled = false, + stream.type, + stream, ) } } @@ -168,7 +229,7 @@ class ItemPlaybackRepository val toSave = if (itemPlayback.userId < 0) { val userRowId = - serverRepository.currentUser.value + serverRepository.currentUser ?.rowId ?.takeIf { it >= 0 } ?: throw IllegalStateException("Trying to save an ItemPlayback without a user, but there is no current user") @@ -180,20 +241,26 @@ class ItemPlaybackRepository return toSave.copy(rowId = id) } + /** + * Wrapper for [ItemPlaybackDao.getTrackModifications] to get the [ItemTrackModification] for an item, if any + */ suspend fun getTrackModifications( itemId: UUID, trackIndex: Int, ): ItemTrackModification? = - serverRepository.currentUser.value?.rowId?.let { userId -> + serverRepository.currentUser?.rowId?.let { userId -> itemPlaybackDao.getTrackModifications(userId, itemId, trackIndex) } + /** + * Create and save a [ItemTrackModification] via [ItemPlaybackDao.saveItem] + */ suspend fun saveTrackModifications( itemId: UUID, trackIndex: Int, delay: Duration, ) { - serverRepository.currentUser.value?.rowId?.let { userId -> + serverRepository.currentUser?.rowId?.let { userId -> Timber.v("Saving track mod item=%s, track=%s, delay=%s", itemId, trackIndex, delay) itemPlaybackDao.saveItem( ItemTrackModification( @@ -206,11 +273,15 @@ class ItemPlaybackRepository } } + /** + * Clear the user's source/stream choices for an item + */ suspend fun deleteChosenStreams(chosenStreams: ChosenStreams?) { Timber.d("deleteChosenStreams: %s", chosenStreams) - chosenStreams?.plc?.let { - Timber.d("Deleting %s", it) - playbackLanguageChoiceDao.delete(it) + val stc = chosenStreams?.audioStc.orEmpty() + chosenStreams?.subtitleStc.orEmpty() + if (stc.isNotEmpty()) { + Timber.d("Deleting %s", stc) + seriesTrackChoiceDao.delete(stc) } chosenStreams?.itemPlayback?.let { Timber.d("Deleting %s", it) @@ -219,13 +290,17 @@ class ItemPlaybackRepository } } +/** + * Info about which media source and streams the user/app has chosen for playback + */ data class ChosenStreams( val itemPlayback: ItemPlayback?, - val plc: PlaybackLanguageChoice?, + val audioStc: List<SeriesTrackChoice>, + val subtitleStc: List<SeriesTrackChoice>, val itemId: UUID, val source: MediaSourceInfo, val videoStream: MediaStream?, - val audioStream: MediaStream?, - val subtitleStream: MediaStream?, + val audioStream: StreamChoiceResult, + val subtitleStream: StreamChoiceResult, val subtitlesDisabled: Boolean, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/JellyfinServerDao.kt b/app/src/main/java/com/github/damontecres/wholphin/data/JellyfinServerDao.kt index 3c2250fb..f72eb3b7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/JellyfinServerDao.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/JellyfinServerDao.kt @@ -9,6 +9,7 @@ import androidx.room.Update import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.data.model.JellyfinServerUsers import com.github.damontecres.wholphin.data.model.JellyfinUser +import kotlinx.coroutines.flow.Flow import java.util.UUID @Dao @@ -56,6 +57,12 @@ interface JellyfinServerDao { userId: UUID, ): JellyfinUser? + @Query("SELECT * FROM users WHERE serverId = :serverId AND id = :userId") + fun getUserFlow( + serverId: UUID, + userId: UUID, + ): Flow<JellyfinUser?> + @Query("DELETE FROM servers WHERE id = :serverId") fun deleteServer(serverId: UUID) 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 new file mode 100644 index 00000000..5139fb73 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/data/MostRecentServer.kt @@ -0,0 +1,175 @@ +package com.github.damontecres.wholphin.data + +import android.content.Context +import android.content.SharedPreferences +import androidx.core.content.edit +import com.github.damontecres.wholphin.data.MostRecentServer.None +import com.github.damontecres.wholphin.data.MostRecentServer.Server +import com.github.damontecres.wholphin.data.MostRecentServer.ServerAndUser +import com.github.damontecres.wholphin.data.model.JellyfinServer +import com.github.damontecres.wholphin.services.SetupDestination +import com.github.damontecres.wholphin.ui.isNotNullOrBlank +import com.github.damontecres.wholphin.ui.toServerString +import org.jellyfin.sdk.model.serializer.toUUIDOrNull +import java.util.UUID + +/** + * Represents the status of the most recently used server + * + * This can be saved/retrieved from a [MostRecentServerProvider] + */ +sealed interface MostRecentServer { + /** + * There is no recently used server + */ + data object None : MostRecentServer + + /** + * The most recently used server without a recent user + */ + data class Server( + val serverId: UUID, + val serverUrl: String, + ) : MostRecentServer + + /** + * Most recently used server and user + */ + data class ServerAndUser( + val serverId: UUID, + val serverUrl: String, + val userId: UUID, + val accessToken: String, + ) : MostRecentServer { + override fun toString(): String = "ServerAndUser(serverId=$serverId, serverUrl=$serverUrl, userId=$userId)" + } + + companion object { + fun from(current: CurrentUser?): MostRecentServer = + if (current != null && current.user.accessToken.isNotNullOrBlank()) { + ServerAndUser(current.server.id, current.server.url, current.user.id, current.user.accessToken) + } else if (current != null) { + Server(current.server.id, current.server.url) + } else { + None + } + } +} + +interface MostRecentServerProvider { + /** + * Get the [MostRecentServer] from this provider + */ + fun get(): MostRecentServer + + /** + * Save the [CurrentUser] as the most recent server + */ + suspend fun save(current: CurrentUser) + + /** + * Clear the most recent server and user + */ + suspend fun clear() + + /** + * CLear the most recent user, leaving the server + */ + suspend fun clearUser() +} + +/** + * A [MostRecentServerProvider] that stores in [SharedPreferences] + */ +class MostRecentServerSharedPreferences( + context: Context, +) : MostRecentServerProvider { + private val prefs = getServerSharedPreferences(context) + + override fun get(): MostRecentServer { + val serverId = prefs.getString(SERVER_ID_KEY, null)?.toUUIDOrNull() + val serverUrl = prefs.getString(SERVER_URL_KEY, null) + val userId = prefs.getString(USER_ID_KEY, null)?.toUUIDOrNull() + val accessToken = prefs.getString(USER_TOKEN_KEY, null) + return if (serverId != null && serverUrl.isNotNullOrBlank() && userId != null && accessToken.isNotNullOrBlank()) { + ServerAndUser(serverId, serverUrl, userId, accessToken) + } else if (serverId != null && serverUrl.isNotNullOrBlank()) { + Server(serverId, serverUrl) + } else { + None + } + } + + override suspend fun save(current: CurrentUser) { + prefs.edit(true) { + putString(SERVER_ID_KEY, current.server.id.toServerString()) + putString(SERVER_URL_KEY, current.server.url) + putString(USER_ID_KEY, current.user.id.toServerString()) + putString(USER_TOKEN_KEY, current.user.accessToken) + } + } + + override suspend fun clear() { + prefs.edit(true) { + remove(SERVER_ID_KEY) + remove(SERVER_URL_KEY) + remove(USER_ID_KEY) + remove(USER_TOKEN_KEY) + } + } + + override suspend fun clearUser() { + prefs.edit(true) { + remove(USER_ID_KEY) + remove(USER_TOKEN_KEY) + } + } + + private fun getServerSharedPreferences(context: Context): SharedPreferences = + context.getSharedPreferences( + "${context.packageName}_server", + Context.MODE_PRIVATE, + ) + + companion object { + private const val SERVER_ID_KEY = "current.server.id" + private const val SERVER_URL_KEY = "current.server.url" + private const val USER_ID_KEY = "current.user.id" + private const val USER_TOKEN_KEY = "current.user.accessToken" + } +} + +/** + * The result of restoring a session + */ +sealed interface RestoredSession { + val destination: SetupDestination + + /** + * Session could not be restored + */ + data object None : RestoredSession { + override val destination: SetupDestination + get() = SetupDestination.ServerList + } + + /** + * Only the server should be restored, so a user still need to be chosen + */ + data class ServerOnly( + val server: JellyfinServer, + ) : RestoredSession { + override val destination: SetupDestination + get() = SetupDestination.UserList(server) + } + + /** + * Session was restored successfully + */ + data class Success( + val currentUser: CurrentUser, + ) : RestoredSession { + override val destination: SetupDestination + get() = SetupDestination.AppContent(currentUser) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/PlaybackLanguageChoiceDao.kt b/app/src/main/java/com/github/damontecres/wholphin/data/PlaybackLanguageChoiceDao.kt index 5f8c16e6..06835381 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/PlaybackLanguageChoiceDao.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/PlaybackLanguageChoiceDao.kt @@ -8,17 +8,27 @@ import androidx.room.Query import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice import java.util.UUID +@Deprecated("Use SeriesTrackChoiceDao") @Dao interface PlaybackLanguageChoiceDao { + @Deprecated("Use SeriesTrackChoiceDao") @Query("SELECT * FROM PlaybackLanguageChoice WHERE userId=:userId AND seriesId=:seriesId") suspend fun get( userId: Int, seriesId: UUID, ): PlaybackLanguageChoice? + @Deprecated("Use SeriesTrackChoiceDao") @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun save(plc: PlaybackLanguageChoice): Long + @Deprecated("Use SeriesTrackChoiceDao") @Delete fun delete(plc: PlaybackLanguageChoice) + + @Query("SELECT * FROM PlaybackLanguageChoice ORDER BY userId, seriesId LIMIT :limit OFFSET :offset") + suspend fun getAll( + limit: Int, + offset: Int, + ): List<PlaybackLanguageChoice> } diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/RememberedTabDao.kt b/app/src/main/java/com/github/damontecres/wholphin/data/RememberedTabDao.kt new file mode 100644 index 00000000..13398228 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/data/RememberedTabDao.kt @@ -0,0 +1,22 @@ +package com.github.damontecres.wholphin.data + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.github.damontecres.wholphin.data.model.RememberedTab + +@Dao +interface RememberedTabDao { + @Query("SELECT * from RememberedTab WHERE userId=:userId AND itemId=:itemId") + suspend fun getRememberedTab( + userId: Int, + itemId: String, + ): RememberedTab? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun save(item: RememberedTab): Long + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun saveAll(items: List<RememberedTab>) +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/SeriesTrackChoiceDao.kt b/app/src/main/java/com/github/damontecres/wholphin/data/SeriesTrackChoiceDao.kt new file mode 100644 index 00000000..b3126723 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/data/SeriesTrackChoiceDao.kt @@ -0,0 +1,52 @@ +package com.github.damontecres.wholphin.data + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.github.damontecres.wholphin.data.model.SeriesTrackChoice +import com.github.damontecres.wholphin.data.model.SeriesTrackChoiceType +import java.util.UUID + +@Dao +interface SeriesTrackChoiceDao { + @Query( + "SELECT * from SeriesTrackChoice WHERE userId=:userId AND type=:type AND (parentId=:seasonId OR parentId=:seriesId) ORDER BY parentId=:seasonId DESC", + ) + suspend fun get( + userId: Int, + seasonId: UUID, + seriesId: UUID, + type: SeriesTrackChoiceType, + ): List<SeriesTrackChoice> + + @Query("SELECT * from SeriesTrackChoice WHERE userId=:userId AND type=:type AND parentId=:seasonId") + suspend fun getBySeasonId( + userId: Int, + seasonId: UUID, + type: SeriesTrackChoiceType, + ): List<SeriesTrackChoice> + + @Query("SELECT * from SeriesTrackChoice WHERE userId=:userId AND type=:type AND parentId=:seriesId") + suspend fun getBySeriesId( + userId: Int, + seriesId: UUID, + type: SeriesTrackChoiceType, + ): List<SeriesTrackChoice> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun save(stc: SeriesTrackChoice): Long + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun save(stc: List<SeriesTrackChoice>): List<Long> + + @Delete + suspend fun delete(stc: List<SeriesTrackChoice>) + + @Query("SELECT * from SeriesTrackChoice WHERE userId=:userId") + suspend fun getAll(userId: Int): List<SeriesTrackChoice> + + @Query("SELECT count(*) from SeriesTrackChoice") + suspend fun count(): Int +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt index 7e32f2e5..2c24c8f3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt @@ -1,21 +1,22 @@ package com.github.damontecres.wholphin.data import android.content.Context -import android.content.SharedPreferences -import androidx.core.content.edit -import androidx.datastore.core.DataStore -import androidx.lifecycle.LiveData -import androidx.lifecycle.map import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.data.model.JellyfinUser -import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.data.model.ServerUserConfig import com.github.damontecres.wholphin.services.hilt.IoDispatcher -import com.github.damontecres.wholphin.ui.setValueOnMain -import com.github.damontecres.wholphin.ui.toServerString -import com.github.damontecres.wholphin.util.EqualityMutableLiveData +import com.github.damontecres.wholphin.util.WholphinDispatchers import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable import org.jellyfin.sdk.Jellyfin @@ -27,6 +28,7 @@ import org.jellyfin.sdk.model.api.AuthenticationResult import org.jellyfin.sdk.model.api.UserDto import org.jellyfin.sdk.model.serializer.toUUIDOrNull import timber.log.Timber +import java.time.ZonedDateTime import java.util.UUID import javax.inject.Inject import javax.inject.Singleton @@ -42,17 +44,35 @@ class ServerRepository val jellyfin: Jellyfin, val serverDao: JellyfinServerDao, val apiClient: ApiClient, - val userPreferencesDataStore: DataStore<AppPreferences>, @param:IoDispatcher private val ioDispatcher: CoroutineDispatcher, + private val mostRecentServerProvider: MostRecentServerProvider, ) { - private var _current = EqualityMutableLiveData<CurrentUser?>(null) - val current: LiveData<CurrentUser?> = _current + private var _current = MutableStateFlow<CurrentUser?>(null) + val current: StateFlow<CurrentUser?> = _current - private var _currentUserDto = EqualityMutableLiveData<UserDto?>(null) - val currentUserDto: LiveData<UserDto?> = _currentUserDto + private var _currentUserDto = MutableStateFlow<ServerUserConfig?>(null) + val currentUserDto: ServerUserConfig? get() = _currentUserDto.value + val currentUserDtoFlow: StateFlow<ServerUserConfig?> get() = _currentUserDto - val currentServer: LiveData<JellyfinServer?> get() = _current.map { it?.server } - val currentUser: LiveData<JellyfinUser?> get() = _current.map { it?.user } + val currentServer: JellyfinServer? get() = _current.value?.server + val currentServerFlow: Flow<JellyfinServer?> get() = _current.map { it?.server } + val currentUser: JellyfinUser? get() = _current.value?.user + + @OptIn(ExperimentalCoroutinesApi::class) + val currentUserFlow: Flow<JellyfinUser?> + get() = + _current + .flatMapLatest { user -> + if (user?.user != null) { + serverDao + .getUserFlow(user.user.serverId, user.user.id) + .distinctUntilChanged() + } else { + flow { emit(null) } + } + } + + val serverPluginInstalled = MutableStateFlow<Boolean>(false) /** * Adds a server to the app database and updated the [ApiClient] to the server's URL @@ -64,11 +84,12 @@ class ServerRepository serverDao.addOrUpdateServer(server) } apiClient.update(baseUrl = server.url, accessToken = null) - _current.setValueOnMain(null) + _current.value = null + mostRecentServerProvider.clearUser() } /** - * Saves the server & User to the app database and updates the [ApiClient] to use this server & user + * Saves the server & user to the app database and updates the [ApiClient] to use this server & user */ suspend fun changeUser( server: JellyfinServer, @@ -96,70 +117,101 @@ class ServerRepository ) serverDao.addOrUpdateServer(updatedServer) updatedUser = serverDao.addOrUpdateUser(updatedUser) - userPreferencesDataStore.updateData { - it - .toBuilder() - .apply { - currentServerId = updatedServer.id.toServerString() - currentUserId = updatedUser.id.toServerString() - }.build() - } + val currentUser = CurrentUser(updatedServer, updatedUser) - withContext(Dispatchers.Main) { - _current.value = currentUser - _currentUserDto.value = userDto - } - getServerSharedPreferences(context).edit(true) { - putString(SERVER_URL_KEY, updatedServer.url) - putString(ACCESS_TOKEN_KEY, updatedUser.accessToken) - } + mostRecentServerProvider.save(currentUser) + _current.value = currentUser + _currentUserDto.value = ServerUserConfig(userDto) return@withContext currentUser } /** - * Restores a session for the given server & user such as when the app reopens + * Try to restore the most recent session * - * If user has a PIN, this returns false + * Does not check if auto sign-in is enabled, but does check if the user profile is protected */ - suspend fun restoreSession( + suspend fun restoreLastSession(): RestoredSession { + val recentServer = mostRecentServerProvider.get() + Timber.d("restoreLastSession: recentServer=%s", recentServer) + val result = + when (recentServer) { + MostRecentServer.None -> { + RestoredSession.None + } + + is MostRecentServer.Server -> { + val server = serverDao.getServer(recentServer.serverId) + if (server != null) { + RestoredSession.ServerOnly(server.server) + } else { + RestoredSession.None + } + } + + is MostRecentServer.ServerAndUser -> { + val currentUser = tryChangeUser(recentServer.serverId, recentServer.userId) + if (currentUser != null) { + if (currentUser.user.isProtected) { + RestoredSession.ServerOnly(currentUser.server) + } else { + RestoredSession.Success(currentUser) + } + } else { + getMostRecentServerInternal(recentServer) + } + } + } + return result + } + + /** + * Get the most recently used server, if any + */ + suspend fun getMostRecentServer(): RestoredSession = getMostRecentServerInternal(mostRecentServerProvider.get()) + + suspend fun getMostRecentServerInternal(recentServer: MostRecentServer): RestoredSession { + val recentServer = recentServer ?: mostRecentServerProvider.get() + val serverId = + when (recentServer) { + MostRecentServer.None -> null + is MostRecentServer.Server -> recentServer.serverId + is MostRecentServer.ServerAndUser -> recentServer.serverId + } + val server = serverId?.let { serverDao.getServer(serverId)?.server } + return if (server != null) { + RestoredSession.ServerOnly(server) + } else { + RestoredSession.None + } + } + + /** + * Try to change to the given server & user. If the user's profile is protected, returns null + * + * @return the server/user or null if the session could not be restored + */ + suspend fun tryChangeUser( serverId: UUID?, userId: UUID?, - ): CurrentUser? { - if (serverId == null || userId == null) { - _current.setValueOnMain(null) - return null - } - val serverAndUsers = - withContext(ioDispatcher) { - serverDao.getServer(serverId) - } - if (serverAndUsers != null) { - val current = _current.value - if (current != null && current.server.id == serverId && current.user.id == userId) { - Timber.v("Restoring session for current user, so shortcut") - apiClient.update( - baseUrl = current.server.url, - accessToken = current.user.accessToken, - ) - return current + ): CurrentUser? = + withContext(ioDispatcher) { + return@withContext if (serverId == null || userId == null) { + _current.value = null + null } else { - val user = serverAndUsers.users.firstOrNull { it.id == userId } - if (user != null) { - return changeUser(serverAndUsers.server, user) + val serverAndUsers = serverDao.getServer(serverId) + if (serverAndUsers != null) { + val user = serverAndUsers.users.firstOrNull { it.id == userId } + if (user != null && !user.isProtected) { + changeUser(serverAndUsers.server, user) + } else { + null + } + } else { + null } } } - return null - } - - suspend fun fetchLastUsedServer(serverId: UUID?): JellyfinServer? = - withContext(ioDispatcher) { - serverId?.let { serverDao.getServer(serverId)?.server } - } - - fun closeSession() { - _current.value = null - } /** * Given a successful [AuthenticationResult], switch to the user that just authenticated @@ -167,6 +219,7 @@ class ServerRepository suspend fun changeUser( serverUrl: String, authenticationResult: AuthenticationResult, + existingUser: JellyfinUser?, ) = withContext(ioDispatcher) { val accessToken = authenticationResult.accessToken if (accessToken != null) { @@ -183,12 +236,24 @@ class ServerRepository if (server != null) { val user = authedUser?.let { - JellyfinUser( - id = it.id, - name = it.name, - serverId = server.id, - accessToken = accessToken, - ) + if (existingUser != null) { + Timber.d("Re-using existing user") + existingUser.copy( + // If the server authenticated via the server, always remove the PIN + pin = null, + accessToken = accessToken, + lastUsed = ZonedDateTime.now(), + ) + } else { + Timber.d("Creating new user") + JellyfinUser( + id = it.id, + name = it.name, + serverId = server.id, + accessToken = accessToken, + lastUsed = ZonedDateTime.now(), + ) + } } if (user != null) { return@withContext changeUser(server, user) @@ -204,17 +269,11 @@ class ServerRepository } suspend fun removeUser(user: JellyfinUser) { - if (currentUser.value?.id == user.id) { - withContext(Dispatchers.Main) { + if (current.value?.user?.id == user.id) { + withContext(WholphinDispatchers.Main) { _current.value = null } - userPreferencesDataStore.updateData { - it - .toBuilder() - .apply { - currentUserId = "" - }.build() - } + mostRecentServerProvider.clearUser() apiClient.update(accessToken = null) } withContext(ioDispatcher) { @@ -223,18 +282,11 @@ class ServerRepository } suspend fun removeServer(server: JellyfinServer) { - if (currentServer.value?.id == server.id) { - withContext(Dispatchers.Main) { + if (current.value?.server?.id == server.id) { + withContext(WholphinDispatchers.Main) { _current.value = null } - userPreferencesDataStore.updateData { - it - .toBuilder() - .apply { - currentServerId = "" - currentUserId = "" - }.build() - } + mostRecentServerProvider.clear() apiClient.update(baseUrl = null, accessToken = null) } withContext(ioDispatcher) { @@ -243,34 +295,29 @@ class ServerRepository } suspend fun switchServerOrUser() { - userPreferencesDataStore.updateData { - it - .toBuilder() - .apply { - currentServerId = "" - currentUserId = "" - }.build() - } + mostRecentServerProvider.clear() } - suspend fun setUserPin( + suspend fun updateUserAuth( user: JellyfinUser, pin: String?, - ) = withContext(ioDispatcher) { - val newUser = user.copy(pin = pin) + requireLogin: Boolean, + ) { + val newUser = user.copy(pin = pin, requireLogin = requireLogin) val updatedUser = serverDao.addOrUpdateUser(newUser) - if (currentUser.value?.id == updatedUser.id && currentServer.value?.id == user.serverId) { + val cur = current.value + if (cur?.user?.id == updatedUser.id && cur.server?.id == user.serverId) { // Updating current user, so push out the change current.value?.let { val newCurrent = it.copy(user = updatedUser) - _current.setValueOnMain(newCurrent) + _current.value = newCurrent } } } suspend fun authorizeQuickConnect(code: String): Boolean = withContext(ioDispatcher) { - val userId = currentUser.value?.id + val userId = current.value?.user?.id if (userId == null) { Timber.e("No user logged in for Quick Connect authorization") throw IllegalStateException("Must be logged in to authorize Quick Connect") @@ -279,15 +326,23 @@ class ServerRepository response.content } - companion object { - fun getServerSharedPreferences(context: Context): SharedPreferences = - context.getSharedPreferences( - "${context.packageName}_server", - Context.MODE_PRIVATE, - ) + /** + * Update [currentUserDto] by querying the server + */ + suspend fun updateUserDto() { + val userDto by apiClient.userApi.getCurrentUser() + updateUserDto(userDto) + } - const val SERVER_URL_KEY = "current.server" - const val ACCESS_TOKEN_KEY = "current.accessToken" + /** + * Update [currentUserDto] with the specified [UserDto] + * + * This will only update if the [UserDto] is for the [currentUser] + */ + fun updateUserDto(userDto: UserDto) { + _currentUserDto.update { + if (currentUser?.id == userDto.id) ServerUserConfig(userDto) else it + } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/filter/DiscoverFilter.kt b/app/src/main/java/com/github/damontecres/wholphin/data/filter/DiscoverFilter.kt new file mode 100644 index 00000000..446a64c0 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/data/filter/DiscoverFilter.kt @@ -0,0 +1,287 @@ +package com.github.damontecres.wholphin.data.filter + +import androidx.annotation.StringRes +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.api.seerr.SearchApi +import com.github.damontecres.wholphin.api.seerr.SearchApi.CertificationModeDiscoverTvGet +import com.github.damontecres.wholphin.api.seerr.model.DiscoverMoviesGet200Response +import com.github.damontecres.wholphin.api.seerr.model.DiscoverTvGet200Response +import com.github.damontecres.wholphin.api.seerr.model.TvShowStatus +import com.github.damontecres.wholphin.ui.data.flip +import kotlinx.serialization.Serializable +import org.jellyfin.sdk.model.api.SortOrder +import java.time.format.DateTimeFormatter +import kotlin.time.Duration + +private val dateFormatter = DateTimeFormatter.ofPattern("yyyy-M-d") + +enum class DiscoverSort( + val key: String, + @param:StringRes val stringRes: Int, + val tvKey: String = key, +) { + POPULARITY("popularity", R.string.popularity), + RELEASE_DATE("release_date", R.string.sort_by_date_released, "first_air_date"), + TMDB_VOTE("vote_average", R.string.community_rating), + ALPHABETICAL("original_title", R.string.sort_by_name), +} + +private val SortOrder.key get() = + when (this) { + SortOrder.ASCENDING -> "asc" + SortOrder.DESCENDING -> "desc" + } + +@Serializable +data class DiscoverSortAndDirection( + val sort: DiscoverSort = DiscoverSort.POPULARITY, + val direction: SortOrder = SortOrder.DESCENDING, +) { + fun key(isTv: Boolean) = if (isTv) "${sort.tvKey}.${direction.key}" else "${sort.key}.${direction.key}" + + fun flip() = copy(direction = direction.flip()) +} + +@Serializable +data class DiscoverFilter( + // /api/v1/languages + val language: String? = null, + val genreIds: List<Int>? = null, + val networkIds: List<Int>? = null, + // /api/v1/search/keyword + val keywordIds: List<Int>? = null, + val excludeKeywordIds: List<Int>? = null, + val sortBy: DiscoverSortAndDirection? = null, +// val firstAirDateGte: LocalDate? = null, +// val firstAirDateLte: LocalDate? = null, + val withRuntimeGte: Duration? = null, + val withRuntimeLte: Duration? = null, + val voteAverageGte: Int? = null, + val voteAverageLte: Int? = null, + val voteCountGte: Int? = null, + val voteCountLte: Int? = null, + // /api/v1/watchproviders/regions + val watchRegion: String? = null, // US + // networkIds? + // val watchProviders: String? = null, + val status: List<TvShowStatus>? = null, + val certification: List<String>? = null, +// val certificationGte: String? = null, +// val certificationLte: String? = null, +// val certificationCountry: String? = null, // US +// val certificationMatchExact: Boolean? = null, +) { + /** + * Returns how many of filters are actually being used in this + */ + fun countFilters(filterOptions: List<DiscoverFilterBy<*>>): Int { + var count = 0 + filterOptions.forEach { + if (it.get(this) != null) count++ + } + return count + } + + /** + * Clear all the values for the given filters + */ + fun delete(filterOptions: List<DiscoverFilterBy<*>>): DiscoverFilter { + var newFilter = this + filterOptions.forEach { + newFilter = it.set(null, newFilter) + } + return newFilter + } + + suspend fun discoverTv( + searchApi: SearchApi, + page: Int, + ): DiscoverTvGet200Response = + searchApi.discoverTvGet( + page = page, + language = language, + genre = genreIds?.joinToString(",") { it.toString() }, + network = null, + keywords = keywordIds?.joinToString(",") { it.toString() }, + excludeKeywords = excludeKeywordIds?.joinToString(",") { it.toString() }, + sortBy = sortBy?.key(true), +// firstAirDateGte = firstAirDateGte?.let { dateFormatter.format(it) }, +// firstAirDateLte = firstAirDateLte?.let { dateFormatter.format(it) }, + withRuntimeGte = withRuntimeGte?.inWholeMinutes?.toInt(), + withRuntimeLte = withRuntimeLte?.inWholeMinutes?.toInt(), + voteAverageGte = voteAverageGte, + voteAverageLte = voteAverageLte, + voteCountGte = voteCountGte, + voteCountLte = voteCountLte, + // TODO +// watchRegion = watchRegion, + watchRegion = networkIds?.let { "US" }, + watchProviders = networkIds?.joinToString(",") { it.toString() }, + status = status?.joinToString("|") { it.ordinal.toString() }, + certification = certification?.joinToString("|"), +// certificationGte = certificationGte, +// certificationLte = certificationLte, + certificationCountry = certification?.let { "US" }, + certificationMode = certification?.let { CertificationModeDiscoverTvGet.EXACT }, + ) + + suspend fun discoverMovies( + searchApi: SearchApi, + page: Int, + ): DiscoverMoviesGet200Response = + searchApi.discoverMoviesGet( + page = page, + language = language, + genre = genreIds?.joinToString(",") { it.toString() }, +// network = null, + keywords = keywordIds?.joinToString(",") { it.toString() }, + excludeKeywords = excludeKeywordIds?.joinToString(",") { it.toString() }, + sortBy = sortBy?.key(false), +// firstAirDateGte = firstAirDateGte?.let { dateFormatter.format(it) }, +// firstAirDateLte = firstAirDateLte?.let { dateFormatter.format(it) }, + withRuntimeGte = withRuntimeGte?.inWholeMinutes?.toInt(), + withRuntimeLte = withRuntimeLte?.inWholeMinutes?.toInt(), + voteAverageGte = voteAverageGte, + voteAverageLte = voteAverageLte, + voteCountGte = voteCountGte, + voteCountLte = voteCountLte, + // TODO +// watchRegion = watchRegion, + watchRegion = networkIds?.let { "US" }, + watchProviders = networkIds?.joinToString(",") { it.toString() }, +// status = status, + certification = certification?.joinToString("|"), +// certificationGte = certificationGte, +// certificationLte = certificationLte, + certificationCountry = certification?.let { "US" }, + certificationMode = certification?.let { SearchApi.CertificationModeDiscoverMoviesGet.EXACT }, + ) +} + +val discoverMovieFilters = + listOf( + DiscoverMovieGenreFilter, + DiscoverMovieStudiosFilter, + DiscoverRatingFilter, + DiscoverMovieContentRatingFilter, + ) + +val discoverTvFilters = + listOf( + DiscoverTvGenreFilter, + DiscoverTvStudiosFilter, + DiscoverRatingFilter, + DiscoverTvContentRatingFilter, + DiscoverTvStatusFilter, + ) + +/** + * A way to filter discover + * + * Gets and sets values within a [DiscoverFilter] + */ +sealed interface DiscoverFilterBy<T> : FilterBy<DiscoverFilter, T> + +data object DiscoverMovieGenreFilter : DiscoverFilterBy<List<Int>> { + override val stringRes: Int = R.string.genres + + override val supportMultiple: Boolean = true + + override fun get(filter: DiscoverFilter): List<Int>? = filter.genreIds + + override fun set( + value: List<Int>?, + filter: DiscoverFilter, + ): DiscoverFilter = filter.copy(genreIds = value) +} + +data object DiscoverTvGenreFilter : DiscoverFilterBy<List<Int>> { + override val stringRes: Int = R.string.genres + + override val supportMultiple: Boolean = true + + override fun get(filter: DiscoverFilter): List<Int>? = filter.genreIds + + override fun set( + value: List<Int>?, + filter: DiscoverFilter, + ): DiscoverFilter = filter.copy(genreIds = value) +} + +data object DiscoverMovieStudiosFilter : DiscoverFilterBy<List<Int>> { + override val stringRes: Int = R.string.studios + + override val supportMultiple: Boolean = true + + override fun get(filter: DiscoverFilter): List<Int>? = filter.networkIds + + override fun set( + value: List<Int>?, + filter: DiscoverFilter, + ): DiscoverFilter = filter.copy(networkIds = value) +} + +data object DiscoverTvStudiosFilter : DiscoverFilterBy<List<Int>> { + override val stringRes: Int = R.string.studios + + override val supportMultiple: Boolean = true + + override fun get(filter: DiscoverFilter): List<Int>? = filter.networkIds + + override fun set( + value: List<Int>?, + filter: DiscoverFilter, + ): DiscoverFilter = filter.copy(networkIds = value) +} + +data object DiscoverTvStatusFilter : DiscoverFilterBy<List<TvShowStatus>> { + override val stringRes: Int = R.string.production_status + + override val supportMultiple: Boolean = true + + override fun get(filter: DiscoverFilter): List<TvShowStatus>? = filter.status + + override fun set( + value: List<TvShowStatus>?, + filter: DiscoverFilter, + ): DiscoverFilter = filter.copy(status = value) +} + +data object DiscoverMovieContentRatingFilter : DiscoverFilterBy<List<String>> { + override val stringRes: Int = R.string.official_rating + + override val supportMultiple: Boolean = true + + override fun get(filter: DiscoverFilter): List<String>? = filter.certification + + override fun set( + value: List<String>?, + filter: DiscoverFilter, + ): DiscoverFilter = filter.copy(certification = value) +} + +data object DiscoverTvContentRatingFilter : DiscoverFilterBy<List<String>> { + override val stringRes: Int = R.string.official_rating + + override val supportMultiple: Boolean = true + + override fun get(filter: DiscoverFilter): List<String>? = filter.certification + + override fun set( + value: List<String>?, + filter: DiscoverFilter, + ): DiscoverFilter = filter.copy(certification = value) +} + +data object DiscoverRatingFilter : DiscoverFilterBy<Int> { + override val stringRes: Int = R.string.community_rating + + override val supportMultiple: Boolean = true + + override fun get(filter: DiscoverFilter): Int? = filter.voteAverageGte + + override fun set( + value: Int?, + filter: DiscoverFilter, + ): DiscoverFilter = filter.copy(voteAverageGte = value) +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/filter/ItemFilterBy.kt b/app/src/main/java/com/github/damontecres/wholphin/data/filter/ItemFilterBy.kt index 37fd9514..b92d251f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/filter/ItemFilterBy.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/filter/ItemFilterBy.kt @@ -77,24 +77,29 @@ val DefaultPlaylistItemsOptions = ) /** - * A way to filter libraries - * - * Gets and sets values within a [GetItemsFilter] + * Abstract way to filter data */ -sealed interface ItemFilterBy<T> { +sealed interface FilterBy<FilterType, DataType> { @get:StringRes val stringRes: Int val supportMultiple: Boolean - fun get(filter: GetItemsFilter): T? + fun get(filter: FilterType): DataType? fun set( - value: T?, - filter: GetItemsFilter, - ): GetItemsFilter + value: DataType?, + filter: FilterType, + ): FilterType } +/** + * A way to filter jellyfin libraries + * + * Gets and sets values within a [GetItemsFilter] + */ +sealed interface ItemFilterBy<T> : FilterBy<GetItemsFilter, T> + data object GenreFilter : ItemFilterBy<List<UUID>> { override val stringRes: Int = R.string.genres diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/filter/SearchFilter.kt b/app/src/main/java/com/github/damontecres/wholphin/data/filter/SearchFilter.kt new file mode 100644 index 00000000..6b30e9ff --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/data/filter/SearchFilter.kt @@ -0,0 +1,27 @@ +package com.github.damontecres.wholphin.data.filter + +import com.github.damontecres.wholphin.data.model.GetItemsFilter + +interface SearchFilter<T : SearchFilter<T>> { + /** + * Returns how many of filters are actually being used in this [GetItemsFilter] + */ + fun countFilters(filterOptions: List<FilterBy<T, *>>): Int { + var count = 0 + filterOptions.forEach { + if (it.get(this as T) != null) count++ + } + return count + } + + /** + * Clear all the values for the given filters + */ + fun delete(filterOptions: List<FilterBy<T, *>>): T { + var newFilter = this + filterOptions.forEach { + newFilter = it.set(null, newFilter as T) + } + return newFilter as T + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/AudioItem.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/AudioItem.kt index 9c8009db..cf8ce4d0 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/AudioItem.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/AudioItem.kt @@ -42,7 +42,7 @@ data class AudioItem( ?.id, title = item.title, albumTitle = item.data.album, - artistNames = item.data.albumArtist, + artistNames = item.data.artists?.joinToString(", "), runtime = item.data.runTimeTicks?.ticks, imageUrl = imageUrl, hasLyrics = item.data.hasLyrics == true, 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 efb21849..256ccd65 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt @@ -1,17 +1,21 @@ 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 import androidx.compose.ui.text.buildAnnotatedString +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.WholphinApplication import com.github.damontecres.wholphin.ui.abbreviateNumber import com.github.damontecres.wholphin.ui.detail.CardGridItem import com.github.damontecres.wholphin.ui.detail.music.artistsString import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds import com.github.damontecres.wholphin.ui.dot import com.github.damontecres.wholphin.ui.formatDateTime +import com.github.damontecres.wholphin.ui.formatDuration +import com.github.damontecres.wholphin.ui.formatEpisodeNumber import com.github.damontecres.wholphin.ui.getDateFormatter +import com.github.damontecres.wholphin.ui.joinNotBlank import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.playback.playable import com.github.damontecres.wholphin.ui.roundMinutes @@ -19,13 +23,14 @@ import com.github.damontecres.wholphin.ui.seasonEpisode import com.github.damontecres.wholphin.ui.seasonEpisodePadded import com.github.damontecres.wholphin.ui.seriesProductionYears import com.github.damontecres.wholphin.ui.timeRemaining +import com.github.damontecres.wholphin.ui.toServerString import kotlinx.serialization.Serializable import kotlinx.serialization.Transient import org.jellyfin.sdk.api.client.ApiClient 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 @@ -48,7 +53,7 @@ data class BaseItem( get() = type.playable override val sortName: String - get() = data.sortName ?: data.name ?: "" + get() = data.alphabetSortName val type get() = data.type @@ -56,14 +61,15 @@ data class BaseItem( val title get() = if (type == BaseItemKind.EPISODE) data.seriesName else name - val subtitle - get() = - when (type) { - BaseItemKind.EPISODE -> data.seasonEpisode + " - " + name - BaseItemKind.SERIES -> data.seriesProductionYears - BaseItemKind.AUDIO -> listOfNotNull(data.album, artistsString).joinToString(" - ") - else -> data.productionYear?.toString() - } + val subtitle: String? by lazy { + when (type) { + BaseItemKind.EPISODE -> listOf(data.seasonEpisode, name).joinNotBlank(" - ") + BaseItemKind.SERIES -> data.seriesProductionYears + BaseItemKind.AUDIO -> listOf(data.album, artistsString).joinNotBlank(" - ") + BaseItemKind.MUSIC_ALBUM -> listOf(data.albumArtist ?: artistsString, data.productionYear?.toString()).joinNotBlank(" - ") + else -> data.productionYear?.toString() + } + } val subtitleLong: String? by lazy { if (type == BaseItemKind.EPISODE) { @@ -71,7 +77,7 @@ data class BaseItem( add(data.seasonEpisodePadded) add(data.name) add(data.premiereDate?.let { formatDateTime(it) }) - }.filterNotNull().joinToString(" - ") + }.joinNotBlank(" - ") } else { data.productionYear?.toString() } @@ -91,7 +97,12 @@ data class BaseItem( val favorite get() = data.userData?.isFavorite ?: false - val timeRemainingOrRuntime: Duration? get() = data.timeRemaining ?: data.runTimeTicks?.ticks + val timeRemainingOrRuntime: Duration? + get() = + when (type) { + BaseItemKind.PROGRAM -> null + else -> data.timeRemaining ?: data.runTimeTicks?.ticks + } /** * Contains pre computed UI elements that would be expensive to create on the main thread @@ -100,78 +111,129 @@ data class BaseItem( val ui = BaseItemUi( episodeCornerText = - data.indexNumber?.let { "E$it" } - ?: data.premiereDate?.let(::formatDateTime), - episodeUnplayedCornerText = - if (type == BaseItemKind.SERIES || - type == BaseItemKind.SEASON || - type == BaseItemKind.EPISODE || - type == BaseItemKind.BOX_SET - ) { - data.indexNumber?.let { "E$it" } - ?: data.userData - ?.unplayedItemCount - ?.takeIf { it > 0 } - ?.let { abbreviateNumber(it) } + if (type == BaseItemKind.EPISODE) { + data.indexNumber?.let { formatEpisodeNumber(it) } + ?: data.premiereDate?.let(::formatDateTime) } else { null }, - quickDetails = - buildAnnotatedString { - val details = - buildList { - if (type == BaseItemKind.EPISODE) { - data.seasonEpisode?.let(::add) - data.premiereDate?.let { add(getDateFormatter().format(it)) } - } else if (type == BaseItemKind.SERIES) { - data.seriesProductionYears?.let(::add) - } else if (type == BaseItemKind.PHOTO) { - if (data.productionYear != null) { - add(data.productionYear!!.toString()) - } else if (data.premiereDate != null) { - add(data.premiereDate!!.toLocalDate().toString()) - } - } else if (type == BaseItemKind.BOX_SET) { - data.productionYear?.let { add(it.toString()) } - data.childCount?.let { add("$it items") } - } else { - data.productionYear?.let { add(it.toString()) } - } - data.runTimeTicks - ?.ticks - ?.roundMinutes - ?.let { add(it.toString()) } - data.timeRemaining - ?.roundMinutes - ?.let { add("$it left") } - } - details.forEachIndexed { index, string -> - append(string) - if (index != details.lastIndex) { - dot() - } + episodeUnplayedCornerText = + when (type) { + BaseItemKind.EPISODE -> { + data.indexNumber?.let { formatEpisodeNumber(it) } } - // TODO time remaining - data.officialRating?.let { - dot() - append(it) + BaseItemKind.SERIES, + BaseItemKind.SEASON, + BaseItemKind.BOX_SET, + -> { + data.userData + ?.unplayedItemCount + ?.takeIf { it > 0 } + ?.let { abbreviateNumber(it) } } - data.communityRating?.let { - dot() - append(String.format(Locale.getDefault(), "%.1f", it)) - appendInlineContent(id = "star") - } - data.criticRating?.let { - dot() - append("${it.toInt()}%") - if (it >= 60f) { - appendInlineContent(id = "fresh") - } else { - appendInlineContent(id = "rotten") - } + + else -> { + null } }, + quickDetails = + QuickDetailsData( + basic = + buildAnnotatedString { + val details = + buildList { + when (type) { + BaseItemKind.EPISODE -> { + data.seasonEpisode?.let(::add) + data.premiereDate?.let { + add( + getDateFormatter().format( + it, + ), + ) + } + } + + BaseItemKind.SEASON -> { + data.childCount?.let { childCount -> + add( + WholphinApplication.instance.resources.getQuantityString( + R.plurals.episodes_count, + childCount, + childCount, + ), + ) + } + data.productionYear?.let { add(it.toString()) } + } + + BaseItemKind.SERIES -> { + data.seriesProductionYears?.let(::add) + } + + BaseItemKind.PHOTO -> { + if (data.productionYear != null) { + add(data.productionYear!!.toString()) + } else if (data.premiereDate != null) { + add(data.premiereDate!!.toLocalDate().toString()) + } + } + + BaseItemKind.BOX_SET -> { + data.productionYear?.let { add(it.toString()) } + data.childCount?.let { add("$it items") } + } + + BaseItemKind.PROGRAM -> { + data.channelName?.let(::add) + if (data.isSeries == true) { + // TV episode + data.seasonEpisode?.let(::add) + data.premiereDate?.let { + add(getDateFormatter().format(it)) + } + } else { + data.productionYear?.let { add(it.toString()) } + } + } + + else -> { + data.productionYear?.let { add(it.toString()) } + } + } + data.runTimeTicks + ?.ticks + ?.roundMinutes + ?.takeIf { it > Duration.ZERO } + ?.let { add(WholphinApplication.instance.resources.formatDuration(it)) } + data.timeRemaining + ?.roundMinutes + ?.let { + val resources = WholphinApplication.instance.resources + add( + resources.getString( + R.string.time_left, + resources.formatDuration(it), + ), + ) + } + } + details.forEachIndexed { index, string -> + append(string) + if (index != details.lastIndex) { + dot() + } + } + }, + officialRating = + data.officialRating?.let { + buildAnnotatedString { + dot() + append(it) + } + }, + ), ) private fun dateAsIndex(): Int? = @@ -187,25 +249,38 @@ data class BaseItem( */ fun destination(index: Int? = null): Destination { if (destinationOverride != null) return destinationOverride + if (data.extraType != null || type == BaseItemKind.TRAILER) { + // Extras including trailers should always play directly + return Destination.Playback(id, 0) + } val result = // Redirect episodes & seasons to their series if possible when (type) { BaseItemKind.EPISODE -> { - data.seasonId?.let { seasonId -> + val seasonId = data.seasonId + val seriesId = data.seriesId + if (seasonId != null && seriesId != null) { Destination.SeriesOverview( - data.seriesId!!, + seriesId, BaseItemKind.SERIES, SeasonEpisodeIds(seasonId, data.parentIndexNumber, id, indexNumber), ) - } ?: Destination.MediaItem(this) + } else { + Destination.MediaItem(this) + } } BaseItemKind.SEASON -> { - Destination.SeriesOverview( - data.seriesId!!, - BaseItemKind.SERIES, - SeasonEpisodeIds(id, indexNumber, null, null), - ) + val seriesId = data.seriesId + if (seriesId != null) { + Destination.SeriesOverview( + seriesId, + BaseItemKind.SERIES, + SeasonEpisodeIds(id, indexNumber, null, null), + ) + } else { + Destination.MediaItem(this) + } } BaseItemKind.TV_CHANNEL -> { @@ -227,6 +302,16 @@ data class BaseItem( } } + BaseItemKind.AUDIO -> { + data.albumId?.let { albumId -> + Destination.MediaItem( + itemId = albumId, + type = BaseItemKind.MUSIC_ALBUM, + initialSongId = id, + ) + } ?: Destination.MediaItem(this) + } + else -> { Destination.MediaItem(this) } @@ -250,11 +335,19 @@ data class BaseItem( val BaseItemDto.aspectRatioFloat: Float? get() = width?.let { w -> height?.let { h -> w.toFloat() / h.toFloat() } } +/** The server strips leading articles (eg "The ") from [BaseItemDto.sortName]. */ +val BaseItemDto.alphabetSortName: String get() = sortName ?: name ?: "" + @Immutable data class BaseItemUi( val episodeCornerText: String?, val episodeUnplayedCornerText: String?, - val quickDetails: AnnotatedString, + val quickDetails: QuickDetailsData, +) + +data class QuickDetailsData( + val basic: AnnotatedString, + val officialRating: AnnotatedString? = null, ) /** @@ -266,6 +359,7 @@ fun createGenreDestination( parentId: UUID, parentName: String?, includeItemTypes: List<BaseItemKind>?, + collectionType: CollectionType, ) = Destination.FilteredCollection( itemId = parentId, parentType = BaseItemKind.GENRE, @@ -281,9 +375,11 @@ fun createGenreDestination( genres = listOf(genreId), includeItemTypes = includeItemTypes, ), - useSavedLibraryDisplayInfo = false, + useSavedLibraryDisplayInfo = true, + libraryDisplayInfoIdOverride = "${parentId.toServerString()}_genres", ), recursive = true, + collectionType = collectionType, ) fun createStudioDestination( @@ -307,9 +403,11 @@ fun createStudioDestination( studios = listOf(studioId), includeItemTypes = includeItemTypes, ), - useSavedLibraryDisplayInfo = false, + useSavedLibraryDisplayInfo = true, + libraryDisplayInfoIdOverride = "${parentId.toServerString()}_studios", ), recursive = true, + collectionType = CollectionType.UNKNOWN, ) val BaseItem.studioNames get() = data.studios?.mapNotNull { it.name }.orEmpty() diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/Chapter.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/Chapter.kt index 3e07964c..71a300fb 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/Chapter.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/Chapter.kt @@ -1,38 +1,30 @@ package com.github.damontecres.wholphin.data.model -import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.api.client.extensions.imageApi import org.jellyfin.sdk.model.api.BaseItemDto -import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.extensions.ticks +import java.util.UUID import kotlin.time.Duration /** * Represents a chapter within a video */ data class Chapter( + val itemId: UUID, val name: String?, val position: Duration, - val imageUrl: String?, + val tag: String?, + val index: Int, ) { companion object { - fun fromDto( - dto: BaseItemDto, - api: ApiClient, - ): List<Chapter> = + fun fromDto(dto: BaseItemDto): List<Chapter> = dto.chapters ?.mapIndexed { index, chapter -> Chapter( - chapter.name, - chapter.startPositionTicks.ticks, - chapter.imageTag?.let { - api.imageApi.getItemImageUrl( - itemId = dto.id, - imageType = ImageType.CHAPTER, - tag = it, - imageIndex = index, - ) - }, + itemId = dto.id, + name = chapter.name, + position = chapter.startPositionTicks.ticks, + tag = chapter.imageTag, + index = index, ) }?.sortedBy { it.position } .orEmpty() 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 db03ff48..fe4650a6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/DiscoverItem.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/DiscoverItem.kt @@ -3,8 +3,6 @@ package com.github.damontecres.wholphin.data.model import androidx.compose.runtime.Stable -import com.github.damontecres.wholphin.api.seerr.model.MovieMovieIdRatingsGet200Response -import com.github.damontecres.wholphin.api.seerr.model.TvTvIdRatingsGet200Response import com.github.damontecres.wholphin.ui.detail.CardGridItem import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.util.LocalDateSerializer @@ -56,12 +54,14 @@ enum class SeerrItemType( enum class SeerrAvailability( val status: Int, ) { + // https://github.com/seerr-team/seerr/blob/develop/server/constants/media.ts#L14 UNKNOWN(1), PENDING(2), PROCESSING(3), PARTIALLY_AVAILABLE(4), AVAILABLE(5), - DELETED(6), + BLOCKLISTED(6), + DELETED(7), ; companion object { @@ -69,6 +69,23 @@ enum class SeerrAvailability( } } +@Serializable +enum class RequestStatus( + val status: Int, +) { + UNKNOWN(0), + PENDING(1), + APPROVED(2), + DECLINED(3), + FAILURE(4), + COMPLETED(5), + ; + + companion object { + fun from(status: Int?) = RequestStatus.entries.firstOrNull { it.status == status } ?: UNKNOWN + } +} + /** * An item provided by a discovery service (ie Seerr). It may exist on the JF server as well, see [availability]. */ @@ -84,6 +101,7 @@ data class DiscoverItem( @Serializable(LocalDateSerializer::class) val releaseDate: LocalDate?, val posterUrl: String?, val backDropUrl: String?, + val logoUrl: String?, val jellyfinItemId: UUID?, ) : CardGridItem { override val gridId: String get() = id.toString() @@ -92,13 +110,7 @@ data class DiscoverItem( val destination: Destination get() { - val jfType = - when (type) { - SeerrItemType.MOVIE -> BaseItemKind.MOVIE - SeerrItemType.TV -> BaseItemKind.SERIES - SeerrItemType.PERSON -> BaseItemKind.PERSON - SeerrItemType.UNKNOWN -> null - } + val jfType = type.baseItemKind return if (jellyfinItemId != null && jfType != null) { Destination.MediaItem( itemId = jellyfinItemId, @@ -109,20 +121,3 @@ data class DiscoverItem( } } } - -/** - * A rating for a discovered item which is usually fetched separately from the item - */ -data class DiscoverRating( - val criticRating: Int?, - val audienceRating: Float?, -) { - constructor(rating: MovieMovieIdRatingsGet200Response) : this( - criticRating = rating.criticsScore, - audienceRating = rating.audienceScore?.div(10f), - ) - constructor(rating: TvTvIdRatingsGet200Response) : this( - criticRating = rating.criticsScore, - audienceRating = null, - ) -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/GetItemsFilter.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/GetItemsFilter.kt index d0d32988..91228969 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/GetItemsFilter.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/GetItemsFilter.kt @@ -9,6 +9,8 @@ import kotlinx.serialization.Serializable import kotlinx.serialization.UseSerializers import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.VideoType +import org.jellyfin.sdk.model.api.request.GetAlbumArtistsRequest +import org.jellyfin.sdk.model.api.request.GetArtistsRequest import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.jellyfin.sdk.model.api.request.GetPersonsRequest import org.jellyfin.sdk.model.serializer.UUIDSerializer @@ -22,9 +24,13 @@ data class CollectionFolderFilter( val nameOverride: String? = null, val filter: GetItemsFilter = GetItemsFilter(), /** - * Whether to use the libray's saved sort & filter + * Whether to use the library's saved sort & filter */ val useSavedLibraryDisplayInfo: Boolean = true, + /** + * Use a different ID for the library's saved sort & filter, such as for library's genres + */ + val libraryDisplayInfoIdOverride: String? = null, ) /** @@ -134,6 +140,38 @@ data class GetItemsFilter( isFavorite = favorite, ) + fun applyTo(req: GetArtistsRequest) = + req.copy( + minCommunityRating = minCommunityRating, + isFavorite = favorite, + genreIds = genres, + personIds = persons, + studioIds = studios, + tags = tags, + officialRatings = officialRatings, + years = + buildSet { + years?.letNotEmpty(::addAll) + decades?.forEach { addAll(it..<(it + 10)) } + }, + ) + + fun applyTo(req: GetAlbumArtistsRequest) = + req.copy( + minCommunityRating = minCommunityRating, + isFavorite = favorite, + genreIds = genres, + personIds = persons, + studioIds = studios, + tags = tags, + officialRatings = officialRatings, + years = + buildSet { + years?.letNotEmpty(::addAll) + decades?.forEach { addAll(it..<(it + 10)) } + }, + ) + /** * Merge another [GetItemsFilter] onto this one, replacing only unset values */ @@ -158,4 +196,5 @@ data class GetItemsFilter( enum class GetItemsFilterOverride { NONE, PERSON, + ARTIST, } diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt index 74a6c6d1..39b82bc8 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt @@ -1,4 +1,7 @@ -@file:UseSerializers(UUIDSerializer::class) +@file:UseSerializers( + UUIDSerializer::class, + DateTimeSerializer::class, +) package com.github.damontecres.wholphin.data.model @@ -7,11 +10,15 @@ import com.github.damontecres.wholphin.ui.AspectRatio import com.github.damontecres.wholphin.ui.Cards import com.github.damontecres.wholphin.ui.components.ViewOptionImageType import com.github.damontecres.wholphin.ui.data.SortAndDirection +import kotlinx.datetime.LocalDateTime import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.UseSerializers import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.ItemSortBy +import org.jellyfin.sdk.model.api.SortOrder import org.jellyfin.sdk.model.api.request.GetItemsRequest +import org.jellyfin.sdk.model.serializer.DateTimeSerializer import org.jellyfin.sdk.model.serializer.UUIDSerializer import java.util.UUID @@ -109,15 +116,7 @@ sealed interface HomeRowConfig { @SerialName("Favorite") data class Favorite( val kind: BaseItemKind, - override val viewOptions: HomeRowViewOptions = - if (kind == BaseItemKind.EPISODE) { - HomeRowViewOptions( - heightDp = Cards.HEIGHT_EPISODE, - aspectRatio = AspectRatio.WIDE, - ) - } else { - HomeRowViewOptions() - }, + override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(), ) : HomeRowConfig { override fun updateViewOptions(viewOptions: HomeRowViewOptions): Favorite = this.copy(viewOptions = viewOptions) } @@ -182,19 +181,45 @@ sealed interface HomeRowConfig { } /** - * An arbitrary [GetItemsRequest] allowing to query for anything + * An arbitrary [HomeGetItemsFilter] allowing to query for most anything */ @Serializable @SerialName("GetItems") data class GetItems( val name: String, - val getItems: GetItemsRequest, + val filter: HomeGetItemsFilter, override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(), ) : HomeRowConfig { override fun updateViewOptions(viewOptions: HomeRowViewOptions): GetItems = this.copy(viewOptions = viewOptions) } + + /** + * Fetch items from an arbitrary Jellyfin endpoint that returns a QueryResult<BaseItemDto>. + * Lets third-party plugins (e.g. home-sections) contribute rows without Wholphin needing + * specific knowledge of them. + * + * Headers/Query are a list of [KeyValueEntry] (not a Map) because the server-side plugin + * config is persisted as XML, which doesn't support IDictionary. + */ + @Serializable + @SerialName("CustomEndpoint") + data class CustomEndpoint( + val endpoint: String, + val title: String, + val headers: List<KeyValueEntry>? = null, + val query: List<KeyValueEntry>? = null, + override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(), + ) : HomeRowConfig { + override fun updateViewOptions(viewOptions: HomeRowViewOptions): CustomEndpoint = this.copy(viewOptions = viewOptions) + } } +@Serializable +data class KeyValueEntry( + val key: String, + val value: String, +) + /** * Root class for home page settings * @@ -243,9 +268,60 @@ data class HomeRowViewOptions( val liveTvDefault = HomeRowViewOptions( - heightDp = 96, + heightDp = Cards.HEIGHT_LIVE_TV, aspectRatio = AspectRatio.WIDE, contentScale = PrefContentScale.FIT, ) } } + +/** + * Subset of [GetItemsFilter] + */ +@Serializable +data class HomeGetItemsFilter( + val sortBy: List<ItemSortBy>? = null, + val sortOrder: List<SortOrder>? = null, + val ids: List<UUID>? = null, + val minCommunityRating: Double? = null, + val minCriticRating: Double? = null, + val recursive: Boolean? = null, + val searchTerm: String? = null, + val parentId: UUID? = null, + val includeItemTypes: List<BaseItemKind>? = null, + val isFavorite: Boolean? = null, + val isPlayed: Boolean? = null, + val officialRatings: Collection<String>? = null, + val tags: List<String>? = null, + val years: List<Int>? = null, + val personIds: List<UUID>? = null, + val minOfficialRating: String? = null, + val hasOfficialRating: Boolean? = null, + val studioIds: List<UUID>? = null, + val genreIds: List<UUID>? = null, + val minPremiereDate: LocalDateTime? = null, + val maxPremiereDate: LocalDateTime? = null, +) { + fun asGetItemsRequest() = + GetItemsRequest( + sortBy = sortBy, + sortOrder = sortOrder, + ids = ids, + minCommunityRating = minCommunityRating, + minCriticRating = minCriticRating, + recursive = recursive, + searchTerm = searchTerm, + parentId = parentId, + includeItemTypes = includeItemTypes, + isFavorite = isFavorite, + isPlayed = isPlayed, + officialRatings = officialRatings, + tags = tags, + years = years, + personIds = personIds, + minOfficialRating = minOfficialRating, + hasOfficialRating = hasOfficialRating, + studioIds = studioIds, + genreIds = genreIds, + ) +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/JellyfinServer.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/JellyfinServer.kt index 0b1759ee..6c5a2a58 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/JellyfinServer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/JellyfinServer.kt @@ -1,4 +1,4 @@ -@file:UseSerializers(UUIDSerializer::class) +@file:UseSerializers(UUIDSerializer::class, ZonedDateTimeSerializer::class) package com.github.damontecres.wholphin.data.model @@ -10,11 +10,16 @@ import androidx.room.Ignore import androidx.room.Index import androidx.room.PrimaryKey import androidx.room.Relation +import com.github.damontecres.wholphin.data.ZonedDateTimeSerializer +import com.github.damontecres.wholphin.preferences.SubtitleModePreference +import com.github.damontecres.wholphin.preferences.UserProfileSettings +import com.github.damontecres.wholphin.services.HomePageSettingsSource import com.github.damontecres.wholphin.ui.isNotNullOrBlank import kotlinx.serialization.Serializable import kotlinx.serialization.UseSerializers import org.jellyfin.sdk.model.ServerVersion import org.jellyfin.sdk.model.serializer.UUIDSerializer +import java.time.ZonedDateTime import java.util.UUID /** @@ -27,6 +32,7 @@ data class JellyfinServer( val name: String?, val url: String, val version: String?, + val lastUsed: ZonedDateTime? = null, ) { @get:Ignore val serverVersion: ServerVersion? by lazy { version?.let(ServerVersion::fromString) } @@ -58,13 +64,32 @@ data class JellyfinUser( val serverId: UUID, val accessToken: String?, val pin: String? = null, + @ColumnInfo(defaultValue = "false") + val requireLogin: Boolean = false, + val lastUsed: ZonedDateTime? = null, + val uiLanguage: String? = null, + @Embedded val appPreferences: JellyfinUserPreferences = JellyfinUserPreferences(), + @Embedded(prefix = "c_") val config: JellyfinUserConfig = JellyfinUserConfig(), ) { val hasPin: Boolean get() = pin.isNotNullOrBlank() + val isProtected: Boolean get() = hasPin || requireLogin + override fun toString(): String = - "JellyfinUser(rowId=$rowId, id=$id, name=$name, serverId=$serverId, accessToken?=${accessToken.isNotNullOrBlank()}, pin?=${pin.isNotNullOrBlank()})" + "JellyfinUser(rowId=$rowId, id=$id, name=$name, serverId=$serverId, lastUsed=$lastUsed, " + + "accessToken?=${accessToken.isNotNullOrBlank()}, pin?=${pin.isNotNullOrBlank()}), " + + "requireLogin=$requireLogin, lastUsed=$lastUsed, uiLanguage=$uiLanguage, " + + "appPreferences=$appPreferences, config=$config" + + fun updateConfig(copy: (JellyfinUserConfig) -> JellyfinUserConfig): JellyfinUser = this.copy(config = copy.invoke(this.config)) } +@Serializable +data class JellyfinUserConfig( + @ColumnInfo(defaultValue = "UNSET") + val homeSettingsSource: HomePageSettingsSource = HomePageSettingsSource.UNSET, +) + /** * Represents the relationship between [JellyfinServer] and its [JellyfinUser] */ @@ -76,3 +101,13 @@ data class JellyfinServerUsers( ) val users: List<JellyfinUser>, ) + +@Serializable +data class JellyfinUserPreferences( + @ColumnInfo(defaultValue = "") + val preferredAudioLanguage: String = UserProfileSettings.USE_USER_PROFILE, + @ColumnInfo(defaultValue = "") + val preferredSubtitleLanguage: String = UserProfileSettings.USE_USER_PROFILE, + @ColumnInfo(defaultValue = "USE_USER_PROFILE") + val subtitleMode: SubtitleModePreference = SubtitleModePreference.USE_USER_PROFILE, +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/Person.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/Person.kt index 4e639009..a20a2dcd 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/Person.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/Person.kt @@ -35,7 +35,7 @@ data class Person( name = dto.name, role = personRole(context, dto.role, dto.type), type = dto.type, - imageUrl = api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY), + imageUrl = api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY, tag = dto.primaryImageTag), favorite = false, ) @@ -50,7 +50,7 @@ data class Person( name = dto.name, role = personRole(context, dto.role, dto.type), type = dto.type, - imageUrl = api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY), + imageUrl = api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY, tag = dto.primaryImageTag), favorite = favorite, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/PlaybackLanguageChoice.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/PlaybackLanguageChoice.kt index fb54dfcd..f5507a50 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/PlaybackLanguageChoice.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/PlaybackLanguageChoice.kt @@ -10,9 +10,14 @@ import org.jellyfin.sdk.model.serializer.UUIDSerializer import java.util.UUID /** + * DEPRECATED + * * Stores the language choices for a series so they can be applied automatically to other episodes * without the user needing to explicitly choose the tracks + * + * @see SeriesTrackChoice */ +@Deprecated("Use SeriesTrackChoice") @Entity( foreignKeys = [ ForeignKey( diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/Playlist.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/Playlist.kt index 3e3cbe18..9f10f58c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/Playlist.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/Playlist.kt @@ -1,8 +1,5 @@ package com.github.damontecres.wholphin.data.model -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.setValue import org.jellyfin.sdk.model.api.MediaType import java.util.UUID @@ -11,38 +8,13 @@ import java.util.UUID * * This is not the same thing as a Jellyfin server playlist */ -class Playlist( - items: List<PlaylistItem>, - startIndex: Int = 0, +data class Playlist( + val items: List<PlaylistItem>, ) { - val items = items.subList(startIndex, items.size) - - var index by mutableIntStateOf(0) - - fun hasPrevious(): Boolean = index > 0 - - fun hasNext(): Boolean = (index + 1) < items.size - - fun getPreviousAndReverse(): PlaylistItem = items[--index] - - fun getAndAdvance(): PlaylistItem = items[++index] - - fun peek(): PlaylistItem? = items.getOrNull(index + 1) - - fun upcomingItems(): List<PlaylistItem> = items.subList(index + 1, items.size) - - fun advanceTo(id: UUID): PlaylistItem? { - while (hasNext()) { - val potential = getAndAdvance() - if (potential.id == id) { - return potential - } - } - return null - } - companion object { const val MAX_SIZE = 100 + + fun fromMedia(list: List<BaseItem>) = Playlist(list.map { PlaylistItem.Media(it) }) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/RelativeMediaStream.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/RelativeMediaStream.kt new file mode 100644 index 00000000..cd2fece7 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/RelativeMediaStream.kt @@ -0,0 +1,61 @@ +package com.github.damontecres.wholphin.data.model + +import com.github.damontecres.wholphin.ui.indexOfFirstOrNull +import org.jellyfin.sdk.model.api.MediaStream +import org.jellyfin.sdk.model.api.MediaStreamType + +/** + * A [MediaStream] with its relative track index, its position within a specific stream type + */ +data class RelativeMediaStream( + val relativeIndex: Int, + val stream: MediaStream, +) { + val type: MediaStreamType get() = stream.type + val language: String? get() = stream.language + val isForced: Boolean get() = stream.isForced + val isExternal: Boolean get() = stream.isExternal + val isDefault: Boolean get() = stream.isDefault + val isHearingImpaired: Boolean get() = stream.isHearingImpaired + + companion object { + fun getRelativeIndex( + index: Int, + streams: List<MediaStream>, + ): Int? { + val stream = streams.firstOrNull { it.index == index } ?: return null + return getRelativeIndex(stream, streams) + } + + fun getRelativeIndex( + stream: MediaStream, + streams: List<MediaStream>, + ): Int? = + streams + .filter { it.type == stream.type && it.isExternal == stream.isExternal } + .sortedBy { it.index } + .indexOfFirstOrNull { it.index == stream.index } + + fun from( + index: Int, + streams: List<MediaStream>, + ): RelativeMediaStream? { + val stream = streams.firstOrNull { it.index == index } ?: return null + val relativeIndex = getRelativeIndex(stream, streams) ?: return null + return RelativeMediaStream(relativeIndex, stream) + } + } +} + +/** + * Maps a list of [MediaStream] into [RelativeMediaStream] + */ +fun List<MediaStream>.asRelative(): List<RelativeMediaStream> { + val map = this.groupBy { Pair(it.type, it.isExternal) } + return map.values + .flatMap { typedStreams -> + typedStreams.sortedBy { it.index }.mapIndexed { index, stream -> + RelativeMediaStream(index, stream) + } + }.sortedBy { it.stream.index } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/RememberedTab.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/RememberedTab.kt new file mode 100644 index 00000000..2fc6f702 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/RememberedTab.kt @@ -0,0 +1,24 @@ +package com.github.damontecres.wholphin.data.model + +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index + +@Entity( + foreignKeys = [ + ForeignKey( + entity = JellyfinUser::class, + parentColumns = arrayOf("rowId"), + childColumns = arrayOf("userId"), + onDelete = ForeignKey.CASCADE, + onUpdate = ForeignKey.CASCADE, + ), + ], + indices = [Index("userId", "itemId", unique = true)], + primaryKeys = ["userId", "itemId"], +) +data class RememberedTab( + val userId: Int, + val itemId: String, + val index: Int, +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrPermission.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrPermission.kt index a6886cf2..918384d4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrPermission.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrPermission.kt @@ -7,6 +7,7 @@ import com.github.damontecres.wholphin.services.SeerrUserConfig */ enum class SeerrPermission( private val flag: Int, + vararg val parents: SeerrPermission, ) { // Source: https://github.com/seerr-team/seerr/blob/develop/server/lib/permissions.ts NONE(0), @@ -17,28 +18,28 @@ enum class SeerrPermission( REQUEST(32), VOTE(64), AUTO_APPROVE(128), - AUTO_APPROVE_MOVIE(256), - AUTO_APPROVE_TV(512), + AUTO_APPROVE_MOVIE(256, AUTO_APPROVE), + AUTO_APPROVE_TV(512, AUTO_APPROVE), REQUEST_4K(1024), - REQUEST_4K_MOVIE(2048), - REQUEST_4K_TV(4096), - REQUEST_ADVANCED(8192), - REQUEST_VIEW(16384), + REQUEST_4K_MOVIE(2048, REQUEST_4K), + REQUEST_4K_TV(4096, REQUEST_4K), + REQUEST_ADVANCED(8192, MANAGE_REQUESTS), + REQUEST_VIEW(16384, MANAGE_REQUESTS), AUTO_APPROVE_4K(32768), - AUTO_APPROVE_4K_MOVIE(65536), - AUTO_APPROVE_4K_TV(131072), - REQUEST_MOVIE(262144), - REQUEST_TV(524288), + AUTO_APPROVE_4K_MOVIE(65536, AUTO_APPROVE_4K), + AUTO_APPROVE_4K_TV(131072, AUTO_APPROVE_4K), + REQUEST_MOVIE(262144, REQUEST), + REQUEST_TV(524288, REQUEST), MANAGE_ISSUES(1048576), - VIEW_ISSUES(2097152), - CREATE_ISSUES(4194304), + VIEW_ISSUES(2097152, MANAGE_ISSUES), + CREATE_ISSUES(4194304, MANAGE_ISSUES), AUTO_REQUEST(8388608), - AUTO_REQUEST_MOVIE(16777216), - AUTO_REQUEST_TV(33554432), - RECENT_VIEW(67108864), - WATCHLIST_VIEW(134217728), - MANAGE_BLACKLIST(268435456), - VIEW_BLACKLIST(1073741824), + AUTO_REQUEST_MOVIE(16777216, AUTO_REQUEST), + AUTO_REQUEST_TV(33554432, AUTO_REQUEST), + RECENT_VIEW(67108864, MANAGE_REQUESTS), + WATCHLIST_VIEW(134217728, MANAGE_REQUESTS), + MANAGE_BLOCKLIST(268435456), + VIEW_BLOCKLIST(1073741824, MANAGE_BLOCKLIST), ; internal fun hasPermission(permissions: Int) = flag.and(permissions) == flag @@ -48,5 +49,16 @@ enum class SeerrPermission( * Check whether the user has the given permissions (or is an admin) */ fun SeerrUserConfig?.hasPermission(permission: SeerrPermission): Boolean { - return permission.hasPermission(this?.permissions ?: return false) || SeerrPermission.ADMIN.hasPermission(permissions) + if (this?.permissions == null) return false + return permission.hasPermission(permissions) || + permission.parents.any { hasPermission(it) } || + SeerrPermission.ADMIN.hasPermission(permissions) } + +val SeerrUserConfig?.canRequestMovies: Boolean + get() = + hasPermission(SeerrPermission.REQUEST_MOVIE) || hasPermission(SeerrPermission.REQUEST_4K_MOVIE) + +val SeerrUserConfig?.canRequestTv: Boolean + get() = + hasPermission(SeerrPermission.REQUEST_TV) || hasPermission(SeerrPermission.REQUEST_4K_TV) diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrPluginConfig.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrPluginConfig.kt new file mode 100644 index 00000000..1ff7212b --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrPluginConfig.kt @@ -0,0 +1,36 @@ +package com.github.damontecres.wholphin.data.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +sealed interface SeerrPluginLogin { + @SerialName("None") + @Serializable + data object None : SeerrPluginLogin + + @SerialName("ApiKey") + @Serializable + data class ApiKey( + val apiKey: String, + ) : SeerrPluginLogin + + @SerialName("Local") + @Serializable + data class Local( + val local: SeerrPluginLocalLogin, + ) : SeerrPluginLogin +} + +@Serializable +data class SeerrPluginLocalLogin( + val username: String, + val password: String, +) + +@Serializable +data class SeerrPluginSettings( + val version: Int = 1, + val serverUrl: String? = null, + val login: SeerrPluginLogin? = null, +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/SeriesTrackChoice.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/SeriesTrackChoice.kt new file mode 100644 index 00000000..5dd313d6 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/SeriesTrackChoice.kt @@ -0,0 +1,199 @@ +@file:UseSerializers(UUIDSerializer::class) + +package com.github.damontecres.wholphin.data.model + +import androidx.room.Entity +import androidx.room.ForeignKey +import com.github.damontecres.wholphin.services.isSigns +import kotlinx.serialization.Serializable +import kotlinx.serialization.UseSerializers +import org.jellyfin.sdk.model.api.MediaStream +import org.jellyfin.sdk.model.api.MediaStreamType +import org.jellyfin.sdk.model.serializer.UUIDSerializer +import java.util.UUID + +/** + * Stores the user's chosen track for a media item. This is by type, ie Audio vs Subtitle tracks + * + * Additional information about the chosen track is stored to compare against tracks in other media + */ +@Entity( + foreignKeys = [ + ForeignKey( + entity = JellyfinUser::class, + parentColumns = arrayOf("rowId"), + childColumns = arrayOf("userId"), + onDelete = ForeignKey.CASCADE, + onUpdate = ForeignKey.CASCADE, + ), + ], + primaryKeys = ["userId", "parentId", "type"], +) +@Serializable +data class SeriesTrackChoice( + val userId: Int, + /** + * Season or Series + */ + val parentId: UUID, + /** + * Type of track, ie audio or subtitle + */ + val type: SeriesTrackChoiceType, + /** + * Origin of the [parentId] + */ + val parentType: TrackChoiceParentType = TrackChoiceParentType.UNKNOWN, + val itemId: UUID? = null, + val language: String? = null, + val activation: ActivationFlag = ActivationFlag.ACTIVATED, + /** + * Flags for the track such as external or forced + * + * @see [TrackFlag] + */ + val trackFlags: Int = 0, + val codec: String? = null, + /** + * The track index based on its position within all streams of the same type + * + * @see [RelativeMediaStream] + */ + val relativeTrackIndex: Int? = null, + val title: String? = null, + val audioChannels: Int? = null, +) { + constructor( + userId: Int, + itemId: UUID, + parentId: UUID, + type: SeriesTrackChoiceType, + parentType: TrackChoiceParentType, + stream: RelativeMediaStream?, + activation: ActivationFlag, + ) : this( + userId = userId, + parentId = parentId, + parentType = parentType, + type = type, + itemId = itemId, + language = stream?.language, + activation = activation, + trackFlags = stream?.let { TrackFlag.calculateTrackFlags(stream.stream) } ?: 0, + codec = stream?.stream?.codec, + relativeTrackIndex = stream?.relativeIndex, + title = stream?.stream?.title, + audioChannels = stream?.stream?.channels, + ) + + companion object { + /** + * Convert the old [PlaybackLanguageChoice] into [SeriesTrackChoice]s + */ + fun from(plc: PlaybackLanguageChoice): List<SeriesTrackChoice> = + buildList { + if (plc.subtitleLanguage != null) { + add( + SeriesTrackChoice( + userId = plc.userId, + parentId = plc.seriesId, + parentType = TrackChoiceParentType.DEPRECATED, + type = SeriesTrackChoiceType.SUBTITLE, + itemId = plc.itemId, + language = plc.subtitleLanguage, + activation = if (plc.subtitlesDisabled == true) ActivationFlag.DISABLED else ActivationFlag.ACTIVATED, + trackFlags = 0, + codec = null, + relativeTrackIndex = null, + title = null, + audioChannels = null, + ), + ) + } + if (plc.audioLanguage != null) { + add( + SeriesTrackChoice( + userId = plc.userId, + parentId = plc.seriesId, + parentType = TrackChoiceParentType.DEPRECATED, + type = SeriesTrackChoiceType.AUDIO, + itemId = plc.itemId, + language = plc.audioLanguage, + activation = ActivationFlag.ACTIVATED, + trackFlags = 0, + codec = null, + relativeTrackIndex = null, + title = null, + audioChannels = null, + ), + ) + } + } + } +} + +enum class SeriesTrackChoiceType( + val mediaStreamType: MediaStreamType, +) { + AUDIO(MediaStreamType.AUDIO), + SUBTITLE(MediaStreamType.SUBTITLE), +} + +/** + * Flags for a track such as forced or defect + */ +enum class TrackFlag( + val flag: Int, + val hasFlag: (MediaStream) -> Boolean, +) { + DEFAULT(flag = 1, hasFlag = { it.isDefault }), + FORCED(flag = 2, hasFlag = { it.isForced }), + SDH(flag = 4, hasFlag = { it.isHearingImpaired }), + EXTERNAL(flag = 8, hasFlag = { it.isExternal }), + SIGNS(flag = 16, hasFlag = { isSigns(it) }), + ; + + fun within(flag: Int) = flag and this.flag == this.flag + + companion object { + fun SeriesTrackChoice.has(flag: TrackFlag) = flag.within(trackFlags) + + fun Iterable<TrackFlag>.calculateFlag() = fold(0) { flag, trackFlag -> flag or trackFlag.flag } + + fun calculateTrackFlags(track: MediaStream): Int = TrackFlag.entries.filter { it.hasFlag.invoke(track) }.calculateFlag() + } +} + +/** + * How was the track activated + */ +enum class ActivationFlag { + ACTIVATED, + DISABLED, + ONLY_FORCED, +} + +/** + * Where the [SeriesTrackChoice.parentId] comes from + */ +enum class TrackChoiceParentType { + /** + * Parent ID is from the series + */ + SERIES, + + /** + * Parent ID is from the season + */ + SEASON, + + /** + * Parent ID is from the deprecated [PlaybackLanguageChoice] + */ + DEPRECATED, + + /** + * Parent ID origin is unknown + */ + UNKNOWN, +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/ServerUserConfig.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/ServerUserConfig.kt new file mode 100644 index 00000000..0eaaa848 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/ServerUserConfig.kt @@ -0,0 +1,28 @@ +package com.github.damontecres.wholphin.data.model + +import org.jellyfin.sdk.model.api.UserConfiguration +import org.jellyfin.sdk.model.api.UserDto +import org.jellyfin.sdk.model.api.UserPolicy +import org.jellyfin.sdk.model.serializer.toUUIDOrNull +import java.util.UUID + +/** + * Server side configuration for a user, similar to [UserDto] but without the timestamps allowing for more reliable equals + */ +data class ServerUserConfig( + val id: UUID, + val name: String? = null, + val serverId: UUID? = null, + val configuration: UserConfiguration? = null, + val policy: UserPolicy? = null, +) { + constructor(userDto: UserDto) : this( + id = userDto.id, + name = userDto.name, + serverId = userDto.serverId?.toUUIDOrNull(), + configuration = userDto.configuration, + policy = userDto.policy, + ) + + val tvAccess: Boolean get() = policy?.enableLiveTvAccess == true +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/SourceRatings.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/SourceRatings.kt new file mode 100644 index 00000000..581a4aa2 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/SourceRatings.kt @@ -0,0 +1,231 @@ +package com.github.damontecres.wholphin.data.model + +import androidx.compose.runtime.Immutable +import java.util.Locale + +/** + * Which Rotten Tomatoes mark to draw. Chosen only from the label Seerr reports, never from a percentage. + */ +enum class RtMark { + FRESH, + ROTTEN, + UPRIGHT, + SPILLED, + + /** Seerr sent a score without a recognised label */ + UNKNOWN, + ; + + companion object { + /** + * Certified Fresh/Fresh -> [FRESH], Rotten -> [ROTTEN], Upright -> [UPRIGHT], Spilled -> [SPILLED], anything else null + */ + fun fromLabel(label: String?): RtMark? = + when (label) { + "Certified Fresh", "Fresh" -> FRESH + "Rotten" -> ROTTEN + "Upright" -> UPRIGHT + "Spilled" -> SPILLED + else -> null + } + } +} + +@Immutable +data class RtScore( + /** 0-100 */ + val percent: Int, + val mark: RtMark, +) + +/** + * The badge drawn in front of a rating's number. One per PWA icon. + */ +enum class RatingBadge { + RT_FRESH, + RT_ROTTEN, + RT_UPRIGHT, + RT_SPILLED, + + /** Rotten Tomatoes without a recognised label, drawn muted */ + RT_UNKNOWN, + IMDB, + TMDB, +} + +/** + * One displayable rating: a badge and its number + */ +@Immutable +sealed interface RatingEntry { + val text: String + val badge: RatingBadge + + /** + * An episode's own score rather than its series', which is drawn with a label so it is not mistaken for the series + */ + val isEpisode: Boolean get() = false + + data class RottenTomatoes( + val score: RtScore, + /** Critics (Tomatometer) or audience (Popcornmeter) */ + val critics: Boolean, + ) : RatingEntry { + override val text: String get() = "${score.percent}%" + override val badge: RatingBadge + get() = + when (score.mark) { + RtMark.FRESH -> RatingBadge.RT_FRESH + RtMark.ROTTEN -> RatingBadge.RT_ROTTEN + RtMark.UPRIGHT -> RatingBadge.RT_UPRIGHT + RtMark.SPILLED -> RatingBadge.RT_SPILLED + RtMark.UNKNOWN -> RatingBadge.RT_UNKNOWN + } + } + + data class Imdb( + val score: Double, + ) : RatingEntry { + override val text: String get() = formatScore(score) + override val badge: RatingBadge get() = RatingBadge.IMDB + } + + data class Tmdb( + val score: Double, + ) : RatingEntry { + override val text: String get() = formatScore(score) + override val badge: RatingBadge get() = RatingBadge.TMDB + } + + data class EpisodeTmdb( + val score: Double, + ) : RatingEntry { + override val text: String get() = formatScore(score) + override val badge: RatingBadge get() = RatingBadge.TMDB + override val isEpisode: Boolean get() = true + } +} + +private fun formatScore(score: Double) = String.format(Locale.getDefault(), "%.1f", score) + +/** + * Ratings for a single title from the same sources as the Wolfskin PWA: Rotten Tomatoes critics & audience, + * IMDb (movies only), and TMDB. + * + * Only scores that were actually observed are present. Use [of] to apply the PWA's rules. + */ +@Immutable +data class SourceRatings( + val rtCritics: RtScore? = null, + val rtAudience: RtScore? = null, + val imdb: Double? = null, + val tmdb: Double? = null, + /** The TMDB user score of the episode itself, for episodes */ + val episodeTmdb: Double? = null, +) { + val isEmpty: Boolean get() = rtCritics == null && rtAudience == null && imdb == null && tmdb == null && episodeTmdb == null + + /** + * Rotten Tomatoes entries in PWA order: critics then audience + */ + val criticEntries: List<RatingEntry.RottenTomatoes> + get() = + listOfNotNull( + rtCritics?.let { RatingEntry.RottenTomatoes(it, true) }, + rtAudience?.let { RatingEntry.RottenTomatoes(it, false) }, + ) + + /** + * IMDb or TMDB + */ + val communityEntries: List<RatingEntry> + get() = + listOfNotNull( + imdb?.let { RatingEntry.Imdb(it) }, + tmdb?.let { RatingEntry.Tmdb(it) }, + episodeTmdb?.let { RatingEntry.EpisodeTmdb(it) }, + ) + + /** + * These ratings of a series with the TMDB score of one of its episodes. Rotten Tomatoes stays for context, but the + * series' own TMDB score is dropped so there is a single TMDB number, which is the episode's. + * + * @param episodeScore from [episodeScore], if null these ratings are unchanged + */ + fun withEpisode(episodeScore: Double?): SourceRatings = if (episodeScore == null) this else copy(tmdb = null, episodeTmdb = episodeScore) + + /** + * The entries to draw in PWA order (Rotten Tomatoes, then IMDb or TMDB). + * + * Rotten Tomatoes is controlled by the critic rating display setting and IMDb/TMDB by the community rating setting. + */ + fun visibleEntries( + community: Boolean, + critic: Boolean, + ): List<RatingEntry> = (if (critic) criticEntries else emptyList()) + (if (community) communityEntries else emptyList()) + + companion object { + 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 ecaa0fac..5d43b394 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 @@ -9,6 +9,7 @@ import com.github.damontecres.wholphin.BuildConfig import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.WholphinApplication import com.github.damontecres.wholphin.services.UpdateChecker +import com.github.damontecres.wholphin.ui.formatMaxBitrate import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.preferences.ConditionalPreferences import com.github.damontecres.wholphin.ui.preferences.PreferenceGroup @@ -53,7 +54,10 @@ sealed interface AppPreference<Pref, T> { value: T?, ): String? = null - fun validate(value: T): PreferenceValidation = PreferenceValidation.Valid + fun validate( + prefs: Pref, + value: T, + ): PreferenceValidation = PreferenceValidation.Valid companion object { val SkipForward = @@ -202,18 +206,6 @@ sealed interface AppPreference<Pref, T> { }, ) - val CombineContinueNext = - AppSwitchPreference<AppPreferences>( - title = R.string.combine_continue_next, - defaultValue = false, - getter = { it.homePagePreferences.combineContinueNext }, - setter = { prefs, value -> - prefs.updateHomePagePreferences { combineContinueNext = value } - }, - summaryOn = R.string.enabled, - summaryOff = R.string.disabled, - ) - val RewatchNextUp = AppSwitchPreference<AppPreferences>( title = R.string.rewatch_next_up, @@ -226,6 +218,19 @@ sealed interface AppPreference<Pref, T> { summaryOff = R.string.disabled, ) + val HomeClickToPlay = + AppChoicePreference<AppPreferences, Boolean>( + title = R.string.continue_watching_click_behavior, + defaultValue = false, + getter = { it.homePagePreferences.clickToPlay }, + setter = { prefs, value -> + prefs.updateHomePagePreferences { clickToPlay = value } + }, + displayValues = R.array.home_click_to_play_options, + indexToValue = { it != 0 }, + valueToIndex = { if (it) 1 else 0 }, + ) + val PlayThemeMusic = AppChoicePreference<AppPreferences, ThemeSongVolume>( title = R.string.play_theme_music, @@ -341,7 +346,7 @@ sealed interface AppPreference<Pref, T> { const val MEGA_BIT = 1024 * 1024L const val DEFAULT_BITRATE = 100 * MEGA_BIT - private val bitrateValues = + val bitrateValues = listOf( 500 * 1024L, 750 * 1024L, @@ -374,35 +379,51 @@ sealed interface AppPreference<Pref, T> { summarizer = { value -> if (value != null) { val v = bitrateValues.getOrNull(value.toInt()) ?: DEFAULT_BITRATE - if (v < MEGA_BIT) { - "${v / 1024} kbps" - } else { - "${v / MEGA_BIT} Mbps" - } + formatMaxBitrate(v) } else { null } }, ) + val MaxResolutionPref = + AppChoicePreference<AppPreferences, MaxResolution>( + title = R.string.max_resolution, + defaultValue = MaxResolution.NO_LIMIT, + getter = { MaxResolution.fromValue(it.playbackPreferences.overrides.maxResolution) }, + setter = { prefs, value -> + prefs.updatePlaybackOverrides { maxResolution = value.value } + }, + displayValues = R.array.max_resolution_options, + indexToValue = { MaxResolution.entries.getOrNull(it) ?: MaxResolution.NO_LIMIT }, + valueToIndex = { it.ordinal }, + ) + val Ac3Supported = AppSwitchPreference<AppPreferences>( title = R.string.ac3_supported, defaultValue = true, getter = { it.playbackPreferences.overrides.ac3Supported }, setter = { prefs, value -> - prefs.updatePlaybackOverrides { ac3Supported = value } + if (!value) prefs.updateExperimentalPreferences { preferAc3Surround = false } + prefs.updatePlaybackOverrides { + ac3Supported = value + } }, summaryOn = R.string.enabled, summaryOff = R.string.disabled, ) + val DownMixStereo = AppSwitchPreference<AppPreferences>( title = R.string.downmix_stereo, defaultValue = false, getter = { it.playbackPreferences.overrides.downmixStereo }, setter = { prefs, value -> - prefs.updatePlaybackOverrides { downmixStereo = value } + if (value) prefs.updateExperimentalPreferences { preferAc3Surround = false } + prefs.updatePlaybackOverrides { + downmixStereo = value + } }, summaryOn = R.string.enabled, summaryOff = R.string.disabled, @@ -432,15 +453,69 @@ sealed interface AppPreference<Pref, T> { summaryOff = R.string.disabled, ) - val DirectPlayDoviProfile7 = - AppSwitchPreference<AppPreferences>( - title = R.string.force_dovi_profile_7, - defaultValue = false, - getter = { it.playbackPreferences.overrides.directPlayDolbyVisionEL }, + val Hdr10Override = + AppChoicePreference<AppPreferences, HdrOverrideMode>( + title = R.string.hdr10_override, + defaultValue = HdrOverrideMode.HDR_OVERRIDE_AUTO, + getter = { it.playbackPreferences.overrides.hdr10Override }, setter = { prefs, value -> - prefs.updatePlaybackOverrides { directPlayDolbyVisionEL = value } + prefs.updatePlaybackOverrides { hdr10Override = value } }, - summary = R.string.force_dovi_profile_7_summary, + displayValues = R.array.hdr_override_modes, + indexToValue = { HdrOverrideMode.forNumber(it) ?: HdrOverrideMode.HDR_OVERRIDE_AUTO }, + valueToIndex = { if (it != HdrOverrideMode.UNRECOGNIZED) it.number else 0 }, + ) + + val Hdr10PlusOverride = + AppChoicePreference<AppPreferences, HdrOverrideMode>( + title = R.string.hdr10_plus_override, + defaultValue = HdrOverrideMode.HDR_OVERRIDE_AUTO, + getter = { it.playbackPreferences.overrides.hdr10PlusOverride }, + setter = { prefs, value -> + prefs.updatePlaybackOverrides { hdr10PlusOverride = value } + }, + displayValues = R.array.hdr_override_modes, + indexToValue = { HdrOverrideMode.forNumber(it) ?: HdrOverrideMode.HDR_OVERRIDE_AUTO }, + valueToIndex = { if (it != HdrOverrideMode.UNRECOGNIZED) it.number else 0 }, + ) + + val DoviProfile5Override = + AppChoicePreference<AppPreferences, HdrOverrideMode>( + title = R.string.dovi_profile_5, + defaultValue = HdrOverrideMode.HDR_OVERRIDE_AUTO, + getter = { it.playbackPreferences.overrides.doviProfile5Override }, + setter = { prefs, value -> + prefs.updatePlaybackOverrides { doviProfile5Override = value } + }, + displayValues = R.array.hdr_override_modes, + indexToValue = { HdrOverrideMode.forNumber(it) ?: HdrOverrideMode.HDR_OVERRIDE_AUTO }, + valueToIndex = { if (it != HdrOverrideMode.UNRECOGNIZED) it.number else 0 }, + ) + + val DoviProfile7Override = + AppChoicePreference<AppPreferences, HdrOverrideMode>( + title = R.string.dovi_profile_7, + defaultValue = HdrOverrideMode.HDR_OVERRIDE_AUTO, + getter = { it.playbackPreferences.overrides.doviProfile7Override }, + setter = { prefs, value -> + prefs.updatePlaybackOverrides { doviProfile7Override = value } + }, + displayValues = R.array.hdr_override_modes, + indexToValue = { HdrOverrideMode.forNumber(it) ?: HdrOverrideMode.HDR_OVERRIDE_AUTO }, + valueToIndex = { if (it != HdrOverrideMode.UNRECOGNIZED) it.number else 0 }, + ) + + val DoviProfile8Override = + AppChoicePreference<AppPreferences, HdrOverrideMode>( + title = R.string.dovi_profile_8, + defaultValue = HdrOverrideMode.HDR_OVERRIDE_AUTO, + getter = { it.playbackPreferences.overrides.doviProfile8Override }, + setter = { prefs, value -> + prefs.updatePlaybackOverrides { doviProfile8Override = value } + }, + displayValues = R.array.hdr_override_modes, + indexToValue = { HdrOverrideMode.forNumber(it) ?: HdrOverrideMode.HDR_OVERRIDE_AUTO }, + valueToIndex = { if (it != HdrOverrideMode.UNRECOGNIZED) it.number else 0 }, ) val DecodeAv1 = @@ -504,6 +579,25 @@ sealed interface AppPreference<Pref, T> { summaryOff = R.string.disabled, ) + val DisplayTogglesPref = + AppMultiChoicePreference<AppPreferences, DisplayToggle>( + title = R.string.display_toggles_title, + summary = R.string.display_toggles_summary, + defaultValue = DisplayToggle.entries.filterNot { it == DisplayToggle.UNRECOGNIZED }, + allValues = DisplayToggle.entries.filterNot { it == DisplayToggle.UNRECOGNIZED }, + displayValues = R.array.display_toggle_types, + displayValuesSubtitles = R.array.display_toggle_types_subtitles, + getter = { + it.interfacePreferences.displayTogglesList + }, + setter = { prefs, value -> + prefs.updateInterfacePreferences { + clearDisplayToggles() + addAllDisplayToggles(value) + } + }, + ) + val InstalledVersion = AppClickablePreference<AppPreferences>( title = R.string.installed_version, @@ -533,7 +627,7 @@ sealed interface AppPreference<Pref, T> { val UpdateUrl = AppStringPreference<AppPreferences>( title = R.string.update_url, - defaultValue = "https://api.github.com/repos/damontecres/Wholphin/releases/latest", + defaultValue = "https://git.justinvisser.org/api/v1/repos/justinzeus/wolfskin/releases/latest", getter = { it.updateUrl }, setter = { prefs, value -> prefs.update { updateUrl = value } @@ -553,6 +647,51 @@ sealed interface AppPreference<Pref, T> { destination = Destination.Settings(PreferenceScreenOption.ADVANCED), ) + val WolfskinScreens = + AppSwitchPreference<AppPreferences>( + title = R.string.wolfskin_screens, + defaultValue = true, + getter = { org.justinvisser.wolfskin.tv.WolfskinScreenPreference.enabled }, + setter = { prefs, value -> + org.justinvisser.wolfskin.tv.WolfskinScreenPreference.enabled = value + prefs + }, + summaryOn = R.string.enabled, + summaryOff = R.string.disabled, + ) + + val ConductarrUrl = + AppStringPreference<AppPreferences>( + title = R.string.wolfskin_conductarr_url, + defaultValue = com.github.damontecres.wholphin.BuildConfig.CONDUCTARR_BASE_URL, + getter = { org.justinvisser.wolfskin.tv.ConductarrPreference.baseUrl }, + setter = { prefs, value -> + org.justinvisser.wolfskin.tv.ConductarrPreference.updateBaseUrl(value) + prefs + }, + summary = R.string.wolfskin_conductarr_url_summary, + ) + + val WolfskinFixtures = + AppSwitchPreference<AppPreferences>( + title = R.string.wolfskin_fixtures, + defaultValue = false, + getter = { org.justinvisser.wolfskin.tv.ConductarrPreference.fixtures }, + setter = { prefs, value -> + org.justinvisser.wolfskin.tv.ConductarrPreference.updateFixtures(value) + prefs + }, + summaryOn = R.string.enabled, + summaryOff = R.string.disabled, + ) + + val UserProfileSettings = + AppDestinationPreference<AppPreferences>( + title = R.string.override_user_profile_settings, + destination = Destination.UserAppPreferences, + summary = R.string.override_user_profile_settings_summary, + ) + val SkipIntros = AppChoicePreference<AppPreferences, SkipSegmentBehavior>( title = R.string.skip_intro_behavior, @@ -625,6 +764,20 @@ sealed interface AppPreference<Pref, T> { destination = Destination.Settings(PreferenceScreenOption.SKIP_SEGMENTS), ) + val DpadSeekModePref = + AppChoicePreference<AppPreferences, DpadSeekMode>( + title = R.string.d_pad_seek_mode_title, + defaultValue = DpadSeekMode.SEEKBAR_MINIMAL, + getter = { it.playbackPreferences.dpadSeekMode }, + setter = { prefs, value -> + prefs.updatePlaybackPreferences { dpadSeekMode = value } + }, + displayValues = R.array.dpad_seek_mode_options, + indexToValue = { DpadSeekMode.forNumber(it) }, + valueToIndex = { if (it != DpadSeekMode.UNRECOGNIZED) it.number else 0 }, + subtitles = R.array.dpad_seek_mode_summaries, + ) + val GlobalContentScale = AppChoicePreference<AppPreferences, PrefContentScale>( title = R.string.global_content_scale, @@ -673,6 +826,13 @@ sealed interface AppPreference<Pref, T> { summary = R.string.customize_home_summary, ) + val UserInterfaceLanguage = + AppClickablePreference<AppPreferences>( + title = R.string.user_interface_language, + getter = { }, + setter = { prefs, _ -> prefs }, + ) + val SendCrashReports = AppSwitchPreference<AppPreferences>( title = R.string.send_crash_reports, @@ -848,6 +1008,12 @@ sealed interface AppPreference<Pref, T> { destination = Destination.Settings(PreferenceScreenOption.MPV), ) + val HdrFormatSettings = + AppDestinationPreference<AppPreferences>( + title = R.string.hdr_formats, + destination = Destination.Settings(PreferenceScreenOption.HDR_FORMATS), + ) + val MpvHardwareDecoding = AppSwitchPreference<AppPreferences>( title = R.string.mpv_hardware_decoding, @@ -902,9 +1068,9 @@ sealed interface AppPreference<Pref, T> { summaryOff = R.string.disabled, ) - val RequireProfilePin = + val ProtectProfilePreference = AppClickablePreference<AppPreferences>( - title = R.string.require_pin_code, + title = R.string.profile_protection, ) val ImageDiskCacheSize = @@ -1046,8 +1212,8 @@ val basicPreferences = preferences = listOf( AppPreference.SignInAuto, - AppPreference.PlayThemeMusic, AppPreference.RememberSelectedTab, + AppPreference.PlayThemeMusic, AppPreference.SubtitleStyle, AppPreference.ThemeColors, AppPreference.ScreensaverSettings, @@ -1076,9 +1242,11 @@ val basicPreferences = title = R.string.profile_specific_settings, preferences = listOf( - AppPreference.RequireProfilePin, + AppPreference.ProtectProfilePreference, AppPreference.CustomizeHome, AppPreference.UserPinnedNavDrawerItems, + AppPreference.UserInterfaceLanguage, + AppPreference.UserProfileSettings, ), ), PreferenceGroup( @@ -1110,8 +1278,17 @@ private val ExoPlayerSettings = AppPreference.Ac3Supported, AppPreference.AssSubtitleMode, AppPreference.DirectPlayPgs, - AppPreference.DirectPlayDoviProfile7, AppPreference.DecodeAv1, + AppPreference.HdrFormatSettings, + ) + +private val HdrFormatSettings = + listOf( + AppPreference.Hdr10Override, + AppPreference.Hdr10PlusOverride, + AppPreference.DoviProfile5Override, + AppPreference.DoviProfile7Override, + AppPreference.DoviProfile8Override, ) val ExoPlayerPreferences = @@ -1122,6 +1299,14 @@ val ExoPlayerPreferences = ), ) +val HdrFormatPreferences = + listOf( + PreferenceGroup( + title = R.string.hdr_formats, + preferences = HdrFormatSettings, + ), + ) + private val MpvSettings = listOf( AppPreference.MpvHardwareDecoding, @@ -1159,14 +1344,16 @@ val advancedPreferences = title = R.string.ui_interface, preferences = listOf( + AppPreference.WolfskinScreens, + AppPreference.ConductarrUrl, + *if (com.github.damontecres.wholphin.BuildConfig.DEBUG) arrayOf(AppPreference.WolfskinFixtures) else emptyArray(), AppPreference.ShowClock, + AppPreference.BackdropStylePref, AppPreference.ShowLogos, AppPreference.ManageMedia, - AppPreference.CombineContinueNext, + AppPreference.DisplayTogglesPref, // Temporarily disabled, see https://github.com/damontecres/Wholphin/pull/127#issuecomment-3478058418 // AppPreference.NavDrawerSwitchOnFocus, - AppPreference.ControllerTimeout, - AppPreference.BackdropStylePref, AppPreference.SlideshowDuration, AppPreference.SlideshowPlayVideos, ), @@ -1178,9 +1365,12 @@ val advancedPreferences = preferences = listOf( AppPreference.OneClickPause, + AppPreference.ControllerTimeout, AppPreference.CinemaMode, AppPreference.GlobalContentScale, AppPreference.SkipSegments, + AppPreference.DpadSeekModePref, + AppPreference.MaxResolutionPref, AppPreference.MaxBitrate, AppPreference.RefreshRateSwitching, AppPreference.ResolutionSwitching, @@ -1239,8 +1429,16 @@ val advancedPreferences = AppPreference.DebugLogging, AppPreference.ImageDiskCacheSize, AppPreference.ClearImageCache, + ExperimentalPreference.Enable, AppPreference.OssLicenseInfo, ), + conditionalPreferences = + listOf( + ConditionalPreferences( + condition = { it.experimentalPreferences.enabled }, + preferences = listOf(ExperimentalPreference.ExperimentalSettings), + ), + ), ), ) } @@ -1269,6 +1467,14 @@ val screensaverPreferences = ScreensaverPreference.Start, ), ), + PreferenceGroup( + title = R.string.dim_screen, + preferences = + listOf( + ScreensaverPreference.DimToggle, + ScreensaverPreference.DimPercentage, + ), + ), ) data class AppSwitchPreference<Pref>( @@ -1276,11 +1482,16 @@ data class AppSwitchPreference<Pref>( override val defaultValue: Boolean, override val getter: (prefs: Pref) -> Boolean, override val setter: (prefs: Pref, value: Boolean) -> Pref, - val validator: (value: Boolean) -> PreferenceValidation = { PreferenceValidation.Valid }, + val validator: (prefs: Pref, value: Boolean) -> PreferenceValidation = { _, _ -> PreferenceValidation.Valid }, @param:StringRes val summary: Int? = null, @param:StringRes val summaryOn: Int? = null, @param:StringRes val summaryOff: Int? = null, ) : AppPreference<Pref, Boolean> { + override fun validate( + prefs: Pref, + value: Boolean, + ): PreferenceValidation = validator.invoke(prefs, value) + override fun summary( context: Context, value: Boolean?, @@ -1325,6 +1536,7 @@ data class AppMultiChoicePreference<Pref, T>( override val getter: (prefs: Pref) -> List<T>, override val setter: (prefs: Pref, value: List<T>) -> Pref, @param:StringRes val summary: Int? = null, + @param:ArrayRes val displayValuesSubtitles: Int? = null, ) : AppPreference<Pref, List<T>> data class AppClickablePreference<Pref>( diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt index 278c148c..85f8aade 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt @@ -55,6 +55,7 @@ class AppPreferencesSerializer AppPreference.RefreshRateSwitching.defaultValue resolutionSwitching = AppPreference.ResolutionSwitching.defaultValue cinemaMode = AppPreference.CinemaMode.defaultValue + dpadSeekMode = AppPreference.DpadSeekModePref.defaultValue overrides = PlaybackOverrides @@ -66,7 +67,8 @@ class AppPreferencesSerializer directPlayPgs = AppPreference.DirectPlayPgs.defaultValue mediaExtensionsEnabled = AppPreference.FfmpegPreference.defaultValue - assPlaybackMode = AppPreference.AssSubtitleMode.defaultValue + assPlaybackMode = + AppPreference.AssSubtitleMode.defaultValue }.build() mpvOptions = @@ -84,7 +86,6 @@ class AppPreferencesSerializer .apply { maxItemsPerRow = AppPreference.HomePageItems.defaultValue.toInt() enableRewatchingNextUp = AppPreference.RewatchNextUp.defaultValue - combineContinueNext = AppPreference.CombineContinueNext.defaultValue maxDaysNextUp = AppPreference.MaxDaysNextUp.defaultValue.toInt() }.build() interfacePreferences = @@ -98,6 +99,16 @@ class AppPreferencesSerializer showClock = AppPreference.ShowClock.defaultValue backdropStyle = AppPreference.BackdropStylePref.defaultValue showLogos = AppPreference.ShowLogos.defaultValue + clearDisplayToggles() + addAllDisplayToggles(AppPreference.DisplayTogglesPref.defaultValue) + + searchPreferences = + SearchPreferences + .newBuilder() + .apply { + combinedSearchResults = false + showVoiceSearchButton = true + }.build() subtitlesPreferences = SubtitlePreferences @@ -164,6 +175,20 @@ class AppPreferencesSerializer showLyrics = true showAlbumArt = true }.build() + + experimentalPreferences = + ExperimentalPreferences + .newBuilder() + .apply { + enabled = false + videoTunnelingEnabled = false + preferAc3Surround = ExperimentalPreference.PreferAc3ForSurround.defaultValue + disableAudioOffload = ExperimentalPreference.DisableAudioOffload.defaultValue + preferDolbyVisionOverHdr10Plus = + ExperimentalPreference.PreferDolbyVisionOverHdr10Plus.defaultValue + convertDolbyVisionProfile7 = + ExperimentalPreference.ConvertDolbyVisionProfile7.defaultValue + }.build() }.build() override suspend fun readFrom(input: InputStream): AppPreferences { @@ -207,6 +232,11 @@ inline fun AppPreferences.updateInterfacePreferences(block: InterfacePreferences interfacePreferences = interfacePreferences.toBuilder().apply(block).build() } +inline fun AppPreferences.updateSearchPreferences(block: SearchPreferences.Builder.() -> Unit): AppPreferences = + updateInterfacePreferences { + searchPreferences = searchPreferences.toBuilder().apply(block).build() + } + inline fun AppPreferences.updateSubtitlePreferences(block: SubtitlePreferences.Builder.() -> Unit): AppPreferences = updateInterfacePreferences { subtitlesPreferences = subtitlesPreferences.toBuilder().apply(block).build() @@ -237,6 +267,11 @@ inline fun AppPreferences.updateMusicPreferences(block: MusicPreferences.Builder musicPreferences = musicPreferences.toBuilder().apply(block).build() } +inline fun AppPreferences.updateExperimentalPreferences(block: ExperimentalPreferences.Builder.() -> Unit): AppPreferences = + update { + experimentalPreferences = experimentalPreferences.toBuilder().apply(block).build() + } + fun SubtitlePreferences.Builder.resetSubtitles() { fontSize = SubtitleSettings.FontSize.defaultValue.toInt() fontColor = SubtitleSettings.FontColor.defaultValue.toArgb() diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/ExperimentalPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/ExperimentalPreference.kt new file mode 100644 index 00000000..46c75636 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/ExperimentalPreference.kt @@ -0,0 +1,120 @@ +package com.github.damontecres.wholphin.preferences + +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.preferences.PreferenceGroup +import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption +import com.github.damontecres.wholphin.ui.preferences.PreferenceValidation + +object ExperimentalPreference { + val Enable = + AppSwitchPreference<AppPreferences>( + title = R.string.experimental_settings, + defaultValue = false, + getter = { it.experimentalPreferences.enabled }, + setter = { prefs, value -> + prefs.updateExperimentalPreferences { enabled = value } + }, + summaryOn = R.string.enabled, + summaryOff = R.string.disabled, + ) + + val ExperimentalSettings = + AppDestinationPreference<AppPreferences>( + title = R.string.experimental_settings, + destination = Destination.Settings(PreferenceScreenOption.EXPERIMENTAL), + ) + + val VideoTunneling = + AppSwitchPreference<AppPreferences>( + title = R.string.video_tunneling, + defaultValue = false, + getter = { it.experimentalPreferences.videoTunnelingEnabled }, + setter = { prefs, value -> + prefs.updateExperimentalPreferences { videoTunnelingEnabled = value } + }, + summaryOn = R.string.enabled, + summaryOff = R.string.disabled, + ) + + val PreferDolbyVisionOverHdr10Plus = + AppSwitchPreference<AppPreferences>( + title = R.string.prefer_dovi_over_hdr10_plus, + defaultValue = false, + getter = { it.experimentalPreferences.preferDolbyVisionOverHdr10Plus }, + setter = { prefs, value -> + prefs.updateExperimentalPreferences { preferDolbyVisionOverHdr10Plus = value } + }, + summary = R.string.prefer_dovi_over_hdr10_plus_summary, + ) + + val ConvertDolbyVisionProfile7 = + AppSwitchPreference<AppPreferences>( + title = R.string.convert_dovi_profile_7, + defaultValue = false, + getter = { it.experimentalPreferences.convertDolbyVisionProfile7 }, + setter = { prefs, value -> + prefs.updateExperimentalPreferences { convertDolbyVisionProfile7 = value } + }, + summary = R.string.convert_dovi_profile_7_summary, + ) + + val PreferAc3ForSurround = + AppSwitchPreference<AppPreferences>( + title = R.string.prefer_ac3_for_surround, + defaultValue = false, + getter = { it.experimentalPreferences.preferAc3Surround }, + setter = { prefs, value -> + prefs.updateExperimentalPreferences { + preferAc3Surround = value + } + }, + summary = R.string.prefer_ac3_for_surround_summary, + validator = { prefs, value -> + prefs.playbackPreferences.overrides.let { + if (value && !it.ac3Supported) { + PreferenceValidation.Invalid("AC3 support is not enabled") + } else if (value && it.downmixStereo) { + PreferenceValidation.Invalid("Always downmixing to stereo") + } else { + PreferenceValidation.Valid + } + } + }, + ) + + val DisableAudioOffload = + AppSwitchPreference<AppPreferences>( + title = R.string.disable_audio_offload, + defaultValue = false, + getter = { it.experimentalPreferences.disableAudioOffload }, + setter = { prefs, value -> + prefs.updateExperimentalPreferences { disableAudioOffload = value } + }, + summary = R.string.disable_audio_offload_summary, + ) +} + +val experimentalPreferences = + buildList { + add( + PreferenceGroup( + title = R.string.experimental_settings, + preferences = + listOf( + ExperimentalPreference.VideoTunneling, + ExperimentalPreference.PreferDolbyVisionOverHdr10Plus, + ExperimentalPreference.ConvertDolbyVisionProfile7, + ExperimentalPreference.PreferAc3ForSurround, + ExperimentalPreference.DisableAudioOffload, + ), + ), + ) + } + +/** + * Get a value from [ExperimentalPreference] or null if not enabled + */ +fun <T> ExperimentalPreferences.get(block: ExperimentalPreferences.() -> T): T? = if (enabled) block.invoke(this) else null + +fun ExperimentalPreferences.enabled(block: ExperimentalPreferences.() -> Boolean): Boolean = enabled && block.invoke(this) diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/Extensions.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/Extensions.kt index 0000878b..478bffb6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/Extensions.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/Extensions.kt @@ -1,5 +1,19 @@ package com.github.damontecres.wholphin.preferences +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.input.key.type +import com.github.damontecres.wholphin.ui.ifElse +import com.github.damontecres.wholphin.ui.playback.isDown +import com.github.damontecres.wholphin.ui.playback.isUp +import com.github.damontecres.wholphin.ui.tryRequestFocus +import kotlinx.coroutines.launch import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds @@ -10,3 +24,48 @@ val PlaybackPreferences.skipBackOnResume: Duration? } else { null } + +/** + * Adds modifiers so that pressing Up on the first element scrolls to the bottom and vice versa + */ +@Composable +fun Modifier.lazyListWrapScrolling( + state: LazyListState, + focused: Boolean, + isFirst: Boolean, + isLast: Boolean, + firstFocusRequester: FocusRequester, + lastFocusRequester: FocusRequester, +): Modifier { + val scope = rememberCoroutineScope() + return this + .ifElse( + isFirst, + Modifier + .focusRequester(firstFocusRequester) + .onKeyEvent { + if (focused && isUp(it) && it.type == KeyEventType.KeyDown) { + scope.launch { + state.animateScrollToItem(state.layoutInfo.totalItemsCount - 1) + lastFocusRequester.tryRequestFocus() + } + return@onKeyEvent true + } + false + }, + ).ifElse( + isLast, + Modifier + .focusRequester(lastFocusRequester) + .onKeyEvent { + if (focused && isDown(it) && it.type == KeyEventType.KeyDown) { + scope.launch { + state.animateScrollToItem(0) + firstFocusRequester.tryRequestFocus() + } + return@onKeyEvent true + } + false + }, + ) +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/HdrFormat.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/HdrFormat.kt new file mode 100644 index 00000000..df0b6823 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/HdrFormat.kt @@ -0,0 +1,56 @@ +package com.github.damontecres.wholphin.preferences + +import com.github.damontecres.wholphin.util.profile.WholphinVideoRangeType + +// Adapted from https://github.com/jellyfin/jellyfin-androidtv/blob/master/app/src/main/java/org/jellyfin/androidtv/preference/constant/HdrFormat.kt + +/** + * HDR formats that the user can override in the device profile. + */ +enum class HdrFormat( + val getter: (AppPreferences) -> HdrOverrideMode, + val rangeTypeNames: (jellyfinTenEleven: Boolean) -> Set<String>, +) { + HDR10( + getter = { it.playbackPreferences.overrides.hdr10Override }, + rangeTypeNames = { setOf(WholphinVideoRangeType.HDR10.serialName) }, + ), + HDR10_PLUS( + getter = { it.playbackPreferences.overrides.hdr10PlusOverride }, + rangeTypeNames = { setOf(WholphinVideoRangeType.HDR10_PLUS.serialName) }, + ), + DOVI_PROFILE_5( + getter = { it.playbackPreferences.overrides.doviProfile5Override }, + rangeTypeNames = { setOf(WholphinVideoRangeType.DOVI.serialName) }, + ), + DOVI_PROFILE_7( + getter = { it.playbackPreferences.overrides.doviProfile7Override }, + rangeTypeNames = { jellyfinTenEleven -> + if (jellyfinTenEleven) { + setOf(WholphinVideoRangeType.DOVI_WITH_EL.serialName, WholphinVideoRangeType.DOVI_WITH_EL_HDR10_PLUS.serialName) + } else { + emptySet() + } + }, + ), + DOVI_PROFILE_8( + getter = { it.playbackPreferences.overrides.doviProfile8Override }, + rangeTypeNames = { jellyfinTenEleven -> + buildSet { + add(WholphinVideoRangeType.DOVI_WITH_HDR10.serialName) + if (jellyfinTenEleven) add(WholphinVideoRangeType.DOVI_WITH_HDR10_PLUS.serialName) + } + }, + ), +} + +/** + * Collects the video range type names for every [HdrFormat] whose preference is currently set to [mode] + */ +fun AppPreferences.getHdrRangeTypesFor( + mode: HdrOverrideMode, + jellyfinTenEleven: Boolean, +): Set<String> = + HdrFormat.entries + .filter { it.getter(this) == mode } + .flatMapTo(mutableSetOf()) { it.rangeTypeNames(jellyfinTenEleven) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/MaxResolution.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/MaxResolution.kt new file mode 100644 index 00000000..8b389927 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/MaxResolution.kt @@ -0,0 +1,19 @@ +package com.github.damontecres.wholphin.preferences + +enum class MaxResolution( + val value: Int, +) { + NO_LIMIT(0), + ULTRA_HD_8K(4320), + ULTRA_HD_4K(2160), + FULL_HD(1080), + HD(720), + PAL_SD(576), + NTSC_SD(480), + LOW_DEF(360), + ; + + companion object { + fun fromValue(value: Int): MaxResolution = entries.firstOrNull { it.value == value } ?: NO_LIMIT + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/ScreensaverPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/ScreensaverPreference.kt index 1e1ad91c..04fb4a63 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/ScreensaverPreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/ScreensaverPreference.kt @@ -176,6 +176,37 @@ object ScreensaverPreference { }, ) + val DimToggle = + AppSwitchPreference<AppPreferences>( + title = R.string.dim_after_delay, + defaultValue = AppPreference.ShowClock.defaultValue, + getter = { it.interfacePreferences.screensaverPreference.dimEnabled }, + setter = { prefs, value -> + prefs.updateScreensaverPreferences { dimEnabled = value } + }, + summaryOn = R.string.yes, + summaryOff = R.string.no, + ) + + val DimPercentage = + AppSliderPreference<AppPreferences>( + title = R.string.dim_percentage, + defaultValue = 75, + min = 0, + max = 95, + interval = 5, + getter = { + it.interfacePreferences.screensaverPreference.dimPercent + .toLong() + }, + setter = { prefs, value -> + prefs.updateScreensaverPreferences { dimPercent = value.toInt() } + }, + summarizer = { value -> + value?.let { "$value%" } + }, + ) + val Start = AppClickablePreference<AppPreferences>( title = R.string.start_screensaver, diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/UserPreferences.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/UserPreferences.kt index 35da376a..c90cd00a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/UserPreferences.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/UserPreferences.kt @@ -1,5 +1,6 @@ package com.github.damontecres.wholphin.preferences +import com.github.damontecres.wholphin.data.model.JellyfinUserPreferences import org.jellyfin.sdk.model.api.SubtitlePlaybackMode import org.jellyfin.sdk.model.api.UserConfiguration @@ -8,6 +9,7 @@ import org.jellyfin.sdk.model.api.UserConfiguration */ data class UserPreferences( val appPreferences: AppPreferences, + val userPreferences: JellyfinUserPreferences?, ) val DefaultUserConfiguration = diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/UserProfileSettings.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/UserProfileSettings.kt new file mode 100644 index 00000000..31d0e98c --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/UserProfileSettings.kt @@ -0,0 +1,70 @@ +package com.github.damontecres.wholphin.preferences + +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.model.JellyfinUserPreferences +import com.github.damontecres.wholphin.ui.preferences.PreferenceGroup + +/** + * Represents settings from the user's profile on the server that may be overridden within the app + */ +object UserProfileSettings { + /** + * Special value that means the preferred language should be taken from the user's profile on the server + */ + const val USE_USER_PROFILE = "" + + /** + * Special value that means the user has no preferred language + */ + const val PREFER_ANY_LANGUAGE = "_any-language" + + val PreferredAudioLang = + AppClickablePreference<JellyfinUserPreferences>( + title = R.string.preferred_audio_language, + getter = { }, + setter = { prefs, _ -> prefs }, + ) + + val PreferredSubtitleLang = + AppClickablePreference<JellyfinUserPreferences>( + title = R.string.preferred_subtitle_language, + summary = null, + getter = { }, + setter = { prefs, _ -> prefs }, + ) + + val SubtitleModePref = + AppChoicePreference<JellyfinUserPreferences, SubtitleModePreference>( + title = R.string.subtitle_mode, + defaultValue = SubtitleModePreference.USE_USER_PROFILE, + getter = { it.subtitleMode }, + setter = { prefs, value -> + prefs.copy(subtitleMode = value) + }, + displayValues = R.array.subtitle_mode_options, + indexToValue = { SubtitleModePreference.entries[it] }, + valueToIndex = { it.ordinal }, + ) + + val Preferences = + listOf( + PreferenceGroup( + title = R.string.profile_specific_settings_from_server, + preferences = + listOf( + PreferredAudioLang, + PreferredSubtitleLang, + SubtitleModePref, + ), + ), + ) +} + +enum class SubtitleModePreference { + USE_USER_PROFILE, + DEFAULT, + SMART, + ONLY_FORCED, + ALWAYS, + NONE, +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt b/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt index 778f300c..17312378 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt @@ -6,12 +6,24 @@ import android.os.Build import androidx.core.content.edit import androidx.datastore.core.DataStore import androidx.preference.PreferenceManager +import com.github.damontecres.wholphin.data.CurrentUser +import com.github.damontecres.wholphin.data.JellyfinServerDao +import com.github.damontecres.wholphin.data.MostRecentServerProvider +import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao +import com.github.damontecres.wholphin.data.RememberedTabDao import com.github.damontecres.wholphin.data.SeerrServerDao +import com.github.damontecres.wholphin.data.SeriesTrackChoiceDao +import com.github.damontecres.wholphin.data.model.JellyfinUser +import com.github.damontecres.wholphin.data.model.RememberedTab +import com.github.damontecres.wholphin.data.model.SeriesTrackChoice import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.ExperimentalPreference +import com.github.damontecres.wholphin.preferences.HdrOverrideMode import com.github.damontecres.wholphin.preferences.ScreensaverPreference import com.github.damontecres.wholphin.preferences.update import com.github.damontecres.wholphin.preferences.updateAdvancedPreferences +import com.github.damontecres.wholphin.preferences.updateExperimentalPreferences import com.github.damontecres.wholphin.preferences.updateHomePagePreferences import com.github.damontecres.wholphin.preferences.updateInterfacePreferences import com.github.damontecres.wholphin.preferences.updateLiveTvPreferences @@ -21,15 +33,21 @@ import com.github.damontecres.wholphin.preferences.updatePhotoPreferences import com.github.damontecres.wholphin.preferences.updatePlaybackOverrides import com.github.damontecres.wholphin.preferences.updatePlaybackPreferences import com.github.damontecres.wholphin.preferences.updateScreensaverPreferences +import com.github.damontecres.wholphin.preferences.updateSearchPreferences import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings +import com.github.damontecres.wholphin.ui.preferences.subtitle.shouldEnableSeparateHdrToggle import com.github.damontecres.wholphin.ui.setup.seerr.migrateSeerrUrl import com.github.damontecres.wholphin.util.Version import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.firstOrNull import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.serializer.toUUIDOrNull import timber.log.Timber import java.io.File +import java.util.UUID import javax.inject.Inject import javax.inject.Singleton @@ -43,6 +61,11 @@ class AppUpgradeHandler @param:ApplicationContext private val context: Context, private val appPreferences: DataStore<AppPreferences>, private val seerrServerDao: SeerrServerDao, + private val serverDao: JellyfinServerDao, + private val rememberedTabDao: RememberedTabDao, + private val mostRecentServerProvider: MostRecentServerProvider, + private val playbackLanguageChoiceDao: PlaybackLanguageChoiceDao, + private val seriesTrackChoiceDao: SeriesTrackChoiceDao, ) { val pkgInfo: PackageInfo get() = context.packageManager.getPackageInfo(context.packageName, 0) val currentVersion: Version get() = Version.fromString(pkgInfo.versionName!!) @@ -334,5 +357,154 @@ class AppUpgradeHandler it.updatePlaybackPreferences { cinemaMode = AppPreference.CinemaMode.defaultValue } } } + + if (previous.isEqualOrBefore(Version.fromString("0.6.4-8-g0"))) { + appPreferences.updateData { + it.updateSearchPreferences { showVoiceSearchButton = true } + } + } + + if (previous.isEqualOrBefore(Version.fromString("0.6.4-26-g0"))) { + appPreferences.updateData { + it.updateInterfacePreferences { + clearDisplayToggles() + addAllDisplayToggles(AppPreference.DisplayTogglesPref.defaultValue) + } + } + } + + if (previous.isEqualOrBefore(Version.fromString("1.0.1-2-g0"))) { + Timber.i("Starting tab migration") + val users = mutableMapOf<UUID, JellyfinUser?>() + val tabs = + appPreferences.data.first().interfacePreferences.rememberedTabsMap.mapNotNull { (key, tabIndex) -> + // "serverId_userId_itemId", itemId can have underscores + try { + val firstUnderScore = key.indexOf("_").takeIf { it >= 1 } + if (firstUnderScore != null) { + val secondUnderscore = + key + .indexOf("_", startIndex = firstUnderScore + 1) + .takeIf { it >= 1 } + if (secondUnderscore != null) { + val serverId = key.substring(0, firstUnderScore).toUUIDOrNull() + val userId = + key + .substring(firstUnderScore + 1, secondUnderscore) + .toUUIDOrNull() + val itemId = key.substring(secondUnderscore + 1, key.length) + if (userId != null && serverId != null) { + return@mapNotNull users + .getOrPut(userId) { + serverDao.getUser(serverId, userId) + }?.let { + RememberedTab(it.rowId, itemId, tabIndex) + } + } + } + } + } catch (ex: Exception) { + Timber.w(ex, "Error parsing '%s'", key) + } + null + } + Timber.v("Migrating %s tabs", tabs.size) + try { + if (tabs.isNotEmpty()) { + rememberedTabDao.saveAll(tabs) + } + appPreferences.updateData { + it.updateInterfacePreferences { + clearRememberedTabs() + } + } + } catch (ex: Exception) { + Timber.e(ex, "Error saving migrated tabs") + } + } + + if (previous.isEqualOrBefore(Version.fromString("1.0.2-8-g0"))) { + appPreferences.updateData { + it.updateSubtitlePreferences { + useSeparateHdr = it.interfacePreferences.shouldEnableSeparateHdrToggle() + } + } + } + + if (previous.isEqualOrBefore(Version.fromString("1.0.6-0-g0"))) { + appPreferences.updateData { + it.updateScreensaverPreferences { + dimPercent = ScreensaverPreference.DimPercentage.defaultValue.toInt() + } + } + } + + if (previous.isEqualOrBefore(Version.fromString("1.0.6-7-g0"))) { + // preferAc3Surround was mistaken enabled by default, reset it only if the user hasn't enabled experimental settings + appPreferences.updateData { + if (!it.experimentalPreferences.enabled) { + it.updateExperimentalPreferences { + preferAc3Surround = + ExperimentalPreference.PreferAc3ForSurround.defaultValue + } + } else { + it + } + } + } + + if (current.isEqualOrBefore(Version.fromString("1.0.8-0-g0"))) { + try { + val prefs = appPreferences.data.firstOrNull() + val serverId = prefs?.currentServerId?.toUUIDOrNull() + val userId = prefs?.currentUserId?.toUUIDOrNull() + if (serverId != null && userId != null) { + val server = serverDao.getServer(serverId) + val user = server?.users?.firstOrNull { it.id == userId } + if (server != null && user != null) { + Timber.i("Migrating most recent server") + mostRecentServerProvider.save(CurrentUser(server.server, user)) + } + } + } catch (ex: Exception) { + Timber.e(ex, "Error during migration for most recent server") + } + appPreferences.updateData { + it.update { + currentServerId = "" + currentUserId = "" + } + } + } + + if (previous.isEqualOrBefore(Version.fromString("1.0.10"))) { + // directPlayDolbyVisionEL was replaced by doviProfile7Override; carry the setting forward + appPreferences.updateData { + if (it.playbackPreferences.overrides.directPlayDolbyVisionEL) { + it.updatePlaybackOverrides { + doviProfile7Override = HdrOverrideMode.HDR_OVERRIDE_ENABLE + directPlayDolbyVisionEL = false + } + } else { + it + } + } + } + + if (previous.isLessThan(Version.fromString("1.0.10-0-g0"))) { + val count = seriesTrackChoiceDao.count() + if (count == 0) { + var offset = 0 + while (true) { + val plcs = playbackLanguageChoiceDao.getAll(100, offset) + if (plcs.isEmpty()) break + offset += plcs.size + + val stcs = plcs.flatMap(SeriesTrackChoice::from) + Timber.i("Migrating %s PLC to %s STC", plcs.size, stcs.size) + seriesTrackChoiceDao.save(stcs) + } + } + } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/BackdropService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/BackdropService.kt index 1bf34929..66ef284c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/BackdropService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/BackdropService.kt @@ -18,8 +18,8 @@ import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.BackdropStyle +import com.github.damontecres.wholphin.util.WholphinDispatchers import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow @@ -55,7 +55,7 @@ class BackdropService * Update the backdrop to use the specified item */ suspend fun submit(item: BaseItem) = - withContext(Dispatchers.IO) { + withContext(WholphinDispatchers.IO) { val imageUrl = if (item.type == BaseItemKind.GENRE) { item.imageUrlOverride @@ -76,8 +76,8 @@ class BackdropService suspend fun submit( itemId: String, imageUrl: String?, - ) = withContext(Dispatchers.IO) { - if (backdropFlow.firstOrNull()?.imageUrl != imageUrl) { + ) = withContext(WholphinDispatchers.IO) { + if (backdropFlow.firstOrNull()?.imageUrl != imageUrl || imageUrl == null) { _backdropFlow.update { it.copy( itemId = itemId, @@ -110,7 +110,7 @@ class BackdropService val dynamicEnabled = backdropStyle == BackdropStyle.BACKDROP_DYNAMIC_COLOR || backdropStyle == BackdropStyle.UNRECOGNIZED - val (primaryColor, secondaryColor, tertiaryColor) = + val colors = if (dynamicEnabled) { extractColorsFromBackdrop(imageUrl) } else { @@ -121,9 +121,9 @@ class BackdropService BackdropResult( itemId = itemId, imageUrl = imageUrl, - primaryColor = primaryColor, - secondaryColor = secondaryColor, - tertiaryColor = tertiaryColor, + primaryColor = colors?.primary ?: it.primaryColor, + secondaryColor = colors?.secondary ?: it.secondaryColor, + tertiaryColor = colors?.tertiary ?: it.tertiaryColor, ) } else { it @@ -131,10 +131,10 @@ class BackdropService } } - suspend fun extractColorsFromBackdrop(imageUrl: String?): ExtractedColors = - withContext(Dispatchers.IO) { + suspend fun extractColorsFromBackdrop(imageUrl: String?): ExtractedColors? = + withContext(WholphinDispatchers.IO) { if (imageUrl.isNullOrBlank()) { - return@withContext ExtractedColors.DEFAULT + return@withContext null } extractedColorCache.get(imageUrl)?.let { return@withContext it diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/DatePlayedService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/DatePlayedService.kt index 9c4cfa5c..ceb84695 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/DatePlayedService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/DatePlayedService.kt @@ -2,17 +2,19 @@ package com.github.damontecres.wholphin.services import android.content.Context import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.lifecycleScope import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope +import com.github.damontecres.wholphin.ui.collectLatestIn import com.github.damontecres.wholphin.util.GetEpisodesRequestHandler +import com.github.damontecres.wholphin.util.WholphinDispatchers import com.google.common.cache.CacheBuilder import dagger.hilt.android.qualifiers.ActivityContext import dagger.hilt.android.scopes.ActivityScoped import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Deferred -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient @@ -54,7 +56,7 @@ class DatePlayedService adjacentTo = item.id, limit = 1, ) - return scope.async(Dispatchers.IO) { + return scope.async(WholphinDispatchers.IO) { val premiereDate = item.data.premiereDate try { val result = @@ -90,7 +92,7 @@ class DatePlayedService * the previous episode's last played timestamp, and the episode's premiere date */ suspend fun getLastPlayed(item: BaseItem): LocalDateTime? = - withContext(Dispatchers.IO) { + withContext(WholphinDispatchers.IO) { val seriesId = item.data.seriesId return@withContext if (seriesId != null) { datePlayedCache @@ -152,7 +154,7 @@ class DatePlayedInvalidationService private val activity = (context as AppCompatActivity) init { - serverRepository.current.observe(activity) { + serverRepository.current.collectLatestIn(activity.lifecycleScope) { datePlayedService.invalidateAll() } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/DeviceProfileService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/DeviceProfileService.kt index 4405092e..13ef5f70 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/DeviceProfileService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/DeviceProfileService.kt @@ -1,12 +1,19 @@ package com.github.damontecres.wholphin.services import android.content.Context +import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AssPlaybackMode -import com.github.damontecres.wholphin.preferences.PlaybackPreferences +import com.github.damontecres.wholphin.preferences.ExperimentalPreferences +import com.github.damontecres.wholphin.preferences.HdrFormat +import com.github.damontecres.wholphin.preferences.HdrOverrideMode +import com.github.damontecres.wholphin.preferences.PlaybackOverrides +import com.github.damontecres.wholphin.preferences.enabled +import com.github.damontecres.wholphin.preferences.getHdrRangeTypesFor +import com.github.damontecres.wholphin.util.DolbyVisionProfile7Conversion +import com.github.damontecres.wholphin.util.WholphinDispatchers import com.github.damontecres.wholphin.util.profile.MediaCodecCapabilitiesTest import com.github.damontecres.wholphin.util.profile.createDeviceProfile import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext @@ -34,20 +41,17 @@ class DeviceProfileService private var deviceProfile: DeviceProfile? = null suspend fun getOrCreateDeviceProfile( - prefs: PlaybackPreferences, + appPrefs: AppPreferences, serverVersion: ServerVersion?, ): DeviceProfile = - withContext(Dispatchers.Default) { + withContext(WholphinDispatchers.Default) { + val prefs = appPrefs.playbackPreferences mutex.withLock { val newConfig = DeviceProfileConfiguration( maxBitrate = prefs.maxBitrate.toInt(), - isAC3Enabled = prefs.overrides.ac3Supported, - downMixAudio = prefs.overrides.downmixStereo, - assPlaybackMode = prefs.overrides.assPlaybackMode, - pgsDirectPlay = prefs.overrides.directPlayPgs, - dolbyVisionELDirectPlay = prefs.overrides.directPlayDolbyVisionEL, - decodeAv1 = prefs.overrides.decodeAv1, + overrides = prefs.overrides, + experimental = appPrefs.experimentalPreferences, jellyfinTenEleven = serverVersion != null && serverVersion >= ServerVersion(10, 11, 0), ) @@ -57,13 +61,28 @@ class DeviceProfileService createDeviceProfile( mediaTest = mediaCodecCapabilitiesTest, maxBitrate = newConfig.maxBitrate, - isAC3Enabled = newConfig.isAC3Enabled, - downMixAudio = newConfig.downMixAudio, - assDirectPlay = newConfig.assPlaybackMode != AssPlaybackMode.ASS_TRANSCODE, - pgsDirectPlay = newConfig.pgsDirectPlay, - dolbyVisionELDirectPlay = newConfig.dolbyVisionELDirectPlay, + isAC3Enabled = newConfig.overrides.ac3Supported, + downMixAudio = newConfig.overrides.downmixStereo, + assDirectPlay = newConfig.overrides.assPlaybackMode != AssPlaybackMode.ASS_TRANSCODE, + pgsDirectPlay = newConfig.overrides.directPlayPgs, decodeAv1 = prefs.overrides.decodeAv1, + preferAc3ForSurround = appPrefs.experimentalPreferences.enabled { preferAc3Surround }, + forceEnabledHdr = + buildSet { + appPrefs + .getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_ENABLE, newConfig.jellyfinTenEleven) + .let(::addAll) + // A profile 7 track the player converts to 8.1 is one the server need not transcode + if (DolbyVisionProfile7Conversion.isWanted(newConfig.experimental, mediaCodecCapabilitiesTest)) { + HdrFormat.DOVI_PROFILE_7 + .rangeTypeNames(newConfig.jellyfinTenEleven) + .let(::addAll) + } + }, + forceDisabledHdr = + appPrefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_DISABLE, newConfig.jellyfinTenEleven), jellyfinTenEleven = newConfig.jellyfinTenEleven, + maxResolution = newConfig.overrides.maxResolution, ) } this@DeviceProfileService.deviceProfile!! @@ -76,11 +95,7 @@ class DeviceProfileService */ data class DeviceProfileConfiguration( val maxBitrate: Int, - val isAC3Enabled: Boolean, - val downMixAudio: Boolean, - val assPlaybackMode: AssPlaybackMode, - val pgsDirectPlay: Boolean, - val dolbyVisionELDirectPlay: Boolean, - val decodeAv1: Boolean, + val overrides: PlaybackOverrides, + val experimental: ExperimentalPreferences, val jellyfinTenEleven: Boolean, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/ExtrasService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/ExtrasService.kt index e7770ff5..80a4e5ab 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/ExtrasService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/ExtrasService.kt @@ -5,6 +5,8 @@ import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.ExtrasItem import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.pluralRes +import com.github.damontecres.wholphin.data.stringRes +import com.github.damontecres.wholphin.ui.isNotNullOrBlank import dagger.hilt.android.qualifiers.ApplicationContext import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.userLibraryApi @@ -48,9 +50,10 @@ class ExtrasService if (items.size == 1) { val item = items.first() val title = - item.title ?: context.resources.getQuantityString(type.pluralRes, 1) + item.title.takeIf { it.isNotNullOrBlank() } + ?: context.resources.getQuantityString(type.pluralRes, 1) val subtitle = - if (item.title == null) { + if (item.title.isNotNullOrBlank()) { context.resources.getQuantityString(type.pluralRes, 1) } else { null @@ -64,14 +67,14 @@ class ExtrasService imageUrl = imageUrlService.getItemImageUrl(item, ImageType.PRIMARY), ) } else if (items.size > 1) { - val title = - context.resources.getQuantityString(type.pluralRes, items.size) + val title = context.resources.getString(type.stringRes) val subtitle = context.resources.getQuantityString( R.plurals.items, items.size, items.size, ) + val itemForImage = items.random() ExtrasItem.Group( parentId = itemId, type = type, @@ -81,9 +84,10 @@ class ExtrasService isPlayed = items.all { it.played }, imageUrl = imageUrlService.getItemImageUrl( - items.random(), + itemForImage, ImageType.PRIMARY, ), + aspectRatio = itemForImage.aspectRatio, ) } else { null diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/FilterOptionCache.kt b/app/src/main/java/com/github/damontecres/wholphin/services/FilterOptionCache.kt new file mode 100644 index 00000000..52734f20 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/FilterOptionCache.kt @@ -0,0 +1,270 @@ +package com.github.damontecres.wholphin.services + +import android.content.Context +import com.github.damontecres.wholphin.api.seerr.model.TvShowStatus +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.filter.CommunityRatingFilter +import com.github.damontecres.wholphin.data.filter.DecadeFilter +import com.github.damontecres.wholphin.data.filter.DiscoverFilterBy +import com.github.damontecres.wholphin.data.filter.DiscoverMovieContentRatingFilter +import com.github.damontecres.wholphin.data.filter.DiscoverMovieGenreFilter +import com.github.damontecres.wholphin.data.filter.DiscoverMovieStudiosFilter +import com.github.damontecres.wholphin.data.filter.DiscoverRatingFilter +import com.github.damontecres.wholphin.data.filter.DiscoverTvContentRatingFilter +import com.github.damontecres.wholphin.data.filter.DiscoverTvGenreFilter +import com.github.damontecres.wholphin.data.filter.DiscoverTvStatusFilter +import com.github.damontecres.wholphin.data.filter.DiscoverTvStudiosFilter +import com.github.damontecres.wholphin.data.filter.FavoriteFilter +import com.github.damontecres.wholphin.data.filter.FilterBy +import com.github.damontecres.wholphin.data.filter.FilterValueOption +import com.github.damontecres.wholphin.data.filter.FilterVideoType +import com.github.damontecres.wholphin.data.filter.GenreFilter +import com.github.damontecres.wholphin.data.filter.OfficialRatingFilter +import com.github.damontecres.wholphin.data.filter.PlayedFilter +import com.github.damontecres.wholphin.data.filter.StudioFilter +import com.github.damontecres.wholphin.data.filter.VideoTypeFilter +import com.github.damontecres.wholphin.data.filter.YearFilter +import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope +import com.github.damontecres.wholphin.ui.showToast +import com.mayakapps.kache.InMemoryKache +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.firstOrNull +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.filterApi +import org.jellyfin.sdk.api.client.extensions.genresApi +import org.jellyfin.sdk.api.client.extensions.localizationApi +import org.jellyfin.sdk.api.client.extensions.studiosApi +import org.jellyfin.sdk.api.client.extensions.yearsApi +import org.jellyfin.sdk.model.UUID +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.ItemSortBy +import org.jellyfin.sdk.model.api.SortOrder +import timber.log.Timber +import java.util.TreeSet +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.time.Duration.Companion.hours + +/** + * Get the possible values for filters in a library + */ +@Singleton +class FilterOptionCache + @Inject + constructor( + @param:ApplicationContext private val context: Context, + @param:IoCoroutineScope private val ioCoroutineScope: CoroutineScope, + private val api: ApiClient, + private val serverRepository: ServerRepository, + private val seerrService: SeerrService, + ) { + private val cache = + InMemoryKache<FilterOptionCacheKey, List<FilterValueOption>>(16) { + creationScope = ioCoroutineScope + expireAfterWriteDuration = 1.hours + } + + /** + * Gets the possible values for a filter + * + * For example, the possible genres in the parent ID + */ + suspend fun getFilterOptionValues( + parentId: UUID?, + filterOption: FilterBy<*, *>, + ): List<FilterValueOption> { + val cacheKey = FilterOptionCacheKey(serverRepository.currentUser?.id, parentId, filterOption) + return try { + cache + .getOrPut(cacheKey) { (userId, parentId, filterOption) -> + getFilterOptionValues(userId, parentId, filterOption) + }.orEmpty() + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Error fetching options for %s", filterOption) + showToast(context, "Error occurred: ${ex.localizedMessage}") + emptyList() + } + } + + private suspend fun getFilterOptionValues( + userId: UUID?, + parentId: UUID?, + filterOption: FilterBy<*, *>, + ) = when (filterOption) { + GenreFilter -> { + api.genresApi + .getGenres( + parentId = parentId, + userId = userId, + ).content.items + .map { FilterValueOption(it.name ?: "", it.id) } + } + + StudioFilter -> { + api.studiosApi + .getStudios( + parentId = parentId, + userId = userId, + includeItemTypes = listOf(BaseItemKind.SERIES), + ).content.items + .map { FilterValueOption(it.name ?: "", it.id) } + } + + FavoriteFilter, + PlayedFilter, + -> { + listOf( + FilterValueOption("True", null), + FilterValueOption("False", null), + ) + } + + OfficialRatingFilter -> { + val ratings = + api.localizationApi + .getParentalRatings() + .content + .associate { + it.name to it.value + } + api.filterApi + .getQueryFiltersLegacy( + parentId = parentId, + userId = userId, + ).content.officialRatings + ?.map { r -> + val value = ratings[r] ?: 0 + FilterValueOption(r, value) + }?.sortedBy { it.value as Int } + .orEmpty() + } + + VideoTypeFilter -> { + FilterVideoType.entries.map { + FilterValueOption(it.readable, it) + } + } + + YearFilter -> { + api.yearsApi + .getYears( + parentId = parentId, + userId = userId, + sortBy = listOf(ItemSortBy.SORT_NAME), + sortOrder = listOf(SortOrder.ASCENDING), + ).content.items + .mapNotNull { + it.name?.toIntOrNull()?.let { FilterValueOption(it.toString(), it) } + } + } + + DecadeFilter -> { + val items = TreeSet<Int>() + api.yearsApi + .getYears( + parentId = parentId, + userId = userId, + sortBy = listOf(ItemSortBy.SORT_NAME), + sortOrder = listOf(SortOrder.ASCENDING), + ).content.items + .mapNotNullTo(items) { + it.name + ?.toIntOrNull() + ?.div(10) + ?.times(10) + } + items.toList().sorted().map { FilterValueOption("$it's", it) } + } + + CommunityRatingFilter -> { + (1..10).map { + FilterValueOption("$it", it) + } + } + + is DiscoverFilterBy -> { + getDiscoverFilterOptionValues(filterOption) + } + } + + private suspend fun getDiscoverFilterOptionValues(filterOption: DiscoverFilterBy<*>): List<FilterValueOption> { + if (seerrService.active.firstOrNull() != true) { + return emptyList() + } + return when (filterOption) { + DiscoverMovieGenreFilter -> { + seerrService.api.searchApi + .discoverGenresliderMovieGet() + .filter { it.name != null && it.id != null } + .map { + FilterValueOption(it.name!!, it.id!!) + } + } + + DiscoverTvGenreFilter -> { + seerrService.api.searchApi + .discoverGenresliderTvGet() + .filter { it.name != null && it.id != null } + .map { + FilterValueOption(it.name!!, it.id!!) + } + } + + DiscoverMovieStudiosFilter -> { + // TODO region + seerrService.api.otherApi + .watchprovidersMoviesGet("US") + .filter { it.name != null && it.id != null } + // TODO not what this is for +// .sortedBy { it.displayPriority } + .map { + // TODO logo? + FilterValueOption(it.name!!, it.id!!) + } + } + + DiscoverTvStudiosFilter -> { + // TODO region + seerrService.api.otherApi + .watchprovidersTvGet("US") + .filter { it.name != null && it.id != null } +// .sortedBy { it.displayPriority } + .map { + FilterValueOption(it.name!!, it.id!!) + } + } + + DiscoverTvStatusFilter -> { + TvShowStatus.entries.map { + FilterValueOption(it.value, it) + } + } + + DiscoverMovieContentRatingFilter -> { + listOf("NR", "G", "PG", "PG-13", "R", "NC-17") + .map { FilterValueOption(it, it) } + } + + DiscoverTvContentRatingFilter -> { + listOf("NR", "TV-Y", "TV-Y7", "TV-G", "TV-PG", "TV-14", "TV-MA") + .map { FilterValueOption(it, it) } + } + + DiscoverRatingFilter -> { + (1..10).map { + FilterValueOption("$it", it) + } + } + } + } + + private data class FilterOptionCacheKey( + val userId: UUID?, + val parentId: UUID?, + val filterOption: FilterBy<*, *>, + ) + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/HomeDataService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/HomeDataService.kt new file mode 100644 index 00000000..fe462b98 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/HomeDataService.kt @@ -0,0 +1,765 @@ +package com.github.damontecres.wholphin.services + +import androidx.core.net.toUri +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.HomeRowConfig +import com.github.damontecres.wholphin.data.model.ServerUserConfig +import com.github.damontecres.wholphin.data.model.createGenreDestination +import com.github.damontecres.wholphin.data.model.createStudioDestination +import com.github.damontecres.wholphin.preferences.HomePagePreferences +import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient +import com.github.damontecres.wholphin.ui.HomeItemFields +import com.github.damontecres.wholphin.ui.ProgramItemFields +import com.github.damontecres.wholphin.ui.components.getGenreImageMap +import com.github.damontecres.wholphin.ui.formatTypeName +import com.github.damontecres.wholphin.ui.main.settings.Library +import com.github.damontecres.wholphin.ui.playback.getTypeFor +import com.github.damontecres.wholphin.ui.toBaseItems +import com.github.damontecres.wholphin.ui.util.ResArgStringProvider +import com.github.damontecres.wholphin.ui.util.ResProviderStringProvider +import com.github.damontecres.wholphin.ui.util.ResStringProvider +import com.github.damontecres.wholphin.ui.util.StringStringProvider +import com.github.damontecres.wholphin.util.ApiRequestPager +import com.github.damontecres.wholphin.util.GetArtistsHandler +import com.github.damontecres.wholphin.util.GetGenresRequestHandler +import com.github.damontecres.wholphin.util.GetItemsRequestHandler +import com.github.damontecres.wholphin.util.GetLiveTvChannelsRequestHandler +import com.github.damontecres.wholphin.util.GetPersonsHandler +import com.github.damontecres.wholphin.util.GetProgramsDtoHandler +import com.github.damontecres.wholphin.util.GetRecordingsRequestHandler +import com.github.damontecres.wholphin.util.GetStudiosRequestHandler +import com.github.damontecres.wholphin.util.HomeRowLoadingState +import com.github.damontecres.wholphin.util.HomeRowLoadingState.Success +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.json.decodeFromStream +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.OkHttpClient +import okhttp3.Request +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.exception.InvalidStatusException +import org.jellyfin.sdk.api.client.extensions.liveTvApi +import org.jellyfin.sdk.api.client.extensions.userLibraryApi +import org.jellyfin.sdk.model.DateTime +import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.CollectionType +import org.jellyfin.sdk.model.api.GetProgramsDto +import org.jellyfin.sdk.model.api.ImageType +import org.jellyfin.sdk.model.api.ItemFields +import org.jellyfin.sdk.model.api.ItemSortBy +import org.jellyfin.sdk.model.api.SortOrder +import org.jellyfin.sdk.model.api.request.GetArtistsRequest +import org.jellyfin.sdk.model.api.request.GetGenresRequest +import org.jellyfin.sdk.model.api.request.GetItemsRequest +import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest +import org.jellyfin.sdk.model.api.request.GetLiveTvChannelsRequest +import org.jellyfin.sdk.model.api.request.GetPersonsRequest +import org.jellyfin.sdk.model.api.request.GetRecordingsRequest +import org.jellyfin.sdk.model.api.request.GetStudiosRequest +import timber.log.Timber +import java.time.LocalDateTime +import javax.inject.Inject +import javax.inject.Singleton + +@OptIn(ExperimentalSerializationApi::class) +@Singleton +class HomeDataService + @Inject + constructor( + private val api: ApiClient, + @param:AuthOkHttpClient private val authOkHttpClient: OkHttpClient, + private val serverRepository: ServerRepository, + private val latestNextUpService: LatestNextUpService, + private val imageUrlService: ImageUrlService, + private val suggestionService: SuggestionService, + ) { + /** + * Fetch the data from the server for a given [HomeRowConfig] + */ + suspend fun fetchDataForRow( + row: HomeRowConfig, + scope: CoroutineScope, + prefs: HomePagePreferences, + userDto: ServerUserConfig, + libraries: List<Library>, + limit: Int = prefs.maxItemsPerRow, + isRefresh: Boolean, + usePaging: Boolean = false, + ): HomeRowLoadingState = + when (row) { + is HomeRowConfig.ContinueWatching -> { + val resume = + latestNextUpService.getResume( + userDto.id, + limit, + true, + row.viewOptions.useSeries, + ) + + Success( + title = ResStringProvider(R.string.continue_watching), + items = resume, + viewOptions = row.viewOptions, + rowType = row, + showViewMore = resume.size >= limit, + ) + } + + is HomeRowConfig.NextUp -> { + val nextUp = + latestNextUpService.getNextUp( + userDto.id, + limit, + prefs.enableRewatchingNextUp, + false, + prefs.maxDaysNextUp, + row.viewOptions.useSeries, + ) + + Success( + title = ResStringProvider(R.string.next_up), + items = nextUp, + viewOptions = row.viewOptions, + rowType = row, + showViewMore = nextUp.size >= limit, + ) + } + + is HomeRowConfig.ContinueWatchingCombined -> { + val resume = + latestNextUpService.getResume( + userDto.id, + limit, + true, + row.viewOptions.useSeries, + ) + val nextUp = + latestNextUpService.getNextUp( + userDto.id, + limit, + prefs.enableRewatchingNextUp, + false, + prefs.maxDaysNextUp, + row.viewOptions.useSeries, + ) + val combined = latestNextUpService.buildCombined(resume, nextUp) + + Success( + title = ResStringProvider(R.string.continue_watching), + items = combined.take(limit), + viewOptions = row.viewOptions, + rowType = row, + showViewMore = combined.size >= limit, + ) + } + + is HomeRowConfig.Genres -> { + val request = + GetGenresRequest( + parentId = row.parentId, + userId = userDto.id, + limit = limit, + ) + val items = + GetGenresRequestHandler + .execute(api, request) + .content.items + val genreIds = items.map { it.id } + val genreImages = + getGenreImageMap( + api = api, + userId = serverRepository.currentUser?.id, + scope = scope, + imageUrlService = imageUrlService, + genres = genreIds, + parentId = row.parentId, + includeItemTypes = null, + cardWidthPx = null, + useCache = isRefresh, + ) + val library = + libraries + .firstOrNull { it.itemId == row.parentId } + + val title = + library?.name?.let { ResArgStringProvider(R.string.genres_in, it) } + ?: ResStringProvider(R.string.genres) + val genres = + items.map { + BaseItem( + it, + false, + genreImages[it.id], + createGenreDestination( + genreId = it.id, + genreName = it.name ?: "", + parentId = row.parentId, + parentName = library?.name, + includeItemTypes = library?.includeItemTypes, + collectionType = + library?.collectionType + ?: CollectionType.UNKNOWN, + ), + ) + } + + Success( + title, + genres, + viewOptions = row.viewOptions, + rowType = row, + showViewMore = genres.size >= limit, + ) + } + + is HomeRowConfig.Studios -> { + val request = + GetStudiosRequest( + parentId = row.parentId, + userId = userDto.id, + limit = limit, + includeItemTypes = listOf(BaseItemKind.SERIES), + ) + val items = + GetStudiosRequestHandler + .execute(api, request) + .content.items + val library = + libraries + .firstOrNull { it.itemId == row.parentId } + val title = + library?.name?.let { ResArgStringProvider(R.string.studios_in, it) } + ?: ResStringProvider(R.string.studios) + val studios = + items.map { + val imageUrl = + imageUrlService.getItemImageUrl( + itemId = it.id, + imageType = ImageType.THUMB, + tag = it.imageTags?.get(ImageType.THUMB), + ) + BaseItem( + it, + false, + imageUrl, + createStudioDestination( + studioId = it.id, + name = it.name ?: "", + parentId = row.parentId, + parentName = library?.name, + includeItemTypes = library?.includeItemTypes, + ), + ) + } + + Success( + title, + studios, + rowType = row, + viewOptions = row.viewOptions, + showViewMore = studios.size >= limit, + ) + } + + is HomeRowConfig.RecentlyAdded -> { + val library = libraries.firstOrNull { it.itemId == row.parentId } + val title = getRecentlyAddedTitle(library?.name) + val request = + GetLatestMediaRequest( + fields = library.itemFields, + imageTypeLimit = 1, + parentId = row.parentId, + groupItems = true, + limit = limit, + isPlayed = null, // Server will handle user's preference + ) + val latest = + api.userLibraryApi + .getLatestMedia(request) + .content + .map { BaseItem(it, row.viewOptions.useSeries) } + .let { + Success( + title, + it, + row.viewOptions, + rowType = row, + showViewMore = it.size >= limit, + ) + } + latest + } + + is HomeRowConfig.RecentlyReleased -> { + val library = libraries.firstOrNull { it.itemId == row.parentId } + val title = + library?.name?.let { + ResArgStringProvider(R.string.recently_released_in, it) + } ?: ResStringProvider(R.string.recently_released) + val request = + GetItemsRequest( + parentId = row.parentId, + limit = limit, + sortBy = + listOf( + ItemSortBy.PREMIERE_DATE, + ItemSortBy.SERIES_SORT_NAME, + ItemSortBy.AIRED_EPISODE_ORDER, + ), + sortOrder = + listOf( + SortOrder.DESCENDING, + SortOrder.ASCENDING, + SortOrder.DESCENDING, + ), + fields = library.itemFields, + recursive = true, + maxPremiereDate = LocalDateTime.now(), + isUnaired = false, + ) + if (usePaging) { + ApiRequestPager( + api, + request, + GetItemsRequestHandler, + scope, + useSeriesForPrimary = row.viewOptions.useSeries, + ).init() + } else { + GetItemsRequestHandler + .execute(api, request) + .content.items + .map { BaseItem(it, row.viewOptions.useSeries) } + }.let { + Success( + title, + it, + row.viewOptions, + rowType = row, + showViewMore = it.size >= limit, + ) + } + } + + is HomeRowConfig.ByParent -> { + val library = libraries.firstOrNull { it.itemId == row.parentId } + val request = + GetItemsRequest( + userId = userDto.id, + parentId = row.parentId, + recursive = row.recursive, + sortBy = + row.sort?.let { + buildList { + if (it.sort == ItemSortBy.RANDOM) { + add(ItemSortBy.SORT_NAME) + add(ItemSortBy.RANDOM) + } else { + add(it.sort) + if (it.sort != ItemSortBy.SORT_NAME) { + add(ItemSortBy.SORT_NAME) + } + } + } + }, + sortOrder = + row.sort?.let { + buildList { + if (it.sort == ItemSortBy.RANDOM) { + add(SortOrder.ASCENDING) + add(it.direction) + } else { + add(it.direction) + if (it.sort != ItemSortBy.SORT_NAME) { + add(SortOrder.ASCENDING) + } + } + } + }, + limit = limit, + fields = library.itemFields, + ) + + // Not using getItemName because we want to throw the 404 + val title = + api.userLibraryApi + .getItem( + userId = serverRepository.currentUser?.id, + itemId = row.parentId, + ).content.name + ?.let { StringStringProvider(it) } + ?: ResStringProvider(R.string.collection) + if (usePaging) { + ApiRequestPager( + api, + request, + GetItemsRequestHandler, + scope, + useSeriesForPrimary = row.viewOptions.useSeries, + ).init() + } else { + GetItemsRequestHandler + .execute(api, request) + .content.items + .map { BaseItem(it, row.viewOptions.useSeries) } + }.let { + Success( + title, + it, + row.viewOptions, + rowType = row, + showViewMore = it.size >= limit, + ) + } + } + + is HomeRowConfig.GetItems -> { + val request = + row.filter.asGetItemsRequest().copy( + userId = userDto.id, + limit = limit, + fields = HomeItemFieldsBoxSets, + ) + if (usePaging) { + ApiRequestPager( + api, + request, + GetItemsRequestHandler, + scope, + useSeriesForPrimary = row.viewOptions.useSeries, + ).init() + } else { + GetItemsRequestHandler + .execute(api, request) + .content.items + .map { BaseItem(it, row.viewOptions.useSeries) } + }.let { + Success( + StringStringProvider(row.name), + it, + row.viewOptions, + rowType = row, + showViewMore = it.size >= limit, + ) + } + } + + is HomeRowConfig.Favorite -> { + val title = + ResProviderStringProvider( + R.string.favorite_items_title, + ResStringProvider(formatTypeName(row.kind)), + ) + val resultList = + when (row.kind) { + BaseItemKind.PERSON -> { + val request = + GetPersonsRequest( + userId = userDto.id, + limit = limit, + fields = HomeItemFields, + isFavorite = true, + enableImages = true, + enableImageTypes = listOf(ImageType.PRIMARY), + ) + + GetPersonsHandler.execute(api, request).toBaseItems() + } + + BaseItemKind.MUSIC_ARTIST -> { + val request = + GetArtistsRequest( + userId = userDto.id, + limit = limit, + fields = HomeItemFields, + isFavorite = true, + ) + if (usePaging) { + ApiRequestPager( + api, + request, + GetArtistsHandler, + scope, + useSeriesForPrimary = row.viewOptions.useSeries, + ).init() + } else { + GetArtistsHandler + .execute(api, request) + .toBaseItems(row.viewOptions.useSeries) + } + } + + else -> { + val fields = + if (row.kind == BaseItemKind.BOX_SET) { + HomeItemFieldsBoxSets + } else { + HomeItemFields + } + val request = + GetItemsRequest( + userId = userDto.id, + recursive = true, + limit = limit, + fields = fields, + includeItemTypes = listOf(row.kind), + isFavorite = true, + ) + if (usePaging) { + ApiRequestPager( + api, + request, + GetItemsRequestHandler, + scope, + useSeriesForPrimary = row.viewOptions.useSeries, + ).init() + } else { + GetItemsRequestHandler + .execute(api, request) + .toBaseItems(row.viewOptions.useSeries) + } + } + } + resultList.let { + Success( + title, + it, + row.viewOptions, + rowType = row, + showViewMore = it.size >= limit, + ) + } + } + + is HomeRowConfig.Recordings -> { + val request = + GetRecordingsRequest( + userId = userDto.id, + isInProgress = true, + fields = HomeItemFields, + limit = limit, + enableImages = true, + enableUserData = true, + enableTotalRecordCount = false, + ) + if (usePaging) { + ApiRequestPager( + api, + request, + GetRecordingsRequestHandler, + scope, + useSeriesForPrimary = row.viewOptions.useSeries, + ).init() + } else { + api.liveTvApi + .getRecordings(request) + .content.items + .map { BaseItem(it, row.viewOptions.useSeries) } + }.let { + Success( + ResStringProvider(R.string.active_recordings), + it, + row.viewOptions, + rowType = row, + showViewMore = it.size >= limit, + ) + } + } + + is HomeRowConfig.TvPrograms -> { + val request = + GetProgramsDto( + userId = userDto.id, + fields = ProgramItemFields, + limit = limit, + enableUserData = true, + enableImages = true, + enableImageTypes = listOf(ImageType.PRIMARY, ImageType.LOGO), + imageTypeLimit = 1, + isAiring = true, + minEndDate = DateTime.now().plusMinutes(1), + ) + if (usePaging) { + ApiRequestPager( + api, + request, + GetProgramsDtoHandler, + scope, + useSeriesForPrimary = row.viewOptions.useSeries, + ).init() + } else { + api.liveTvApi + .getPrograms(request) + .content.items + .map { BaseItem(it, row.viewOptions.useSeries) } + }.let { + Success( + ResStringProvider(R.string.watch_live), + it, + row.viewOptions, + rowType = row, + showViewMore = it.size >= limit, + ) + } + } + + is HomeRowConfig.TvChannels -> { + val request = + GetLiveTvChannelsRequest( + userId = userDto.id, + fields = HomeItemFields, + limit = limit, + enableImages = true, + ) + if (usePaging) { + ApiRequestPager( + api, + request, + GetLiveTvChannelsRequestHandler, + scope, + useSeriesForPrimary = row.viewOptions.useSeries, + ).init() + } else { + api.liveTvApi + .getLiveTvChannels(request) + .toBaseItems(api, row.viewOptions.useSeries) + }.let { + Success( + ResStringProvider(R.string.channels), + it, + row.viewOptions, + rowType = row, + showViewMore = it.size >= limit, + ) + } + } + + is HomeRowConfig.Suggestions -> { + val library = + api.userLibraryApi + .getItem(itemId = row.parentId) + .content + val title = ResArgStringProvider(R.string.suggestions_for, library.name ?: "") + val itemKind = SuggestionsWorker.getTypeForCollection(library.collectionType) + if (itemKind != null) { + val suggestions = + suggestionService + .getSuggestionsFlow(row.parentId, itemKind) + .firstOrNull() + when (suggestions) { + SuggestionsResource.Empty -> { + Success( + title, + listOf(), + row.viewOptions, + rowType = row, + ) + } + + is SuggestionsResource.Success -> { + Success( + title, + suggestions.items, + row.viewOptions, + rowType = row, + showViewMore = suggestions.items.size >= limit, + ) + } + + SuggestionsResource.Loading, + null, + -> { + HomeRowLoadingState.Loading(title) + } + } + } else { + HomeRowLoadingState.Error( + title = title, + message = "Unsupported type ${library.collectionType}", + ) + } + } + + is HomeRowConfig.CustomEndpoint -> { + val title = StringStringProvider(row.title) + try { + val items = + fetchCustomEndpointItems(row) + .map { BaseItem(it, row.viewOptions.useSeries) } + Success(title, items, row.viewOptions, rowType = row) + } catch (ex: Exception) { + Timber.w(ex, "Custom endpoint %s failed", row.endpoint) + HomeRowLoadingState.Error(title, exception = ex) + } + } + } + + private suspend fun fetchCustomEndpointItems(row: HomeRowConfig.CustomEndpoint): List<BaseItemDto> { + val base = + api.baseUrl?.toHttpUrlOrNull() + ?: throw IllegalStateException("Jellyfin baseUrl not set") + val endpointUri = row.endpoint.toUri() + if (!endpointUri.isRelative) { + throw IllegalArgumentException("Custom endpoint must be an path relative to Jellyfin baseUrl: ${row.endpoint}") + } + val params = + buildMap { + serverRepository.currentUser + ?.id + ?.toString() + ?.let { put("userId", it) } + row.query?.forEach { put(it.key, it.value) } + } + val resolved = + base + .resolve(row.endpoint) + ?.newBuilder() + ?.apply { + params.forEach { (k, v) -> addQueryParameter(k, v) } + }?.build() + ?: throw IllegalStateException("Could not resolve endpoint: ${row.endpoint}") + val request = + Request + .Builder() + .url(resolved) + .get() + .apply { + row.headers?.forEach { header(it.key, it.value) } + }.build() + val response = + authOkHttpClient + .newCall(request) + .execute() + return response.use { + if (!it.isSuccessful) { + throw InvalidStatusException(it.code, null) + } + val queryResult = + HomeSettingsService.jsonParser.decodeFromStream<BaseItemDtoQueryResult>(it.body.byteStream()) + Timber.v("Got %s items from custom endpoint", queryResult.items.size) + queryResult.items + } + } + } + +private val Library?.itemFields: List<ItemFields> + get() = + if (this?.type == BaseItemKind.COLLECTION_FOLDER && this.collectionType == CollectionType.BOXSETS) { + // Get child count to show in the header + HomeItemFieldsBoxSets + } else { + HomeItemFields + } + +private val Library.includeItemTypes: List<BaseItemKind>? + get() = + collectionType.let { collectionType -> + getTypeFor(collectionType)?.let { type -> listOf(type) } + } + +private val HomeItemFieldsBoxSets get() = HomeItemFields + listOf(ItemFields.CHILD_COUNT) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt index 784440aa..1c05640e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt @@ -1,36 +1,28 @@ package com.github.damontecres.wholphin.services import android.content.Context +import androidx.annotation.StringRes +import androidx.annotation.VisibleForTesting import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.JellyfinServerDao import com.github.damontecres.wholphin.data.ServerRepository -import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.HomePageSettings import com.github.damontecres.wholphin.data.model.HomeRowConfig +import com.github.damontecres.wholphin.data.model.HomeRowViewOptions +import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.data.model.SUPPORTED_HOME_PAGE_SETTINGS_VERSION -import com.github.damontecres.wholphin.data.model.createGenreDestination -import com.github.damontecres.wholphin.data.model.createStudioDestination import com.github.damontecres.wholphin.preferences.DefaultUserConfiguration -import com.github.damontecres.wholphin.preferences.HomePagePreferences -import com.github.damontecres.wholphin.ui.DefaultItemFields -import com.github.damontecres.wholphin.ui.SlimItemFields -import com.github.damontecres.wholphin.ui.components.getGenreImageMap -import com.github.damontecres.wholphin.ui.main.settings.Library -import com.github.damontecres.wholphin.ui.main.settings.favoriteOptions -import com.github.damontecres.wholphin.ui.playback.getTypeFor -import com.github.damontecres.wholphin.ui.toBaseItems +import com.github.damontecres.wholphin.ui.AspectRatio +import com.github.damontecres.wholphin.ui.Cards +import com.github.damontecres.wholphin.ui.formatTypeName import com.github.damontecres.wholphin.ui.toServerString -import com.github.damontecres.wholphin.util.GetGenresRequestHandler -import com.github.damontecres.wholphin.util.GetItemsRequestHandler -import com.github.damontecres.wholphin.util.GetPersonsHandler -import com.github.damontecres.wholphin.util.GetStudiosRequestHandler -import com.github.damontecres.wholphin.util.HomeRowLoadingState -import com.github.damontecres.wholphin.util.HomeRowLoadingState.Error -import com.github.damontecres.wholphin.util.HomeRowLoadingState.Success -import com.github.damontecres.wholphin.util.supportedHomeCollectionTypes +import com.github.damontecres.wholphin.ui.util.ResArgStringProvider +import com.github.damontecres.wholphin.ui.util.ResProviderStringProvider +import com.github.damontecres.wholphin.ui.util.ResStringProvider +import com.github.damontecres.wholphin.ui.util.StringProvider +import com.github.damontecres.wholphin.ui.util.StringStringProvider import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.update import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.json.Json @@ -42,28 +34,15 @@ import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.api.client.extensions.liveTvApi import org.jellyfin.sdk.api.client.extensions.userApi import org.jellyfin.sdk.api.client.extensions.userLibraryApi -import org.jellyfin.sdk.api.client.extensions.userViewsApi import org.jellyfin.sdk.model.UUID -import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.CollectionType -import org.jellyfin.sdk.model.api.ImageType -import org.jellyfin.sdk.model.api.ItemSortBy -import org.jellyfin.sdk.model.api.SortOrder -import org.jellyfin.sdk.model.api.UserDto -import org.jellyfin.sdk.model.api.request.GetGenresRequest -import org.jellyfin.sdk.model.api.request.GetItemsRequest -import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest -import org.jellyfin.sdk.model.api.request.GetPersonsRequest -import org.jellyfin.sdk.model.api.request.GetRecommendedProgramsRequest -import org.jellyfin.sdk.model.api.request.GetRecordingsRequest -import org.jellyfin.sdk.model.api.request.GetStudiosRequest import timber.log.Timber import java.io.File import javax.inject.Inject import javax.inject.Singleton +import kotlin.coroutines.cancellation.CancellationException /** * Handles getting home page settings and data @@ -75,21 +54,11 @@ class HomeSettingsService @param:ApplicationContext private val context: Context, private val api: ApiClient, private val serverRepository: ServerRepository, - private val userPreferencesService: UserPreferencesService, private val navDrawerService: NavDrawerService, - private val latestNextUpService: LatestNextUpService, - private val imageUrlService: ImageUrlService, - private val suggestionService: SuggestionService, private val displayPreferencesService: DisplayPreferencesService, + private val serverPluginApi: ServerPluginApi, + private val serverDao: JellyfinServerDao, ) { - @OptIn(ExperimentalSerializationApi::class) - val jsonParser = - Json { - isLenient = true - ignoreUnknownKeys = true - allowTrailingComma = true - } - /** * The current home page settings */ @@ -171,9 +140,8 @@ class HomeSettingsService /** * Decodes [HomePageSettings] from a [JsonElement] skipping any unknown/unparsable rows - * - * This is public only for testing */ + @VisibleForTesting fun decode(element: JsonElement): HomePageSettings { val version = element.jsonObject["version"]?.jsonPrimitive?.intOrNull if (version == null || version > SUPPORTED_HOME_PAGE_SETTINGS_VERSION) { @@ -194,6 +162,42 @@ class HomeSettingsService return HomePageSettings(rows, version) } + private suspend fun tryLoad(block: suspend () -> HomePageSettings?): HomePageSettings? = + try { + block.invoke() + } catch (ex: Exception) { + Timber.w(ex, "Error loading settings") + null + } + + suspend fun fetchSettingsFor( + userId: UUID, + source: HomePageSettingsSource, + ): HomePageResolvedSettings? { + val (resolvedSource, settings) = + when (source) { + HomePageSettingsSource.UNSET -> loadInOrder(userId) + HomePageSettingsSource.LOCAL -> source to loadFromLocal(userId) + HomePageSettingsSource.SERVER_PROFILE -> source to loadFromServer(userId) + HomePageSettingsSource.PLUGIN -> source to serverPluginApi.fetchHomePageSettings() + HomePageSettingsSource.DEFAULT -> source to createDefault(userId).asHomePageSettings() + HomePageSettingsSource.WEB_CONFIG -> source to parseFromWebConfig(userId)?.asHomePageSettings() + } + Timber.i( + "fetch home settings: resolvedSource=%s, userId=%s, found=%s", + resolvedSource, + userId, + settings != null, + ) + return settings?.let { + val resolvedRows = + settings.rows.mapIndexed { index, config -> + resolve(index, config) + } + HomePageResolvedSettings(userId, resolvedSource, resolvedRows) + } + } + /** * Loads [HomePageSettings] into [currentSettings] * @@ -201,52 +205,70 @@ class HomeSettingsService * * Does not persist either the server nor default */ - suspend fun loadCurrentSettings(userId: UUID) { - Timber.v("Getting setting for %s", userId) - // User local then server/remote otherwise create a default + suspend fun loadCurrentSettings(user: JellyfinUser) { + currentSettings.update { HomePageResolvedSettings.EMPTY } + + Timber.v("Getting setting for %s", user.id) val settings = try { - val local = loadFromLocal(userId) - Timber.v("Found local? %s", local != null) - local + fetchSettingsFor(user.id, user.config.homeSettingsSource) + } catch (ex: CancellationException) { + throw ex } catch (ex: Exception) { - Timber.w(ex, "Error loading local settings") - // TODO show toast? - null - } ?: try { - val remote = loadFromServer(userId) - Timber.v("Found remote? %s", remote != null) - remote - } catch (ex: Exception) { - Timber.w(ex, "Error loading remote settings") + Timber.e(ex, "Error loading settings for %s", user.config.homeSettingsSource) null } - val resolvedSettings = - if (settings != null) { - Timber.v("Found settings") - // Resolve - val resolvedRows = - settings.rows.mapIndexed { index, config -> - resolve(index, config) - } - HomePageResolvedSettings(resolvedRows) - } else { - createDefault(userId) - } - + Timber.v("Found settings: %s", settings != null) + val resolvedSettings = settings ?: createDefault(user.id) currentSettings.update { resolvedSettings } } + /** + * Tries to load settings from local->server->plugin + */ + private suspend fun loadInOrder(userId: UUID): Pair<HomePageSettingsSource, HomePageSettings?> { + Timber.v("loadInOrder: local") + var settings = + HomePageSettingsSource.LOCAL to + tryLoad { + loadFromLocal(userId) + } + + if (settings.second == null) { + Timber.v("loadInOrder: server") + settings = + HomePageSettingsSource.SERVER_PROFILE to + tryLoad { + loadFromServer(userId) + } + } + + if (settings.second == null && serverRepository.serverPluginInstalled.value) { + Timber.v("loadInOrder: plugin") + settings = HomePageSettingsSource.PLUGIN to + tryLoad { + serverPluginApi.fetchHomePageSettings() + } + } + return settings + } + /** * Resolve the settings and set them to be the current settings */ - suspend fun updateCurrent(settings: HomePageSettings) { + suspend fun updateCurrent( + source: HomePageSettingsSource, + settings: HomePageSettings, + ) { val resolvedRows = settings.rows.mapIndexed { index, config -> resolve(index, config) } - val resolvedSettings = HomePageResolvedSettings(resolvedRows) - currentSettings.update { resolvedSettings } + currentSettings.update { it.copy(rows = resolvedRows) } + serverRepository.currentUser?.let { user -> + val toSave = user.updateConfig { it.copy(homeSettingsSource = source) } + serverDao.updateUser(toSave) + } } /** @@ -254,8 +276,8 @@ class HomeSettingsService */ suspend fun createDefault(userId: UUID): HomePageResolvedSettings { Timber.v("Creating default settings") - val user = serverRepository.currentUser.value?.takeIf { it.id == userId } - val userDto = serverRepository.currentUserDto.value?.takeIf { it.id == userId } + val user = serverRepository.currentUser?.takeIf { it.id == userId } + val userDto = serverRepository.currentUserDto?.takeIf { it.id == userId } val libraries = if (user != null) { navDrawerService.getFilteredUserLibraries(user, userDto?.tvAccess ?: false) @@ -263,53 +285,40 @@ class HomeSettingsService navDrawerService.getAllUserLibraries(userId, userDto?.tvAccess ?: false) } - val prefs = - userPreferencesService.getCurrent().appPreferences.homePagePreferences - val includedIds = libraries - .mapIndexed { index, it -> - val parentId = it.itemId - val title = getRecentlyAddedTitle(context, it) - if (it.collectionType == CollectionType.LIVETV) { + .mapIndexed { index, library -> + val parentId = library.itemId + val title = getRecentlyAddedTitle(library.name) + if (library.collectionType == CollectionType.LIVETV) { HomeRowConfigDisplay( id = index, - title = context.getString(R.string.live_tv), + title = ResStringProvider(R.string.watch_live), config = HomeRowConfig.TvPrograms(), ) } else { + val viewOptions = viewOptionsForCollectionType(library.collectionType) HomeRowConfigDisplay( id = index, title = title, - config = HomeRowConfig.RecentlyAdded(parentId), + config = + HomeRowConfig.RecentlyAdded( + parentId = parentId, + viewOptions = viewOptions, + ), ) } } - val continueWatchingRows = - if (prefs.combineContinueNext) { - listOf( - HomeRowConfigDisplay( - id = includedIds.size + 1, - title = context.getString(R.string.combine_continue_next), - config = HomeRowConfig.ContinueWatchingCombined(), - ), - ) - } else { - listOf( - HomeRowConfigDisplay( - id = includedIds.size + 1, - title = context.getString(R.string.continue_watching), - config = HomeRowConfig.ContinueWatching(), - ), - HomeRowConfigDisplay( - id = includedIds.size + 2, - title = context.getString(R.string.next_up), - config = HomeRowConfig.NextUp(), - ), - ) - } - val rowConfig = continueWatchingRows + includedIds - return HomePageResolvedSettings(rowConfig) + val continueWatchingRow = + listOf( + HomeRowConfigDisplay( + id = includedIds.size + 1, + title = ResStringProvider(R.string.combine_continue_next), + config = HomeRowConfig.ContinueWatchingCombined(), + ), + ) + val rowConfig = continueWatchingRow + includedIds + return HomePageResolvedSettings(userId, HomePageSettingsSource.DEFAULT, rowConfig) } /** @@ -326,12 +335,10 @@ class HomeSettingsService val userDto by api.userApi.getUserById(userId) val config = userDto.configuration ?: DefaultUserConfiguration val libraries = - api.userViewsApi - .getUserViews(userId = userId) - .content.items - .filter { - it.collectionType in supportedHomeCollectionTypes && - it.id !in config.latestItemsExcludes + navDrawerService + .getAllUserLibraries(userId, userDto.tvAccess) + .filterNot { + it.itemId in config.latestItemsExcludes } return if (customPrefs.isNotEmpty()) { @@ -350,7 +357,7 @@ class HomeSettingsService HomeSectionType.ACTIVE_RECORDINGS -> { HomeRowConfigDisplay( id = id++, - title = context.getString(R.string.active_recordings), + title = ResStringProvider(R.string.active_recordings), config = HomeRowConfig.Recordings(), ) } @@ -358,7 +365,7 @@ class HomeSettingsService HomeSectionType.RESUME -> { HomeRowConfigDisplay( id = id++, - title = context.getString(R.string.continue_watching), + title = ResStringProvider(R.string.continue_watching), config = HomeRowConfig.ContinueWatching(), ) } @@ -366,7 +373,7 @@ class HomeSettingsService HomeSectionType.NEXT_UP -> { HomeRowConfigDisplay( id = id++, - title = context.getString(R.string.next_up), + title = ResStringProvider(R.string.next_up), config = HomeRowConfig.NextUp(), ) } @@ -375,7 +382,7 @@ class HomeSettingsService if (userDto.tvAccess) { HomeRowConfigDisplay( id = id++, - title = context.getString(R.string.live_tv), + title = ResStringProvider(R.string.watch_live), config = HomeRowConfig.TvPrograms(), ) } else { @@ -404,15 +411,21 @@ class HomeSettingsService } } if (sectionType == HomeSectionType.LATEST_MEDIA) { - libraries.map { + libraries.map { library -> + val viewOptions = + viewOptionsForCollectionType(library.collectionType) HomeRowConfigDisplay( id = id++, title = - context.getString( + ResArgStringProvider( R.string.recently_added_in, - it.name ?: "", + library.name ?: "", + ), + config = + HomeRowConfig.RecentlyAdded( + parentId = library.itemId, + viewOptions = viewOptions, ), - config = HomeRowConfig.RecentlyAdded(it.id), ) } } else if (config != null) { @@ -421,7 +434,7 @@ class HomeSettingsService null } }.flatten() - HomePageResolvedSettings(rowConfigs) + HomePageResolvedSettings(userId, HomePageSettingsSource.WEB_CONFIG, rowConfigs) } else { null } @@ -436,7 +449,7 @@ class HomeSettingsService ): HomeRowConfigDisplay = when (config) { is HomeRowConfig.ByParent -> { - val name = getItemName(config.parentId) ?: "" + val name = getItemName(null, config.parentId) HomeRowConfigDisplay( id, name, @@ -447,7 +460,7 @@ class HomeSettingsService is HomeRowConfig.ContinueWatching -> { HomeRowConfigDisplay( id, - context.getString(R.string.continue_watching), + ResStringProvider(R.string.continue_watching), config, ) } @@ -455,64 +468,64 @@ class HomeSettingsService is HomeRowConfig.ContinueWatchingCombined -> { HomeRowConfigDisplay( id, - context.getString(R.string.combine_continue_next), + ResStringProvider(R.string.combine_continue_next), config, ) } is HomeRowConfig.Genres -> { - val name = getItemName(config.parentId) ?: "" + val title = getItemName(R.string.genres_in, config.parentId) HomeRowConfigDisplay( id, - context.getString(R.string.genres_in, name), + title, config, ) } is HomeRowConfig.Studios -> { - val name = getItemName(config.parentId) ?: "" + val title = getItemName(R.string.studios_in, config.parentId) HomeRowConfigDisplay( id, - context.getString(R.string.studios_in, name), + title, config, ) } is HomeRowConfig.GetItems -> { - HomeRowConfigDisplay(id, config.name, config) + HomeRowConfigDisplay(id, StringStringProvider(config.name), config) } is HomeRowConfig.NextUp -> { HomeRowConfigDisplay( id, - context.getString(R.string.next_up), + ResStringProvider(R.string.next_up), config, ) } is HomeRowConfig.RecentlyAdded -> { - val name = getItemName(config.parentId) ?: "" + val title = getItemName(R.string.recently_added_in, config.parentId) HomeRowConfigDisplay( id, - context.getString(R.string.recently_added_in, name), + title, config, ) } is HomeRowConfig.RecentlyReleased -> { - val name = getItemName(config.parentId) ?: "" + val title = getItemName(R.string.recently_released_in, config.parentId) HomeRowConfigDisplay( id, - context.getString(R.string.recently_released_in, name), + title, config, ) } is HomeRowConfig.Favorite -> { val name = - context.getString( - R.string.favorite_items, - context.getString(favoriteOptions[config.kind]!!), + ResProviderStringProvider( + R.string.favorite_items_title, + ResStringProvider(formatTypeName(config.kind)), ) HomeRowConfigDisplay(id, name, config) } @@ -520,7 +533,7 @@ class HomeSettingsService is HomeRowConfig.Recordings -> { HomeRowConfigDisplay( id = id, - title = context.getString(R.string.active_recordings), + title = ResStringProvider(R.string.active_recordings), config, ) } @@ -528,7 +541,7 @@ class HomeSettingsService is HomeRowConfig.TvPrograms -> { HomeRowConfigDisplay( id = id, - title = context.getString(R.string.live_tv), + title = ResStringProvider(R.string.watch_live), config, ) } @@ -536,497 +549,62 @@ class HomeSettingsService is HomeRowConfig.TvChannels -> { HomeRowConfigDisplay( id = id, - title = context.getString(R.string.channels), + title = ResStringProvider(R.string.channels), config, ) } is HomeRowConfig.Suggestions -> { - val name = getItemName(config.parentId) ?: "" + val title = getItemName(R.string.suggestions_for, config.parentId) HomeRowConfigDisplay( id = id, - title = context.getString(R.string.suggestions_for, name), + title = title, + config, + ) + } + + is HomeRowConfig.CustomEndpoint -> { + HomeRowConfigDisplay( + id = id, + title = StringStringProvider(config.title), config, ) } } - private suspend fun getItemName(itemId: UUID): String? = + private suspend fun getItemName( + @StringRes stringRes: Int?, + itemId: UUID, + default: StringProvider = StringStringProvider(""), + ): StringProvider = try { api.userLibraryApi - .getItem(itemId = itemId) - .content.name + .getItem( + userId = serverRepository.currentUser?.id, + itemId = itemId, + ).content.name + ?.let { + if (stringRes == null) { + StringStringProvider(it) + } else { + ResArgStringProvider(stringRes, it) + } + } ?: default } catch (ex: Exception) { Timber.e(ex, "Could not get name for %s", itemId) - context.getString(R.string.unknown) - } - - /** - * Fetch the data from the server for a given [HomeRowConfig] - */ - suspend fun fetchDataForRow( - row: HomeRowConfig, - scope: CoroutineScope, - prefs: HomePagePreferences, - userDto: UserDto, - libraries: List<Library>, - limit: Int = prefs.maxItemsPerRow, - isRefresh: Boolean, - ): HomeRowLoadingState = - when (row) { - is HomeRowConfig.ContinueWatching -> { - val resume = - latestNextUpService.getResume( - userDto.id, - limit, - true, - row.viewOptions.useSeries, - ) - - Success( - title = context.getString(R.string.continue_watching), - items = resume, - viewOptions = row.viewOptions, - rowType = row, - ) - } - - is HomeRowConfig.NextUp -> { - val nextUp = - latestNextUpService.getNextUp( - userDto.id, - limit, - prefs.enableRewatchingNextUp, - false, - prefs.maxDaysNextUp, - row.viewOptions.useSeries, - ) - - Success( - title = context.getString(R.string.next_up), - items = nextUp, - viewOptions = row.viewOptions, - rowType = row, - ) - } - - is HomeRowConfig.ContinueWatchingCombined -> { - val resume = - latestNextUpService.getResume( - userDto.id, - limit, - true, - row.viewOptions.useSeries, - ) - val nextUp = - latestNextUpService.getNextUp( - userDto.id, - limit, - prefs.enableRewatchingNextUp, - false, - prefs.maxDaysNextUp, - row.viewOptions.useSeries, - ) - - Success( - title = context.getString(R.string.continue_watching), - items = - latestNextUpService.buildCombined( - resume, - nextUp, - ), - viewOptions = row.viewOptions, - rowType = row, - ) - } - - is HomeRowConfig.Genres -> { - val request = - GetGenresRequest( - parentId = row.parentId, - userId = userDto.id, - limit = limit, - ) - val items = - GetGenresRequestHandler - .execute(api, request) - .content.items - val genreIds = items.map { it.id } - val genreImages = - getGenreImageMap( - api = api, - userId = serverRepository.currentUser.value?.id, - scope = scope, - imageUrlService = imageUrlService, - genres = genreIds, - parentId = row.parentId, - includeItemTypes = null, - cardWidthPx = null, - useCache = isRefresh, - ) - val library = - libraries - .firstOrNull { it.itemId == row.parentId } - - val title = - library?.name?.let { context.getString(R.string.genres_in, it) } - ?: context.getString(R.string.genres) - val genres = - items.map { - BaseItem( - it, - false, - genreImages[it.id], - createGenreDestination( - genreId = it.id, - genreName = it.name ?: "", - parentId = row.parentId, - parentName = library?.name, - includeItemTypes = - library?.collectionType?.let { - getTypeFor(it)?.let { - listOf(it) - } - }, - ), - ) - } - - Success( - title, - genres, - viewOptions = row.viewOptions, - rowType = row, - ) - } - - is HomeRowConfig.Studios -> { - val request = - GetStudiosRequest( - parentId = row.parentId, - userId = userDto.id, - limit = limit, - includeItemTypes = listOf(BaseItemKind.SERIES), - ) - val items = - GetStudiosRequestHandler - .execute(api, request) - .content.items - val library = - libraries - .firstOrNull { it.itemId == row.parentId } - val title = - library?.name?.let { context.getString(R.string.studios_in, it) } - ?: context.getString(R.string.studios) - val studios = - items.map { - val imageUrl = - imageUrlService.getItemImageUrl( - itemId = it.id, - imageType = ImageType.THUMB, - ) - BaseItem( - it, - false, - imageUrl, - createStudioDestination( - studioId = it.id, - name = it.name ?: "", - parentId = row.parentId, - parentName = library?.name, - includeItemTypes = - library?.collectionType?.let { - getTypeFor(it)?.let { - listOf(it) - } - }, - ), - ) - } - - Success( - title, - studios, - viewOptions = row.viewOptions, - ) - } - - is HomeRowConfig.RecentlyAdded -> { - val library = - libraries - .firstOrNull { it.itemId == row.parentId } - val title = getRecentlyAddedTitle(context, library) - val request = - GetLatestMediaRequest( - fields = SlimItemFields, - imageTypeLimit = 1, - parentId = row.parentId, - groupItems = true, - limit = limit, - isPlayed = null, // Server will handle user's preference - ) - val latest = - api.userLibraryApi - .getLatestMedia(request) - .content - .map { BaseItem.Companion.from(it, api, row.viewOptions.useSeries) } - .let { - Success( - title, - it, - row.viewOptions, - rowType = row, - ) - } - latest - } - - is HomeRowConfig.RecentlyReleased -> { - val name = - libraries - .firstOrNull { it.itemId == row.parentId } - ?.name - val title = - name?.let { - context.getString(R.string.recently_released_in, it) - } ?: context.getString(R.string.recently_released) - val request = - GetItemsRequest( - parentId = row.parentId, - limit = limit, - sortBy = listOf(ItemSortBy.PREMIERE_DATE), - sortOrder = listOf(SortOrder.DESCENDING), - fields = DefaultItemFields, - recursive = true, - ) - GetItemsRequestHandler - .execute(api, request) - .content.items - .map { BaseItem.Companion.from(it, api, row.viewOptions.useSeries) } - .let { - Success( - title, - it, - row.viewOptions, - rowType = row, - ) - } - } - - is HomeRowConfig.ByParent -> { - val request = - GetItemsRequest( - userId = userDto.id, - parentId = row.parentId, - recursive = row.recursive, - sortBy = row.sort?.let { listOf(it.sort) }, - sortOrder = row.sort?.let { listOf(it.direction) }, - limit = limit, - fields = DefaultItemFields, - ) - val name = - api.userLibraryApi - .getItem(itemId = row.parentId) - .content.name - GetItemsRequestHandler - .execute(api, request) - .content.items - .map { BaseItem(it, row.viewOptions.useSeries) } - .let { - Success( - name ?: context.getString(R.string.collection), - it, - row.viewOptions, - rowType = row, - ) - } - } - - is HomeRowConfig.GetItems -> { - val request = - row.getItems.let { - if (it.limit == null) { - it.copy( - userId = userDto.id, - limit = limit, - ) - } else { - it.copy( - userId = userDto.id, - ) - } - } - GetItemsRequestHandler - .execute(api, request) - .content.items - .map { BaseItem(it, row.viewOptions.useSeries) } - .let { - Success( - row.name, - it, - row.viewOptions, - rowType = row, - ) - } - } - - is HomeRowConfig.Favorite -> { - val title = - context.getString( - R.string.favorite_items, - context.getString(favoriteOptions[row.kind]!!), - ) - if (row.kind == BaseItemKind.PERSON) { - val request = - GetPersonsRequest( - userId = userDto.id, - limit = limit, - fields = DefaultItemFields, - isFavorite = true, - enableImages = true, - enableImageTypes = listOf(ImageType.PRIMARY), - ) - GetPersonsHandler - .execute(api, request) - .content.items - .map { BaseItem(it, true) } - .let { - Success( - title, - it, - row.viewOptions, - ) - } - } else { - val request = - GetItemsRequest( - userId = userDto.id, - recursive = true, - limit = limit, - fields = DefaultItemFields, - includeItemTypes = listOf(row.kind), - isFavorite = true, - ) - GetItemsRequestHandler - .execute(api, request) - .content.items - .map { BaseItem(it, row.viewOptions.useSeries) } - .let { - Success( - title, - it, - row.viewOptions, - rowType = row, - ) - } - } - } - - is HomeRowConfig.Recordings -> { - val request = - GetRecordingsRequest( - userId = userDto.id, - isInProgress = true, - fields = DefaultItemFields, - limit = limit, - enableImages = true, - enableUserData = true, - ) - api.liveTvApi - .getRecordings(request) - .content.items - .map { BaseItem(it, row.viewOptions.useSeries) } - .let { - Success( - context.getString(R.string.active_recordings), - it, - row.viewOptions, - rowType = row, - ) - } - } - - is HomeRowConfig.TvPrograms -> { - val request = - GetRecommendedProgramsRequest( - userId = userDto.id, - fields = DefaultItemFields, - limit = limit, - enableUserData = true, - enableImages = true, - enableImageTypes = listOf(ImageType.PRIMARY, ImageType.LOGO), - imageTypeLimit = 1, - ) - api.liveTvApi - .getRecommendedPrograms(request) - .content.items - .map { BaseItem(it, row.viewOptions.useSeries) } - .let { - Success( - context.getString(R.string.live_tv), - it, - row.viewOptions, - rowType = row, - ) - } - } - - is HomeRowConfig.TvChannels -> { - api.liveTvApi - .getLiveTvChannels( - userId = userDto.id, - fields = DefaultItemFields, - limit = limit, - enableImages = true, - ).toBaseItems(api, row.viewOptions.useSeries) - .let { - Success( - context.getString(R.string.channels), - it, - row.viewOptions, - rowType = row, - ) - } - } - - is HomeRowConfig.Suggestions -> { - val library = - api.userLibraryApi - .getItem(itemId = row.parentId) - .content - val title = context.getString(R.string.suggestions_for, library.name ?: "") - val itemKind = SuggestionsWorker.getTypeForCollection(library.collectionType) - val suggestions = - itemKind?.let { - suggestionService - .getSuggestionsFlow(row.parentId, itemKind) - .firstOrNull() - } - if (suggestions != null && suggestions is SuggestionsResource.Success) { - Success( - title, - suggestions.items, - row.viewOptions, - rowType = row, - ) - } else if (suggestions is SuggestionsResource.Empty) { - Success( - title, - listOf(), - row.viewOptions, - rowType = row, - ) - } else { - Error( - title, - message = "Unsupported type ${library.collectionType}", - ) - } - } + ResStringProvider(R.string.unknown) } companion object { const val CUSTOM_PREF_ID = "home_settings" + + @OptIn(ExperimentalSerializationApi::class) + val jsonParser = + Json { + isLenient = true + ignoreUnknownKeys = true + allowTrailingComma = true + } } } @@ -1035,7 +613,7 @@ class HomeSettingsService */ data class HomeRowConfigDisplay( val id: Int, - val title: String, + val title: StringProvider, val config: HomeRowConfig, ) @@ -1045,10 +623,15 @@ data class HomeRowConfigDisplay( * @see HomePageSettings */ data class HomePageResolvedSettings( + val userId: UUID, + val source: HomePageSettingsSource, val rows: List<HomeRowConfigDisplay>, ) { + fun asHomePageSettings(): HomePageSettings = HomePageSettings(rows.map { it.config }, SUPPORTED_HOME_PAGE_SETTINGS_VERSION) + companion object { - val EMPTY = HomePageResolvedSettings(listOf()) + val EMPTY = + HomePageResolvedSettings(UUID.randomUUID(), HomePageSettingsSource.UNSET, emptyList()) } } @@ -1078,13 +661,56 @@ class UnsupportedHomeSettingsVersionException( val maxSupportedVersion: Int = SUPPORTED_HOME_PAGE_SETTINGS_VERSION, ) : Exception("Unsupported version $unsupportedVersion, max supported is $maxSupportedVersion") -fun getRecentlyAddedTitle( - context: Context, - library: Library?, -): String = - if (library?.isRecordingFolder == true) { - context.getString(R.string.recently_recorded) - } else { - library?.name?.let { context.getString(R.string.recently_added_in, it) } - ?: context.getString(R.string.recently_added) +fun getRecentlyAddedTitle(name: String?): StringProvider = + name?.let { ResArgStringProvider(R.string.recently_added_in, it) } + ?: ResStringProvider(R.string.recently_added) + +fun viewOptionsForCollectionType(collectionType: CollectionType?): HomeRowViewOptions = + when (collectionType) { + CollectionType.MUSIC, + -> { + HomeRowViewOptions( + heightDp = Cards.HEIGHT_EPISODE, + aspectRatio = AspectRatio.SQUARE, + ) + } + + CollectionType.PHOTOS, + CollectionType.HOMEVIDEOS, + CollectionType.MUSICVIDEOS, + CollectionType.TRAILERS, + -> { + HomeRowViewOptions( + heightDp = Cards.HEIGHT_EPISODE, + aspectRatio = AspectRatio.WIDE, + ) + } + + CollectionType.LIVETV, + -> { + HomeRowViewOptions.liveTvDefault + } + + CollectionType.MOVIES, + CollectionType.TVSHOWS, + CollectionType.BOXSETS, + CollectionType.BOOKS, + CollectionType.PLAYLISTS, + CollectionType.FOLDERS, + CollectionType.UNKNOWN, + null, + -> { + HomeRowViewOptions() + } } + +enum class HomePageSettingsSource( + @param:StringRes val stringResId: Int, +) { + UNSET(R.string.unknown), + LOCAL(R.string.home_settings_source_local), + SERVER_PROFILE(R.string.home_settings_source_server_profile), + PLUGIN(R.string.home_settings_source_plugin), + DEFAULT(R.string.home_settings_source_unset), + WEB_CONFIG(R.string.home_settings_source_web), +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt index 1dd642b0..967629da 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt @@ -35,6 +35,11 @@ class ImageUrlService parentBackdropId: UUID? = null, fillWidth: Int? = null, fillHeight: Int? = null, + seriesPrimaryTag: String? = null, + parentLogoId: UUID? = null, + parentLogoTag: String? = null, + parentThumbTag: String? = null, + parentBackdropTags: List<String> = emptyList(), ): String? = when (imageType) { ImageType.LOGO -> { @@ -44,6 +49,7 @@ class ImageUrlService imageType = imageType, fillWidth = fillWidth, fillHeight = fillHeight, + tag = parentLogoTag.takeIf { parentLogoId == seriesId }, ) } else { getItemImageUrl( @@ -51,6 +57,7 @@ class ImageUrlService imageType = imageType, fillWidth = fillWidth, fillHeight = fillHeight, + tag = imageTags[imageType], ) } } @@ -63,6 +70,7 @@ class ImageUrlService imageType = imageType, fillWidth = fillWidth, fillHeight = fillHeight, + tag = parentBackdropTags.firstOrNull().takeIf { parentBackdropId == seriesId }, ) } else if (backdropTags.isNotEmpty()) { getItemImageUrl( @@ -70,6 +78,7 @@ class ImageUrlService imageType = imageType, fillWidth = fillWidth, fillHeight = fillHeight, + tag = backdropTags.first(), ) } else { null @@ -86,6 +95,7 @@ class ImageUrlService imageType = imageType, fillWidth = fillWidth, fillHeight = fillHeight, + tag = parentThumbTag, ) } else if (useSeriesForPrimary && parentBackdropId != null && (itemType == BaseItemKind.EPISODE || itemType == BaseItemKind.SEASON) @@ -96,6 +106,7 @@ class ImageUrlService imageType = ImageType.BACKDROP, fillWidth = fillWidth, fillHeight = fillHeight, + tag = parentBackdropTags.firstOrNull(), ) } else if (parentThumbId != null && itemType == BaseItemKind.SEASON && imageType !in imageTags) { getItemImageUrl( @@ -103,6 +114,7 @@ class ImageUrlService imageType = imageType, fillWidth = fillWidth, fillHeight = fillHeight, + tag = parentThumbTag, ) } else if (useSeriesForPrimary && parentThumbId == null && @@ -115,6 +127,7 @@ class ImageUrlService imageType = ImageType.PRIMARY, fillWidth = fillWidth, fillHeight = fillHeight, + tag = imageTags[ImageType.PRIMARY], ) } else if (imageType !in imageTags && backdropTags.isNotEmpty()) { // If no thumb, use backdrop if available @@ -123,6 +136,7 @@ class ImageUrlService imageType = ImageType.BACKDROP, fillWidth = fillWidth, fillHeight = fillHeight, + tag = backdropTags.first(), ) } else { getItemImageUrl( @@ -130,6 +144,7 @@ class ImageUrlService imageType = imageType, fillWidth = fillWidth, fillHeight = fillHeight, + tag = imageTags[imageType], ) } } @@ -145,6 +160,7 @@ class ImageUrlService imageType = imageType, fillWidth = fillWidth, fillHeight = fillHeight, + tag = seriesPrimaryTag.takeIf { imageType == ImageType.PRIMARY }, ) } else if (seriesId != null && itemType == BaseItemKind.SEASON && imageType !in imageTags) { getItemImageUrl( @@ -152,6 +168,7 @@ class ImageUrlService imageType = imageType, fillWidth = fillWidth, fillHeight = fillHeight, + tag = seriesPrimaryTag.takeIf { imageType == ImageType.PRIMARY }, ) } else { getItemImageUrl( @@ -159,6 +176,7 @@ class ImageUrlService imageType = imageType, fillWidth = fillWidth, fillHeight = fillHeight, + tag = imageTags[imageType], ) } } @@ -169,6 +187,7 @@ class ImageUrlService imageType = imageType, fillWidth = fillWidth, fillHeight = fillHeight, + tag = imageTags[imageType], ) } } @@ -193,6 +212,11 @@ class ImageUrlService backdropTags = item.data.backdropImageTags.orEmpty(), fillWidth = fillWidth, fillHeight = fillHeight, + seriesPrimaryTag = item.data.seriesPrimaryImageTag, + parentLogoId = item.data.parentLogoItemId, + parentLogoTag = item.data.parentLogoImageTag, + parentThumbTag = item.data.parentThumbImageTag, + parentBackdropTags = item.data.parentBackdropImageTags.orEmpty(), ) } else { null @@ -223,11 +247,11 @@ class ImageUrlService imageType = imageType, maxWidth = maxWidth, maxHeight = maxHeight, - width = width, - height = height, + width = width?.takeIf { it > 0 }, + height = height?.takeIf { it > 0 }, quality = quality, - fillWidth = fillWidth, - fillHeight = fillHeight, + fillWidth = fillWidth?.takeIf { it > 0 }, + fillHeight = fillHeight?.takeIf { it > 0 }, tag = tag, format = format, percentPlayed = percentPlayed, diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/IntentService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/IntentService.kt new file mode 100644 index 00000000..4fda8260 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/IntentService.kt @@ -0,0 +1,218 @@ +package com.github.damontecres.wholphin.services + +import android.app.SearchManager +import android.content.Intent +import androidx.annotation.VisibleForTesting +import com.github.damontecres.wholphin.data.RestoredSession +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds +import com.github.damontecres.wholphin.ui.nav.Destination +import kotlinx.coroutines.flow.first +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.userLibraryApi +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.serializer.toUUIDOrNull +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class IntentService + @Inject + constructor( + private val api: ApiClient, + private val serverRepository: ServerRepository, + private val userPreferencesService: UserPreferencesService, + ) { + suspend fun parseIntent(intent: Intent): IntentResult { + Timber.v("Parsing intent %s", intent) + Timber.v("Intent extras: %s", intent.extras) + Timber.v("Intent data: %s", intent.data) + val action = intent.action ?: intent.data?.host + if ( + intent.getStringParam(INTENT_ITEM_TYPE) == null && + (action == Intent.ACTION_MAIN || action.isNullOrBlank()) + ) { + // Normal app launch + return IntentResult.NoOp + } + + val result = prepare(intent) + if (result != null) { + return result + } + + // Existence of this key implies launching from Play Next channel + if (intent.getStringParam(INTENT_ITEM_TYPE) != null) { + return getDestinationFromChannel(intent)?.let { + IntentResult.Target( + destinations = listOf(it), + addHomeToBackStack = false, + ) + } ?: IntentResult.Error("Invalid parameters") + } + + if (action == Intent.ACTION_SEARCH || action == "search") { + val query = intent.getStringParam(SearchManager.QUERY) + return IntentResult.Target(listOf(Destination.Search(query ?: ""))) + } + + val itemId = intent.getStringParam("itemId")?.toUUIDOrNull() + val item = + itemId?.let { + try { + api.userLibraryApi + .getItem(itemId) + .content + .let { BaseItem(it) } + } catch (ex: Exception) { + Timber.w(ex, "Error fetching item %s", itemId) + return IntentResult.Error("Could not fetch item $itemId") + } + } + val itemDestination = item?.destination() + + val destinations = + when (action) { + Intent.ACTION_VIEW, "view" -> { + if (itemDestination != null) { + listOf(itemDestination) + } else { + // Will go to the home page + emptyList() + } + } + + ACTION_PLAYBACK, "play" -> { + if (itemId == null || itemDestination == null) { + return IntentResult.Error("Cannot start playback with no itemId") + } + val position = intent.getLongParam("position")?.coerceAtLeast(0) + val shuffle = intent.getBooleanExtra("shuffle", false) + + val playbackDestination = + Destination.Playback( + itemId = itemId, + positionMs = position ?: 0L, + shuffle = shuffle, + ) + + if (itemDestination is Destination.Playback) { + listOf(playbackDestination) + } else { + listOf( + itemDestination, + playbackDestination, + ) + } + } + + else -> { + return IntentResult.Error("Invalid action: ${intent.action}") + } + } + + return IntentResult.Target(destinations, true) + } + + @VisibleForTesting + internal suspend fun prepare(intent: Intent): IntentResult? { + val appPrefs = userPreferencesService.flow.first().appPreferences + + val userId = intent.getStringParam(INTENT_USER_ID)?.toUUIDOrNull() + val serverId = intent.getStringParam(INTENT_SERVER_ID)?.toUUIDOrNull() + return if (userId != null && serverId != null) { + Timber.v("Intent switches user") + val user = serverRepository.tryChangeUser(serverId, userId) + if (user != null) { + null + } else { + IntentResult.Error("Error restoring user") + } + } else if (appPrefs.signInAutomatically) { + Timber.v("No current user, so restoring last") + val result = serverRepository.restoreLastSession() + Timber.v("Restored result=%s", result) + when (result) { + RestoredSession.None, + is RestoredSession.ServerOnly, + -> { + IntentResult.Error("Could not auto sign-in, specify a server & user") + } + + is RestoredSession.Success -> { + if (result.currentUser.user.isProtected) { + IntentResult.Error("Could not auto sign-in, user is protected") + } else { + null + } + } + } + } else { + IntentResult.Error("Auto sign-in not enabled") + } + } + + private fun Intent.getStringParam(key: String) = getStringExtra(key) ?: data?.getQueryParameter(key) + + private fun Intent.getLongParam(key: String) = + getLongExtra(key, -1).takeIf { it >= 0 } ?: data?.getQueryParameter(key)?.toLongOrNull() + + private fun getDestinationFromChannel(intent: Intent): Destination? = + intent.let { + val itemId = + it.getStringExtra(INTENT_ITEM_ID)?.toUUIDOrNull() + val type = + it.getStringExtra(INTENT_ITEM_TYPE)?.let(BaseItemKind::fromNameOrNull) + if (itemId != null && type != null) { + val seriesId = it.getStringExtra(INTENT_SERIES_ID)?.toUUIDOrNull() + val seasonId = it.getStringExtra(INTENT_SEASON_ID)?.toUUIDOrNull() + val episodeNumber = it.getIntExtra(INTENT_EPISODE_NUMBER, -1) + val seasonNumber = it.getIntExtra(INTENT_SEASON_NUMBER, -1) + if (seriesId != null && seasonId != null && episodeNumber >= 0 && seasonNumber >= 0) { + Destination.SeriesOverview( + itemId = seriesId, + type = BaseItemKind.SERIES, + seasonEpisode = + SeasonEpisodeIds( + seasonId = seasonId, + seasonNumber = seasonNumber, + episodeId = itemId, + episodeNumber = episodeNumber, + ), + ) + } else { + Destination.MediaItem(itemId, type) + } + } else { + null + } + } + + companion object { + const val INTENT_ITEM_ID = "itemId" + const val INTENT_ITEM_TYPE = "itemType" + const val INTENT_SERIES_ID = "seriesId" + const val INTENT_EPISODE_NUMBER = "epNum" + const val INTENT_SEASON_NUMBER = "seaNum" + const val INTENT_SEASON_ID = "seaId" + const val INTENT_SERVER_ID = "serverId" + const val INTENT_USER_ID = "userId" + + const val ACTION_PLAYBACK = "com.github.damontecres.wholphin.PLAYBACK" + } + } + +sealed interface IntentResult { + data class Error( + val message: String, + ) : IntentResult + + data object NoOp : IntentResult + + data class Target( + val destinations: List<Destination>, + val addHomeToBackStack: Boolean = true, + ) : IntentResult +} 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 01bdba3f..dbaa219e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt @@ -6,10 +6,9 @@ package com.github.damontecres.wholphin.services import com.github.damontecres.wholphin.data.model.BaseItem -import com.github.damontecres.wholphin.ui.SlimItemFields +import com.github.damontecres.wholphin.ui.HomeSlimItemFields import com.github.damontecres.wholphin.util.LocalDateTimeSerializer -import com.github.damontecres.wholphin.util.supportItemKinds -import kotlinx.coroutines.Dispatchers +import com.github.damontecres.wholphin.util.WholphinDispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.sync.Semaphore @@ -23,6 +22,7 @@ import org.jellyfin.sdk.api.client.extensions.itemsApi import org.jellyfin.sdk.api.client.extensions.tvShowsApi import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.ItemSortBy +import org.jellyfin.sdk.model.api.MediaType import org.jellyfin.sdk.model.api.SortOrder import org.jellyfin.sdk.model.api.request.GetNextUpRequest import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest @@ -58,25 +58,18 @@ class LatestNextUpService val request = GetResumeItemsRequest( userId = userId, - fields = SlimItemFields, + fields = HomeSlimItemFields, limit = limit, - includeItemTypes = - if (includeEpisodes) { - supportItemKinds - } else { - supportItemKinds - .toMutableSet() - .apply { - remove(BaseItemKind.EPISODE) - } - }, + mediaTypes = listOf(MediaType.VIDEO), + excludeItemTypes = if (!includeEpisodes) listOf(BaseItemKind.EPISODE) else null, + enableTotalRecordCount = false, ) val items = api.itemsApi .getResumeItems(request) .content .items - .map { BaseItem.from(it, api, useSeriesForPrimary) } + .map { BaseItem(it, useSeriesForPrimary) } return items } @@ -97,7 +90,7 @@ class LatestNextUpService val request = GetNextUpRequest( userId = userId, - fields = SlimItemFields, + fields = HomeSlimItemFields, imageTypeLimit = 1, parentId = null, limit = limit, @@ -105,13 +98,14 @@ class LatestNextUpService enableUserData = true, enableRewatching = enableRewatching, nextUpDateCutoff = nextUpDateCutoff, + enableTotalRecordCount = false, ) val nextUp = api.tvShowsApi .getNextUp(request) .content .items - .map { BaseItem.from(it, api, useSeriesForPrimary) } + .map { BaseItem(it, useSeriesForPrimary) } .filter { val seriesId = it.data.seriesId if (seriesId != null && seriesId in removedSeries) { @@ -141,14 +135,14 @@ class LatestNextUpService resume: List<BaseItem>, nextUp: List<BaseItem>, ): List<BaseItem> = - withContext(Dispatchers.IO) { + withContext(WholphinDispatchers.IO) { val start = System.currentTimeMillis() val semaphore = Semaphore(3) val deferred = nextUp .filter { it.data.seriesId != null } .map { item -> - async(Dispatchers.IO) { + async(WholphinDispatchers.IO) { try { semaphore.withPermit { datePlayedService.getLastPlayed(item) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpWorker.kt b/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpWorker.kt index 328346f3..a60bf7c4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpWorker.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpWorker.kt @@ -1,6 +1,7 @@ package com.github.damontecres.wholphin.services import android.content.Context +import androidx.annotation.VisibleForTesting import androidx.appcompat.app.AppCompatActivity import androidx.hilt.work.HiltWorker import androidx.lifecycle.lifecycleScope @@ -13,14 +14,16 @@ import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager import androidx.work.WorkerParameters import androidx.work.workDataOf +import com.github.damontecres.wholphin.data.RestoredSession import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.ui.collectLatestIn import com.github.damontecres.wholphin.util.ExceptionHandler +import com.github.damontecres.wholphin.util.WholphinDispatchers import dagger.assisted.Assisted import dagger.assisted.AssistedInject import dagger.hilt.android.qualifiers.ActivityContext import dagger.hilt.android.scopes.ActivityScoped import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.model.serializer.toUUIDOrNull @@ -51,12 +54,8 @@ class LatestNextUpWorker try { if (api.baseUrl.isNullOrBlank() || api.accessToken.isNullOrBlank()) { // Not active - var currentUser = serverRepository.current.value - if (currentUser == null) { - serverRepository.restoreSession(serverId, userId) - currentUser = serverRepository.current.value - } - if (currentUser == null) { + val result = serverRepository.restoreLastSession() + if (result !is RestoredSession.Success) { Timber.w("No user found during run") return Result.failure() } @@ -90,14 +89,14 @@ class LatestNextUpSchedulerService "SuggestionsSchedulerService requires an AppCompatActivity context, but received: ${context::class.java.name}", ) - // Exposed for testing - internal var dispatcher: CoroutineDispatcher = Dispatchers.IO + @VisibleForTesting + internal var dispatcher: CoroutineDispatcher = WholphinDispatchers.IO init { - serverRepository.current.observe(activity) { user -> + serverRepository.current.collectLatestIn(activity.lifecycleScope) { user -> Timber.v("New user %s", user?.user?.id) if (user == null) { - workManager.cancelUniqueWork(SuggestionsWorker.WORK_NAME) + workManager.cancelUniqueWork(LatestNextUpWorker.WORK_NAME) } else { activity.lifecycleScope.launch(dispatcher + ExceptionHandler()) { scheduleWork(user.user.id, user.server.id) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/LiveTvService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/LiveTvService.kt new file mode 100644 index 00000000..d89916f0 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/LiveTvService.kt @@ -0,0 +1,94 @@ +package com.github.damontecres.wholphin.services + +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.ui.toServerString +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.liveTvApi +import org.jellyfin.sdk.model.api.TimerInfoDto +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Functions for recording live tv programs + */ +@Singleton +class LiveTvService + @Inject + constructor( + private val api: ApiClient, + ) { + /** + * Fetch a tv program by ID + */ + suspend fun fetchProgramForDialog(programId: UUID): BaseItem { + val result = + api.liveTvApi + .getProgram(programId.toServerString()) + .content + .let { BaseItem(it) } + return result + } + + /** + * Cancel a recording by [timerId] + * + * @param series whether the timerId is a series recording or not + * @param timerId the recording ID + */ + suspend fun cancelRecording( + series: Boolean, + timerId: String?, + ): Boolean = + if (timerId != null) { + if (series) { + api.liveTvApi.cancelSeriesTimer(timerId) + } else { + api.liveTvApi.cancelTimer(timerId) + } + true + } else { + false + } + + /** + * Record tv program + * + * @param programId the tv program ID + * @param series whether to set up series recording or just a single recording + */ + suspend fun record( + programId: UUID, + series: Boolean, + ) { + val d by api.liveTvApi.getDefaultTimer(programId.toServerString()) + if (series) { + api.liveTvApi.createSeriesTimer(d) + } else { + val payload = + TimerInfoDto( + id = d.id, + type = d.type, + serverId = d.serverId, + externalId = d.externalId, + channelId = d.channelId, + externalChannelId = d.externalChannelId, + channelName = d.channelName, + programId = d.programId, + externalProgramId = d.externalProgramId, + name = d.name, + overview = d.overview, + startDate = d.startDate, + endDate = d.endDate, + serviceName = d.serviceName, + priority = d.priority, + prePaddingSeconds = d.prePaddingSeconds, + postPaddingSeconds = d.postPaddingSeconds, + isPrePaddingRequired = d.isPrePaddingRequired, + isPostPaddingRequired = d.isPostPaddingRequired, + keepUntil = d.keepUntil, + ) + api.liveTvApi.createTimer(payload) + } + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/MediaManagementService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/MediaManagementService.kt index 5a6d8ff0..684d56b0 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/MediaManagementService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/MediaManagementService.kt @@ -63,7 +63,7 @@ class MediaManagementService return enabled && item.canDelete && if (item.type == BaseItemKind.RECORDING) { - serverRepository.currentUserDto.value + serverRepository.currentUserDto ?.policy ?.enableLiveTvManagement == true } else { diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt index 6eddb51c..16ac4edd 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt @@ -13,8 +13,11 @@ import androidx.media3.session.MediaSession import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.AudioItem import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.preferences.enabled +import com.github.damontecres.wholphin.preferences.get import com.github.damontecres.wholphin.services.hilt.DefaultCoroutineScope -import com.github.damontecres.wholphin.ui.DefaultItemFields +import com.github.damontecres.wholphin.ui.DetailItemFields +import com.github.damontecres.wholphin.ui.gt import com.github.damontecres.wholphin.ui.main.settings.MoveDirection import com.github.damontecres.wholphin.ui.onMain import com.github.damontecres.wholphin.ui.seekBack @@ -24,10 +27,11 @@ import com.github.damontecres.wholphin.util.BlockingList import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.PlaybackItemState import com.github.damontecres.wholphin.util.TrackActivityPlaybackListener +import com.github.damontecres.wholphin.util.WholphinDispatchers +import com.github.damontecres.wholphin.util.profile.Codec import com.github.damontecres.wholphin.util.profile.supportedAudioCodecs import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -35,6 +39,7 @@ import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext @@ -44,6 +49,7 @@ import org.jellyfin.sdk.api.client.extensions.universalAudioApi import org.jellyfin.sdk.api.sockets.subscribe import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.ImageType +import org.jellyfin.sdk.model.api.MediaStreamType import org.jellyfin.sdk.model.api.PlayMethod import org.jellyfin.sdk.model.api.PlaystateCommand import org.jellyfin.sdk.model.api.PlaystateMessage @@ -72,6 +78,7 @@ class MusicService private val playerFactory: PlayerFactory, private val serverRepository: ServerRepository, private val imageUrlService: ImageUrlService, + private val userPreferencesService: UserPreferencesService, ) { private val _state = MutableStateFlow(MusicServiceState.EMPTY) val state: StateFlow<MusicServiceState> = _state @@ -79,8 +86,15 @@ class MusicService private val audioFormats by lazy { listOf(*supportedAudioCodecs) } val player: Player by lazy { + val disableAudioOffload = + runBlocking { + userPreferencesService + .getCurrent() + .appPreferences.experimentalPreferences + .get { disableAudioOffload } ?: false + } playerFactory - .createAudioPlayer() + .createAudioPlayer(disableAudioOffload = disableAudioOffload) .also { it.addListener(MusicPlayerListener(it, _state)) } @@ -92,6 +106,12 @@ class MusicService private var activityTracker: TrackActivityPlaybackListener? = null private var websocketJob: Job? = null + private suspend fun preferAc3Surround() = + userPreferencesService + .getCurrent() + .appPreferences.experimentalPreferences + .enabled { preferAc3Surround } + /** * Start music playback * @@ -102,7 +122,11 @@ class MusicService mutex.withLock { if (mediaSession == null) { Timber.i("Starting music MediaSession") - mediaSession = MediaSession.Builder(context, player).build() + mediaSession = + MediaSession + .Builder(context, player) + .setCallback(TrustAllControllersCallback) + .build() activityTracker = TrackActivityPlaybackListener(api, player) { state.value.currentItemId?.let { itemId -> @@ -159,10 +183,10 @@ class MusicService val items = api.instantMixApi .getInstantMixFromItem( - userId = serverRepository.currentUser.value?.id, + userId = serverRepository.currentUser?.id, itemId = itemId, limit = 200, - fields = DefaultItemFields, + fields = DetailItemFields, ).content.items .map { BaseItem(it, false) } setQueue(items, false) @@ -177,9 +201,9 @@ class MusicService items: BlockingList<BaseItem?>, startIndex: Int, shuffled: Boolean, - ) = withContext(Dispatchers.IO) { + ) = withContext(WholphinDispatchers.IO) { Timber.d("setQueue: %s items, startIndex=%s, shuffled=%s", items.size, startIndex, shuffled) - withContext(Dispatchers.Main) { + withContext(WholphinDispatchers.Main) { player.setMediaItems(emptyList()) player.shuffleModeEnabled = shuffled } @@ -195,11 +219,12 @@ class MusicService shuffled: Boolean, ) { Timber.d("setQueue: %s items, shuffled=%s", items.size, shuffled) + val preferAc3Surround = preferAc3Surround() val mediaItems = items .filter { it.type == BaseItemKind.AUDIO } - .map(::convert) - withContext(Dispatchers.Main) { + .map { convert(it, preferAc3Surround) } + withContext(WholphinDispatchers.Main) { player.setMediaItems(mediaItems) player.shuffleModeEnabled = shuffled updateQueueSize() @@ -215,8 +240,9 @@ class MusicService index: Int? = null, ) { if (item.type == BaseItemKind.AUDIO) { - val mediaItem = convert(item) - withContext(Dispatchers.Main) { + val preferAc3Surround = preferAc3Surround() + val mediaItem = convert(item, preferAc3Surround) + withContext(WholphinDispatchers.Main) { if (index != null) { player.addMediaItem(index, mediaItem) } else { @@ -228,6 +254,8 @@ class MusicService start() } } + } else { + Timber.w("Tried to add non-audio type %s: %s", item.type, item.id) } } @@ -240,6 +268,7 @@ class MusicService list: BlockingList<BaseItem?>, startIndex: Int, ) = loading { + val preferAc3Surround = preferAc3Surround() var remaining = startIndex list.indices .chunked(25) @@ -250,7 +279,7 @@ class MusicService list .getBlocking(it) ?.takeIf { it.type == BaseItemKind.AUDIO } - ?.let(::convert) + ?.let { convert(it, preferAc3Surround) } } else { Timber.v("Skipping $remaining") remaining-- @@ -266,18 +295,43 @@ class MusicService /** * Converts a [BaseItem] into a [MediaItem] setting an [AudioItem] as its tag */ - private fun convert(audio: BaseItem): MediaItem { + private fun convert( + audio: BaseItem, + preferAc3Surround: Boolean, + ): MediaItem { + val needsAc3Transcode = + preferAc3Surround && + audio.data.mediaSources + ?.firstOrNull() + ?.mediaStreams + ?.any { stream -> + stream.type == MediaStreamType.AUDIO && + stream.channels.gt(2) + stream.codec != Codec.Audio.AC3 + } == true val url = - api.universalAudioApi.getUniversalAudioStreamUrl( - itemId = audio.id, - container = audioFormats, - ) + if (needsAc3Transcode) { + api.universalAudioApi.getUniversalAudioStreamUrl( + itemId = audio.id, + container = listOf("mka"), + transcodingContainer = "mka", + maxAudioChannels = 6, + transcodingAudioChannels = 6, + audioCodec = Codec.Audio.AC3, + ) + } else { + api.universalAudioApi.getUniversalAudioStreamUrl( + itemId = audio.id, + container = audioFormats, + ) + } Timber.i("url=%s", url) val imageUrl = audio.data.albumId?.let { albumId -> imageUrlService.getItemImageUrl( itemId = albumId, imageType = ImageType.PRIMARY, + tag = audio.data.albumPrimaryImageTag, ) } return MediaItem @@ -293,7 +347,7 @@ class MusicService */ private suspend fun updateQueueSize() { // val ids = -// withContext(Dispatchers.Default) { +// withContext(WholphinDispatchers.Default) { // (0..<player.mediaItemCount).map { player.getMediaItemAt(it).mediaId.toUUID() } // } val timeline = onMain { player.currentTimeline } @@ -304,7 +358,7 @@ class MusicService timeline.getWindow(it, window) window.mediaItem.mediaId.toUUID() }.toSet() - withContext(Dispatchers.Main) { + withContext(WholphinDispatchers.Main) { _state.update { it.copy( queueVersion = it.queueVersion + 1, @@ -321,7 +375,7 @@ class MusicService suspend fun moveQueue( index: Int, direction: MoveDirection, - ) = withContext(Dispatchers.Main) { + ) = withContext(WholphinDispatchers.Main) { player.moveMediaItem(index, if (direction == MoveDirection.UP) index - 1 else index + 1) updateQueueSize() } @@ -332,7 +386,7 @@ class MusicService suspend fun moveQueue( index: Int, newIndex: Int, - ) = withContext(Dispatchers.Main) { + ) = withContext(WholphinDispatchers.Main) { player.moveMediaItem(index, newIndex) updateQueueSize() } @@ -352,7 +406,7 @@ class MusicService * Play this item next after the current, ie add the item as the next index in the queue */ suspend fun playNext(song: BaseItem) { - val mediaItem = convert(song) + val mediaItem = convert(song, preferAc3Surround()) onMain { player.addMediaItem(state.value.currentIndex + 1, mediaItem) if (player.mediaItemCount == 1) { @@ -385,7 +439,7 @@ class MusicService .subscribe<PlaystateMessage>() .onEach { message -> message.data?.let { - withContext(Dispatchers.Main) { + withContext(WholphinDispatchers.Main) { when (it.command) { PlaystateCommand.STOP -> { stop() diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt index 68486430..ea45cd75 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt @@ -1,33 +1,33 @@ package com.github.damontecres.wholphin.services import android.content.Context -import androidx.lifecycle.asFlow import com.github.damontecres.wholphin.data.ServerPreferencesDao import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.data.model.NavPinType +import com.github.damontecres.wholphin.data.model.ServerUserConfig import com.github.damontecres.wholphin.services.hilt.DefaultCoroutineScope +import com.github.damontecres.wholphin.ui.collectLatestIn import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.main.settings.Library import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.NavDrawerItem import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem import com.github.damontecres.wholphin.ui.showToast +import com.github.damontecres.wholphin.util.WholphinDispatchers import com.github.damontecres.wholphin.util.supportedCollectionTypes import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.exception.InvalidStatusException import org.jellyfin.sdk.api.client.extensions.liveTvApi import org.jellyfin.sdk.api.client.extensions.userViewsApi import org.jellyfin.sdk.model.api.CollectionType @@ -53,36 +53,37 @@ class NavDrawerService private val seerrServerRepository: SeerrServerRepository, private val musicService: MusicService, ) { - private val _state = MutableStateFlow(NavDrawerItemState.EMPTY) + private val _state = MutableStateFlow(NavDrawerItemState()) val state: StateFlow<NavDrawerItemState> = _state init { // Handle updating the nav drawer when the user changes - serverRepository.currentUser - .asFlow() - .combine(serverRepository.currentUserDto.asFlow()) { user, userDto -> - Pair(user, userDto) - }.onEach { (user, userDto) -> - Timber.d("User updated: user=%s, userDto=%s", user?.id, userDto?.id) - _state.update { - it.copy( - items = emptyList(), - moreItems = emptyList(), - ) - } + combine( + serverRepository.currentUserFlow, + serverRepository.currentUserDtoFlow, + seerrServerRepository.active, + ) { user, userDto, discoverActive -> + Triple(user, userDto, discoverActive) + }.collectLatestIn(coroutineScope) { (user, userDto, discoverActive) -> + Timber.d( + "User updated: user=%s, userDto=%s, discoverActive=%s", + user?.id, + userDto?.id, + discoverActive, + ) + try { if (user != null && userDto != null && user.id == userDto.id) { - updateNavDrawer(user, userDto) + updateNavDrawer(user, userDto, discoverActive) + } else { + _state.update { NavDrawerItemState() } } - }.catch { ex -> + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { Timber.e(ex, "Error updating nav drawer") showToast(context, "Error fetching user's views") - }.launchIn(coroutineScope) - - // Handle when the user has logged into a Seerr server - seerrServerRepository.active - .onEach { discoverActive -> - _state.update { it.copy(discoverEnabled = discoverActive) } - }.launchIn(coroutineScope) + } + } // Handle when music is actively playing or not coroutineScope.launchDefault { @@ -134,13 +135,22 @@ class NavDrawerService .content.items val recordingFolders = if (tvAccess) { - api.liveTvApi - .getRecordingFolders(userId = userId) - .content.items - .map { it.id } - .toSet() + try { + api.liveTvApi + .getRecordingFolders(userId = userId) + .content.items + .map { it.id } + .toSet() + } catch (ex: InvalidStatusException) { + if (ex.status == 401 || ex.status == 403) { + Timber.w("Got HTTP %s querying for recording folders", ex.status) + emptySet() + } else { + throw ex + } + } } else { - setOf() + emptySet() } val libraries = userViews @@ -179,9 +189,14 @@ class NavDrawerService */ suspend fun updateNavDrawer( user: JellyfinUser, - userDto: UserDto, + userDto: ServerUserConfig, + discoverActive: Boolean, ) { - val builtins = listOf(NavDrawerItem.Favorites, NavDrawerItem.Discover) + val builtins = + buildList { + add(NavDrawerItem.Favorites) + if (discoverActive) add(NavDrawerItem.Discover) + } val allLibraries = getAllUserLibraries(user.id, userDto.tvAccess) val libraries = allLibraries @@ -206,7 +221,7 @@ class NavDrawerService val allItems = builtins + libraries val navDrawerPins = - withContext(Dispatchers.IO) { + withContext(WholphinDispatchers.IO) { serverPreferencesDao.getNavDrawerPinnedItems(user).associateBy { it.itemId } } @@ -231,21 +246,18 @@ class NavDrawerService it.copy( items = items, moreItems = moreItems, + allLibraries = allLibraries, ) } } } data class NavDrawerItemState( - val items: List<NavDrawerItem>, - val moreItems: List<NavDrawerItem>, - val discoverEnabled: Boolean, - val nowPlayingEnabled: Boolean, - val nowPlayingTitle: String?, -) { - companion object { - val EMPTY = NavDrawerItemState(emptyList(), emptyList(), false, false, null) - } -} + val items: List<NavDrawerItem> = emptyList(), + val moreItems: List<NavDrawerItem> = emptyList(), + val nowPlayingEnabled: Boolean = false, + val nowPlayingTitle: String? = null, + val allLibraries: List<Library> = emptyList(), +) val UserDto.tvAccess: Boolean get() = policy?.enableLiveTvAccess == true diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/NavigationManager.kt b/app/src/main/java/com/github/damontecres/wholphin/services/NavigationManager.kt index 3208f4ec..87095f8b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/NavigationManager.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/NavigationManager.kt @@ -24,6 +24,13 @@ class NavigationManager log() } + /** Replace only the current page, preserving every parent entry. */ + fun replaceTop(destination: Destination) { + if (backStack.isEmpty()) backStack.add(destination) + else backStack[backStack.lastIndex] = destination + log() + } + /** * Go to the specified [Destination], but reset the back stack to Home first */ diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt b/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt index 7f4886b4..a2fd0eb7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt @@ -24,19 +24,30 @@ import androidx.media3.exoplayer.trackselection.DefaultTrackSelector import androidx.media3.exoplayer.video.MediaCodecVideoRenderer import androidx.media3.exoplayer.video.VideoRendererEventListener import androidx.media3.extractor.DefaultExtractorsFactory +import androidx.media3.extractor.ExtractorsFactory +import androidx.media3.session.MediaSession +import com.github.damontecres.wholphin.mpv.MpvPlayer +import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AssPlaybackMode import com.github.damontecres.wholphin.preferences.MediaExtensionStatus -import com.github.damontecres.wholphin.preferences.PlaybackPreferences import com.github.damontecres.wholphin.preferences.PlayerBackend +import com.github.damontecres.wholphin.preferences.get import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient -import com.github.damontecres.wholphin.util.mpv.MpvPlayer +import com.github.damontecres.wholphin.util.BitstreamFilter +import com.github.damontecres.wholphin.util.BitstreamFilteringCodecAdapterFactory +import com.github.damontecres.wholphin.util.DolbyVisionProfile7Conversion +import com.github.damontecres.wholphin.util.DolbyVisionProfile7ExtractorsFactory +import com.github.damontecres.wholphin.util.DolbyVisionProfile7Filter +import com.github.damontecres.wholphin.util.Hdr10PlusMaskingFilter +import com.github.damontecres.wholphin.util.WholphinDispatchers +import com.google.common.util.concurrent.Futures +import com.google.common.util.concurrent.ListenableFuture import dagger.hilt.android.qualifiers.ApplicationContext import io.github.peerless2012.ass.media.AssHandler import io.github.peerless2012.ass.media.factory.AssRenderersFactory import io.github.peerless2012.ass.media.kt.withAssMkvSupport import io.github.peerless2012.ass.media.parser.AssSubtitleParserFactory import io.github.peerless2012.ass.media.type.AssRenderType -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okhttp3.OkHttpClient import timber.log.Timber @@ -53,6 +64,7 @@ class PlayerFactory constructor( @param:ApplicationContext private val context: Context, @param:AuthOkHttpClient private val authOkHttpClient: OkHttpClient, + private val deviceProfileService: DeviceProfileService, ) { @Volatile var currentPlayer: Player? = null @@ -60,9 +72,10 @@ class PlayerFactory suspend fun createVideoPlayer( backend: PlayerBackend, - prefs: PlaybackPreferences, + appPreferences: AppPreferences, ): PlayerCreation { - withContext(Dispatchers.Main) { + val prefs = appPreferences.playbackPreferences + withContext(WholphinDispatchers.Main) { if (currentPlayer?.isReleased == false) { Timber.w("Player was not released before trying to create a new one!") currentPlayer?.release() @@ -86,10 +99,19 @@ class PlayerFactory val useLibAss = prefs.overrides.assPlaybackMode == AssPlaybackMode.ASS_LIBASS val decodeAv1 = prefs.overrides.decodeAv1 + val preferDolbyVision = + appPreferences.experimentalPreferences + .get { preferDolbyVisionOverHdr10Plus } ?: false + val convertDolbyVisionProfile7 = + DolbyVisionProfile7Conversion.isWanted( + appPreferences.experimentalPreferences, + deviceProfileService.mediaCodecCapabilitiesTest, + ) Timber.v( - "extensions=%s, assPlaybackMode=%s", + "extensions=%s, assPlaybackMode=%s, convertDolbyVisionProfile7=%s", extensions, prefs.overrides.assPlaybackMode, + convertDolbyVisionProfile7, ) val rendererMode = when (extensions) { @@ -100,10 +122,20 @@ class PlayerFactory } val dataSourceFactory = DefaultDataSource.Factory(context) val extractorsFactory = createExtractorsFactory() + + // Applied outermost so that a profile 7 track is advertised as 8.1 whichever + // extractor, media3's own or libass's Matroska replacement, produced it + fun ExtractorsFactory.forDolbyVision(): ExtractorsFactory = + if (convertDolbyVisionProfile7) DolbyVisionProfile7ExtractorsFactory(this) else this var renderersFactory: RenderersFactory = - WholphinRenderersFactory(context, decodeAv1) - .setEnableDecoderFallback(true) + WholphinRenderersFactory( + context, + decodeAv1, + preferDolbyVision, + convertDolbyVisionProfile7, + ).setEnableDecoderFallback(true) .setExtensionRendererMode(rendererMode) + val mediaSourceFactory = if (useLibAss) { val renderType = @@ -117,18 +149,27 @@ class PlayerFactory renderersFactory = AssRenderersFactory(assHandler, renderersFactory) DefaultMediaSourceFactory( dataSourceFactory, - extractorsFactory.withAssMkvSupport( - assSubtitleParserFactory, - assHandler, - ), + extractorsFactory + .withAssMkvSupport( + assSubtitleParserFactory, + assHandler, + ).forDolbyVision(), ).setSubtitleParserFactory(assSubtitleParserFactory) } else { DefaultMediaSourceFactory( dataSourceFactory, - extractorsFactory, + extractorsFactory.forDolbyVision(), ) } - val trackSelector = createTrackSelector() + val disableAudioOffload = + appPreferences.experimentalPreferences.get { disableAudioOffload } ?: false + val tunneling = + appPreferences.experimentalPreferences.get { videoTunnelingEnabled } + val trackSelector = + createTrackSelector( + tunneling = tunneling, + disableAudioOffload = disableAudioOffload, + ) ExoPlayer .Builder(context) @@ -138,13 +179,14 @@ class PlayerFactory .build() .apply { assHandler?.init(this) - withContext(Dispatchers.Main) { + withContext(WholphinDispatchers.Main) { setAudioAttributes( AudioAttributes .Builder() .setContentType(C.AUDIO_CONTENT_TYPE_MOVIE) + .setUsage(C.USAGE_MEDIA) .build(), - false, + true, ) } } @@ -158,7 +200,10 @@ class PlayerFactory return PlayerCreation(newPlayer, assHandler) } - fun createAudioPlayer(extensions: MediaExtensionStatus = MediaExtensionStatus.MES_FALLBACK): ExoPlayer { + fun createAudioPlayer( + disableAudioOffload: Boolean, + extensions: MediaExtensionStatus = MediaExtensionStatus.MES_FALLBACK, + ): ExoPlayer { val rendererMode = when (extensions) { MediaExtensionStatus.MES_FALLBACK -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON @@ -176,7 +221,7 @@ class PlayerFactory OkHttpDataSource.Factory(authOkHttpClient), extractorsFactory, ) - val trackSelector = createTrackSelector() + val trackSelector = createTrackSelector(disableAudioOffload = disableAudioOffload) return ExoPlayer .Builder(context) .setMediaSourceFactory(mediaSourceFactory) @@ -187,9 +232,10 @@ class PlayerFactory it.setAudioAttributes( AudioAttributes .Builder() + .setUsage(C.USAGE_MEDIA) .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC) .build(), - false, + true, ) } } @@ -199,20 +245,55 @@ class PlayerFactory .setConstantBitrateSeekingEnabled(true) .setConstantBitrateSeekingAlwaysEnabled(true) - private fun createTrackSelector() = - DefaultTrackSelector(context).apply { - setParameters( - buildUponParameters() - .setAudioOffloadPreferences( - AudioOffloadPreferences - .Builder() - .setAudioOffloadMode(AudioOffloadPreferences.AUDIO_OFFLOAD_MODE_ENABLED) - .build(), - ), - ) - } + private fun createTrackSelector( + tunneling: Boolean? = null, + disableAudioOffload: Boolean = false, + ) = DefaultTrackSelector(context).apply { + val offloadMode = + if (disableAudioOffload) { + AudioOffloadPreferences.AUDIO_OFFLOAD_MODE_DISABLED + } else { + AudioOffloadPreferences.AUDIO_OFFLOAD_MODE_ENABLED + } + setParameters( + buildUponParameters() + .apply { + tunneling?.let { setTunnelingEnabled(tunneling) } + }.setAudioOffloadPreferences( + AudioOffloadPreferences + .Builder() + .setAudioOffloadMode(offloadMode) + .build(), + ), + ) + } + + fun createMediaSession(player: Player) = + MediaSession + .Builder(context, player) + .setCallback(TrustAllControllersCallback) + .build() } +/** + * Since media3 1.11, untrusted controllers (e.g. Google Home's Cast bridge, `com.google.android.apps.mediashell`) + * only get read-only player commands by default, so play/pause from them is silently dropped. + * Restore the pre-1.11 behavior of granting all player commands to every controller. + */ +@OptIn(UnstableApi::class) +object TrustAllControllersCallback : MediaSession.Callback { + override fun onConnectAsync( + session: MediaSession, + controller: MediaSession.ControllerInfo, + ): ListenableFuture<MediaSession.ConnectionResult> = + Futures.immediateFuture( + MediaSession.ConnectionResult + .AcceptedResultBuilder(session, controller) + .setAvailablePlayerCommands(MediaSession.ConnectionResult.DEFAULT_PLAYER_COMMANDS) + .build(), + ) +} + val Player.isReleased: Boolean get() { return when (this) { @@ -231,6 +312,8 @@ data class PlayerCreation( class WholphinRenderersFactory( context: Context, private val av1Enabled: Boolean, + private val preferDolbyVisionOverHdr10Plus: Boolean = false, + private val convertDolbyVisionProfile7: Boolean = false, ) : DefaultRenderersFactory(context) { @OptIn(ExperimentalApi::class) override fun buildVideoRenderers( @@ -243,10 +326,23 @@ class WholphinRenderersFactory( allowedVideoJoiningTimeMs: Long, out: ArrayList<Renderer>, ) { + // The conversion goes first: it may shorten the access unit, and the masking then only + // has to walk what is left of it + val bitstreamFilters = + buildList<BitstreamFilter> { + if (convertDolbyVisionProfile7) add(DolbyVisionProfile7Filter()) + if (preferDolbyVisionOverHdr10Plus) add(Hdr10PlusMaskingFilter()) + } + val videoCodecAdapterFactory = + if (bitstreamFilters.isNotEmpty()) { + BitstreamFilteringCodecAdapterFactory(codecAdapterFactory, bitstreamFilters) + } else { + codecAdapterFactory + } var videoRendererBuilder = MediaCodecVideoRenderer .Builder(context) - .setCodecAdapterFactory(codecAdapterFactory) + .setCodecAdapterFactory(videoCodecAdapterFactory) .setMediaCodecSelector(mediaCodecSelector) .setAllowedJoiningTimeMs(allowedVideoJoiningTimeMs) .setEnableDecoderFallback(enableDecoderFallback) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/PlaylistCreator.kt b/app/src/main/java/com/github/damontecres/wholphin/services/PlaylistCreator.kt index 7e115212..b1a48753 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/PlaylistCreator.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/PlaylistCreator.kt @@ -8,29 +8,27 @@ import com.github.damontecres.wholphin.data.model.GetItemsFilter import com.github.damontecres.wholphin.data.model.Playlist import com.github.damontecres.wholphin.data.model.PlaylistInfo import com.github.damontecres.wholphin.data.model.PlaylistItem -import com.github.damontecres.wholphin.ui.DefaultItemFields +import com.github.damontecres.wholphin.ui.DetailItemFields import com.github.damontecres.wholphin.ui.components.baseItemKinds import com.github.damontecres.wholphin.ui.data.SortAndDirection import com.github.damontecres.wholphin.ui.gt import com.github.damontecres.wholphin.ui.indexOfFirstOrNull import com.github.damontecres.wholphin.ui.playback.playable -import com.github.damontecres.wholphin.ui.toBaseItems import com.github.damontecres.wholphin.ui.toServerString -import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.GetEpisodesRequestHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler -import com.github.damontecres.wholphin.util.TransformList import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.CoroutineScope import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.exception.InvalidStatusException import org.jellyfin.sdk.api.client.extensions.playlistsApi -import org.jellyfin.sdk.api.client.extensions.userLibraryApi +import org.jellyfin.sdk.api.client.extensions.tvShowsApi import org.jellyfin.sdk.api.client.extensions.videosApi import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.CreatePlaylistDto import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.api.ItemSortBy +import org.jellyfin.sdk.model.api.LocationType import org.jellyfin.sdk.model.api.MediaType import org.jellyfin.sdk.model.api.PlaylistUserPermissions import org.jellyfin.sdk.model.api.SortOrder @@ -57,7 +55,7 @@ class PlaylistCreator /** * Creates a playlist of next up episodes for the given series starting with the given episode */ - suspend fun createFromEpisode( + private suspend fun createFromEpisode( seriesId: UUID, episodeId: UUID?, seasonId: UUID? = null, @@ -67,7 +65,7 @@ class PlaylistCreator GetEpisodesRequest( seriesId = seriesId, seasonId = seasonId, - fields = DefaultItemFields, + fields = DetailItemFields, startItemId = episodeId, sortBy = if (shuffled) ItemSortBy.RANDOM else null, limit = Playlist.MAX_SIZE, @@ -79,13 +77,13 @@ class PlaylistCreator .convertAndAddParts(false) val startIndex = episodeId?.let { episodes.indexOfFirstOrNull { it.id == episodeId } } ?: 0 - return Playlist(episodes, startIndex) + return Playlist(episodes.subList(startIndex, episodes.size)) } /** * Create from a server playlist ID */ - suspend fun createFromPlaylistId( + private suspend fun createFromPlaylistId( playlistId: UUID, startIndex: Int?, sortAndDirection: SortAndDirection, @@ -94,17 +92,18 @@ class PlaylistCreator val request = filter.applyTo( GetItemsRequest( - userId = serverRepository.currentUser.value?.id, + userId = serverRepository.currentUser?.id, parentId = playlistId, - fields = DefaultItemFields, + fields = DetailItemFields, startIndex = startIndex, limit = Playlist.MAX_SIZE, sortBy = listOf(sortAndDirection.sort), sortOrder = listOf(sortAndDirection.direction), + excludeLocationTypes = listOf(LocationType.VIRTUAL), ), ) val items = GetItemsRequestHandler.execute(api, request).content.items - return Playlist(items.convertAndAddParts(), 0) + return Playlist(items.convertAndAddParts()) } private suspend fun createFromCollection( @@ -136,9 +135,10 @@ class PlaylistCreator excludeItemIds = listOf(item.id), sortBy = sortAndDirection?.let { listOf(sortAndDirection.sort) }, sortOrder = sortAndDirection?.let { listOf(sortAndDirection.direction) }, - fields = DefaultItemFields, + fields = DetailItemFields, startIndex = startIndex, limit = Playlist.MAX_SIZE, + excludeLocationTypes = listOf(LocationType.VIRTUAL), ), ) val items = @@ -146,7 +146,7 @@ class PlaylistCreator .execute(api, request) .content.items .convertAndAddParts() - return Playlist(items, 0) + return Playlist(items) } /** @@ -201,28 +201,72 @@ class PlaylistCreator BaseItemKind.SEASON -> { val seriesId = item.seriesId if (seriesId != null) { - PlaylistCreationResult.Success( - createFromEpisode( - seriesId = seriesId, - seasonId = item.id, - episodeId = null, - shuffled = shuffled, - ), - ) + if (shuffled) { + api.tvShowsApi + .getEpisodes( + seriesId = seriesId, + seasonId = item.id, + limit = Playlist.MAX_SIZE, + sortBy = ItemSortBy.RANDOM, + fields = DetailItemFields, + ).content.items + .convertAndAddParts() + .let { + PlaylistCreationResult.Success(Playlist(it)) + } + } else { + PlaylistCreationResult.Success( + createFromEpisode( + seriesId = seriesId, + seasonId = item.id, + episodeId = null, + shuffled = shuffled, + ), + ) + } } else { PlaylistCreationResult.Error(null, "Episode has no seriesId") } } BaseItemKind.SERIES -> { - PlaylistCreationResult.Success( - createFromEpisode( - seriesId = item.id, - seasonId = null, - episodeId = null, - shuffled = shuffled, - ), - ) + if (shuffled) { + api.tvShowsApi + .getEpisodes( + seriesId = item.id, + limit = Playlist.MAX_SIZE, + sortBy = ItemSortBy.RANDOM, + fields = DetailItemFields, + ).content.items + .convertAndAddParts() + .let { + PlaylistCreationResult.Success(Playlist(it)) + } + } else { + val result by api.tvShowsApi.getNextUp(seriesId = item.id) + val nextUp = + result.items.firstOrNull() ?: api.tvShowsApi + .getEpisodes( + item.id, + limit = 1, + ).content.items + .firstOrNull() + if (nextUp != null) { + PlaylistCreationResult.Success( + createFromEpisode( + seriesId = item.id, + seasonId = null, + episodeId = nextUp.id, + shuffled = shuffled, + ), + ) + } else { + PlaylistCreationResult.Error( + null, + "Could not determine next up episode for series: " + item.id, + ) + } + } } BaseItemKind.PLAYLIST -> { @@ -247,6 +291,7 @@ class PlaylistCreator BaseItemKind.MOVIE, BaseItemKind.VIDEO, BaseItemKind.MUSIC_VIDEO, + BaseItemKind.TRAILER, -> { val list = buildList { @@ -261,7 +306,7 @@ class PlaylistCreator }.let(::addAll) } } - PlaylistCreationResult.Success(Playlist(list, 0)) + PlaylistCreationResult.Success(Playlist(list)) } // Not support yet @@ -279,49 +324,74 @@ class PlaylistCreator private suspend fun List<BaseItemDto>.convertAndAddParts(useSeriesForPrimary: Boolean = false): List<PlaylistItem> = buildList { - this@convertAndAddParts.forEach { ep -> - add(PlaylistItem.Media(BaseItem(ep, useSeriesForPrimary))) - if (ep.partCount.gt(1)) { - val parts = - api.videosApi.getAdditionalPart(ep.id).content.items.map { part -> - PlaylistItem.Media(BaseItem(part, useSeriesForPrimary)) - } - addAll(parts) + this@convertAndAddParts + .filter { it.locationType != LocationType.VIRTUAL } + .forEach { ep -> + add(PlaylistItem.Media(BaseItem(ep, useSeriesForPrimary))) + if (ep.partCount.gt(1)) { + val parts = + api.videosApi.getAdditionalPart(ep.id).content.items.map { part -> + PlaylistItem.Media(BaseItem(part, useSeriesForPrimary)) + } + addAll(parts) + } } - } } /** * Get the playlists on the server for a given media type */ suspend fun getServerPlaylists( + query: String, mediaType: MediaType?, - scope: CoroutineScope, - ): List<PlaylistInfo?> { + ): List<PlaylistInfo> { + val userId = + serverRepository.currentUser?.id ?: throw IllegalStateException("No user found") val request = GetItemsRequest( + searchTerm = query.takeIf { it.isNotBlank() }, includeItemTypes = listOf(BaseItemKind.PLAYLIST), mediaTypes = mediaType?.let { listOf(mediaType) }, recursive = true, + limit = 25, + sortBy = listOf(ItemSortBy.DATE_LAST_CONTENT_ADDED), + sortOrder = listOf(SortOrder.DESCENDING), ) - val pager = ApiRequestPager(api, request, GetItemsRequestHandler, scope).init() - return TransformList(pager) { - it?.let { - PlaylistInfo( - id = it.id, - name = it.name ?: context.getString(R.string.unknown), - count = it.data.childCount ?: 0, - mediaType = it.data.mediaType, - ) + val playlists = GetItemsRequestHandler.execute(api, request).content.items + return playlists + .mapNotNull { playlist -> + try { + val response = api.playlistsApi.getPlaylistUser(playlist.id, userId).content + if (response.canEdit) { + PlaylistInfo( + id = playlist.id, + name = playlist.name ?: context.getString(R.string.unknown), + count = playlist.childCount ?: 0, + mediaType = playlist.mediaType, + ) + } else { + null + } + } catch (ex: InvalidStatusException) { + if (ex.status == 404) { + null + } else { + throw ex + } + } } - } +// .sortedWith( +// compareBy<PlaylistInfo> { +// SearchRelevance.score(it.name, BaseItemKind.PLAYLIST, query) +// }.thenBy { it.name }, +// ) } suspend fun createServerPlaylist( name: String, initialItems: List<UUID>, ): UUID? = - serverRepository.currentUser.value?.let { user -> + serverRepository.currentUser?.let { user -> api.playlistsApi .createPlaylist( CreatePlaylistDto( diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/RefreshRateService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/RefreshRateService.kt index 91df9518..174c103b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/RefreshRateService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/RefreshRateService.kt @@ -8,9 +8,9 @@ import android.os.Looper import android.view.Display import com.github.damontecres.wholphin.MainActivity import com.github.damontecres.wholphin.ui.showToast +import com.github.damontecres.wholphin.util.WholphinDispatchers import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull @@ -38,7 +38,7 @@ class RefreshRateService stream: MediaStream, switchRefreshRate: Boolean, switchResolution: Boolean, - ) = withContext(Dispatchers.IO) { + ) = withContext(WholphinDispatchers.IO) { if (!switchRefreshRate && !switchResolution) { Timber.v("Not switching either refresh rate nor resolution") return@withContext diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/RememberedTabService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/RememberedTabService.kt new file mode 100644 index 00000000..a3b5b795 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/RememberedTabService.kt @@ -0,0 +1,34 @@ +package com.github.damontecres.wholphin.services + +import com.github.damontecres.wholphin.data.RememberedTabDao +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.RememberedTab +import com.github.damontecres.wholphin.util.WholphinDispatchers +import kotlinx.coroutines.withContext +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class RememberedTabService + @Inject + constructor( + private val serverRepository: ServerRepository, + private val rememberedTabDao: RememberedTabDao, + ) { + suspend fun getRememberedTab(itemId: String): Int? = + withContext(WholphinDispatchers.IO) { + serverRepository.currentUser?.rowId?.let { userId -> + rememberedTabDao.getRememberedTab(userId, itemId)?.index + } + } + + suspend fun saveRememberedTab( + itemId: String, + tabIndex: Int, + ): Unit = + withContext(WholphinDispatchers.IO) { + serverRepository.currentUser?.rowId?.let { userId -> + rememberedTabDao.save(RememberedTab(userId, itemId, tabIndex)) + } + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/ScreensaverService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/ScreensaverService.kt index 6069c00a..c3fcb478 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/ScreensaverService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/ScreensaverService.kt @@ -1,6 +1,9 @@ package com.github.damontecres.wholphin.services import android.content.Context +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut import coil3.imageLoader import coil3.request.ImageRequest import com.github.damontecres.wholphin.services.hilt.DefaultCoroutineScope @@ -10,10 +13,10 @@ import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler +import com.github.damontecres.wholphin.util.WholphinDispatchers import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow @@ -57,6 +60,7 @@ class ScreensaverService val keepScreenOn = MutableStateFlow(false) private var waitJob: Job? = null + private var dimJob: Job? = null init { userPreferencesService.flow @@ -65,7 +69,14 @@ class ScreensaverService val enabled = prefs.appPreferences.interfacePreferences.screensaverPreference.enabled keepScreenOnInternal(enabled) - ScreensaverState(enabled, false, false, false) + ScreensaverState( + enabled = enabled, + enabledTemp = false, + active = false, + paused = false, + dimEnabled = prefs.appPreferences.interfacePreferences.screensaverPreference.dimEnabled, + dimActive = false, + ) } }.launchIn(scope) } @@ -79,7 +90,10 @@ class ScreensaverService // Timber.v("pulse") _state.update { if (!it.active) { - it.copy(active = false) + it.copy( + active = false, + dimActive = false, + ) } else { it } @@ -94,7 +108,30 @@ class ScreensaverService .appPreferences.interfacePreferences.screensaverPreference.startDelay.milliseconds delay(startDelay) _state.update { - it.copy(active = true) + it.copy( + active = true, + dimActive = it.dimEnabled, + ) + } + } + } + } + dimJob?.cancel() + if (_state.value.dimEnabled) { + _state.update { + it.copy(dimActive = false) + } + if (!_state.value.paused) { + dimJob = + scope.launch(ExceptionHandler()) { + val startDelay = + userPreferencesService + .getCurrent() + .appPreferences.interfacePreferences.screensaverPreference.startDelay.milliseconds + delay(startDelay) + Timber.v("state dim=%s", _state.value) + _state.update { + it.copy(dimActive = !_state.value.paused) } } } @@ -109,6 +146,7 @@ class ScreensaverService it.copy( enabledTemp = true, active = true, + dimActive = true, ) } } @@ -121,9 +159,13 @@ class ScreensaverService it.copy( enabledTemp = false, active = false, + dimActive = false, ) } - if (cancelJob) waitJob?.cancel() + if (cancelJob) { + waitJob?.cancel() + dimJob?.cancel() + } } /** @@ -131,17 +173,24 @@ class ScreensaverService */ fun keepScreenOn(keep: Boolean) { scope.launchDefault { - val screensaverEnabled = _state.value.enabled + val screensaverEnabled = state.value.enabled + val dimEnabled = state.value.dimEnabled Timber.d("Keep screen on: %s, screensaverEnabled=%s", keep, screensaverEnabled) - if (screensaverEnabled) { + if (screensaverEnabled || dimEnabled) { // Page is requesting to keep screen on, so we don't wait to show the screensaver _state.update { - it.copy(active = false, paused = keep) + it.copy( + active = false, + dimActive = false, + paused = keep, + ) } if (!keep) { pulse() } - } else { + } + if (!screensaverEnabled) { + // If in-app screensaver is not enabled, send keep screen on to the OS keepScreenOnInternal(keep) } } @@ -224,7 +273,7 @@ class ScreensaverService if (index > pager.lastIndex) index = 0 } } - }.flowOn(Dispatchers.Default).cancellable() + }.flowOn(WholphinDispatchers.Default).cancellable() private suspend fun createPager(): ApiRequestPager<GetItemsRequest> { val prefs = @@ -245,6 +294,11 @@ class ScreensaverService ) return ApiRequestPager(api, request, GetItemsRequestHandler, scope).init() } + + companion object { + val enterAnimation = fadeIn(animationSpec = tween(durationMillis = 1000)) + val exitAnimation = fadeOut(animationSpec = tween(durationMillis = 500)) + } } data class ScreensaverState( @@ -252,6 +306,10 @@ data class ScreensaverState( val enabledTemp: Boolean, val active: Boolean, val paused: Boolean, + val dimEnabled: Boolean = false, + val dimActive: Boolean = false, ) { val show get() = (enabled || enabledTemp) && active && !paused + + val showDim get() = dimEnabled && dimActive && !paused } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrRatingsService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrRatingsService.kt new file mode 100644 index 00000000..08d31a9d --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrRatingsService.kt @@ -0,0 +1,411 @@ +package com.github.damontecres.wholphin.services + +import com.github.damontecres.wholphin.api.seerr.infrastructure.ClientException +import com.github.damontecres.wholphin.api.seerr.model.MovieMovieIdRatingscombinedGet200Response +import com.github.damontecres.wholphin.api.seerr.model.TvTvIdRatingsGet200Response +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.SeerrItemType +import com.github.damontecres.wholphin.data.model.SourceRatings +import com.google.common.cache.CacheBuilder +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.mapLatest +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.userLibraryApi +import org.jellyfin.sdk.model.api.BaseItemKind +import timber.log.Timber +import java.util.Optional +import java.util.UUID +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import javax.inject.Singleton + +/** + * The TMDB user vote average & count as reported by Seerr's movie/TV details + */ +data class TmdbVote( + val average: Double?, + val count: Long?, +) + +/** + * The Seerr title whose ratings are shown for a Jellyfin item + */ +data class RatingsTarget( + val type: SeerrItemType, + val tmdbId: Int, +) + +/** + * Fetches [SourceRatings] for a title from Seerr, the same source the Wolfskin PWA uses. This is the only way ratings + * reach the UI, see [com.github.damontecres.wholphin.ui.components.rememberSourceRatings]. + * + * Which Jellyfin items have ratings follows the PWA, which shows ratings for a title and nothing else: + * - movies show their own ratings + * - series show their own ratings + * - seasons show their series' ratings, as the PWA does for the title page a season belongs to + * - episodes show their series' Rotten Tomatoes ratings for context and their own TMDB user score, from the season + * Seerr reports (one request for all episodes of the season). The series' TMDB score is not shown next to it. + * - everything else (collections, music, photos, programs, ...) has none + * + * Movies use `ratingscombined` (RT critics, RT audience, IMDb), series use `tv/{id}/ratings` (RT). TMDB comes from + * `voteAverage`/`voteCount` and is only requested when no other source has a score. + * + * Results are cached by media type and TMDB id because movie and TV ids are separate namespaces. Failures are never cached. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@Singleton +class SeerrRatingsService + @Inject + constructor( + private val seerrService: SeerrService, + private val api: ApiClient, + ) { + private data class Key( + val type: SeerrItemType, + val tmdbId: Int, + ) + + private val cache = + CacheBuilder + .newBuilder() + .maximumSize(CACHE_SIZE) + .expireAfterWrite(CACHE_HOURS, TimeUnit.HOURS) + .build<Key, SourceRatings>() + + private data class SeasonKey( + val tmdbId: Int, + val season: Int, + ) + + /** + * The TMDB vote of each episode (by episode number) of a series' season + */ + private val seasonCache = + CacheBuilder + .newBuilder() + .maximumSize(CACHE_SIZE) + .expireAfterWrite(CACHE_HOURS, TimeUnit.HOURS) + .build<SeasonKey, Map<Int, TmdbVote>>() + + /** + * The TMDB id of a Jellyfin movie or series, empty if it has none. Items such as an episode on the home page + * don't carry their series' provider ids, so this is how they are found. + */ + private val tmdbIds = + CacheBuilder + .newBuilder() + .maximumSize(CACHE_SIZE) + .expireAfterWrite(CACHE_HOURS, TimeUnit.HOURS) + .build<UUID, Optional<Int>>() + + fun cached( + 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<Int>? = + item.data.providerIds + ?.takeIf { ownerId == item.id && it.isNotEmpty() } + ?.let { Optional.ofNullable(it["Tmdb"]?.toIntOrNull()) } + ?: tmdbIds.getIfPresent(ownerId) + + private fun type(item: BaseItem): SeerrItemType? = + when (item.type) { + BaseItemKind.MOVIE -> SeerrItemType.MOVIE + BaseItemKind.SERIES, BaseItemKind.SEASON, BaseItemKind.EPISODE -> SeerrItemType.TV + else -> null + } + + private fun knownTarget(item: BaseItem): RatingsTarget? { + val type = type(item) ?: return null + val owner = ownerId(item) ?: return null + return knownTmdbId(item, owner)?.orElse(null)?.let { RatingsTarget(type, it) } + } + + /** + * 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<SourceRatings?> { + if (item == null || type(item) == null) return flowOf(null) + cachedFor(item)?.let { return flowOf(it) } + val owner = ownerId(item) ?: return flowOf(null) + if (knownTmdbId(item, owner)?.isPresent == false) return flowOf(null) + return flow { + delay(debounceMillis) + emitAll( + seerrService.active + .distinctUntilChanged() + .mapLatest { active -> + if (active) resolveTarget(item)?.let { getFor(item, it) } else null + }, + ) + } + } + + /** + * The ratings for the target of an item: for an episode, those of its series with its own TMDB score + */ + private suspend fun getFor( + item: BaseItem, + target: RatingsTarget, + ): SourceRatings? { + val series = get(target.type, target.tmdbId) + val season = item.data.parentIndexNumber + val episode = item.data.indexNumber + if (item.type != BaseItemKind.EPISODE || season == null || episode == null) return series + val votes = getSeason(target.tmdbId, season) + if (series == null && votes == null) return null + return (series ?: SourceRatings()).withEpisode(episodeScore(votes?.get(episode))) + } + + /** + * The TMDB votes of the episodes of a season, fetched once for all of them. Null if the request failed, which is not cached. + */ + private suspend fun getSeason( + tmdbId: Int, + season: Int, + ): Map<Int, TmdbVote>? { + val key = SeasonKey(tmdbId, season) + seasonCache.getIfPresent(key)?.let { return it } + val votes = + try { + seerrService.api.tvApi + .tvTvIdSeasonSeasonNumberGet(tvId = tmdbId, seasonNumber = season) + .episodes + .orEmpty() + .mapNotNull { episode -> + episode.episodeNumber?.let { it to TmdbVote(episode.voteAverage?.toDouble(), episode.voteCount) } + }.toMap() + } catch (ex: CancellationException) { + throw ex + } catch (ex: ClientException) { + if (ex.statusCode == 404) { + // TMDB has no such season, that is an answer rather than a failure + emptyMap() + } else { + Timber.w(ex, "Error fetching season %s of %s", season, tmdbId) + return null + } + } catch (ex: Exception) { + Timber.w(ex, "Error fetching season %s of %s", season, tmdbId) + return null + } + seasonCache.put(key, votes) + return votes + } + + /** + * @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/SeerrServerRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt index 7096b833..0d0eb0b6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt @@ -1,10 +1,5 @@ package com.github.damontecres.wholphin.services -import android.content.Context -import androidx.appcompat.app.AppCompatActivity -import androidx.lifecycle.asFlow -import androidx.lifecycle.lifecycleScope -import com.github.damontecres.wholphin.BuildConfig import com.github.damontecres.wholphin.api.seerr.SeerrApiClient import com.github.damontecres.wholphin.api.seerr.model.AuthJellyfinPostRequest import com.github.damontecres.wholphin.api.seerr.model.AuthLocalPostRequest @@ -12,25 +7,23 @@ import com.github.damontecres.wholphin.api.seerr.model.PublicSettings import com.github.damontecres.wholphin.api.seerr.model.User import com.github.damontecres.wholphin.data.SeerrServerDao import com.github.damontecres.wholphin.data.ServerRepository -import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.data.model.SeerrAuthMethod import com.github.damontecres.wholphin.data.model.SeerrPermission +import com.github.damontecres.wholphin.data.model.SeerrPluginLogin +import com.github.damontecres.wholphin.data.model.SeerrPluginSettings import com.github.damontecres.wholphin.data.model.SeerrServer import com.github.damontecres.wholphin.data.model.SeerrUser import com.github.damontecres.wholphin.data.model.hasPermission import com.github.damontecres.wholphin.services.hilt.StandardOkHttpClient -import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.setup.seerr.createSeerrApiUrl import com.github.damontecres.wholphin.util.LoadingState -import dagger.hilt.android.qualifiers.ActivityContext -import dagger.hilt.android.scopes.ActivityScoped +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update -import kotlinx.coroutines.supervisorScope import okhttp3.OkHttpClient import org.jellyfin.sdk.model.api.ImageType import timber.log.Timber @@ -50,26 +43,25 @@ class SeerrServerRepository private val serverRepository: ServerRepository, @param:StandardOkHttpClient private val okHttpClient: OkHttpClient, ) { - private val _connection = - MutableStateFlow<SeerrConnectionStatus>(SeerrConnectionStatus.NotConfigured) - val connection: StateFlow<SeerrConnectionStatus> = _connection + private val _connection = MutableStateFlow<SeerrState>(SeerrState()) + val connection: StateFlow<SeerrState> = _connection val current: Flow<CurrentSeerr?> = - _connection.map { (it as? SeerrConnectionStatus.Success)?.current } + _connection.map { (it.connection as? SeerrConnectionStatus.Success)?.current } val currentServer: Flow<SeerrServer?> = - connection.map { (it as? SeerrConnectionStatus.Success)?.current?.server } + connection.map { (it.connection as? SeerrConnectionStatus.Success)?.current?.server } val currentUser: Flow<SeerrUser?> = - connection.map { (it as? SeerrConnectionStatus.Success)?.current?.user } + connection.map { (it.connection as? SeerrConnectionStatus.Success)?.current?.user } val currentUserId: Flow<Int?> = current.map { it?.config?.id } /** * Whether Seerr integration is currently active of not */ val active: Flow<Boolean> = - connection.map { it is SeerrConnectionStatus.Success && seerrApi.active } + connection.map { it.connection is SeerrConnectionStatus.Success && seerrApi.active } fun clear() { - _connection.update { SeerrConnectionStatus.NotConfigured } + _connection.update { SeerrState() } seerrApi.update("", null) } @@ -78,7 +70,7 @@ class SeerrServerRepository user: SeerrUser, exception: Exception, ) { - _connection.update { SeerrConnectionStatus.Error(server, user, exception) } + _connection.update { it.copy(connection = SeerrConnectionStatus.Error(server, user, exception)) } seerrApi.update("", null) } @@ -89,8 +81,9 @@ class SeerrServerRepository ) { val publicSettings = seerrApi.api.settingsApi.settingsPublicGet() _connection.update { - SeerrConnectionStatus.Success( - CurrentSeerr(server, user, userConfig, publicSettings), + it.copy( + connection = + SeerrConnectionStatus.Success(CurrentSeerr(server, user, userConfig, publicSettings)), ) } } @@ -105,7 +98,7 @@ class SeerrServerRepository server = seerrServerDao.getServer(url) } server?.server?.let { server -> - serverRepository.currentUser.value?.let { jellyfinUser -> + serverRepository.currentUser?.let { jellyfinUser -> // TODO test api key val user = SeerrUser( @@ -137,10 +130,10 @@ class SeerrServerRepository server = seerrServerDao.getServer(url) } server?.server?.let { server -> - serverRepository.currentUser.value?.let { jellyfinUser -> + serverRepository.currentUser?.let { jellyfinUser -> // TODO Need to update server early so that cookies are saved seerrApi.update(server.url, null) - val userConfig = login(seerrApi.api, authMethod, username, password) + val userConfig = seerrLogin(seerrApi.api, authMethod, username, password) val user = SeerrUser( @@ -174,13 +167,13 @@ class SeerrServerRepository .readTimeout(6.seconds) .build(), ) - login(api, authMethod, username, passwordOrApiKey) + seerrLogin(api, authMethod, username, passwordOrApiKey) return LoadingState.Success } suspend fun removeServerForCurrentUser(): Boolean { val user = - when (val conn = connection.first()) { + when (val conn = connection.value.connection) { SeerrConnectionStatus.NotConfigured -> return false is SeerrConnectionStatus.Error -> conn.user is SeerrConnectionStatus.Success -> conn.current.user @@ -189,6 +182,81 @@ class SeerrServerRepository clear() return rows > 0 } + + /** + * Given the [SeerrPluginSettings], try to connect to the Seerr server + * + * @return true if successfully updated the SeerrApi and logged in + */ + suspend fun usePluginProvidedServer(settings: SeerrPluginSettings): Boolean { + val currentUser = serverRepository.currentUser ?: return false + if (settings.serverUrl.isNotNullOrBlank() && settings.login != null && settings.login != SeerrPluginLogin.None) { + val server = + SeerrServer( + id = -1, + url = settings.serverUrl, + ) + val user = + SeerrUser( + jellyfinUserRowId = currentUser.rowId, + serverId = server.id, + authMethod = + when (settings.login) { + is SeerrPluginLogin.ApiKey -> SeerrAuthMethod.API_KEY + is SeerrPluginLogin.Local -> SeerrAuthMethod.LOCAL + }, + username = + when (settings.login) { + is SeerrPluginLogin.ApiKey -> null + is SeerrPluginLogin.Local -> settings.login.local.username + }, + password = null, + credential = null, + ) + try { + val userConfig = + when (settings.login) { + is SeerrPluginLogin.ApiKey -> { + Timber.v("maybeAddPluginServer, using API key") + seerrApi.update(settings.serverUrl, settings.login.apiKey) + seerrLogin(seerrApi.api, SeerrAuthMethod.API_KEY, null, null) + } + + is SeerrPluginLogin.Local -> { + Timber.v("maybeAddPluginServer, using local user") + seerrApi.update(settings.serverUrl, null) + seerrLogin( + seerrApi.api, + SeerrAuthMethod.LOCAL, + settings.login.local.username, + settings.login.local.password, + ) + } + + SeerrPluginLogin.None -> { + return false + } + } + // Success + _connection.update { it.copy(pluginProvided = true) } + set(server, user, userConfig) + return true + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + clear() + Timber.w(ex, "Error adding plugin provided seerr") + _connection.update { + it.copy( + pluginProvided = true, + connection = SeerrConnectionStatus.Error(server, user, ex), + ) + } + return false + } + } + return false + } } /** @@ -196,6 +264,11 @@ class SeerrServerRepository */ typealias SeerrUserConfig = User +data class SeerrState( + val connection: SeerrConnectionStatus = SeerrConnectionStatus.NotConfigured, + val pluginProvided: Boolean = false, +) + sealed interface SeerrConnectionStatus { data object NotConfigured : SeerrConnectionStatus @@ -216,18 +289,22 @@ data class CurrentSeerr( val config: SeerrUserConfig, val serverConfig: PublicSettings, ) { + val requestMovieEnabled: Boolean + get() = config.hasPermission(SeerrPermission.REQUEST_MOVIE) val request4kMovieEnabled: Boolean get() = (serverConfig.movie4kEnabled ?: false) && config.hasPermission(SeerrPermission.REQUEST_4K_MOVIE) + val requestTvEnabled: Boolean + get() = config.hasPermission(SeerrPermission.REQUEST_TV) val request4kTvEnabled: Boolean get() = (serverConfig.series4kEnabled ?: false) && config.hasPermission(SeerrPermission.REQUEST_4K_TV) } -private suspend fun login( +suspend fun seerrLogin( client: SeerrApiClient, authMethod: SeerrAuthMethod, username: String?, @@ -259,84 +336,6 @@ private suspend fun login( } } -/** - * Listens for JF user switching in the app to also switch the Seerr user/server - */ -@ActivityScoped -class UserSwitchListener - @Inject - constructor( - @param:ActivityContext private val context: Context, - private val serverRepository: ServerRepository, - private val seerrServerRepository: SeerrServerRepository, - private val seerrServerDao: SeerrServerDao, - private val seerrApi: SeerrApi, - private val homeSettingsService: HomeSettingsService, - ) { - init { - context as AppCompatActivity - context.lifecycleScope.launchIO { - serverRepository.currentUser.asFlow().collect { user -> - Timber.d("New user") - seerrServerRepository.clear() - homeSettingsService.currentSettings.update { HomePageResolvedSettings.EMPTY } - if (user != null) { - switchUser(user) - } - } - } - } - - private suspend fun switchUser(user: JellyfinUser) = - supervisorScope { - // Check for home settings - launchIO { - homeSettingsService.loadCurrentSettings(user.id) - } - if (BuildConfig.DISCOVER_ENABLED) { - // Check for seerr server - launchIO { - seerrServerDao - .getUsersByJellyfinUser(user.rowId) - .lastOrNull() - ?.let { seerrUser -> - val server = - seerrServerDao.getServer(seerrUser.serverId)?.server - if (server != null) { - Timber.i("Found a seerr user & server") - try { - seerrApi.update(server.url, seerrUser.credential) - val userConfig = - if (seerrUser.authMethod != SeerrAuthMethod.API_KEY) { - login( - seerrApi.api, - seerrUser.authMethod, - seerrUser.username, - seerrUser.password, - ) - } else { - seerrApi.api.usersApi.authMeGet() - } - seerrServerRepository.set( - server, - seerrUser, - userConfig, - ) - } catch (ex: Exception) { - Timber.w( - ex, - "Error logging into %s", - server.url, - ) - seerrServerRepository.error(server, seerrUser, ex) - } - } - } - } - } - } - } - fun CurrentSeerr?.imageUrlBuilder( imageType: ImageType, path: String?, diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrService.kt index d9a340c9..d60988a2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrService.kt @@ -6,13 +6,21 @@ import com.github.damontecres.wholphin.api.seerr.model.CreditCrew import com.github.damontecres.wholphin.api.seerr.model.MediaInfo import com.github.damontecres.wholphin.api.seerr.model.MovieDetails import com.github.damontecres.wholphin.api.seerr.model.MovieResult +import com.github.damontecres.wholphin.api.seerr.model.RootFolder import com.github.damontecres.wholphin.api.seerr.model.SearchGet200ResponseResultsInner +import com.github.damontecres.wholphin.api.seerr.model.ServiceProfile import com.github.damontecres.wholphin.api.seerr.model.TvDetails import com.github.damontecres.wholphin.api.seerr.model.TvResult import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.data.model.SeerrAvailability import com.github.damontecres.wholphin.data.model.SeerrItemType +import com.github.damontecres.wholphin.data.model.SeerrPermission +import com.github.damontecres.wholphin.data.model.hasPermission +import com.github.damontecres.wholphin.ui.detail.discover.SeerrProfile +import com.github.damontecres.wholphin.ui.detail.discover.SeerrRequestData +import com.github.damontecres.wholphin.ui.detail.discover.SeerrRootFolder +import com.github.damontecres.wholphin.ui.formatBytes import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.toLocalDate import kotlinx.coroutines.flow.first @@ -20,6 +28,7 @@ import kotlinx.coroutines.flow.firstOrNull import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.serializer.toUUIDOrNull +import java.util.UUID import javax.inject.Inject import javax.inject.Singleton @@ -115,21 +124,7 @@ class SeerrService } BaseItemKind.PERSON -> { - api.personApi - .personPersonIdCombinedCreditsGet(personId = it) - .let { credits -> - val cast = - credits.cast - ?.take(25) - ?.map { createDiscoverItem(it) } - .orEmpty() - val crew = - credits.crew - ?.take(25) - ?.map { createDiscoverItem(it) } - .orEmpty() - cast + crew - } + personCredits(it) } else -> { @@ -141,6 +136,50 @@ class SeerrService null } + /** + * Get the combined movie & TV credits of a person as [DiscoverItem]s + * + * The two lists returned by the API are merged, de-duplicated by id since a person can be + * both cast and crew on the same title, and sorted by release date descending with undated + * entries last + */ + suspend fun personCredits(personId: Int): List<DiscoverItem> = + api.personApi + .personPersonIdCombinedCreditsGet(personId = personId) + .let { credits -> + val cast = credits.cast?.map { createDiscoverItem(it) }.orEmpty() + val crew = credits.crew?.map { createDiscoverItem(it) }.orEmpty() + (cast + crew) + .distinctBy { it.id } + .sortedByDescending { it.releaseDate } + } + + /** + * Create a [DiscoverItem] for a person in the library so that the full grid of their + * credits can be opened from their page + * + * @return the item or null if the person has no TMDB id + */ + fun discoverPerson(item: BaseItem): DiscoverItem? = + item.data.providerIds + ?.get("Tmdb") + ?.toIntOrNull() + ?.let { + DiscoverItem( + id = it, + type = SeerrItemType.PERSON, + title = item.name, + subtitle = null, + overview = item.data.overview, + availability = SeerrAvailability.UNKNOWN, + releaseDate = null, + posterUrl = null, + backDropUrl = null, + logoUrl = null, + jellyfinItemId = null, + ) + } + suspend fun getTvSeries(item: BaseItem): TvDetails? = if (active.first()) { item.data.providerIds @@ -153,20 +192,15 @@ class SeerrService null } - private suspend fun createImageUrl( + suspend fun createImageUrl( imageType: ImageType, path: String?, mediaInfo: MediaInfo?, + backdropHeight: Int = 1080, + backdropWidth: Int = 1920, ): String? { if (mediaInfo != null) { - val itemId = - if (mediaInfo.jellyfinMediaId.isNotNullOrBlank()) { - mediaInfo.jellyfinMediaId.toUUIDOrNull() - } else if (mediaInfo.jellyfinMediaId4k.isNotNullOrBlank()) { - mediaInfo.jellyfinMediaId4k.toUUIDOrNull() - } else { - null - } + val itemId = mediaInfo.jellyfinId if (itemId != null) { return imageUrlService.getItemImageUrl( itemId = itemId, @@ -174,21 +208,143 @@ class SeerrService ) } } - val current = seerrServerRepository.current.firstOrNull() ?: return null - val cacheImages = current.serverConfig.cacheImages == true - val base = - if (cacheImages) { - current.server.url.removeSuffix("/") + "/imageproxy/tmdb" + return path?.takeIf { it.isNotNullOrBlank() }?.let { + val current = seerrServerRepository.current.firstOrNull() ?: return null + val cacheImages = current.serverConfig.cacheImages == true + val base = + if (cacheImages) { + current.server.url.removeSuffix("/") + "/imageproxy/tmdb" + } else { + "https://image.tmdb.org" + } + val prefix = + when (imageType) { + ImageType.PRIMARY -> "/t/p/w500" + ImageType.BACKDROP -> "/t/p/w${backdropWidth}_and_h${backdropHeight}_multi_faces" + else -> throw IllegalArgumentException("Image type not supported: $imageType") + } + return "${base}${prefix}$path" + } + } + + suspend fun getProfilesAndFolders(type: SeerrItemType): SeerrRequestData { + var result = SeerrRequestData() + val current = seerrServerRepository.current.firstOrNull() + if (current != null && current.config.hasPermission(SeerrPermission.REQUEST_ADVANCED)) { + if (type == SeerrItemType.MOVIE) { + val radarrs = api.serviceApi.serviceRadarrGet() + val radarr = + radarrs.firstOrNull { it.isDefault && !it.is4k } + ?: radarrs.firstOrNull { !it.is4k } + val radarr4k = + radarrs.firstOrNull { it.isDefault && it.is4k } + ?: radarrs.firstOrNull { it.is4k } + + if (radarr != null && radarr.id != null) { + val settings = api.serviceApi.serviceRadarrRadarrIdGet(radarr.id) + + setProfilesAndFolder( + settings.profiles, + settings.rootFolders, + settings.server?.activeProfileId, + settings.server?.activeDirectory, + ) { profiles, rootFolders -> + result = + result.copy( + serverId = radarr.id, + profiles = profiles, + rootFolders = rootFolders, + ) + } + } + if (radarr4k != null && radarr4k.id != null) { + val settings = api.serviceApi.serviceRadarrRadarrIdGet(radarr4k.id) + setProfilesAndFolder( + settings.profiles, + settings.rootFolders, + settings.server?.activeProfileId, + settings.server?.activeDirectory, + ) { profiles, rootFolders -> + result = + result.copy( + server4kId = radarr4k.id, + profiles4k = profiles, + rootFolders4k = rootFolders, + ) + } + } } else { - "https://image.tmdb.org" + val sonarrs = api.serviceApi.serviceSonarrGet() + val sonarr = + sonarrs.firstOrNull { it.isDefault && !it.is4k } + ?: sonarrs.firstOrNull { !it.is4k } + val sonarr4k = + sonarrs.firstOrNull { it.isDefault && it.is4k } + ?: sonarrs.firstOrNull { it.is4k } + + if (sonarr != null && sonarr.id != null) { + val settings = api.serviceApi.serviceSonarrSonarrIdGet(sonarr.id) + + setProfilesAndFolder( + settings.profiles, + settings.rootFolders, + settings.server?.activeProfileId, + settings.server?.activeDirectory, + ) { profiles, rootFolders -> + result = + result.copy( + serverId = sonarr.id, + profiles = profiles, + rootFolders = rootFolders, + ) + } + } + if (sonarr4k != null && sonarr4k.id != null) { + val settings = api.serviceApi.serviceSonarrSonarrIdGet(sonarr4k.id) + setProfilesAndFolder( + settings.profiles, + settings.rootFolders, + settings.server?.activeProfileId, + settings.server?.activeDirectory, + ) { profiles, rootFolders -> + result = + result.copy( + server4kId = sonarr4k.id, + profiles4k = profiles, + rootFolders4k = rootFolders, + ) + } + } } - val prefix = - when (imageType) { - ImageType.PRIMARY -> "/t/p/w500" - ImageType.BACKDROP -> "/t/p/w1920_and_h1080_multi_faces" - else -> throw IllegalArgumentException("Image type not supported: $imageType") + } + return result + } + + private fun setProfilesAndFolder( + profiles: List<ServiceProfile>?, + rootFolders: List<RootFolder>?, + activeProfileId: Int?, + activeDirectory: String?, + setter: (List<SeerrProfile>, List<SeerrRootFolder>) -> Unit, + ) { + val profiles = + profiles.orEmpty().mapNotNull { + if (it.id != null && it.name != null) { + SeerrProfile(it.id, it.name, activeProfileId == it.id) + } else { + null + } } - return "${base}${prefix}$path" + val rootFolders = + rootFolders.orEmpty().mapNotNull { + if (it.id != null && it.path != null) { + val freeSpace = it.freeSpace?.let { formatBytes(it) } ?: "" + SeerrRootFolder(it.id, it.path, freeSpace, activeDirectory == it.path) + } else { + null + } + } + setter.invoke(profiles, rootFolders) } suspend fun createDiscoverItem(item: Any): DiscoverItem = @@ -216,7 +372,8 @@ class SeerrService releaseDate = toLocalDate(movie.releaseDate), posterUrl = createImageUrl(ImageType.PRIMARY, movie.posterPath, movie.mediaInfo), backDropUrl = createImageUrl(ImageType.BACKDROP, movie.backdropPath, movie.mediaInfo), - jellyfinItemId = movie.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(), + logoUrl = createImageUrl(ImageType.LOGO, null, movie.mediaInfo), + jellyfinItemId = movie.mediaInfo?.jellyfinId, ) suspend fun createDiscoverItem(movie: MovieDetails): DiscoverItem = @@ -232,7 +389,8 @@ class SeerrService releaseDate = toLocalDate(movie.releaseDate), posterUrl = createImageUrl(ImageType.PRIMARY, movie.posterPath, movie.mediaInfo), backDropUrl = createImageUrl(ImageType.BACKDROP, movie.backdropPath, movie.mediaInfo), - jellyfinItemId = movie.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(), + logoUrl = createImageUrl(ImageType.LOGO, null, movie.mediaInfo), + jellyfinItemId = movie.mediaInfo?.jellyfinId, ) suspend fun createDiscoverItem(tv: TvResult): DiscoverItem = @@ -248,7 +406,8 @@ class SeerrService releaseDate = toLocalDate(tv.firstAirDate), posterUrl = createImageUrl(ImageType.PRIMARY, tv.posterPath, tv.mediaInfo), backDropUrl = createImageUrl(ImageType.BACKDROP, tv.backdropPath, tv.mediaInfo), - jellyfinItemId = tv.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(), + logoUrl = createImageUrl(ImageType.LOGO, null, tv.mediaInfo), + jellyfinItemId = tv.mediaInfo?.jellyfinId, ) suspend fun createDiscoverItem(tv: TvDetails): DiscoverItem = @@ -264,7 +423,8 @@ class SeerrService releaseDate = toLocalDate(tv.firstAirDate), posterUrl = createImageUrl(ImageType.PRIMARY, tv.posterPath, tv.mediaInfo), backDropUrl = createImageUrl(ImageType.BACKDROP, tv.backdropPath, tv.mediaInfo), - jellyfinItemId = tv.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(), + logoUrl = createImageUrl(ImageType.LOGO, null, tv.mediaInfo), + jellyfinItemId = tv.mediaInfo?.jellyfinId, ) suspend fun createDiscoverItem(search: SeerrSearchResult): DiscoverItem = @@ -280,7 +440,8 @@ class SeerrService releaseDate = toLocalDate(search.releaseDate ?: search.firstAirDate), posterUrl = createImageUrl(ImageType.PRIMARY, search.posterPath, search.mediaInfo), backDropUrl = createImageUrl(ImageType.BACKDROP, search.backdropPath, search.mediaInfo), - jellyfinItemId = search.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(), + logoUrl = createImageUrl(ImageType.LOGO, null, search.mediaInfo), + jellyfinItemId = search.mediaInfo?.jellyfinId, ) suspend fun createDiscoverItem(credit: CreditCast): DiscoverItem = @@ -293,7 +454,7 @@ class SeerrService availability = SeerrAvailability.from(credit.mediaInfo?.status) ?: SeerrAvailability.UNKNOWN, - releaseDate = toLocalDate(credit.firstAirDate), + releaseDate = toLocalDate(credit.releaseDate ?: credit.firstAirDate), posterUrl = createImageUrl( ImageType.PRIMARY, @@ -306,7 +467,8 @@ class SeerrService credit.backdropPath, credit.mediaInfo, ), - jellyfinItemId = credit.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(), + logoUrl = createImageUrl(ImageType.LOGO, null, credit.mediaInfo), + jellyfinItemId = credit.mediaInfo?.jellyfinId, ) suspend fun createDiscoverItem(credit: CreditCrew): DiscoverItem = @@ -319,7 +481,7 @@ class SeerrService availability = SeerrAvailability.from(credit.mediaInfo?.status) ?: SeerrAvailability.UNKNOWN, - releaseDate = toLocalDate(credit.firstAirDate), + releaseDate = toLocalDate(credit.releaseDate ?: credit.firstAirDate), posterUrl = createImageUrl( ImageType.PRIMARY, @@ -332,6 +494,13 @@ class SeerrService credit.backdropPath, credit.mediaInfo, ), - jellyfinItemId = credit.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(), + logoUrl = createImageUrl(ImageType.LOGO, null, credit.mediaInfo), + jellyfinItemId = credit.mediaInfo?.jellyfinId, ) } + +val MediaInfo.jellyfinId: UUID? + get() = jellyfinMediaId4k?.toUUIDOrNull() ?: jellyfinMediaId?.toUUIDOrNull() + +val MediaInfo.jellyfinIdAsString: String? + get() = jellyfinMediaId4k ?: jellyfinMediaId diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/ServerEventListener.kt b/app/src/main/java/com/github/damontecres/wholphin/services/ServerEventListener.kt index 979c90bd..ca6cceb8 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/ServerEventListener.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/ServerEventListener.kt @@ -9,19 +9,26 @@ import androidx.lifecycle.lifecycleScope import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.data.model.JellyfinUser +import com.github.damontecres.wholphin.ui.collectLatestIn +import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.showToast import dagger.hilt.android.qualifiers.ActivityContext import dagger.hilt.android.scopes.ActivityScoped +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Job +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.isActive import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.sessionApi import org.jellyfin.sdk.api.sockets.subscribe import org.jellyfin.sdk.model.api.GeneralCommandMessage import org.jellyfin.sdk.model.api.GeneralCommandType import org.jellyfin.sdk.model.api.MediaType +import org.jellyfin.sdk.model.api.UserUpdatedMessage import timber.log.Timber import javax.inject.Inject @@ -42,7 +49,7 @@ class ServerEventListener init { activity.lifecycle.addObserver(this) - serverRepository.current.observe(activity) { + serverRepository.current.collectLatestIn(activity.lifecycleScope) { Timber.d("New user/server: %s", it) listenJob?.cancel() if (it != null) { @@ -66,34 +73,71 @@ class ServerEventListener ), supportsMediaControl = true, ) - setupListeners() + subscribeToWebSocket() } } } - fun setupListeners() { - serverRepository.currentUser + fun subscribeToWebSocket() { Timber.v("Subscribing to WebSocket") listenJob?.cancel() listenJob = - api.webSocket - .subscribe<GeneralCommandMessage>() - .onEach { message -> - if (message.data?.name in - setOf( - GeneralCommandType.DISPLAY_MESSAGE, - GeneralCommandType.SEND_STRING, - ) - ) { - val header = message.data?.arguments["Header"] - val text = - message.data?.arguments["Text"] ?: message.data?.arguments["String"] - val toast = - listOfNotNull(header, text) - .joinToString("\n") - showToast(context, toast, Toast.LENGTH_LONG) + activity.lifecycleScope.launchDefault { + try { + // Launch multiple listeners, but stop all if one fails + coroutineScope { + api.webSocket + .subscribe<GeneralCommandMessage>() + .onEach { message -> + Timber.v( + "Got GeneralCommandMessage: %s", + message.data?.name, + ) + when (message.data?.name) { + GeneralCommandType.DISPLAY_MESSAGE, + GeneralCommandType.SEND_STRING, + -> { + val header = message.data?.arguments["Header"] + val text = + message.data?.arguments["Text"] + ?: message.data?.arguments["String"] + val toast = + listOfNotNull(header, text) + .joinToString("\n") + if (toast.isNotBlank()) { + showToast(context, toast, Toast.LENGTH_LONG) + } + } + + else -> { + Timber.v( + "Ignoring GeneralCommandMessage: %s", + message.data?.name, + ) + } + } + }.catch { ex -> + Timber.e(ex, "Error in general message websocket subscription") + }.launchIn(this@coroutineScope) + + api.webSocket + .subscribe<UserUpdatedMessage>() + .catch { ex -> + Timber.e(ex, "Error in user updated websocket subscription") + }.collectLatestIn(this@coroutineScope) { msg -> + Timber.v("Got updated user: %s", msg.data?.id) + msg.data?.let { serverRepository.updateUserDto(it) } + } } - }.launchIn(activity.lifecycleScope) + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Error in websocket connection") + if (activity.lifecycleScope.isActive) { + subscribeToWebSocket() + } + } + } } override fun onResume(owner: LifecycleOwner) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/ServerPluginApi.kt b/app/src/main/java/com/github/damontecres/wholphin/services/ServerPluginApi.kt new file mode 100644 index 00000000..8b51ad63 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/ServerPluginApi.kt @@ -0,0 +1,96 @@ +package com.github.damontecres.wholphin.services + +import com.github.damontecres.wholphin.data.model.HomePageSettings +import com.github.damontecres.wholphin.data.model.SeerrPluginSettings +import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient +import com.github.damontecres.wholphin.util.WholphinDispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.json.decodeFromStream +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.OkHttpClient +import okhttp3.Request +import okio.IOException +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.exception.ApiClientException +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Service for interacting with the companion plugin + */ +@Singleton +class ServerPluginApi + @Inject + constructor( + @param:AuthOkHttpClient private val okHttpClient: OkHttpClient, + private val api: ApiClient, + ) { + private fun createUrl(path: String): String? = + api.baseUrl?.let { if (it.endsWith("/")) "${it}wholphin/$path" else "$it/wholphin/$path" } + + private val json get() = HomeSettingsService.jsonParser + + companion object { + private const val HOME_CONFIG_PATH = "homesettings" + private const val SEERR_CONFIG_PATH = "seerrsettings" + } + + /** + * Check if the plugin is installed on the server + */ + suspend fun checkInstalled(): Boolean = + withContext(WholphinDispatchers.IO) { + val url = createUrl("public") ?: return@withContext false + val request = + Request + .Builder() + .url(url) + .get() + .build() + try { + return@withContext okHttpClient + .newCall(request) + .execute() + .use { it.isSuccessful } + } catch (ex: IOException) { + Timber.w("Could not connect to server plugin: %s", ex.message) + return@withContext false + } + } + + private suspend inline fun <reified T> executeRequest(path: String): T? { + val url = createUrl(path) ?: return null + val request = Request(url.toHttpUrl()) + return executeRequest<T>(request) + } + + @OptIn(ExperimentalSerializationApi::class) + private suspend inline fun <reified T> executeRequest(request: Request): T? = + withContext(WholphinDispatchers.IO) { + okHttpClient.newCall(request).execute().use { res -> + if (res.isSuccessful) { + json.decodeFromStream<T>(res.body.byteStream()) + } else if (res.code == 404) { + Timber.w("Plugin 404: %s", request.url.encodedPath) + null + } else { + throw ApiClientException(res.code.toString() + " " + res.body.string()) + } + } + } + + /** + * Get the default home page settings from the plugin + * + * @return the home page settings or null if no rows are configured or the server isn't installed + */ + suspend fun fetchHomePageSettings(): HomePageSettings? = + executeRequest<HomePageSettings>(HOME_CONFIG_PATH)?.takeIf { it.rows.isNotEmpty() } + + /** + * Get the [SeerrPluginSettings] as configured by teh server plugin, or null if none are configured + */ + suspend fun fetchSeerrSettings(): SeerrPluginSettings? = executeRequest(SEERR_CONFIG_PATH) + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/MediaReportService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/ServerReportService.kt similarity index 58% rename from app/src/main/java/com/github/damontecres/wholphin/services/MediaReportService.kt rename to app/src/main/java/com/github/damontecres/wholphin/services/ServerReportService.kt index d1a1543a..97369c36 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/MediaReportService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/ServerReportService.kt @@ -2,11 +2,14 @@ package com.github.damontecres.wholphin.services import android.content.Context import android.os.Build +import com.github.damontecres.wholphin.BuildConfig import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope +import com.github.damontecres.wholphin.ui.detail.DebugViewModel.Companion.getLogCatLines import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.util.ExceptionHandler +import com.google.protobuf.MessageLiteOrBuilder import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope import kotlinx.serialization.json.Json @@ -22,10 +25,10 @@ import javax.inject.Inject import javax.inject.Singleton /** - * Send media info to the server + * Send reports to the server such as media info or app logs */ @Singleton -class MediaReportService +class ServerReportService @Inject constructor( @param:ApplicationContext private val context: Context, @@ -45,45 +48,65 @@ class MediaReportService /** * Fetch the media info and send it to the server */ - fun sendReportFor(itemId: UUID) { + fun sendMediaReportFor(itemId: UUID) { ioScope.launchIO(ExceptionHandler(autoToast = true)) { val item = api.userLibraryApi.getItem(itemId = itemId).content - sendReportFor(item) + sendMediaReportFor(item) } } /** * Send the media report for the given item */ - suspend fun sendReportFor(item: BaseItemDto) { + suspend fun sendMediaReportFor(item: BaseItemDto) { val sources = item.mediaSources ?: api.userLibraryApi .getItem(itemId = item.id) .content.mediaSources val sourcesJson = json.encodeToString(sources) - val playbackPrefs = userPreferencesService.getCurrent().appPreferences.playbackPreferences - val serverVersion = serverRepository.currentServer.value?.serverVersion + val appPreferences = userPreferencesService.getCurrent().appPreferences + val serverVersion = serverRepository.currentServer?.serverVersion val deviceProfile = - deviceProfileService.getOrCreateDeviceProfile(playbackPrefs, serverVersion) + deviceProfileService.getOrCreateDeviceProfile(appPreferences, serverVersion) val deviceProfileJson = json.encodeToString(deviceProfile) val body = - """ - Send media info + buildLogHeader("Send media info") + "\n\n" + + """ + playbackPrefs=${appPreferences.playbackPreferences.toStringOneLine()} + + experimental=${appPreferences.experimentalPreferences.toStringOneLine()} + + mediaSources=$sourcesJson + + deviceProfile=$deviceProfileJson + """.trimIndent() + body.chunked(2048).forEach { Timber.w(it) } + Timber.w("End send media info") + val response by api.clientLogApi.logFile(body) + showToast(context, "Sent! Filename=${response.fileName}") + } + + fun buildLogHeader(title: String): String { + val serverVersion = serverRepository.currentServer?.serverVersion + return """ + $title serverVersion=$serverVersion clientInfo=$clientInfo + flavor=${BuildConfig.FLAVOR} deviceInfo=$deviceInfo manufacturer=${Build.MANUFACTURER} model=${Build.MODEL} apiLevel=${Build.VERSION.SDK_INT} - - playbackPrefs=${playbackPrefs.toString().replace("\n", ", ").replace("\t", " ")} - - deviceProfile=$deviceProfileJson - - mediaSources=$sourcesJson """.trimIndent() - Timber.w(body) - val response by api.clientLogApi.logFile(body) + } + + suspend fun sendAppLogs() { + val logcat = getLogCatLines().joinToString("\n") { it.text } + val header = buildLogHeader("Send App Logs") + Timber.w(header) + val response by api.clientLogApi.logFile(header + "\n\n" + logcat) showToast(context, "Sent! Filename=${response.fileName}") } } + +private fun MessageLiteOrBuilder.toStringOneLine(): String = toString().replace("\n", ", ").replace("\t", " ") diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SetupNavigationManager.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SetupNavigationManager.kt index fa42469e..abe39959 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SetupNavigationManager.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SetupNavigationManager.kt @@ -27,9 +27,6 @@ class SetupNavigationManager fun navigateTo(destination: SetupDestination) { backStack[0] = destination log() - if (destination !is SetupDestination.AppContent) { - navigationManager.reloadHome() - } } private fun log() { diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt index 05eac34e..0b9da9f8 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt @@ -1,13 +1,27 @@ package com.github.damontecres.wholphin.services -import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.SeriesTrackChoiceDao import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.ActivationFlag import com.github.damontecres.wholphin.data.model.ItemPlayback -import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice +import com.github.damontecres.wholphin.data.model.RelativeMediaStream +import com.github.damontecres.wholphin.data.model.SeriesTrackChoice +import com.github.damontecres.wholphin.data.model.SeriesTrackChoiceType +import com.github.damontecres.wholphin.data.model.TrackChoiceParentType +import com.github.damontecres.wholphin.data.model.TrackFlag +import com.github.damontecres.wholphin.data.model.TrackFlag.Companion.has import com.github.damontecres.wholphin.data.model.TrackIndex +import com.github.damontecres.wholphin.data.model.asRelative +import com.github.damontecres.wholphin.preferences.SubtitleModePreference import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.preferences.UserProfileSettings +import com.github.damontecres.wholphin.ui.gt import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.letNotEmpty +import com.github.damontecres.wholphin.ui.util.ResProviderStringProvider +import com.github.damontecres.wholphin.ui.util.ResStringProvider +import com.github.damontecres.wholphin.ui.util.StringProvider import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.MediaSourceInfo import org.jellyfin.sdk.model.api.MediaStream @@ -28,50 +42,69 @@ class StreamChoiceService @Inject constructor( private val serverRepository: ServerRepository, - private val playbackLanguageChoiceDao: PlaybackLanguageChoiceDao, + private val seriesTrackChoiceDao: SeriesTrackChoiceDao, ) { - private val userConfig: UserConfiguration? get() = serverRepository.currentUserDto.value?.configuration + private val userConfig: UserConfiguration? get() = serverRepository.currentUserDto?.configuration - suspend fun updateAudio( + suspend fun saveSeriesTrackChoice( dto: BaseItemDto, - audioLang: String, - ) = update(dto) { - it.copy( - audioLanguage = audioLang, - ) - } - - suspend fun updateSubtitles( - dto: BaseItemDto, - subtitleLang: String?, - subtitlesDisabled: Boolean, - ) = update(dto) { - it.copy( - subtitleLanguage = if (subtitlesDisabled) null else subtitleLang, - subtitlesDisabled = subtitlesDisabled, - ) - } - - suspend fun update( - dto: BaseItemDto, - update: (PlaybackLanguageChoice) -> PlaybackLanguageChoice, + type: MediaStreamType, + stream: RelativeMediaStream?, + activation: ActivationFlag = ActivationFlag.ACTIVATED, ) { - val seriesId = dto.seriesId - if (seriesId != null) { - val userId = serverRepository.currentUser.value!!.rowId - val currentPlc = - playbackLanguageChoiceDao.get(userId, seriesId) - ?: PlaybackLanguageChoice(userId, seriesId, dto.id) - val newPlc = update.invoke(currentPlc) - Timber.v("Saving series PLC: %s", newPlc) - playbackLanguageChoiceDao.save(newPlc) - } + val type = + when (stream?.type ?: type) { + MediaStreamType.AUDIO -> SeriesTrackChoiceType.AUDIO + MediaStreamType.SUBTITLE -> SeriesTrackChoiceType.SUBTITLE + else -> return + } + val userId = serverRepository.currentUser!!.rowId + val newStc = + buildList { + dto.parentId + ?.let { seasonId -> + SeriesTrackChoice( + userId = userId, + itemId = dto.id, + parentId = seasonId, + type = type, + parentType = TrackChoiceParentType.SEASON, + stream = stream, + activation = activation, + ) + }?.let(::add) + dto.seriesId + ?.let { seriesId -> + SeriesTrackChoice( + userId = userId, + itemId = dto.id, + parentId = seriesId, + type = type, + parentType = TrackChoiceParentType.SERIES, + stream = stream, + activation = activation, + ) + }?.let(::add) + } + Timber.d( + "Saving series track choices for itemId=%s, seriesId=%s, parentId=%s: %s", + dto.id, + dto.seriesId, + dto.parentId, + newStc, + ) + seriesTrackChoiceDao.save(newStc) } - suspend fun getPlaybackLanguageChoice(dto: BaseItemDto) = - dto.seriesId?.let { - playbackLanguageChoiceDao.get(serverRepository.currentUser.value!!.rowId, it) - } + suspend fun saveDisabledSeriesTrackChoice( + dto: BaseItemDto, + type: MediaStreamType, + ) = saveSeriesTrackChoice(dto, type, null, ActivationFlag.DISABLED) + + suspend fun saveOnlyForcedSeriesTrackChoice( + dto: BaseItemDto, + type: MediaStreamType, + ) = saveSeriesTrackChoice(dto, type, null, ActivationFlag.ONLY_FORCED) /** * Returns the [MediaSourceInfo] that matched the [ItemPlayback] or else the one with the highest resolution @@ -102,15 +135,15 @@ class StreamChoiceService */ suspend fun chooseAudioStream( source: MediaSourceInfo, - seriesId: UUID?, + item: BaseItemDto, itemPlayback: ItemPlayback?, - plc: PlaybackLanguageChoice?, + stc: List<SeriesTrackChoice>?, prefs: UserPreferences, - ): MediaStream? { - val plc = plc ?: seriesId?.let { playbackLanguageChoiceDao.get(serverRepository.currentUser.value!!.rowId, it) } + ): StreamChoiceResult? { + val stc = stc ?: getSeriesTrackChoices(item, SeriesTrackChoiceType.SUBTITLE) return source.mediaStreams?.letNotEmpty { streams -> val candidates = streams.filter { it.type == MediaStreamType.AUDIO } - chooseAudioStream(candidates, itemPlayback, plc, prefs) + chooseAudioStream(candidates, itemPlayback, stc, prefs) } } @@ -120,59 +153,70 @@ class StreamChoiceService fun chooseAudioStream( candidates: List<MediaStream>, itemPlayback: ItemPlayback?, - playbackLanguageChoice: PlaybackLanguageChoice?, + stc: List<SeriesTrackChoice>, prefs: UserPreferences, - ): MediaStream? = + ): StreamChoiceResult = if (itemPlayback?.audioIndexEnabled == true) { - candidates.firstOrNull { it.index == itemPlayback.audioIndex } - } else { - val seriesLang = - playbackLanguageChoice?.audioLanguage?.takeIf { it.isNotNullOrBlank() } - // If the user has chosen a different language for the series, prefer that - val audioLanguage = seriesLang ?: userConfig?.audioLanguagePreference - - if (audioLanguage.isNotNullOrBlank()) { - val sorted = - candidates.sortedWith(compareBy<MediaStream> { it.language }.thenByDescending { it.channels }) - sorted.firstOrNull { it.language == audioLanguage && it.isDefault } - ?: sorted.firstOrNull { it.language == audioLanguage } - ?: sorted.firstOrNull { it.isDefault } - ?: sorted.firstOrNull() + candidates.firstOrNull { it.index == itemPlayback.audioIndex } with + StreamChoiceReason.Item( + itemPlayback, + ) + } else if (stc.isNotEmpty()) { + val first = stc.first() + val result = scoreStreams(candidates, first) + if (result == null) { + null with StreamChoiceReason.from(first) + } else if (result.isEmpty() && stc.size > 1) { + // SeriesTrackChoice did not apply to any streams, but there are more options + chooseAudioStream(candidates, itemPlayback, stc.subList(1, stc.size), prefs) + } else if (result.isEmpty()) { + // SeriesTrackChoice did not apply to any streams, so use regular selection logic + Timber.v("No audio STC applied") + chooseAudioStream(candidates, itemPlayback, emptyList(), prefs) } else { - candidates.firstOrNull { it.isDefault } - ?: candidates.firstOrNull() + // Otherwise, use the best scored stream + Timber.v("Using audio STC from %s", first.parentId) + result.first().stream with StreamChoiceReason.from(first, result) } + } else { + val audioLanguage = getPreferredLanguage(MediaStreamType.AUDIO, prefs, userConfig) + val stream = + if (audioLanguage.isNotNullOrBlank()) { + val sorted = + candidates.sortedWith(compareBy<MediaStream> { it.language }.thenByDescending { it.channels }) + sorted.firstOrNull { it.language == audioLanguage && it.isDefault } + ?: sorted.firstOrNull { it.language == audioLanguage } + ?: sorted.firstOrNull { it.isDefault } + ?: sorted.firstOrNull() + } else { + candidates.firstOrNull { it.isDefault } + ?: candidates.firstOrNull() + } + stream with StreamChoiceReason.UserPreferences } /** * Returns the subtitle stream that should play */ suspend fun chooseSubtitleStream( + item: BaseItemDto, source: MediaSourceInfo, audioStream: MediaStream?, - seriesId: UUID?, itemPlayback: ItemPlayback?, - plc: PlaybackLanguageChoice?, + stc: List<SeriesTrackChoice>?, prefs: UserPreferences, - ): MediaStream? { - val plc = - plc ?: seriesId?.let { - playbackLanguageChoiceDao.get( - serverRepository.currentUser.value!!.rowId, - it, - ) - } - return source.mediaStreams?.letNotEmpty { streams -> + ): StreamChoiceResult? = + source.mediaStreams?.letNotEmpty { streams -> val candidates = streams.filter { it.type == MediaStreamType.SUBTITLE } + val stc = stc ?: getSeriesTrackChoices(item, SeriesTrackChoiceType.SUBTITLE) chooseSubtitleStream( audioStream?.language, candidates, itemPlayback, - plc, + stc, prefs, ) } - } /** * Resolves ONLY_FORCED to an actual subtitle track index. @@ -181,7 +225,7 @@ class StreamChoiceService suspend fun resolveSubtitleIndex( source: MediaSourceInfo, audioStreamIndex: Int?, - seriesId: UUID?, + item: BaseItemDto, subtitleIndex: Int, prefs: UserPreferences, ): Int? = @@ -194,18 +238,18 @@ class StreamChoiceService } val itemPlayback = ItemPlayback( - userId = serverRepository.currentUser.value!!.rowId, - itemId = UUID.randomUUID(), // Not used for ONLY_FORCED resolution + userId = serverRepository.currentUser!!.rowId, + itemId = item.id, subtitleIndex = TrackIndex.ONLY_FORCED, ) chooseSubtitleStream( + item = item, source = source, audioStream = audioStream, - seriesId = seriesId, itemPlayback = itemPlayback, - plc = null, + stc = null, prefs = prefs, - )?.index + )?.stream?.index } /** @@ -215,46 +259,82 @@ class StreamChoiceService audioStreamLang: String?, candidates: List<MediaStream>, itemPlayback: ItemPlayback?, - playbackLanguageChoice: PlaybackLanguageChoice?, + stc: List<SeriesTrackChoice>, prefs: UserPreferences, - ): MediaStream? { - if (itemPlayback?.subtitleIndex == TrackIndex.DISABLED) { - return null - } else if (itemPlayback?.subtitleIndex == TrackIndex.ONLY_FORCED) { - // Client-side manual override: User selected "Only Forced" in player menu - val seriesLang = - playbackLanguageChoice?.subtitleLanguage?.takeIf { it.isNotNullOrBlank() } - val subtitleLanguage = - (seriesLang ?: userConfig?.subtitleLanguagePreference) - ?.takeIf { it.isNotNullOrBlank() } - return findForcedTrack(candidates, subtitleLanguage, audioStreamLang) - } else if (itemPlayback?.subtitleIndexEnabled == true) { - return candidates.firstOrNull { it.index == itemPlayback.subtitleIndex } - } else { - val seriesLang = - playbackLanguageChoice?.subtitleLanguage?.takeIf { it.isNotNullOrBlank() } - val subtitleLanguage = - (seriesLang ?: userConfig?.subtitleLanguagePreference) - ?.takeIf { it.isNotNullOrBlank() } + ): StreamChoiceResult { + if (itemPlayback != null && itemPlayback.subtitleIndex != TrackIndex.UNSPECIFIED) { + if (itemPlayback.subtitleIndex == TrackIndex.DISABLED) { + return null with StreamChoiceReason.Item(itemPlayback) + } else if (itemPlayback.subtitleIndex == TrackIndex.ONLY_FORCED) { + val subtitleLanguage = + getPreferredLanguage(MediaStreamType.SUBTITLE, prefs, userConfig) + // Client-side manual override: User selected "Only Forced" in player menu + return findForcedTrack( + candidates, + subtitleLanguage, + audioStreamLang, + ) with StreamChoiceReason.Item(itemPlayback) + } else { + return candidates.firstOrNull { it.index == itemPlayback.subtitleIndex } with + StreamChoiceReason.Item(itemPlayback) + } + } else if (stc.isNotEmpty()) { + val first = stc.first() + return when (first.activation) { + ActivationFlag.DISABLED -> { + null with StreamChoiceReason.from(first) + } - val subtitleMode = - when { - playbackLanguageChoice?.subtitlesDisabled == false && seriesLang != null -> { - // User has chosen a series level subtitle language, so override their normal - // subtitle mode to display that language - SubtitlePlaybackMode.ALWAYS - } - - playbackLanguageChoice?.subtitlesDisabled == true && seriesLang == null -> { - // Series level settings disables subtitles - SubtitlePlaybackMode.NONE - } - - else -> { - // Fallback to the user's preference - userConfig?.subtitleMode ?: SubtitlePlaybackMode.DEFAULT + ActivationFlag.ONLY_FORCED -> { + val subtitleLanguage = + getPreferredLanguage(MediaStreamType.SUBTITLE, prefs, userConfig) + val stream = + findForcedTrack( + candidates, + subtitleLanguage, + audioStreamLang, + ) + if (stream != null) { + stream with StreamChoiceReason.from(first) + } else { + handleSubtitleStreamChoiceResult( + result = emptyList(), + audioStreamLang = audioStreamLang, + candidates = candidates, + itemPlayback = itemPlayback, + stc = stc, + prefs = prefs, + ) } } + + ActivationFlag.ACTIVATED -> { + val result = + scoreStreams(candidates, first) + ?: throw IllegalStateException("scoreStreams should not return null for activated") + handleSubtitleStreamChoiceResult( + result = result, + audioStreamLang = audioStreamLang, + candidates = candidates, + itemPlayback = itemPlayback, + stc = stc, + prefs = prefs, + ) + } + } + } else { + val subtitleLanguage = + getPreferredLanguage(MediaStreamType.SUBTITLE, prefs, userConfig) + val subtitleMode = + when (prefs.userPreferences?.subtitleMode) { + SubtitleModePreference.USE_USER_PROFILE -> userConfig?.subtitleMode + SubtitleModePreference.DEFAULT -> SubtitlePlaybackMode.DEFAULT + SubtitleModePreference.SMART -> SubtitlePlaybackMode.SMART + SubtitleModePreference.ONLY_FORCED -> SubtitlePlaybackMode.ONLY_FORCED + SubtitleModePreference.ALWAYS -> SubtitlePlaybackMode.ALWAYS + SubtitleModePreference.NONE -> SubtitlePlaybackMode.NONE + null -> SubtitlePlaybackMode.DEFAULT + } ?: SubtitlePlaybackMode.DEFAULT val candidates = candidates .sortedWith( @@ -267,71 +347,104 @@ class StreamChoiceService }.thenByDescending { it.isForced && it.language.isUnknown } .thenByDescending { it.isForced }, ) - return when (subtitleMode) { - SubtitlePlaybackMode.ALWAYS -> { - if (subtitleLanguage.isNotNullOrBlank()) { - candidates.firstOrNull { - it.language.equals(subtitleLanguage, true) || - it.language.isUnknown - } - } else { - candidates.firstOrNull() - } - } - - SubtitlePlaybackMode.ONLY_FORCED -> { - if (subtitleLanguage.isNotNullOrBlank()) { - candidates.firstOrNull { it.language == subtitleLanguage && it.isForced } - ?: candidates.firstOrNull { it.language.isUnknown && it.isForced } - } else { - candidates.firstOrNull { it.isForced } - } - } - - SubtitlePlaybackMode.SMART -> { - if (subtitleLanguage.isNotNullOrBlank()) { - val audioLanguage = userConfig?.audioLanguagePreference - if ( - // Has preferred subtitle lang & preferred audio, so only show subtitles if actual audio is different - (audioLanguage.isNotNullOrBlank() && audioLanguage != audioStreamLang) || - // Has preferred subtitle lang, but no preferred audio lang, so show subtitle if subtitle lang is different from actual audio - (audioLanguage.isNullOrBlank() && subtitleLanguage != audioStreamLang) - ) { - candidates.firstOrNull { it.language == subtitleLanguage } - ?: candidates.firstOrNull { it.language.isUnknown } + val stream = + when (subtitleMode) { + SubtitlePlaybackMode.ALWAYS -> { + if (subtitleLanguage.isNotNullOrBlank()) { + candidates.firstOrNull { + // Prefer non-forced first + !it.isForced && it.language.equalsLangOrUnknown(subtitleLanguage) + } ?: candidates.firstOrNull { + it.language.equalsLangOrUnknown(subtitleLanguage) + } } else { - // Otherwise, show forced subtitles in preferred lang - candidates.firstOrNull { it.isForced && it.language == subtitleLanguage } - ?: candidates.firstOrNull { it.isForced && it.language.isUnknown } + candidates.firstOrNull { !it.isForced } ?: candidates.firstOrNull() } - } else { - candidates.firstOrNull { it.isDefault } + } + + SubtitlePlaybackMode.ONLY_FORCED -> { + if (subtitleLanguage.isNotNullOrBlank()) { + candidates.firstOrNull { it.language == subtitleLanguage && it.isForced } + ?: candidates.firstOrNull { it.language.isUnknown && it.isForced } + } else { + candidates.firstOrNull { it.isForced } + } + } + + SubtitlePlaybackMode.SMART -> { + if (subtitleLanguage.isNotNullOrBlank()) { + val audioLanguage = userConfig?.audioLanguagePreference + if ( + // Has preferred subtitle lang & preferred audio, so only show subtitles if actual audio is different + (audioLanguage.isNotNullOrBlank() && audioLanguage != audioStreamLang) || + // Has preferred subtitle lang, but no preferred audio lang, so show subtitle if subtitle lang is different from actual audio + (audioLanguage.isNullOrBlank() && subtitleLanguage != audioStreamLang) + ) { + candidates.firstOrNull { it.language == subtitleLanguage } + ?: candidates.firstOrNull { it.language.isUnknown } + } else { + // Otherwise, show forced subtitles in preferred lang + candidates.firstOrNull { it.isForced && it.language == subtitleLanguage } + ?: candidates.firstOrNull { it.isForced && it.language.isUnknown } + } + } else { + candidates.firstOrNull { it.isDefault } + } + } + + SubtitlePlaybackMode.DEFAULT -> { + if (subtitleLanguage.isNotNullOrBlank()) { + candidates.firstOrNull { it.language == subtitleLanguage && (it.isDefault || it.isForced) } + ?: candidates.firstOrNull { it.isDefault || it.isForced } + } else { + candidates.firstOrNull { it.isDefault || it.isForced } + } + } + + SubtitlePlaybackMode.NONE -> { + null } } - - SubtitlePlaybackMode.DEFAULT -> { - if (subtitleLanguage.isNotNullOrBlank()) { - candidates.firstOrNull { it.language == subtitleLanguage && (it.isDefault || it.isForced) } - ?: candidates.firstOrNull { it.isDefault || it.isForced } - } else { - candidates.firstOrNull { it.isDefault || it.isForced } - } - } - - SubtitlePlaybackMode.NONE -> { - null - } - } + return stream with StreamChoiceReason.UserPreferences } } - /** Returns true if the track is forced (via metadata flag or title patterns). */ - private fun isForcedOrSigns(track: MediaStream): Boolean { - if (track.isForced) return true - val title = track.title ?: track.displayTitle ?: return false - return title.contains("forced", ignoreCase = true) || - title.contains("signs", ignoreCase = true) || - title.contains("songs", ignoreCase = true) + /** + * Recursively calls [chooseSubtitleStream] based on [result] and [stc] + */ + private fun handleSubtitleStreamChoiceResult( + result: List<ScoredMediaStream>, + audioStreamLang: String?, + candidates: List<MediaStream>, + itemPlayback: ItemPlayback?, + stc: List<SeriesTrackChoice>, + prefs: UserPreferences, + ): StreamChoiceResult { + val first = stc.first() + return if (result.isEmpty() && stc.size > 1) { + // SeriesTrackChoice did not apply to any streams, but there are more options + chooseSubtitleStream( + audioStreamLang, + candidates, + itemPlayback, + stc.subList(1, stc.size), + prefs, + ) + } else if (result.isEmpty()) { + // SeriesTrackChoice did not apply to any streams, so use regular selection logic + Timber.v("No subtitle STC applied") + chooseSubtitleStream( + audioStreamLang, + candidates, + itemPlayback, + emptyList(), + prefs, + ) + } else { + // Otherwise, use the best scored stream + Timber.v("Using subtitle STC from %s", first.parentId) + result.first().stream with StreamChoiceReason.from(first, result) + } } /** Finds a forced/signs track: subtitle pref -> audio -> unknown -> null. */ @@ -355,13 +468,271 @@ class StreamChoiceService // 3. Unknown language forced track return candidates.firstOrNull { it.language.isUnknown && isForcedOrSigns(it) } } + + suspend fun getSeriesTrackChoices( + seriesId: UUID?, + seasonId: UUID?, + type: SeriesTrackChoiceType, + ): List<SeriesTrackChoice> { + val userId = serverRepository.currentUser?.rowId ?: return emptyList() + return when { + seriesId != null && seasonId != null -> { + seriesTrackChoiceDao.get( + userId = userId, + seasonId = seasonId, + seriesId = seriesId, + type = type, + ) + } + + seriesId != null -> { + seriesTrackChoiceDao.getBySeriesId( + userId = userId, + seriesId = seriesId, + type = type, + ) + } + + seasonId != null -> { + seriesTrackChoiceDao.getBySeasonId( + userId = userId, + seasonId = seasonId, + type = type, + ) + } + + else -> { + emptyList() + } + } + } + + suspend fun getSeriesTrackChoices( + item: BaseItemDto, + type: SeriesTrackChoiceType, + ): List<SeriesTrackChoice> = getSeriesTrackChoices(item.seriesId, item.parentId, type) + + suspend fun getSeriesTrackChoices( + item: BaseItemDto, + type: MediaStreamType, + ): List<SeriesTrackChoice> = + getSeriesTrackChoices( + seriesId = item.seriesId, + seasonId = item.parentId, + type = + when (type) { + MediaStreamType.AUDIO -> SeriesTrackChoiceType.AUDIO + MediaStreamType.SUBTITLE -> SeriesTrackChoiceType.SUBTITLE + else -> return emptyList() + }, + ) } private val String?.isUnknown: Boolean get() = - this == null || + this.isNullOrBlank() || this.equals("unknown", true) || this.equals("und", true) || this.equals("undetermined", true) || this.equals("mul", true) || this.equals("zxx", true) + +private val String?.isNotUnknown: Boolean + get() = !this.isUnknown + +private fun String?.equalsLangOrUnknown(lang: String): Boolean = equals(lang, ignoreCase = true) || this.isUnknown + +private fun String?.equalsLangExact(lang: String?): Boolean = this.isNotUnknown && lang.isNotUnknown && equals(lang, ignoreCase = true) + +/** Returns true if the track is forced (via metadata flag or title patterns). */ +fun isForcedOrSigns(track: MediaStream): Boolean = track.isForced || isSigns(track) + +fun isSigns(track: MediaStream): Boolean { + val title = track.title ?: track.displayTitle ?: return false + return title.contains("forced", ignoreCase = true) || + title.contains("signs", ignoreCase = true) || + title.contains("songs", ignoreCase = true) +} + +/** + * Based on the user's preferences, get their preferred language for audio or subtitles + * + * @param prefs the [UserPreferences] + */ +fun getPreferredLanguage( + type: MediaStreamType, + prefs: UserPreferences, + userConfig: UserConfiguration?, +): String? { + val (pref, profileLang) = + when (type) { + MediaStreamType.AUDIO -> { + prefs.userPreferences?.preferredAudioLanguage to + userConfig?.audioLanguagePreference + } + + MediaStreamType.SUBTITLE -> { + prefs.userPreferences?.preferredSubtitleLanguage to + userConfig?.subtitleLanguagePreference + } + + else -> { + throw IllegalArgumentException("Only audio or subtitle supported, not $type") + } + } + return when (pref) { + UserProfileSettings.USE_USER_PROFILE -> profileLang + UserProfileSettings.PREFER_ANY_LANGUAGE -> null + else -> pref + }?.takeIf { it.isNotNullOrBlank() } +} + +/** + * Scores streams based the user's explicit [SeriesTrackChoice] + * + * @return sorted list of preferred streams or null if stream should be disabled + */ +fun scoreStreams( + streams: List<MediaStream>, + choice: SeriesTrackChoice, +): List<ScoredMediaStream>? { + if (choice.activation == ActivationFlag.DISABLED) { + return null + } + val streams = + streams.filter { + if ( + (choice.activation == ActivationFlag.ONLY_FORCED || choice.has(TrackFlag.FORCED)) && + !isForcedOrSigns(it) + ) { + // Filter out non-forced tracks if the user only wants forced + return@filter false + } + if (choice.language.isNotUnknown && + !choice.language.equalsLangExact(it.language) + ) { + // Filter out languages that do not match + return@filter false + } + // Filter by type + choice.type.mediaStreamType == it.type + } + val scored = + streams + .asRelative() + .map { s -> + var score = 0 + + // Prefer exact language match over unknown + if (choice.language.equalsLangExact(s.language)) score += 10_000 + + if (s.isForced && choice.has(TrackFlag.FORCED)) { + score += 1_000 + } + if (choice.has(TrackFlag.SIGNS) && isSigns(s.stream)) { + score += 500 + } + if (s.isDefault && choice.has(TrackFlag.DEFAULT)) { + score += 500 + } + + if (s.isHearingImpaired && choice.has(TrackFlag.SDH)) { + score += 500 + } else if (choice.has(TrackFlag.SDH) && s.isForced) { + // If user wants SDH, prefer non-forced "regular" over forced + score -= 10 + } + + if (s.isExternal && choice.has(TrackFlag.EXTERNAL)) { + score += 100 + } + + if (s.relativeIndex == choice.relativeTrackIndex) { + score += 100 + } + if (s.stream.title == choice.title) { + score += 10 + } + + if (choice.audioChannels != null && s.stream.channels == choice.audioChannels) { + score += 100 + } else if (choice.audioChannels != null && s.stream.channels.gt(choice.audioChannels)) { + score += 50 + } + + if (s.isDefault) { + // All else being equal, prefer the default track + score += 1 + } + ScoredMediaStream(score, s.stream) + }.sortedByDescending { it.score } + return scored +} + +data class StreamChoiceResult( + val stream: MediaStream?, + val reason: StreamChoiceReason, +) + +data class ScoredMediaStream( + val score: Int, + val stream: MediaStream, +) + +private infix fun MediaStream?.with(reason: StreamChoiceReason) = StreamChoiceResult(this, reason) + +/** + * Why a particular [MediaStream] was chosen + */ +sealed interface StreamChoiceReason { + val description: StringProvider + val scores: List<ScoredMediaStream> get() = emptyList() + + data object Unknown : StreamChoiceReason { + override val description: StringProvider = ResStringProvider(R.string.unknown) + } + + data object UserPreferences : StreamChoiceReason { + override val description: StringProvider = + ResStringProvider(R.string.profile_specific_settings) + } + + data class Series( + val stc: SeriesTrackChoice, + override val scores: List<ScoredMediaStream>, + ) : StreamChoiceReason { + override val description: StringProvider = + ResProviderStringProvider(R.string.chosen_by, SERIES_PROVIDER) + } + + data class Season( + val stc: SeriesTrackChoice, + override val scores: List<ScoredMediaStream>, + ) : StreamChoiceReason { + override val description: StringProvider = + ResProviderStringProvider(R.string.chosen_by, SEASON_PROVIDER) + } + + data class Item( + val itemPlayback: ItemPlayback, + ) : StreamChoiceReason { + override val description: StringProvider = + ResProviderStringProvider(R.string.chosen_by, USER_PROVIDER) + } + + companion object { + fun from( + stc: SeriesTrackChoice, + scores: List<ScoredMediaStream> = emptyList(), + ) = when (stc.parentType) { + TrackChoiceParentType.SERIES -> Series(stc, scores) + TrackChoiceParentType.SEASON -> Season(stc, scores) + TrackChoiceParentType.DEPRECATED -> Unknown + TrackChoiceParentType.UNKNOWN -> Unknown + } + + private val USER_PROVIDER: StringProvider by lazy { ResStringProvider(R.string.user) } + private val SERIES_PROVIDER: StringProvider by lazy { ResStringProvider(R.string.series) } + private val SEASON_PROVIDER: StringProvider by lazy { ResStringProvider(R.string.tv_season) } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionService.kt index 0fe32179..bed98436 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionService.kt @@ -1,10 +1,15 @@ package com.github.damontecres.wholphin.services -import androidx.lifecycle.asFlow +import androidx.work.Constraints +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder import androidx.work.WorkInfo import androidx.work.WorkManager +import androidx.work.workDataOf import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.util.GetItemsRequestHandler import kotlinx.coroutines.CancellationException import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -46,42 +51,111 @@ class SuggestionService parentId: UUID, itemKind: BaseItemKind, ): Flow<SuggestionsResource> { - return serverRepository.currentUser - .asFlow() + return serverRepository.currentUserFlow .flatMapLatest { user -> val userId = user?.id ?: return@flatMapLatest flowOf(SuggestionsResource.Empty) val cachedSuggestions = cache.get(userId, parentId, itemKind) if (cachedSuggestions == null) { + val workName = SuggestionsWorker.getOnDemandWorkName(userId, parentId, itemKind) + Timber.d( + "No cached suggestions for parent=%s kind=%s; scheduling one-time suggestions refresh", + parentId, + itemKind, + ) + enqueueSuggestionsWork(user, parentId, itemKind, workName) workManager - .getWorkInfosForUniqueWorkFlow(SuggestionsWorker.WORK_NAME) + .getWorkInfosForUniqueWorkFlow(workName) .map { workInfos -> + cache.get(userId, parentId, itemKind)?.let { + Timber.d( + "Loaded cached suggestions after work update for parent=%s kind=%s count=%d", + parentId, + itemKind, + it.ids.size, + ) + return@map it.toResource(parentId, itemKind) + } + val states = workInfos.map { it.state }.distinct() + Timber.v( + "Observed suggestions work states for parent=%s kind=%s states=%s", + parentId, + itemKind, + states, + ) val isActive = workInfos.any { it.state == WorkInfo.State.RUNNING || it.state == WorkInfo.State.ENQUEUED } if (isActive) SuggestionsResource.Loading else SuggestionsResource.Empty } - } else if (cachedSuggestions.ids.isEmpty()) { - flowOf(SuggestionsResource.Empty) } else { + Timber.v( + "Using cached suggestions for parent=%s kind=%s count=%d", + parentId, + itemKind, + cachedSuggestions.ids.size, + ) flow { - try { - emit( - SuggestionsResource.Success( - fetchItemsByIds(cachedSuggestions.ids, itemKind), - ), - ) - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - Timber.e(e, "Failed to fetch items") - emit(SuggestionsResource.Empty) - } + emit(cachedSuggestions.toResource(parentId, itemKind)) } } } } + private fun enqueueSuggestionsWork( + user: JellyfinUser, + parentId: UUID, + itemKind: BaseItemKind, + workName: String, + ) { + val constraints = + Constraints + .Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + val request = + OneTimeWorkRequestBuilder<SuggestionsWorker>() + .setConstraints(constraints) + .setInputData( + workDataOf( + SuggestionsWorker.PARAM_USER_ID to user.id.toString(), + SuggestionsWorker.PARAM_SERVER_ID to user.serverId.toString(), + SuggestionsWorker.PARAM_PARENT_ID to parentId.toString(), + SuggestionsWorker.PARAM_ITEM_KIND to itemKind.serialName, + ), + ).addTag(SuggestionsWorker.getOnDemandWorkTag(user.id, parentId, itemKind)) + .build() + + workManager.enqueueUniqueWork( + workName, + ExistingWorkPolicy.REPLACE, + request, + ) + Timber.d( + "Enqueued on-demand suggestions for parent=%s kind=%s", + parentId, + itemKind, + ) + } + + private suspend fun CachedSuggestions.toResource( + parentId: UUID, + itemKind: BaseItemKind, + ): SuggestionsResource { + if (ids.isEmpty()) { + Timber.d("Cached suggestions are empty for parent=%s kind=%s", parentId, itemKind) + return SuggestionsResource.Empty + } + return try { + SuggestionsResource.Success(fetchItemsByIds(ids, itemKind)) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Timber.e(e, "Failed to fetch items") + SuggestionsResource.Empty + } + } + private suspend fun fetchItemsByIds( ids: List<UUID>, itemKind: BaseItemKind, diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsCache.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsCache.kt index 0921a1be..eedbffc5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsCache.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsCache.kt @@ -4,11 +4,11 @@ package com.github.damontecres.wholphin.services import android.content.Context import com.github.damontecres.wholphin.ui.toServerString +import com.github.damontecres.wholphin.util.WholphinDispatchers import com.mayakapps.kache.InMemoryKache import com.mayakapps.kache.ObjectKache import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.serialization.ExperimentalSerializationApi @@ -43,7 +43,7 @@ class SuggestionsCache private val memoryCache = InMemoryKache<String, CachedSuggestions>(maxSize = 8) { - creationScope = CoroutineScope(Dispatchers.IO) + creationScope = CoroutineScope(WholphinDispatchers.IO) } private fun cacheKey( diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerService.kt index 86bab2f3..da6ffef9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerService.kt @@ -8,15 +8,14 @@ import androidx.work.Constraints import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.NetworkType import androidx.work.PeriodicWorkRequestBuilder -import androidx.work.WorkInfo import androidx.work.WorkManager import androidx.work.workDataOf import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.services.hilt.IoDispatcher import com.github.damontecres.wholphin.util.ExceptionHandler import dagger.hilt.android.qualifiers.ActivityContext import dagger.hilt.android.scopes.ActivityScoped import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import timber.log.Timber @@ -35,6 +34,7 @@ class SuggestionsSchedulerService @param:ActivityContext private val context: Context, private val serverRepository: ServerRepository, private val workManager: WorkManager, + @param:IoDispatcher private val dispatcher: CoroutineDispatcher, ) { private val activity = (context as? AppCompatActivity) @@ -42,18 +42,18 @@ class SuggestionsSchedulerService "SuggestionsSchedulerService requires an AppCompatActivity context, but received: ${context::class.java.name}", ) - // Exposed for testing - internal var dispatcher: CoroutineDispatcher = Dispatchers.IO internal var initialDelaySecondsProvider: () -> Long = { 60L + Random.nextLong(0L, 121L) } init { - serverRepository.current.observe(activity) { user -> - Timber.v("New user %s", user?.user?.id) - if (user == null) { - workManager.cancelUniqueWork(SuggestionsWorker.WORK_NAME) - } else { - activity.lifecycleScope.launch(dispatcher + ExceptionHandler()) { - scheduleWork(user.user.id, user.server.id) + activity.lifecycleScope.launch(dispatcher + ExceptionHandler()) { + serverRepository.current.collect { user -> + Timber.v("New user %s", user?.user?.id) + if (user == null) { + workManager.cancelUniqueWork(SuggestionsWorker.WORK_NAME) + } else { + activity.lifecycleScope.launch(dispatcher + ExceptionHandler()) { + scheduleWork(user.user.id, user.server.id) + } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsWorker.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsWorker.kt index 4560f2d9..e0dd472f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsWorker.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsWorker.kt @@ -5,13 +5,14 @@ import androidx.datastore.core.DataStore import androidx.hilt.work.HiltWorker import androidx.work.CoroutineWorker import androidx.work.WorkerParameters +import com.github.damontecres.wholphin.data.RestoredSession import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.util.GetItemsRequestHandler +import com.github.damontecres.wholphin.util.WholphinDispatchers import dagger.assisted.Assisted import dagger.assisted.AssistedInject -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope @@ -48,17 +49,23 @@ class SuggestionsWorker private val cache: SuggestionsCache, ) : CoroutineWorker(context, workerParams) { override suspend fun doWork(): Result { - Timber.d("Start") val serverId = inputData.getString(PARAM_SERVER_ID)?.toUUIDOrNull() ?: return Result.failure() val userId = inputData.getString(PARAM_USER_ID)?.toUUIDOrNull() ?: return Result.failure() + val requestedParentId = inputData.getString(PARAM_PARENT_ID)?.toUUIDOrNull() + val requestedItemKind = + inputData + .getString(PARAM_ITEM_KIND) + ?.let { runCatching { BaseItemKind.fromName(it) }.getOrNull() } + val mode = if (requestedParentId == null && requestedItemKind == null) "periodic" else "on-demand" + Timber.d("Starting SuggestionsWorker mode=%s", mode) + if ((requestedParentId == null) != (requestedItemKind == null)) { + Timber.w("Invalid on-demand suggestions input parent=%s kind=%s", requestedParentId, requestedItemKind) + return Result.failure() + } if (api.baseUrl.isNullOrBlank() || api.accessToken.isNullOrBlank()) { - var currentUser = serverRepository.current.value - if (currentUser == null) { - serverRepository.restoreSession(serverId, userId) - currentUser = serverRepository.current.value - } - if (currentUser == null) { + val result = serverRepository.restoreLastSession() + if (result !is RestoredSession.Success) { Timber.w("No user found during run") return Result.failure() } @@ -71,51 +78,70 @@ class SuggestionsWorker .takeIf { it > 0 } ?: AppPreference.HomePageItems.defaultValue.toInt() + val context = WholphinDispatchers.IO.limitedParallelism(2, "fetchSuggestions") + if (requestedParentId != null && requestedItemKind != null) { + Timber.d( + "Fetching on-demand suggestions for parent=%s kind=%s", + requestedParentId, + requestedItemKind, + ) + fetchAndCacheSuggestions( + context, + requestedParentId, + userId, + requestedItemKind, + itemsPerRow, + ) + Timber.d( + "Completed on-demand suggestions for parent=%s kind=%s", + requestedParentId, + requestedItemKind, + ) + return Result.success() + } + val views = api.userViewsApi .getUserViews(userId = userId) .content.items .orEmpty() if (views.isEmpty()) { + Timber.d("No user views found for periodic suggestions refresh") return Result.success() } + val supportedViews = + views.mapNotNull { view -> + getTypeForCollection(view.collectionType)?.let { + SuggestionsView(view.id, it) + } + } + val skippedCount = views.size - supportedViews.size + Timber.d( + "Refreshing periodic suggestions for %d supported views; skipped=%d", + supportedViews.size, + skippedCount, + ) val results = supervisorScope { - val context = Dispatchers.IO.limitedParallelism(2, "fetchSuggestions") - views - .mapNotNull { view -> - val itemKind = - getTypeForCollection(view.collectionType) - ?: return@mapNotNull null - async(Dispatchers.IO) { + supportedViews + .map { view -> + async(WholphinDispatchers.IO) { runCatching { - Timber.v("Fetching suggestions for view %s", view.id) - val suggestions = - fetchSuggestions( - context, - view.id, - userId, - itemKind, - itemsPerRow, - ) - ensureActive() - val newIds = suggestions.map { it.id } - val cachedIds = cache.get(userId, view.id, itemKind)?.ids - if (cachedIds == newIds) { - Timber.v("Suggestions unchanged for view %s, skipping cache write", view.id) - return@runCatching - } - cache.put( - userId, + Timber.v("Fetching suggestions for parent=%s kind=%s", view.id, view.itemKind) + fetchAndCacheSuggestions( + context, view.id, - itemKind, - newIds, + userId, + view.itemKind, + itemsPerRow, ) + ensureActive() }.onFailure { e -> Timber.e( e, - "Failed to fetch suggestions for view %s", + "Failed to fetch suggestions for parent=%s kind=%s", view.id, + view.itemKind, ) } } @@ -130,7 +156,10 @@ class SuggestionsWorker Timber.d("Completed with $successCount successes and $failureCount failures") return Result.success() } catch (ex: ApiClientException) { - Timber.w(ex, "SuggestionsWorker ApiClientException, will retry") + Timber.w(ex, "SuggestionsWorker ApiClientException, mode=%s", mode) + if (mode == "on-demand") { + return Result.failure() + } return Result.retry() } catch (e: Exception) { Timber.e(e, "SuggestionsWorker failed") @@ -138,6 +167,50 @@ class SuggestionsWorker } } + private suspend fun fetchAndCacheSuggestions( + coroutineContext: CoroutineContext, + parentId: UUID, + userId: UUID, + itemKind: BaseItemKind, + itemsPerRow: Int, + ) { + val suggestions = + fetchSuggestions( + coroutineContext, + parentId, + userId, + itemKind, + itemsPerRow, + ) + val newIds = suggestions.map { it.id } + val cachedIds = cache.get(userId, parentId, itemKind)?.ids + if (cachedIds == newIds) { + Timber.v( + "Suggestions unchanged for parent=%s kind=%s count=%d; skipping cache write", + parentId, + itemKind, + newIds.size, + ) + return + } + cache.put( + userId, + parentId, + itemKind, + newIds, + ) + if (newIds.isEmpty()) { + Timber.d("Cached empty suggestions for parent=%s kind=%s", parentId, itemKind) + } else { + Timber.d( + "Cached %d suggestions for parent=%s kind=%s", + newIds.size, + parentId, + itemKind, + ) + } + } + private suspend fun fetchSuggestions( coroutineContext: CoroutineContext, parentId: UUID, @@ -217,7 +290,7 @@ class SuggestionsWorker limit = freshLimit, ) } - withContext(Dispatchers.Default) { + withContext(WholphinDispatchers.Default) { val contextual = contextualDeferred.await() val random = randomDeferred.await() val fresh = freshDeferred.await() @@ -270,6 +343,20 @@ class SuggestionsWorker const val WORK_NAME = "com.github.damontecres.wholphin.services.SuggestionsWorker" const val PARAM_USER_ID = "userId" const val PARAM_SERVER_ID = "serverId" + const val PARAM_PARENT_ID = "parentId" + const val PARAM_ITEM_KIND = "itemKind" + + fun getOnDemandWorkName( + userId: UUID, + parentId: UUID, + itemKind: BaseItemKind, + ): String = "$WORK_NAME.onDemand.$userId.$parentId.${itemKind.serialName}" + + fun getOnDemandWorkTag( + userId: UUID, + parentId: UUID, + itemKind: BaseItemKind, + ): String = "suggestions:$userId:$parentId:${itemKind.serialName}" fun getTypeForCollection(collectionType: CollectionType?): BaseItemKind? = when (collectionType) { @@ -278,4 +365,9 @@ class SuggestionsWorker else -> null } } + + private data class SuggestionsView( + val id: UUID, + val itemKind: BaseItemKind, + ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/ThemeSongPlayer.kt b/app/src/main/java/com/github/damontecres/wholphin/services/ThemeSongPlayer.kt index e6bcb867..036ca505 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/ThemeSongPlayer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/ThemeSongPlayer.kt @@ -2,17 +2,30 @@ package com.github.damontecres.wholphin.services import android.content.Context import androidx.annotation.OptIn +import androidx.datastore.core.DataStore import androidx.media3.common.MediaItem import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi import androidx.media3.datasource.okhttp.OkHttpDataSource import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.source.DefaultMediaSourceFactory +import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.ThemeSongVolume import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient +import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope +import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.util.WholphinDispatchers import com.github.damontecres.wholphin.util.profile.Codec import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import okhttp3.OkHttpClient import org.jellyfin.sdk.api.client.ApiClient @@ -34,7 +47,12 @@ class ThemeSongPlayer @param:ApplicationContext private val context: Context, @param:AuthOkHttpClient private val authOkHttpClient: OkHttpClient, private val api: ApiClient, + @param:IoCoroutineScope private val scope: CoroutineScope, + private val preferences: DataStore<AppPreferences>, ) { + private val mutex = Mutex() + private val state = MutableStateFlow(ThemeSongState()) + private val player: Player by lazy { ExoPlayer .Builder(context) @@ -45,68 +63,86 @@ class ThemeSongPlayer ).build() } - suspend fun playThemeFor( - itemId: UUID, - volume: ThemeSongVolume, - ): Boolean = - withContext(Dispatchers.IO) { - if (volume == ThemeSongVolume.DISABLED || volume == ThemeSongVolume.UNRECOGNIZED) { - return@withContext false - } - val themeSongs by api.libraryApi.getThemeSongs(itemId) - return@withContext themeSongs.items.randomOrNull()?.let { theme -> - val url = - api.universalAudioApi.getUniversalAudioStreamUrl( - theme.id, - container = - listOf( - Codec.Audio.OPUS, - Codec.Audio.MP3, - Codec.Audio.AAC, - Codec.Audio.FLAC, - ), + fun playThemeFor(itemId: UUID) { + scope.launch { + mutex.withLock { + state.value.job?.join() + val volumeLevel = + when ( + preferences.data + .first() + .interfacePreferences.playThemeSongs + ) { + ThemeSongVolume.UNRECOGNIZED, + ThemeSongVolume.DISABLED, + -> return@withLock + + ThemeSongVolume.LOWEST -> .05f + + ThemeSongVolume.LOW -> .1f + + ThemeSongVolume.MEDIUM -> .25f + + ThemeSongVolume.HIGH -> .5f + + ThemeSongVolume.HIGHEST -> 75f + } + val job = + scope.launchIO { + val themeSongs by api.libraryApi.getThemeSongs(itemId) + themeSongs.items.randomOrNull()?.let { theme -> + val url = + api.universalAudioApi.getUniversalAudioStreamUrl( + theme.id, + container = + listOf( + Codec.Audio.OPUS, + Codec.Audio.MP3, + Codec.Audio.AAC, + Codec.Audio.FLAC, + ), + ) + withContext(WholphinDispatchers.Main) { + player.apply { + stop() + volume = volumeLevel + setMediaItem(MediaItem.fromUri(url)) + prepare() + play() + } + } + } + } + state.update { + it.copy( + itemId = itemId, + job = job, ) - Timber.v("Found theme song for $itemId") - withContext(Dispatchers.Main) { - play(volume, url) } - true - } ?: false - } - - private fun play( - volumeLevel: ThemeSongVolume, - url: String, - ) { - val volumeLevel = - when (volumeLevel) { - ThemeSongVolume.UNRECOGNIZED, - ThemeSongVolume.DISABLED, - -> return - - ThemeSongVolume.LOWEST -> .05f - - ThemeSongVolume.LOW -> .1f - - ThemeSongVolume.MEDIUM -> .25f - - ThemeSongVolume.HIGH -> .5f - - ThemeSongVolume.HIGHEST -> 75f } - player.apply { - stop() - volume = volumeLevel - setMediaItem(MediaItem.fromUri(url)) - prepare() - play() } } fun stop() { - if (player.isPlaying) { - Timber.v("Stopping theme song") - player.stop() + scope.launch { + mutex.withLock { + state.value.job?.cancelAndJoin() + withContext(WholphinDispatchers.Main) { + Timber.v("Stopping theme song") + player.stop() + } + state.update { + it.copy( + itemId = null, + job = null, + ) + } + } } } } + +data class ThemeSongState( + val itemId: UUID? = null, + val job: Job? = null, +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/TrailerService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/TrailerService.kt index 6be37807..22df106b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/TrailerService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/TrailerService.kt @@ -34,7 +34,7 @@ class TrailerService // TODO would be nice to clean up the trailer name // ?.replace(item.name ?: "", "") // ?.removePrefix(" - ") - ?: context.getString(R.string.trailer) + ?: context.resources.getQuantityString(R.plurals.trailers, 1) val subtitle = when (url.toUri().host) { "youtube.com", "www.youtube.com" -> "YouTube" 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 b56d8a7d..706285f2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt @@ -1,6 +1,7 @@ package com.github.damontecres.wholphin.services import android.Manifest +import android.annotation.SuppressLint import android.content.ContentValues import android.content.Context import android.content.Intent @@ -21,8 +22,8 @@ import com.github.damontecres.wholphin.services.hilt.StandardOkHttpClient import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.util.Version +import com.github.damontecres.wholphin.util.WholphinDispatchers import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json @@ -56,16 +57,61 @@ class UpdateChecker @param:StandardOkHttpClient private val okHttpClient: OkHttpClient, ) { companion object { - const val ASSET_NAME = "Wholphin" + const val ASSET_NAME = "Wolfskin" const val APK_NAME = "$ASSET_NAME.apk" private const val APK_MIME_TYPE = "application/vnd.android.package-archive" - private const val PERMISSION_REQUEST_CODE = 123456 - private val NOTE_REGEX = Regex("<!-- app-note:(.+) -->") 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<String> = 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) + } } /** @@ -128,17 +174,29 @@ class UpdateChecker return Version.fromString(pkgInfo.versionName!!) } + /** + * Get the release whose name parses to the given version. + * + * Wolfskin releases are named by `git describe` (e.g. `v1.0.9-2-g3094fc8c`) rather than tagged `vX.Y.Z`, + * so this searches the release list instead of fetching a tag. + */ suspend fun getRelease(version: Version): Release? { - val url = - "https://api.github.com/repos/damontecres/Wholphin/releases/tags/v${version.major}.${version.minor}.${version.patch}" - return withContext(Dispatchers.IO) { - val request = - Request - .Builder() - .url(url) - .get() - .build() - getRelease(request) + val request = + Request + .Builder() + .url(RELEASES_URL) + .get() + .build() + 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 + } + } } } @@ -146,7 +204,7 @@ class UpdateChecker * Get the latest released version */ suspend fun getLatestRelease(updateUrl: String): Release? = - withContext(Dispatchers.IO) { + withContext(WholphinDispatchers.IO) { val request = Request .Builder() @@ -158,48 +216,24 @@ class UpdateChecker private fun getRelease(request: Request): Release? { return okHttpClient.newCall(request).execute().use { - if (it.isSuccessful && it.body != null) { - 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") - } + if (it.isSuccessful) { + parseRelease(Json.parseToJsonElement(it.body.string()).jsonObject, BuildConfig.DEBUG) } else { Timber.w("Update check failed ${it.code}: ${it.message}") + null } - return@use null } } /** * Download and install an update */ + @SuppressLint("RequestInstallPackagesPolicy") suspend fun installRelease( release: Release, callback: DownloadCallback, ) { - withContext(Dispatchers.IO) { + withContext(WholphinDispatchers.IO) { cleanup() val request = Request @@ -208,11 +242,9 @@ class UpdateChecker .get() .build() okHttpClient.newCall(request).execute().use { - if (it.isSuccessful && it.body != null) { + if (it.isSuccessful) { Timber.v("Request successful for ${release.downloadUrl}") - withContext(Dispatchers.Main) { - callback.contentLength(it.body.contentLength()) - } + callback.contentLength(it.body.contentLength()) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { val contentValues = ContentValues().apply { @@ -230,7 +262,7 @@ class UpdateChecker contentValues, ) if (uri != null) { - it.body!!.byteStream().use { input -> + it.body.byteStream().use { input -> resolver.openOutputStream(uri).use { output -> copyTo(input, output!!, callback = callback) } @@ -290,7 +322,7 @@ class UpdateChecker downloadDir.mkdirs() val targetFile = File(downloadDir, APK_NAME) targetFile.outputStream().use { output -> - response.body!!.byteStream().use { input -> + response.body.byteStream().use { input -> copyTo(input, output, callback = callback) } } @@ -374,7 +406,7 @@ data class Release( Regex("https://github.com/\\w*/\\w+/pull/(\\d+)"), "#$1", ) - // Remove the last line for full changelog since its just a link + // Remove the last line for full changelog since it's just a link .replace(Regex("\\*\\*Full Changelog\\*\\*.*"), "") } @@ -387,35 +419,39 @@ interface DownloadCallback { suspend fun copyTo( input: InputStream, out: OutputStream, - bufferSize: Int = 16 * 1024, + bufferSize: Int = 64 * 1024, callback: DownloadCallback, -): Long { - var bytesCopied: Long = 0 - val buffer = ByteArray(bufferSize) - var bytes = input.read(buffer) - while (bytes >= 0) { - out.write(buffer, 0, bytes) - bytesCopied += bytes - withContext(Dispatchers.Main) { +): Long = + withContext(WholphinDispatchers.IO) { + var bytesCopied: Long = 0 + val buffer = ByteArray(bufferSize) + var bytes = input.read(buffer) + while (bytes >= 0) { + out.write(buffer, 0, bytes) + bytesCopied += bytes callback.bytesDownloaded(bytesCopied) + bytes = input.read(buffer) } - bytes = input.read(buffer) + return@withContext bytesCopied } - return bytesCopied -} fun getDownloadUrl( assets: JsonArray, debug: Boolean, - supportedAbis: List<String> = Build.SUPPORTED_ABIS.toList(), + supportedABIs: List<String> = Build.SUPPORTED_ABIS.toList(), ): String? { - val abiSuffix = supportedAbis.firstOrNull().let { if (it != null) "-$it" else "" } + val abiSuffix = supportedABIs.firstOrNull().let { if (it != null) "-$it" else "" } val releaseSuffix = if (debug) "-debug" else "-release" val preferredNames = buildList { add("$ASSET_NAME${releaseSuffix}$abiSuffix.apk") add("$ASSET_NAME$releaseSuffix.apk") - if (!debug) add("$ASSET_NAME.apk") + if (!debug) { + if (abiSuffix.isNotNullOrBlank()) { + add("$ASSET_NAME$abiSuffix.apk") + } + add("$ASSET_NAME.apk") + } } var preferredAsset: JsonObject? = null outer@ for (name in preferredNames) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/UserPreferencesService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/UserPreferencesService.kt index 7c7fce11..03b8846f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/UserPreferencesService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/UserPreferencesService.kt @@ -4,8 +4,9 @@ import androidx.datastore.core.DataStore import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.UserPreferences -import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first import javax.inject.Inject import javax.inject.Singleton @@ -19,13 +20,10 @@ class UserPreferencesService private val serverRepository: ServerRepository, private val preferencesDataStore: DataStore<AppPreferences>, ) { - val flow = preferencesDataStore.data.map { UserPreferences(it) } - - suspend fun getCurrent(): UserPreferences = - serverRepository.currentUserDto.value!!.configuration.let { userConfig -> - val appPrefs = preferencesDataStore.data.firstOrNull() ?: AppPreferences.getDefaultInstance() - UserPreferences( - appPrefs, - ) + val flow: Flow<UserPreferences> = + preferencesDataStore.data.combine(serverRepository.currentUserFlow) { appPrefs, user -> + UserPreferences(appPrefs, user?.appPreferences) } + + suspend fun getCurrent(): UserPreferences = flow.first() } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/UserSwitchListener.kt b/app/src/main/java/com/github/damontecres/wholphin/services/UserSwitchListener.kt new file mode 100644 index 00000000..b208d663 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/UserSwitchListener.kt @@ -0,0 +1,145 @@ +package com.github.damontecres.wholphin.services + +import android.content.Context +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.app.AppCompatDelegate +import androidx.core.os.LocaleListCompat +import androidx.lifecycle.lifecycleScope +import com.github.damontecres.wholphin.BuildConfig +import com.github.damontecres.wholphin.data.SeerrServerDao +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.JellyfinUser +import com.github.damontecres.wholphin.data.model.SeerrAuthMethod +import com.github.damontecres.wholphin.ui.launchDefault +import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.util.WholphinDispatchers +import dagger.hilt.android.qualifiers.ActivityContext +import dagger.hilt.android.scopes.ActivityScoped +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.supervisorScope +import kotlinx.coroutines.withContext +import timber.log.Timber +import javax.inject.Inject +import kotlin.coroutines.cancellation.CancellationException + +/** + * Listens for JF user switching in the app to also switch other settings like Seerr user/server + */ +@ActivityScoped +class UserSwitchListener + @Inject + constructor( + @param:ActivityContext private val context: Context, + private val serverRepository: ServerRepository, + private val seerrServerRepository: SeerrServerRepository, + private val seerrServerDao: SeerrServerDao, + private val seerrApi: SeerrApi, + private val homeSettingsService: HomeSettingsService, + private val serverPluginApi: ServerPluginApi, + ) { + init { + context as AppCompatActivity + context.lifecycleScope.launchDefault { + serverRepository.currentUserFlow + .distinctUntilChanged { old, new -> + old?.id == new?.id && old?.uiLanguage == new?.uiLanguage + }.collectLatest { user -> + Timber.d("UserSwitch: %s", user?.id) + seerrServerRepository.clear() + homeSettingsService.currentSettings.update { HomePageResolvedSettings.EMPTY } + if (user != null) { + switchUser(user) + } + } + } + } + + private suspend fun switchUser(user: JellyfinUser) = + supervisorScope { + // Switch the locale to either the user's choice or the system default (empty) + val localeList = + user.uiLanguage?.let { LocaleListCompat.forLanguageTags(it) } + ?: LocaleListCompat.getEmptyLocaleList() + Timber.i("Switching locale to %s", localeList) + withContext(WholphinDispatchers.Main) { + AppCompatDelegate.setApplicationLocales(localeList) + } + + // Check if plugin is installed, then for home settings & seerr settings + launchIO { + val serverPluginInstalled = + try { + serverPluginApi.checkInstalled() + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Error checking for server plugin") + false + } + Timber.i("Server plugin installed: %s", serverPluginInstalled) + serverRepository.serverPluginInstalled.value = serverPluginInstalled + + launchIO { + // Check for home settings + homeSettingsService.loadCurrentSettings(user) + } + + if (BuildConfig.DISCOVER_ENABLED) { + // Check for seerr server + launchIO { + if (serverPluginInstalled) { + serverPluginApi.fetchSeerrSettings()?.let { + Timber.d("Trying seerr auto login") + val result = seerrServerRepository.usePluginProvidedServer(it) + if (result) { + // Successfully used server plugin provided seerr + Timber.i("Seerr auto login succeeded") + return@launchIO + } + } + } + Timber.v("Checking for existing configured seerr server") + // Either no server plugin seerr or there was an issue with it + seerrServerDao + .getUsersByJellyfinUser(user.rowId) + .lastOrNull() + ?.let { seerrUser -> + val server = + seerrServerDao.getServer(seerrUser.serverId)?.server + if (server != null) { + Timber.i("Found a seerr user & server") + try { + seerrApi.update(server.url, seerrUser.credential) + val userConfig = + if (seerrUser.authMethod != SeerrAuthMethod.API_KEY) { + seerrLogin( + seerrApi.api, + seerrUser.authMethod, + seerrUser.username, + seerrUser.password, + ) + } else { + seerrApi.api.usersApi.authMeGet() + } + seerrServerRepository.set( + server, + seerrUser, + userConfig, + ) + } catch (ex: Exception) { + Timber.w( + ex, + "Error logging into %s", + server.url, + ) + seerrServerRepository.error(server, seerrUser, ex) + } + } + } + } + } + } + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt index 38071cac..3a114017 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt @@ -1,18 +1,15 @@ package com.github.damontecres.wholphin.services.hilt import android.content.Context -import androidx.datastore.core.DataStore import androidx.work.WorkManager import com.github.damontecres.wholphin.BuildConfig import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.MostRecentServerProvider +import com.github.damontecres.wholphin.data.MostRecentServerSharedPreferences import com.github.damontecres.wholphin.data.ServerRepository -import com.github.damontecres.wholphin.preferences.AppPreferences -import com.github.damontecres.wholphin.preferences.UserPreferences -import com.github.damontecres.wholphin.preferences.updateInterfacePreferences import com.github.damontecres.wholphin.services.SeerrApi import com.github.damontecres.wholphin.util.CoroutineContextApiClientFactory -import com.github.damontecres.wholphin.util.ExceptionHandler -import com.github.damontecres.wholphin.util.RememberTabManager +import com.github.damontecres.wholphin.util.WholphinDispatchers import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -20,9 +17,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.launch import okhttp3.OkHttpClient import org.jellyfin.sdk.Jellyfin import org.jellyfin.sdk.android.androidDevice @@ -49,21 +44,21 @@ annotation class AuthOkHttpClient annotation class StandardOkHttpClient /** - * A [CoroutineScope] with [Dispatchers.IO] + * A [CoroutineScope] with [WholphinDispatchers.IO] */ @Qualifier @Retention(AnnotationRetention.BINARY) annotation class IoCoroutineScope /** - * A [CoroutineScope] with [Dispatchers.Default] + * A [CoroutineScope] with [WholphinDispatchers.Default] */ @Qualifier @Retention(AnnotationRetention.BINARY) annotation class DefaultCoroutineScope /** - * [Dispatchers.IO] + * [WholphinDispatchers.IO] * * @see IoCoroutineScope */ @@ -72,7 +67,7 @@ annotation class DefaultCoroutineScope annotation class IoDispatcher /** - * [Dispatchers.Default] + * [WholphinDispatchers.Default] * * @see DefaultCoroutineScope */ @@ -116,7 +111,7 @@ object AppModule { .addInterceptor { val request = it.request() val newRequest = - serverRepository.currentUser.value?.accessToken?.let { token -> + serverRepository.current.value?.user?.accessToken?.let { token -> request .newBuilder() .addHeader( @@ -160,52 +155,10 @@ object AppModule { @Singleton fun apiClient(jellyfin: Jellyfin) = jellyfin.createApi() - /** - * Implementation of [RememberTabManager] which remembers by server, user, & item - */ - @Provides - @Singleton - fun rememberTabManager( - serverRepository: ServerRepository, - appPreference: DataStore<AppPreferences>, - @IoCoroutineScope scope: CoroutineScope, - ) = object : RememberTabManager { - fun key(itemId: String) = "${serverRepository.currentServer.value?.id}_${serverRepository.currentUser.value?.id}_$itemId" - - override fun getRememberedTab( - preferences: UserPreferences, - itemId: String, - defaultTab: Int, - ): Int { - if (preferences.appPreferences.interfacePreferences.rememberSelectedTab) { - return preferences.appPreferences.interfacePreferences - .getRememberedTabsOrDefault(key(itemId), defaultTab) - } else { - return defaultTab - } - } - - override fun saveRememberedTab( - preferences: UserPreferences, - itemId: String, - tabIndex: Int, - ) { - if (preferences.appPreferences.interfacePreferences.rememberSelectedTab) { - scope.launch(ExceptionHandler()) { - appPreference.updateData { - it.updateInterfacePreferences { - putRememberedTabs(key(itemId), tabIndex) - } - } - } - } - } - } - @Provides @Singleton @IoDispatcher - fun ioDispatcher(): CoroutineDispatcher = Dispatchers.IO + fun ioDispatcher(): CoroutineDispatcher = WholphinDispatchers.IO @Provides @Singleton @@ -217,7 +170,7 @@ object AppModule { @Provides @Singleton @DefaultDispatcher - fun defaultDispatcher(): CoroutineDispatcher = Dispatchers.Default + fun defaultDispatcher(): CoroutineDispatcher = WholphinDispatchers.Default @Provides @Singleton @@ -237,6 +190,12 @@ object AppModule { fun seerrApi( @StandardOkHttpClient okHttpClient: OkHttpClient, ) = SeerrApi(okHttpClient) + + @Provides + @Singleton + fun mostRecentServerProvider( + @ApplicationContext context: Context, + ): MostRecentServerProvider = MostRecentServerSharedPreferences(context) } @Module diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/DatabaseModule.kt b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/DatabaseModule.kt index 62609628..78270a9b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/DatabaseModule.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/DatabaseModule.kt @@ -16,7 +16,9 @@ import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao import com.github.damontecres.wholphin.data.Migrations import com.github.damontecres.wholphin.data.PlaybackEffectDao import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao +import com.github.damontecres.wholphin.data.RememberedTabDao import com.github.damontecres.wholphin.data.SeerrServerDao +import com.github.damontecres.wholphin.data.SeriesTrackChoiceDao import com.github.damontecres.wholphin.data.ServerPreferencesDao import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppPreferencesSerializer @@ -43,7 +45,7 @@ object DatabaseModule { ).addMigrations(Migrations.Migrate2to3) // .setQueryCallback({ sqlQuery, args -> // Timber.v("sqlQuery=$sqlQuery, args=$args") -// }, Dispatchers.IO.asExecutor()) +// }, WholphinDispatchers.IO.asExecutor()) .build() @Provides @@ -74,6 +76,14 @@ object DatabaseModule { @Singleton fun playbackEffectDao(db: AppDatabase): PlaybackEffectDao = db.playbackEffectDao() + @Provides + @Singleton + fun rememberedTabDao(db: AppDatabase): RememberedTabDao = db.rememberedTabDao() + + @Provides + @Singleton + fun seriesTrackChoiceTabDao(db: AppDatabase): SeriesTrackChoiceDao = db.seriesTrackChoiceDao() + @Provides @Singleton fun userPreferencesDataStore( diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderSchedulerService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderSchedulerService.kt index fb40c008..336be1fb 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderSchedulerService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderSchedulerService.kt @@ -13,6 +13,7 @@ import androidx.work.WorkManager import androidx.work.await import androidx.work.workDataOf import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.ui.collectLatestIn import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.util.ExceptionHandler import dagger.hilt.android.qualifiers.ActivityContext @@ -43,7 +44,7 @@ class TvProviderSchedulerService context.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK) init { - serverRepository.current.observe(activity) { user -> + serverRepository.current.collectLatestIn(activity.lifecycleScope) { user -> workManager.cancelUniqueWork(TvProviderWorker.WORK_NAME) if (supportsTvProvider) { if (user != null) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt b/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt index 5a21d0d2..a4d73caa 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt @@ -19,11 +19,13 @@ import androidx.work.CoroutineWorker import androidx.work.WorkerParameters import com.github.damontecres.wholphin.MainActivity import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.RestoredSession import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.services.ImageUrlService +import com.github.damontecres.wholphin.services.IntentService import com.github.damontecres.wholphin.services.LatestNextUpService import com.github.damontecres.wholphin.ui.SlimItemFields import dagger.assisted.Assisted @@ -69,12 +71,8 @@ class TvProviderWorker if (api.baseUrl.isNullOrBlank() || api.accessToken.isNullOrBlank()) { // Not active - var currentUser = serverRepository.current.value - if (currentUser == null) { - serverRepository.restoreSession(serverId, userId) - currentUser = serverRepository.current.value - } - if (currentUser == null) { + val result = serverRepository.restoreLastSession() + if (result !is RestoredSession.Success) { Timber.w("No user found during run") return Result.failure() } @@ -93,19 +91,19 @@ class TvProviderWorker val currentItems = getCurrentTvChannelNextUp() val currentItemIds = currentItems.map { it.internalProviderId } - // TODO Remove after v0.3.10 release - // This cleans up duplicates added to the watch next due a bug in https://github.com/damontecres/Wholphin/pull/372 - currentItems.groupBy { it.internalProviderId }.forEach { (id, items) -> - if (items.size > 1) { - Timber.v("Duplicate ID %s", id) - items - .subList(1, items.size) - .map { TvContractCompat.buildWatchNextProgramUri(it.id) } - .forEach { - context.contentResolver.delete(it, null, null) - } + // TODO Remove after v0.6.5 release + // This cleans up movies that are using the poster image, see https://github.com/damontecres/Wholphin/issues/1425 + currentItems + .filter { + it.type == WatchNextPrograms.TYPE_MOVIE && + it.posterArtUri + .toString() + .lowercase() + .contains("primary") + }.map { TvContractCompat.buildWatchNextProgramUri(it.id) } + .forEach { + context.contentResolver.delete(it, null, null) } - } // End temporary clean up val toRemove = @@ -220,33 +218,33 @@ class TvProviderWorker val imageType = when (item.type) { BaseItemKind.EPISODE -> ImageType.THUMB - else -> ImageType.PRIMARY + else -> if (ImageType.THUMB in item.data.imageTags.orEmpty()) ImageType.THUMB else ImageType.PRIMARY } setPosterArtUri(imageUrlService.getItemImageUrl(item, imageType)!!.toUri()) setIntent( Intent(context, MainActivity::class.java) - .putExtra(MainActivity.INTENT_ITEM_ID, item.id.toString()) - .putExtra(MainActivity.INTENT_ITEM_TYPE, item.type.serialName) + .putExtra(IntentService.INTENT_ITEM_ID, item.id.toString()) + .putExtra(IntentService.INTENT_ITEM_TYPE, item.type.serialName) .apply { if (item.type == BaseItemKind.EPISODE) { putExtra( - MainActivity.INTENT_SERIES_ID, + IntentService.INTENT_SERIES_ID, dto.seriesId?.toString(), ) putExtra( - MainActivity.INTENT_SEASON_ID, + IntentService.INTENT_SEASON_ID, dto.seasonId?.toString(), ) dto.parentIndexNumber?.let { putExtra( - MainActivity.INTENT_SEASON_NUMBER, + IntentService.INTENT_SEASON_NUMBER, it, ) } dto.indexNumber?.let { putExtra( - MainActivity.INTENT_EPISODE_NUMBER, + IntentService.INTENT_EPISODE_NUMBER, it, ) } @@ -264,6 +262,7 @@ class TvProviderWorker .getLatestMedia( GetLatestMediaRequest( fields = SlimItemFields, + includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.EPISODE), imageTypeLimit = 1, parentId = null, groupItems = true, @@ -362,27 +361,27 @@ class TvProviderWorker setIntent( Intent(context, MainActivity::class.java) - .putExtra(MainActivity.INTENT_ITEM_ID, item.id.toString()) - .putExtra(MainActivity.INTENT_ITEM_TYPE, item.type.serialName) + .putExtra(IntentService.INTENT_ITEM_ID, item.id.toString()) + .putExtra(IntentService.INTENT_ITEM_TYPE, item.type.serialName) .apply { if (item.type == BaseItemKind.EPISODE) { putExtra( - MainActivity.INTENT_SERIES_ID, + IntentService.INTENT_SERIES_ID, dto.seriesId?.toString(), ) putExtra( - MainActivity.INTENT_SEASON_ID, + IntentService.INTENT_SEASON_ID, dto.seasonId?.toString(), ) dto.parentIndexNumber?.let { putExtra( - MainActivity.INTENT_SEASON_NUMBER, + IntentService.INTENT_SEASON_NUMBER, it, ) } dto.indexNumber?.let { putExtra( - MainActivity.INTENT_EPISODE_NUMBER, + IntentService.INTENT_EPISODE_NUMBER, it, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/CoilConfig.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/CoilConfig.kt index e2c4f976..703182ba 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/CoilConfig.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/CoilConfig.kt @@ -9,6 +9,7 @@ import coil3.disk.DiskCache import coil3.disk.directory import coil3.memory.MemoryCache import coil3.network.CacheStrategy +import coil3.network.ConnectivityChecker import coil3.network.NetworkRequest import coil3.network.NetworkResponse import coil3.network.cachecontrol.CacheControlCacheStrategy @@ -95,6 +96,8 @@ fun CoilConfig( OkHttpNetworkFetcherFactory( cacheStrategy = { WholphinCacheStrategy(CacheControlCacheStrategy()) }, callFactory = { client }, + // Disable connectivity checker which may be unreliable for VPNs and/or some versions of Android + connectivityChecker = { ConnectivityChecker { true } }, ), ) }.build() diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt index 4d69fe0c..312bc6cc 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt @@ -27,16 +27,15 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import androidx.lifecycle.MutableLiveData import androidx.media3.common.Player import coil3.request.ErrorResult import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.ui.data.RowColumn import com.github.damontecres.wholphin.ui.data.RowColumnSaver import com.github.damontecres.wholphin.util.ExceptionHandler +import com.github.damontecres.wholphin.util.WholphinDispatchers import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineStart -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.collectLatest @@ -75,6 +74,8 @@ inline fun <T> List<T>.indexOfFirstOrNull(predicate: (T) -> Boolean): Int? { return if (index >= 0) index else null } +fun Iterable<CharSequence?>.joinNotBlank(sep: String): String? = filter { it.isNotNullOrBlank() }.letNotEmpty { it.joinToString(sep) } + /** * Try to call [FocusRequester.requestFocus], but catch & log the exception if something is not configured properly */ @@ -243,7 +244,7 @@ val BaseItemDto.timeRemaining: Duration? get() = userData?.playbackPositionTicks?.let { if (it > 0) { - runTimeTicks?.minus(it)?.ticks + runTimeTicks?.minus(it)?.ticks?.takeIf { it > Duration.ZERO } } else { null } @@ -360,34 +361,34 @@ suspend fun showToast( context: Context, text: CharSequence, duration: Int, -) = withContext(Dispatchers.Main) { +) = withContext(WholphinDispatchers.Main) { Toast.makeText(context, text, duration).show() } suspend fun showToast( context: Context, text: CharSequence, -) = withContext(Dispatchers.Main) { +) = withContext(WholphinDispatchers.Main) { Toast.makeText(context, text, Toast.LENGTH_LONG).show() } /** - * Launches a coroutine with [Dispatchers.IO] plus the provided [CoroutineContext] defaulting to using [ExceptionHandler] + * Launches a coroutine with [WholphinDispatchers.IO] plus the provided [CoroutineContext] defaulting to using [ExceptionHandler] */ fun CoroutineScope.launchIO( context: CoroutineContext = ExceptionHandler(), start: CoroutineStart = CoroutineStart.DEFAULT, block: suspend CoroutineScope.() -> Unit, -): Job = launch(context = Dispatchers.IO + context, start = start, block = block) +): Job = launch(context = WholphinDispatchers.IO + context, start = start, block = block) /** - * Launches a coroutine with [Dispatchers.Default] plus the provided [CoroutineContext] defaulting to using [ExceptionHandler] + * Launches a coroutine with [WholphinDispatchers.Default] plus the provided [CoroutineContext] defaulting to using [ExceptionHandler] */ fun CoroutineScope.launchDefault( context: CoroutineContext = ExceptionHandler(), start: CoroutineStart = CoroutineStart.DEFAULT, block: suspend CoroutineScope.() -> Unit, -): Job = launch(context = Dispatchers.Default + context, start = start, block = block) +): Job = launch(context = WholphinDispatchers.Default + context, start = start, block = block) /** * Converts a UUID to the format used server-side (ie without hyphens). @@ -397,11 +398,6 @@ fun CoroutineScope.launchDefault( */ fun UUID.toServerString() = this.toString().replace("-", "") -suspend fun <T> MutableLiveData<T>.setValueOnMain(value: T) = - withContext(Dispatchers.Main) { - this@setValueOnMain.value = value - } - fun equalsNotNull( a: Any?, b: Any?, @@ -416,7 +412,7 @@ fun logTab( ACRA.errorReporter.putCustomData("tabInfo", info) } -suspend fun <T> onMain(block: suspend CoroutineScope.() -> T) = withContext(Dispatchers.Main, block) +suspend fun <T> onMain(block: suspend CoroutineScope.() -> T) = withContext(WholphinDispatchers.Main, block) fun Modifier.dimAndBlur(enabled: Boolean) = this.ifElse( @@ -431,6 +427,9 @@ fun Response<BaseItemDtoQueryResult>.toBaseItems( useSeriesForPrimary: Boolean, ) = this.content.items.map { BaseItem.from(it, api, useSeriesForPrimary) } +fun Response<BaseItemDtoQueryResult>.toBaseItems(useSeriesForPrimary: Boolean = false) = + this.content.items.map { BaseItem(it, useSeriesForPrimary = useSeriesForPrimary) } + /** * Check if this, coalescing nulls to zero, is greater than that */ @@ -452,3 +451,15 @@ fun <T> Flow<T>.collectLatestIn( * Easy way to combine two flows into a [Pair] */ fun <T1, T2> Flow<T1>.combinePair(flow: Flow<T2>): Flow<Pair<T1, T2>> = combine(flow) { t1, t2 -> Pair(t1, t2) } + +/** + * Easy way to combine three flows into a [Triple] + */ +fun <T1, T2, T3> Flow<T1>.combineTriple( + flow: Flow<T2>, + flow2: Flow<T3>, +): Flow<Triple<T1, T2, T3>> = + combine(flow) { t1, t2 -> Pair(t1, t2) } + .combine(flow2) { (t1, t2), t3 -> + Triple(t1, t2, t3) + } 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 7b64b1a4..a284ef0b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt @@ -1,30 +1,52 @@ package com.github.damontecres.wholphin.ui +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 import com.github.damontecres.wholphin.WholphinApplication +import com.github.damontecres.wholphin.preferences.AppPreference.Companion.MEGA_BIT +import com.github.damontecres.wholphin.ui.setup.ServerVersionSupported +import org.jellyfin.sdk.model.ServerVersion import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.MediaSegmentType import timber.log.Timber import java.time.LocalDate import java.time.LocalDateTime +import java.time.LocalTime +import java.time.ZoneId import java.time.format.DateTimeFormatter import java.time.format.DateTimeParseException import java.time.format.FormatStyle +import java.util.Date import java.util.Locale +import kotlin.time.Duration -private var timeFormatter: DateTimeFormatter = - DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(Locale.getDefault()) +/** + * Format the time-of-day of [time] honouring the device's 12/24-hour setting and the current + * locale. [context] should be an Activity/composition context (e.g. `LocalContext.current`) so an + * in-app language switch (`AppCompatDelegate.setApplicationLocales`) is reflected; the application + * context may carry a stale locale. + */ +fun formatTime( + context: Context, + time: LocalDateTime, +): String = formatTime(context, time.toLocalTime()) -fun getTimeFormatter(): DateTimeFormatter { - if (timeFormatter.locale != Locale.getDefault()) { - timeFormatter = timeFormatter.withLocale(Locale.getDefault()) - } - return timeFormatter +fun formatTime( + context: Context, + time: LocalTime, +): String { + val instant = + time + .atDate(LocalDate.now()) + .atZone(ZoneId.systemDefault()) + .toInstant() + return DateFormat.getTimeFormat(context).format(Date.from(instant)) } private var dateFormatter: DateTimeFormatter = @@ -59,36 +81,74 @@ fun toLocalDate(date: String?): LocalDate? = } } +// Falls back to the default format if the application is not initialized (e.g. unit tests) +private fun formatStringWithFallback( + @StringRes resId: Int, + defaultFormat: String, + vararg args: Any, +): String { + val resources = + try { + WholphinApplication.instance.resources + } catch (_: UninitializedPropertyAccessException) { + null + } + return resources?.getString(resId, *args) + ?: String.format(Locale.getDefault(), defaultFormat, *args) +} + /** - * If the item has season & episode info, format as `S# E#` + * Format season & episode numbers using localized string resources. + * Returns null if [season] or [episode] is missing. + */ +fun formatSeasonEpisode( + season: Int?, + episode: Int?, + episodeEnd: Int? = null, + padded: Boolean = false, +): String? { + if (season == null || episode == null) return null + return if (episodeEnd != null) { + if (padded) { + formatStringWithFallback(R.string.season_episode_number_padded_range, "S%1\$02dE%2\$02d-E%3\$02d", season, episode, episodeEnd) + } else { + formatStringWithFallback(R.string.season_episode_number_range, "S%1\$d E%2\$d-E%3\$d", season, episode, episodeEnd) + } + } else if (padded) { + formatStringWithFallback(R.string.season_episode_number_padded, "S%1\$02dE%2\$02d", season, episode) + } else { + formatStringWithFallback(R.string.season_episode_number, "S%1\$d E%2\$d", season, episode) + } +} + +/** Compact episode-only label, e.g. `E3`. */ +fun formatEpisodeNumber(episode: Int): String = formatStringWithFallback(R.string.episode_number_short, "E%1\$d", episode) + +/** Fallback season title when the library has no season name, e.g. `Season 2`. */ +fun formatSeasonNumber(season: Int): String = formatStringWithFallback(R.string.season_number, "Season %1\$d", season) + +/** + * If the item has season & episode info, format as localized `S# E#` */ val BaseItemDto.seasonEpisode: String? get() = - if (parentIndexNumber != null && indexNumber != null && indexNumberEnd != null) { - "S$parentIndexNumber E$indexNumber-E$indexNumberEnd" - } else if (parentIndexNumber != null && indexNumber != null) { - "S$parentIndexNumber E$indexNumber" - } else { - null - } + formatSeasonEpisode( + parentIndexNumber, + indexNumber, + indexNumberEnd, + ) /** - * If the item has season & episode info, format padded as `S## E##` + * If the item has season & episode info, format padded as localized `S##E##` */ val BaseItemDto.seasonEpisodePadded: String? get() = - if (parentIndexNumber != null && indexNumber != null) { - val season = parentIndexNumber?.toString()?.padStart(2, '0') - val episode = indexNumber?.toString()?.padStart(2, '0') - val endEpisode = indexNumberEnd?.toString()?.padStart(2, '0') - if (endEpisode != null) { - "S${season}E$episode-E$endEpisode" - } else { - "S${season}E$episode" - } - } else { - null - } + formatSeasonEpisode( + parentIndexNumber, + indexNumber, + indexNumberEnd, + padded = true, + ) val BaseItemDto.seriesProductionYears: String? get() = @@ -148,6 +208,13 @@ fun formatBytes( fun formatBitrate(bitrate: Int) = formatBytes(bitrate.toLong(), byteRateSuffixes, 1000) +fun formatMaxBitrate(bitrate: Long) = + if (bitrate < MEGA_BIT) { + "${bitrate / 1024} kbps" + } else { + "${bitrate / MEGA_BIT} Mbps" + } + @get:StringRes val MediaSegmentType.stringRes: Int get() = @@ -174,70 +241,124 @@ val MediaSegmentType.skipStringRes: Int fun AnnotatedString.Builder.dot() = append(" \u2022 ") -fun listToDotString( - strings: List<String>, - communityRating: Float?, - criticRating: Float?, -): AnnotatedString = +fun listToDotString(strings: List<String>): 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 +val BaseItemKind.titleStringRes: Int + get() = formatTypeName(this) + @StringRes fun formatTypeName(type: BaseItemKind): Int = when (type) { - BaseItemKind.MOVIE -> R.string.movies - BaseItemKind.SERIES -> R.string.tv_shows + BaseItemKind.MOVIE -> R.string.movies_title + BaseItemKind.SERIES -> R.string.tv_shows_title BaseItemKind.EPISODE -> R.string.episodes BaseItemKind.VIDEO -> R.string.videos BaseItemKind.PLAYLIST -> R.string.playlists - BaseItemKind.PERSON -> R.string.people + BaseItemKind.PERSON -> R.string.people_title BaseItemKind.BOX_SET -> R.string.collections - BaseItemKind.AUDIO -> TODO() + BaseItemKind.AUDIO -> R.string.songs BaseItemKind.CHANNEL -> R.string.channels BaseItemKind.GENRE -> R.string.genres BaseItemKind.LIVE_TV_CHANNEL -> R.string.channels - BaseItemKind.MUSIC_ALBUM -> TODO() - BaseItemKind.MUSIC_ARTIST -> TODO() - BaseItemKind.MUSIC_GENRE -> TODO() - BaseItemKind.MUSIC_VIDEO -> TODO() + BaseItemKind.MUSIC_ALBUM -> R.string.albums + BaseItemKind.MUSIC_ARTIST -> R.string.artists + BaseItemKind.MUSIC_GENRE -> R.string.genres + BaseItemKind.MUSIC_VIDEO -> R.string.music_videos BaseItemKind.PHOTO -> R.string.photos - BaseItemKind.PHOTO_ALBUM -> TODO() - BaseItemKind.PROGRAM -> TODO() - BaseItemKind.RECORDING -> TODO() + BaseItemKind.PHOTO_ALBUM -> R.string.photo_albums + BaseItemKind.PROGRAM -> R.string.programs + BaseItemKind.RECORDING -> R.string.recordings BaseItemKind.SEASON -> R.string.tv_seasons BaseItemKind.STUDIO -> R.string.studios - BaseItemKind.TRAILER -> R.string.trailers + BaseItemKind.TRAILER -> R.string.trailers_title BaseItemKind.TV_CHANNEL -> R.string.channels - BaseItemKind.TV_PROGRAM -> TODO() - BaseItemKind.USER_ROOT_FOLDER -> TODO() - BaseItemKind.USER_VIEW -> TODO() - BaseItemKind.YEAR -> TODO() - BaseItemKind.AGGREGATE_FOLDER -> TODO() - BaseItemKind.AUDIO_BOOK -> TODO() - BaseItemKind.BASE_PLUGIN_FOLDER -> TODO() - BaseItemKind.BOOK -> TODO() - BaseItemKind.CHANNEL_FOLDER_ITEM -> TODO() - BaseItemKind.COLLECTION_FOLDER -> TODO() - BaseItemKind.FOLDER -> TODO() - BaseItemKind.MANUAL_PLAYLISTS_FOLDER -> TODO() - BaseItemKind.LIVE_TV_PROGRAM -> TODO() - BaseItemKind.PLAYLISTS_FOLDER -> TODO() + BaseItemKind.TV_PROGRAM -> R.string.programs + BaseItemKind.USER_ROOT_FOLDER -> R.string.folders_title + BaseItemKind.USER_VIEW -> R.string.user_views + BaseItemKind.YEAR -> R.string.year + BaseItemKind.AGGREGATE_FOLDER -> R.string.folders_title + BaseItemKind.AUDIO_BOOK -> R.string.audio_books + BaseItemKind.BASE_PLUGIN_FOLDER -> R.string.folders_title + BaseItemKind.BOOK -> R.string.books + BaseItemKind.CHANNEL_FOLDER_ITEM -> R.string.folders_title + BaseItemKind.COLLECTION_FOLDER -> R.string.library + BaseItemKind.FOLDER -> R.string.folders_title + BaseItemKind.MANUAL_PLAYLISTS_FOLDER -> R.string.playlists + BaseItemKind.LIVE_TV_PROGRAM -> R.string.programs + BaseItemKind.PLAYLISTS_FOLDER -> R.string.playlists } + +/** + * Format a [Duration] into a localized "Xh Ym Zs" string using string resources. + */ +fun Resources.formatDuration(duration: Duration): String = + duration.toComponents { hours, minutes, seconds, _ -> + when { + hours > 0 && minutes > 0 && seconds > 0 -> getString(R.string.duration_hours_minutes_seconds, hours.toInt(), minutes, seconds) + hours > 0 && minutes > 0 -> getString(R.string.duration_hours_minutes, hours.toInt(), minutes) + hours > 0 && seconds > 0 -> getString(R.string.duration_hours_seconds, hours.toInt(), seconds) + hours > 0 -> getString(R.string.duration_hours, hours.toInt()) + minutes > 0 && seconds > 0 -> getString(R.string.duration_minutes_seconds, minutes, seconds) + minutes > 0 -> getString(R.string.duration_minutes, minutes) + else -> getString(R.string.duration_seconds, seconds) + } + } + +fun Resources.serverSupportedMessage( + serverVersionSupported: ServerVersionSupported, + serverVersion: ServerVersion?, +): String? { + val errorMsg = + when (serverVersionSupported) { + ServerVersionSupported.SUPPORTED -> { + null + } + + ServerVersionSupported.NOT_SUPPORTED, + ServerVersionSupported.UNKNOWN, + -> { + getString(R.string.server_version_not_supported) + } + + ServerVersionSupported.NOT_SUPPORTED_IN_FUTURE -> { + getString(R.string.server_version_not_supported_future) + } + } + return errorMsg?.let { + val msg = + if (serverVersion != null) { + "$it: $serverVersion" + } else { + it + ": " + getString(R.string.unknown) + } + msg + "\n" + + when (serverVersionSupported) { + ServerVersionSupported.SUPPORTED -> { + "" + } + + ServerVersionSupported.NOT_SUPPORTED, + ServerVersionSupported.UNKNOWN, + -> { + getString( + R.string.server_version_required, + WholphinApplication.minimumServerVersion.toString(), + ) + } + + ServerVersionSupported.NOT_SUPPORTED_IN_FUTURE -> { + getString( + R.string.server_version_required, + WholphinApplication.newMinimumServerVersion.toString(), + ) + } + } + } +} 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 ab0f69f8..786db3d5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt @@ -38,27 +38,25 @@ object AppColors { val Purple = Color(147, 51, 234) val Green = Color(74, 222, 128) val Yellow = Color(234, 179, 8) + val Red = Color(204, 38, 38) } } const val DEFAULT_PAGE_SIZE = 100 /** - * The default [ItemFields] to fetch for most queries + * The [ItemFields] to fetch for detailed paged and playback */ -val DefaultItemFields = +val DetailItemFields = listOf( - ItemFields.PRIMARY_IMAGE_ASPECT_RATIO, - ItemFields.SEASON_USER_DATA, - ItemFields.CHILD_COUNT, ItemFields.OVERVIEW, ItemFields.TRICKPLAY, ItemFields.SORT_NAME, ItemFields.CHAPTERS, ItemFields.MEDIA_SOURCES, ItemFields.MEDIA_SOURCE_COUNT, - ItemFields.PARENT_ID, ItemFields.CAN_DELETE, + ItemFields.PRIMARY_IMAGE_ASPECT_RATIO, ) /** @@ -66,27 +64,47 @@ val DefaultItemFields = */ val SlimItemFields = listOf( - ItemFields.PRIMARY_IMAGE_ASPECT_RATIO, - ItemFields.SEASON_USER_DATA, - ItemFields.CHILD_COUNT, ItemFields.OVERVIEW, ItemFields.SORT_NAME, ItemFields.MEDIA_SOURCE_COUNT, - ItemFields.PARENT_ID, ItemFields.CAN_DELETE, ) +/** + * ItemFields for displaying items in rows such as in a [com.github.damontecres.wholphin.ui.cards.ItemRow] with [com.github.damontecres.wholphin.ui.cards.SeasonCard] + */ +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 = - DefaultItemFields + + DetailItemFields + listOf( ItemFields.WIDTH, ItemFields.HEIGHT, ) +val ProgramItemFields = DetailItemFields + listOf(ItemFields.CHANNEL_INFO) + object Cards { const val HEIGHT_2X3_DP = 172 val height2x3 = HEIGHT_2X3_DP.dp const val HEIGHT_EPISODE = 128 + const val HEIGHT_LIVE_TV = 96 val heightEpisode = HEIGHT_EPISODE.dp val playedPercentHeight = 6.dp val serverUserCircle = height2x3 * .75f diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/BannerCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/BannerCard.kt index 8d00edfd..4536a364 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/BannerCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/BannerCard.kt @@ -1,6 +1,5 @@ package com.github.damontecres.wholphin.ui.cards -import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.background import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsFocusedAsState @@ -74,7 +73,7 @@ fun BannerCard( val imageUrlService = LocalImageUrlService.current val density = LocalDensity.current val fillHeight = - remember(cardHeight) { + remember(cardHeight, density) { if (cardHeight.isSpecified) { with(density) { cardHeight.roundToPx() @@ -219,13 +218,11 @@ fun BannerCardWithTitle( useSeriesForPrimary: Boolean = item?.useSeriesForPrimary ?: true, ) { val focused by interactionSource.collectIsFocusedAsState() - val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp) - val spaceBelow by animateDpAsState(if (focused) 0.dp else 8.dp) val focusedAfterDelay by rememberFocusedAfterDelay(interactionSource) val aspectRationToUse = aspectRatio.coerceAtLeast(AspectRatios.MIN) val width = cardHeight * aspectRationToUse Column( - verticalArrangement = Arrangement.spacedBy(spaceBetween), + verticalArrangement = Arrangement.spacedBy(4.dp), modifier = modifier.width(width), ) { BannerCard( @@ -245,13 +242,7 @@ fun BannerCardWithTitle( imageContentScale = imageContentScale, useSeriesForPrimary = useSeriesForPrimary, ) - Column( - verticalArrangement = Arrangement.spacedBy(0.dp), - modifier = - Modifier - .padding(bottom = spaceBelow) - .fillMaxWidth(), - ) { + SlidingCardText(focused) { Text( text = title ?: "", style = MaterialTheme.typography.bodyLarge, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ChapterCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ChapterCard.kt index 54d1d2fc..34966e43 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ChapterCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ChapterCard.kt @@ -5,39 +5,62 @@ import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +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.layout.ContentScale +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalResources +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.times import androidx.tv.material3.Card +import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import coil3.compose.AsyncImage +import com.github.damontecres.wholphin.data.model.Chapter import com.github.damontecres.wholphin.ui.AppColors +import com.github.damontecres.wholphin.ui.AspectRatios +import com.github.damontecres.wholphin.ui.LocalImageUrlService +import com.github.damontecres.wholphin.ui.formatDuration import com.github.damontecres.wholphin.ui.roundSeconds -import kotlin.time.Duration +import org.jellyfin.sdk.model.api.ImageType /** * Card for a [com.github.damontecres.wholphin.data.model.Chapter] */ @Composable fun ChapterCard( - name: String?, - position: Duration, - imageUrl: String?, + chapter: Chapter, onClick: () -> Unit, - aspectRatio: Float, modifier: Modifier = Modifier, cardHeight: Dp = 120.dp, onLongClick: (() -> Unit)? = null, interactionSource: MutableInteractionSource? = null, ) { + val density = LocalDensity.current + val imageUrlService = LocalImageUrlService.current + val imageUrl = + remember(chapter, cardHeight, density) { + imageUrlService.getItemImageUrl( + itemId = chapter.itemId, + imageType = ImageType.CHAPTER, + tag = chapter.tag, + imageIndex = chapter.index, + fillHeight = with(density) { cardHeight.roundToPx() }, + ) + } + var width by remember { mutableStateOf(AspectRatios.WIDE * cardHeight) } Card( - modifier = modifier.size(cardHeight * aspectRatio, cardHeight), + modifier = modifier.height(cardHeight), onClick = onClick, onLongClick = onLongClick, interactionSource = interactionSource, @@ -48,22 +71,36 @@ fun ChapterCard( contentDescription = null, contentScale = ContentScale.Fit, modifier = Modifier.fillMaxSize(), + onSuccess = { + width = + with(density) { + it.painter.intrinsicSize.width + .toDp() + } + }, ) Box( modifier = Modifier + .width(width) .align(Alignment.BottomStart) - .fillMaxWidth() .background(AppColors.TransparentBlack50), ) { Column(modifier = Modifier.padding(4.dp)) { - name?.let { + chapter.name?.let { Text( text = it, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, ) } + val resources = LocalResources.current + val positionText = + remember(chapter.position) { resources.formatDuration(chapter.position.roundSeconds) } Text( - text = position.roundSeconds.toString(), + text = positionText, + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Normal, ) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ChapterRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ChapterRow.kt index b1e64a2a..5fa6316f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ChapterRow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ChapterRow.kt @@ -21,7 +21,6 @@ import com.github.damontecres.wholphin.data.model.Chapter @Composable fun ChapterRow( chapters: List<Chapter>, - aspectRatio: Float, onClick: (Chapter) -> Unit, modifier: Modifier = Modifier, onLongClick: ((Chapter) -> Unit)? = null, @@ -47,11 +46,8 @@ fun ChapterRow( ) { itemsIndexed(chapters) { index, item -> ChapterCard( - name = item.name, - position = item.position, - imageUrl = item.imageUrl, + chapter = item, onClick = { onClick(item) }, - aspectRatio = aspectRatio, modifier = Modifier, onLongClick = onLongClick?.let { { it(item) } }, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/DiscoverItemCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/DiscoverItemCard.kt index 93e9adcc..20cab060 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/DiscoverItemCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/DiscoverItemCard.kt @@ -14,8 +14,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowForward import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -35,10 +33,8 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.tv.material3.Card import androidx.tv.material3.CardDefaults -import androidx.tv.material3.Icon import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text -import androidx.tv.material3.surfaceColorAtElevation import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.data.model.SeerrAvailability @@ -63,8 +59,6 @@ fun DiscoverItemCard( width: Dp = Cards.height2x3 * AspectRatios.TALL, ) { val focused by interactionSource.collectIsFocusedAsState() - val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp) - val spaceBelow by animateDpAsState(if (focused) 4.dp else 12.dp) var focusedAfterDelay by remember { mutableStateOf(false) } val hideOverlayDelay = 500L @@ -82,7 +76,7 @@ fun DiscoverItemCard( } Column( horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(spaceBetween), + verticalArrangement = Arrangement.spacedBy(4.dp), modifier = modifier.size(width, Dp.Unspecified), ) { Card( @@ -134,7 +128,16 @@ fun DiscoverItemCard( AvailableIndicator(Modifier.align(Alignment.TopEnd)) } - else -> {} + SeerrAvailability.BLOCKLISTED -> { + // TODO handle block listed +// BlocklistedIndicator(Modifier.align(Alignment.TopEnd)) + } + + SeerrAvailability.UNKNOWN, + SeerrAvailability.DELETED, + null, + -> { + } } if (showOverlay) { val color = @@ -175,13 +178,7 @@ fun DiscoverItemCard( } } } - Column( - verticalArrangement = Arrangement.spacedBy(0.dp), - modifier = - Modifier - .padding(bottom = spaceBelow) - .fillMaxWidth(), - ) { + SlidingCardText(focused) { Text( text = item?.title ?: "", maxLines = 1, @@ -289,84 +286,27 @@ fun PartiallyAvailableIndicator(modifier: Modifier = Modifier) { } @Composable -fun DiscoverViewMoreCard( - onClick: () -> Unit, - onLongClick: () -> Unit, - modifier: Modifier = Modifier, - interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, -) { - val focused by interactionSource.collectIsFocusedAsState() - val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp) - val spaceBelow by animateDpAsState(if (focused) 4.dp else 12.dp) - var focusedAfterDelay by remember { mutableStateOf(false) } - - val hideOverlayDelay = 500L - if (focused) { - LaunchedEffect(Unit) { - delay(hideOverlayDelay) - if (focused) { - focusedAfterDelay = true - } else { - focusedAfterDelay = false - } - } - } else { - focusedAfterDelay = false - } - val width = Cards.height2x3 * AspectRatios.TALL - val height = Dp.Unspecified * (1f / AspectRatios.TALL) - Column( - verticalArrangement = Arrangement.spacedBy(spaceBetween), - modifier = modifier.size(width, height), +fun BlocklistedIndicator(modifier: Modifier = Modifier) { + Box( + modifier = + modifier + .padding(4.dp) + .border( + width = .5.dp, + color = Color.White, + shape = CircleShape, + ).background( + color = AppColors.Discover.Red.copy(alpha = .85f), + shape = CircleShape, + ).size(16.dp), ) { - Card( - modifier = - Modifier - .size(Dp.Unspecified, Cards.height2x3) - .aspectRatio(AspectRatios.TALL), - onClick = onClick, - onLongClick = onLongClick, - interactionSource = interactionSource, - colors = - CardDefaults.colors( - containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp), - ), - ) { - Box( - modifier = - Modifier - .fillMaxSize(), - ) { - Icon( - imageVector = Icons.Default.ArrowForward, - tint = MaterialTheme.colorScheme.onSurface, - contentDescription = "View more", - modifier = Modifier.fillMaxSize(), - ) - } - } - - Column( - verticalArrangement = Arrangement.spacedBy(0.dp), - modifier = - Modifier - .padding(bottom = spaceBelow) - .fillMaxWidth(), - ) { - Text( - text = stringResource(R.string.view_more), - maxLines = 1, - textAlign = TextAlign.Center, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.SemiBold, - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 4.dp) - .enableMarquee(focusedAfterDelay), - ) - } + Text( + text = stringResource(R.string.fa_xmark), + fontFamily = FontAwesome, + fontSize = 10.sp, + color = Color.White, + modifier = Modifier.align(Alignment.Center), + ) } } @@ -378,11 +318,7 @@ private fun Preview() { PendingIndicator() AvailableIndicator() PartiallyAvailableIndicator() - DiscoverViewMoreCard( - onClick = {}, - onLongClick = {}, - modifier = Modifier, - ) + BlocklistedIndicator() } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/EpisodeCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/EpisodeCard.kt deleted file mode 100644 index 78080c3a..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/EpisodeCard.kt +++ /dev/null @@ -1,162 +0,0 @@ -package com.github.damontecres.wholphin.ui.cards - -import androidx.compose.animation.core.animateDpAsState -import androidx.compose.foundation.background -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsFocusedAsState -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.RoundedCornerShape -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.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import androidx.tv.material3.Card -import androidx.tv.material3.CardDefaults -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Text -import com.github.damontecres.wholphin.data.model.BaseItem -import com.github.damontecres.wholphin.ui.AppColors -import com.github.damontecres.wholphin.ui.AspectRatios -import com.github.damontecres.wholphin.ui.enableMarquee -import com.github.damontecres.wholphin.ui.seasonEpisode -import kotlinx.coroutines.delay - -@Composable -fun EpisodeCard( - item: BaseItem?, - onClick: () -> Unit, - onLongClick: () -> Unit, - modifier: Modifier = Modifier, - imageHeight: Dp = Dp.Unspecified, - imageWidth: Dp = Dp.Unspecified, - showImageOverlay: Boolean = false, - interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, -) { - val dto = item?.data - val focused by interactionSource.collectIsFocusedAsState() - val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp) - val spaceBelow by animateDpAsState(if (focused) 4.dp else 12.dp) - var focusedAfterDelay by remember { mutableStateOf(false) } - - val hideOverlayDelay = 500L - if (focused) { - LaunchedEffect(Unit) { - delay(hideOverlayDelay) - if (focused) { - focusedAfterDelay = true - } else { - focusedAfterDelay = false - } - } - } else { - focusedAfterDelay = false - } - val aspectRatio = item?.aspectRatio?.coerceAtLeast(AspectRatios.MIN) ?: AspectRatios.MIN - val width = imageHeight * aspectRatio - val height = imageWidth * (1f / aspectRatio) - val density = LocalDensity.current - val imageWidthPx = remember(imageWidth) { with(density) { imageWidth.roundToPx() } } - Column( - verticalArrangement = Arrangement.spacedBy(spaceBetween), - modifier = modifier.size(width, height), - ) { - Card( - modifier = - Modifier - .size(imageWidth, imageHeight) - .aspectRatio(aspectRatio), - onClick = onClick, - onLongClick = onLongClick, - interactionSource = interactionSource, - colors = - CardDefaults.colors( - containerColor = Color.Transparent, - ), - ) { - Box( - modifier = - Modifier - .fillMaxSize(), - ) { - ItemCardImage( - item = item, - name = item?.name, - showOverlay = showImageOverlay, - favorite = dto?.userData?.isFavorite ?: false, - watched = dto?.userData?.played ?: false, - unwatchedCount = dto?.userData?.unplayedItemCount ?: -1, - watchedPercent = dto?.userData?.playedPercentage, - numberOfVersions = dto?.mediaSourceCount ?: 0, - useFallbackText = false, - fillWidth = imageWidthPx, - modifier = - Modifier - .fillMaxSize(), - ) - Box( - modifier = - Modifier - .align(Alignment.TopEnd) - .background( - AppColors.TransparentBlack50, - shape = RoundedCornerShape(8.dp), - ), - ) { - Text( - text = dto?.seasonEpisode ?: "", - modifier = Modifier.padding(4.dp), - ) - } - } - } - Column( - verticalArrangement = Arrangement.spacedBy(0.dp), - modifier = - Modifier - .padding(bottom = spaceBelow) - .fillMaxWidth(), - ) { - Text( - text = dto?.seriesName ?: "", - maxLines = 1, - textAlign = TextAlign.Center, - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.SemiBold, - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 4.dp) - .enableMarquee(focusedAfterDelay), - ) - Text( - text = item?.name ?: "", - maxLines = 1, - textAlign = TextAlign.Center, - style = MaterialTheme.typography.bodySmall, - fontWeight = FontWeight.Normal, - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 4.dp) - .enableMarquee(focusedAfterDelay), - ) - } - } -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ExtrasRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ExtrasRow.kt index 1d9927fe..238adaf8 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ExtrasRow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ExtrasRow.kt @@ -17,7 +17,7 @@ fun ExtrasRow( modifier: Modifier = Modifier, ) { ItemRow( - title = stringResource(R.string.extras), + title = stringResource(R.string.extras_title), items = extras, onClickItem = onClickItem, onLongClickItem = onLongClickItem, @@ -38,7 +38,7 @@ fun ExtrasRow( unplayedItemCount = -1, playedPercentage = item?.playedPercentage ?: 0.0, numberOfVersions = -1, - aspectRatio = AspectRatios.FOUR_THREE, // TODO + aspectRatio = item?.aspectRatio ?: AspectRatios.WIDE, ) }, modifier = modifier, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GenreCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GenreCard.kt index a32fece9..654999f4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GenreCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GenreCard.kt @@ -30,7 +30,6 @@ import coil3.request.crossfade import com.github.damontecres.wholphin.ui.AspectRatios import com.github.damontecres.wholphin.ui.PreviewTvSpec import com.github.damontecres.wholphin.ui.components.Genre -import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.setup.rememberIdColor import com.github.damontecres.wholphin.ui.theme.WholphinTheme import java.util.UUID diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GridCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GridCard.kt index 5cf16a61..f49f2d0b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GridCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GridCard.kt @@ -52,8 +52,6 @@ fun GridCard( ) { val dto = item?.data val focused by interactionSource.collectIsFocusedAsState() - val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp) - val spaceBelow by animateDpAsState(if (focused) 4.dp else 12.dp) var focusedAfterDelay by remember { mutableStateOf(false) } val hideOverlayDelay = 500L @@ -71,7 +69,7 @@ fun GridCard( } Column( - verticalArrangement = Arrangement.spacedBy(spaceBetween), + verticalArrangement = Arrangement.spacedBy(4.dp), modifier = modifier, ) { Card( @@ -107,13 +105,7 @@ fun GridCard( ) } AnimatedVisibility(showTitle) { - Column( - verticalArrangement = Arrangement.spacedBy(0.dp), - modifier = - Modifier - .padding(bottom = spaceBelow) - .fillMaxWidth(), - ) { + SlidingCardText(focused) { Text( text = item?.title ?: "", maxLines = 1, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemCardImage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemCardImage.kt index 624f01c1..aaa3de97 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemCardImage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemCardImage.kt @@ -27,6 +27,7 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.tv.material3.MaterialTheme @@ -38,6 +39,7 @@ import com.github.damontecres.wholphin.ui.AppColors import com.github.damontecres.wholphin.ui.Cards import com.github.damontecres.wholphin.ui.FontAwesome import com.github.damontecres.wholphin.ui.LocalImageUrlService +import com.github.damontecres.wholphin.ui.gt import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.logCoilError import org.jellyfin.sdk.model.api.ImageType @@ -66,14 +68,16 @@ fun ItemCardImage( ) { val imageUrlService = LocalImageUrlService.current val imageUrl = - remember(item) { - item?.let { + remember(item, imageType, fillWidth, fillHeight) { + if (item != null && (fillWidth.gt(0) || fillHeight.gt(0))) { imageUrlService.getItemImageUrl( item, imageType, fillWidth = fillWidth, fillHeight = fillHeight, ) + } else { + null } } ItemCardImage( @@ -227,13 +231,7 @@ fun ItemCardImageOverlay( } } if (favorite) { - Text( - color = colorResource(android.R.color.holo_red_light), - text = stringResource(R.string.fa_heart), - fontSize = 20.sp, - fontFamily = FontAwesome, - modifier = Modifier, - ) + FavoriteIndicator() } } Row( @@ -267,7 +265,7 @@ fun ItemCardImageOverlay( } } - watchedPercent?.let { percent -> + if (watchedPercent != null && watchedPercent > 0 && watchedPercent < 100) { Box( modifier = Modifier @@ -276,8 +274,20 @@ fun ItemCardImageOverlay( MaterialTheme.colorScheme.tertiary, ).clip(RectangleShape) .height(Cards.playedPercentHeight) - .fillMaxWidth((percent / 100.0).toFloat()), + .fillMaxWidth((watchedPercent / 100.0).toFloat()), ) } } } + +@Composable +fun FavoriteIndicator( + modifier: Modifier = Modifier, + fontSize: TextUnit = 20.sp, +) = Text( + color = colorResource(android.R.color.holo_red_light), + text = stringResource(R.string.fa_heart), + fontSize = fontSize, + fontFamily = FontAwesome, + modifier = modifier, +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt index c9f6a142..6544895b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt @@ -42,6 +42,8 @@ fun <T> ItemRow( ) -> Unit, modifier: Modifier = Modifier, horizontalPadding: Dp = 16.dp, + showViewMore: Boolean = false, + viewMoreCardContent: @Composable (Modifier) -> Unit = {}, ) { val state = rememberLazyListState() val firstFocus = remember { FocusRequester() } @@ -107,6 +109,19 @@ fun <T> ItemRow( onLongClick, ) } + if (showViewMore) { + item { + val cardModifier = + remember(items.size, position) { + if (position == items.size) { + Modifier.focusRequester(firstFocus) + } else { + Modifier + } + } + viewMoreCardContent.invoke(cardModifier) + } + } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonCard.kt index 4e146134..3154d257 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonCard.kt @@ -1,6 +1,5 @@ package com.github.damontecres.wholphin.ui.cards -import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.interaction.MutableInteractionSource @@ -11,7 +10,6 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape @@ -26,11 +24,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.layout.layout import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.tv.material3.Border @@ -99,10 +95,6 @@ fun PersonCard( focusedAfterDelay = false } - // Do not use `by` here, this way we are Defer reads and recompositions to only when modifier calculates - val spaceBetweenState = animateDpAsState(if (focused) 12.dp else 4.dp, label = "spaceBetween") - val spaceBelowState = animateDpAsState(if (focused) 4.dp else 12.dp, label = "spaceBelow") - Column( verticalArrangement = Arrangement.spacedBy(4.dp), // Fixed base spacing modifier = modifier, @@ -170,21 +162,7 @@ fun PersonCard( .clip(CircleShape), ) } - Column( - verticalArrangement = Arrangement.spacedBy(0.dp), - modifier = - Modifier - // Optimization: move animation reads to layout/draw phase - .offset { - IntOffset(0, (spaceBetweenState.value - 4.dp).roundToPx()) - }.layout { measurable, constraints -> - val paddingPx = spaceBelowState.value.roundToPx() - val placeable = measurable.measure(constraints) - layout(placeable.width, placeable.height + paddingPx) { - placeable.placeRelative(0, 0) - } - }.fillMaxWidth(), - ) { + SlidingCardText(focused) { Text( text = name ?: "", maxLines = 1, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonRow.kt index 140cc2fd..637aa5cf 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonRow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonRow.kt @@ -33,7 +33,7 @@ fun PersonRow( people: List<Person>, onClick: (Person) -> Unit, modifier: Modifier = Modifier, - @StringRes title: Int = R.string.people, + @StringRes title: Int = R.string.people_title, onLongClick: ((Int, Person) -> Unit)? = null, ) { val firstFocus = remember { FocusRequester() } @@ -84,7 +84,7 @@ fun DiscoverPersonRow( people: List<DiscoverItem>, onClick: (DiscoverItem) -> Unit, modifier: Modifier = Modifier, - @StringRes title: Int = R.string.people, + @StringRes title: Int = R.string.people_title, onLongClick: ((Int, DiscoverItem) -> Unit)? = null, ) { val firstFocus = remember { FocusRequester() } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SeasonCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SeasonCard.kt index 962ab727..1992cab4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SeasonCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SeasonCard.kt @@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable @@ -24,8 +23,8 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.isSpecified import androidx.tv.material3.Card import androidx.tv.material3.CardDefaults import androidx.tv.material3.MaterialTheme @@ -38,51 +37,21 @@ import kotlinx.coroutines.delay import org.jellyfin.sdk.model.api.ImageType /** - * A Card for a TV Show Season, but can generically show most items + * A Card for a TV Show Season, but can generally show most items */ @Composable fun SeasonCard( item: BaseItem?, onClick: () -> Unit, onLongClick: () -> Unit, + imageHeight: Dp, modifier: Modifier = Modifier, - imageHeight: Dp = Dp.Unspecified, imageWidth: Dp = Dp.Unspecified, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, showImageOverlay: Boolean = false, aspectRatio: Float = item?.aspectRatio ?: AspectRatios.TALL, ) { - val imageUrlService = LocalImageUrlService.current - val density = LocalDensity.current - val imageUrl = - remember(item, imageHeight, imageWidth, density) { - if (item != null) { - val fillHeight = - if (imageHeight != Dp.Unspecified) { - with(density) { - imageHeight.roundToPx() - } - } else { - null - } - val fillWidth = - if (imageWidth != Dp.Unspecified) { - with(density) { - imageWidth.roundToPx() - } - } else { - null - } - imageUrlService.getItemImageUrl( - item, - ImageType.PRIMARY, - fillWidth = fillWidth, - fillHeight = fillHeight, - ) - } else { - null - } - } + val imageUrl = rememberImageUrl(item, imageHeight, imageWidth) SeasonCard( title = item?.title, subtitle = item?.subtitle, @@ -104,6 +73,47 @@ fun SeasonCard( ) } +@Composable +fun rememberImageUrl( + item: BaseItem?, + imageHeight: Dp, + imageWidth: Dp = Dp.Unspecified, +): String? { + val density = LocalDensity.current + val imageUrlService = LocalImageUrlService.current + return remember(item, imageHeight, imageWidth, density) { + if (item != null) { + val fillHeight = + if (imageHeight.isSpecified) { + with(density) { + imageHeight.roundToPx() + } + } else { + null + } + val fillWidth = + if (imageWidth.isSpecified) { + with(density) { + imageWidth.roundToPx() + } + } else { + null + } + imageUrlService.getItemImageUrl( + item, + ImageType.PRIMARY, + fillWidth = fillWidth, + fillHeight = fillHeight, + ) + } else { + null + } + } +} + +private val spaceBetweenFocused = 4.dp +private val spaceBetweenUnfocused = 12.dp + /** * A Card for a TV Show Season, but can generically show most items */ @@ -129,8 +139,11 @@ fun SeasonCard( ) { val focused by interactionSource.collectIsFocusedAsState() // Do not use `by` here, this way we are Defer reads and recompositions to only when modifier calculates - val spaceBetween = animateDpAsState(if (focused) 12.dp else 4.dp, label = "spaceBetween") - val spaceBelow = animateDpAsState(if (focused) 4.dp else 12.dp, label = "spaceBelow") + val spaceBetween = + animateDpAsState( + if (focused) spaceBetweenFocused else spaceBetweenUnfocused, + label = "spaceBetween", + ) val focusedAfterDelay by rememberFocusedAfterDelay(interactionSource) val aspectRationToUse = aspectRatio.coerceAtLeast(AspectRatios.MIN) @@ -179,14 +192,12 @@ fun SeasonCard( verticalArrangement = Arrangement.spacedBy(0.dp), modifier = Modifier - // Optimization: move animation reads to layout/draw phase - .offset { - IntOffset(0, (spaceBetween.value - 4.dp).roundToPx()) - }.layout { measurable, constraints -> - val paddingPx = spaceBelow.value.roundToPx() + .layout { measurable, constraints -> + val topPaddingPx = (spaceBetweenUnfocused - spaceBetween.value).roundToPx() + val bottomPaddingPx = spaceBetween.value.roundToPx() val placeable = measurable.measure(constraints) - layout(placeable.width, placeable.height + paddingPx) { - placeable.placeRelative(0, 0) + layout(placeable.width, placeable.height + bottomPaddingPx + topPaddingPx) { + placeable.placeRelative(0, topPaddingPx) } }.fillMaxWidth(), ) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SlidingCardText.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SlidingCardText.kt new file mode 100644 index 00000000..90d99281 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SlidingCardText.kt @@ -0,0 +1,46 @@ +package com.github.damontecres.wholphin.ui.cards + +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.layout +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * Slides the [content] up/down as the card is [focused] or not + * + * This is used for the card's title, moving it down when focused as the card itself scales up + */ +@Composable +fun SlidingCardText( + focused: Boolean, + modifier: Modifier = Modifier, + spaceBetweenFocused: Dp = 4.dp, + spaceBetweenUnfocused: Dp = 12.dp, + content: @Composable ColumnScope.() -> Unit, +) { + val spaceBetween = + animateDpAsState( + if (focused) spaceBetweenFocused else spaceBetweenUnfocused, + label = "spaceBetween", + ) + Column( + verticalArrangement = Arrangement.spacedBy(0.dp), + modifier = + modifier + .layout { measurable, constraints -> + val topPaddingPx = (spaceBetweenUnfocused - spaceBetween.value).roundToPx() + val bottomPaddingPx = spaceBetween.value.roundToPx() + val placeable = measurable.measure(constraints) + layout(placeable.width, placeable.height + bottomPaddingPx + topPaddingPx) { + placeable.placeRelative(0, topPaddingPx) + } + }.fillMaxWidth(), + content = content, + ) +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/StudioCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/StudioCard.kt index fb89a58b..c128285d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/StudioCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/StudioCard.kt @@ -32,9 +32,7 @@ import coil3.request.ImageRequest import coil3.request.crossfade import com.github.damontecres.wholphin.ui.AspectRatios import com.github.damontecres.wholphin.ui.PreviewTvSpec -import com.github.damontecres.wholphin.ui.components.Genre import com.github.damontecres.wholphin.ui.components.Studio -import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.setup.rememberIdColor import com.github.damontecres.wholphin.ui.theme.WholphinTheme import java.util.UUID diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ViewMoreCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ViewMoreCard.kt new file mode 100644 index 00000000..fa7df8d6 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ViewMoreCard.kt @@ -0,0 +1,143 @@ +package com.github.damontecres.wholphin.ui.cards + +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowForward +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.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.isSpecified +import androidx.tv.material3.Card +import androidx.tv.material3.CardDefaults +import androidx.tv.material3.Icon +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import androidx.tv.material3.surfaceColorAtElevation +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.ui.AspectRatio +import com.github.damontecres.wholphin.ui.Cards +import com.github.damontecres.wholphin.ui.PreviewTvSpec +import com.github.damontecres.wholphin.ui.enableMarquee +import com.github.damontecres.wholphin.ui.theme.WholphinTheme +import kotlinx.coroutines.delay + +@Composable +fun ViewMoreCard( + onClick: () -> Unit, + onLongClick: () -> Unit, + modifier: Modifier = Modifier, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + aspectRatio: AspectRatio = AspectRatio.TALL, + size: DpSize = DpSize(width = Cards.height2x3 * aspectRatio.ratio, height = Cards.height2x3), + showTitle: Boolean = true, +) { + val focused by interactionSource.collectIsFocusedAsState() + var focusedAfterDelay by remember { mutableStateOf(false) } + + val hideOverlayDelay = 500L + if (focused) { + LaunchedEffect(Unit) { + delay(hideOverlayDelay) + if (focused) { + focusedAfterDelay = true + } else { + focusedAfterDelay = false + } + } + } else { + focusedAfterDelay = false + } + val width = + remember(size, aspectRatio) { + size.width.takeIf { it.isSpecified } ?: (size.height * aspectRatio.ratio) + } + val height = + remember(size, aspectRatio) { + size.height.takeIf { it.isSpecified } ?: (size.height * (1f / aspectRatio.ratio)) + } + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = modifier, + ) { + Card( + modifier = + Modifier + .size(width, height) + .aspectRatio(aspectRatio.ratio), + onClick = onClick, + onLongClick = onLongClick, + interactionSource = interactionSource, + colors = + CardDefaults.colors( + containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp), + ), + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxSize(), + ) { + Icon( + imageVector = Icons.Default.ArrowForward, + tint = MaterialTheme.colorScheme.onSurface, + contentDescription = "View more", + modifier = Modifier.fillMaxSize(.66f), + ) + } + } + if (showTitle) { + SlidingCardText(focused) { + Text( + text = stringResource(R.string.view_more), + maxLines = 1, + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + modifier = + Modifier + .width(width) + .padding(horizontal = 4.dp) + .enableMarquee(focusedAfterDelay), + ) + } + } + } +} + +@PreviewTvSpec +@Composable +private fun Preview() { + WholphinTheme { + Column { + ViewMoreCard( + onClick = {}, + onLongClick = {}, + modifier = Modifier.padding(16.dp), + aspectRatio = AspectRatio.TALL, + size = DpSize(width = Dp.Unspecified, height = Cards.heightEpisode), + showTitle = false, + ) + } + } +} 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 cbec7d53..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 @@ -10,6 +10,7 @@ import androidx.compose.material.icons.filled.Check import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.tv.material3.Icon import androidx.tv.material3.MaterialTheme @@ -19,7 +20,10 @@ import com.github.damontecres.wholphin.ui.theme.LocalTheme import com.github.damontecres.wholphin.ui.theme.WholphinTheme @Composable -fun WatchedIcon(modifier: Modifier = Modifier) { +fun WatchedIcon( + modifier: Modifier = Modifier, + padding: Dp = 2.dp, +) { Icon( imageVector = Icons.Default.Check, contentDescription = null, @@ -28,7 +32,7 @@ fun WatchedIcon(modifier: Modifier = Modifier) { modifier .background(WatchedIconBackground(), shape = CircleShape) .border(.5.dp, Color.Black, CircleShape) - .padding(2.dp), + .padding(padding), ) } @@ -41,6 +45,8 @@ fun WatchedIconBackground(): Color = AppThemeColors.GREEN, AppThemeColors.ORANGE, AppThemeColors.BOLD_BLUE, + AppThemeColors.RED, + AppThemeColors.BROWN, -> MaterialTheme.colorScheme.border.copy(alpha = 1f) AppThemeColors.OLED_BLACK -> MaterialTheme.colorScheme.secondaryContainer @@ -56,6 +62,8 @@ fun WatchedIconColor(): Color = AppThemeColors.ORANGE, AppThemeColors.BOLD_BLUE, AppThemeColors.OLED_BLACK, + AppThemeColors.RED, + AppThemeColors.BROWN, -> Color.White // MaterialTheme.colorScheme.onSurface } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CircularProgress.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CircularProgress.kt index b62c24c7..e00a2185 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CircularProgress.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CircularProgress.kt @@ -7,7 +7,10 @@ import androidx.compose.foundation.layout.size import androidx.compose.material3.CircularProgressIndicator 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.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester @@ -15,6 +18,9 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.unit.dp import androidx.tv.material3.MaterialTheme import com.github.damontecres.wholphin.ui.tryRequestFocus +import kotlinx.coroutines.delay +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds @Composable fun CircularProgress(modifier: Modifier = Modifier) { @@ -38,7 +44,9 @@ fun LoadingPage( focusEnabled: Boolean = true, ) { val focusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + if (focusEnabled) { + LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + } Box( contentAlignment = Alignment.Center, modifier = @@ -56,3 +64,41 @@ fun LoadingPage( ) } } + +/** + * Fill the space and take focus, showing a loading indicator after the specified delay + */ +@Composable +fun DelayedLoadingPage( + modifier: Modifier = Modifier, + focusEnabled: Boolean = true, + delay: Duration = 300.milliseconds, +) { + val focusRequester = remember { FocusRequester() } + if (focusEnabled) { + LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + } + var show by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { + delay(delay) + show = true + } + Box( + contentAlignment = Alignment.Center, + modifier = + modifier + .fillMaxSize() + .focusRequester(focusRequester) + .focusable(focusEnabled), + ) { + if (show) { + CircularProgressIndicator( + color = MaterialTheme.colorScheme.border, + modifier = + Modifier + .align(Alignment.Center) + .size(48.dp), + ) + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt index 8ed6acbe..af67f12c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt @@ -1,1024 +1,77 @@ package com.github.damontecres.wholphin.ui.components -import android.content.Context import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandVertically import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.background import androidx.compose.foundation.gestures.LocalBringIntoViewSpec 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.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.NonRestartableComposable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.compose.LifecycleResumeEffect -import androidx.lifecycle.viewModelScope import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao -import com.github.damontecres.wholphin.data.ServerRepository -import com.github.damontecres.wholphin.data.filter.DefaultFilterOptions import com.github.damontecres.wholphin.data.filter.FilterValueOption import com.github.damontecres.wholphin.data.filter.ItemFilterBy import com.github.damontecres.wholphin.data.model.BaseItem -import com.github.damontecres.wholphin.data.model.CollectionFolderFilter import com.github.damontecres.wholphin.data.model.GetItemsFilter -import com.github.damontecres.wholphin.data.model.GetItemsFilterOverride -import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo -import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.UserPreferences -import com.github.damontecres.wholphin.services.BackdropService -import com.github.damontecres.wholphin.services.FavoriteWatchManager -import com.github.damontecres.wholphin.services.MediaManagementService -import com.github.damontecres.wholphin.services.MediaReportService -import com.github.damontecres.wholphin.services.MusicService -import com.github.damontecres.wholphin.services.NavigationManager -import com.github.damontecres.wholphin.services.StreamChoiceService -import com.github.damontecres.wholphin.services.ThemeSongPlayer -import com.github.damontecres.wholphin.services.UserPreferencesService -import com.github.damontecres.wholphin.services.deleteItem import com.github.damontecres.wholphin.ui.AspectRatios -import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus -import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.cards.GridCard -import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel -import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog -import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo import com.github.damontecres.wholphin.ui.data.SortAndDirection import com.github.damontecres.wholphin.ui.detail.CardGrid import com.github.damontecres.wholphin.ui.detail.GridItemDetails -import com.github.damontecres.wholphin.ui.detail.ItemViewModel -import com.github.damontecres.wholphin.ui.detail.PlaylistDialog -import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState -import com.github.damontecres.wholphin.ui.detail.music.addToQueue -import com.github.damontecres.wholphin.ui.equalsNotNull -import com.github.damontecres.wholphin.ui.launchDefault -import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.main.HomePageHeader -import com.github.damontecres.wholphin.ui.nav.Destination -import com.github.damontecres.wholphin.ui.playback.scale -import com.github.damontecres.wholphin.ui.rememberInt -import com.github.damontecres.wholphin.ui.setValueOnMain -import com.github.damontecres.wholphin.ui.showToast -import com.github.damontecres.wholphin.ui.toServerString -import com.github.damontecres.wholphin.ui.tryRequestFocus -import com.github.damontecres.wholphin.ui.util.FilterUtils import com.github.damontecres.wholphin.ui.util.ScrollToTopBringIntoViewSpec -import com.github.damontecres.wholphin.util.ApiRequestPager -import com.github.damontecres.wholphin.util.DataLoadingState -import com.github.damontecres.wholphin.util.ExceptionHandler -import com.github.damontecres.wholphin.util.GetItemsRequestHandler -import com.github.damontecres.wholphin.util.GetPersonsHandler -import com.github.damontecres.wholphin.util.LoadingState -import dagger.assisted.Assisted -import dagger.assisted.AssistedFactory -import dagger.assisted.AssistedInject -import dagger.hilt.android.lifecycle.HiltViewModel -import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.CollectionType -import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.api.ItemSortBy -import org.jellyfin.sdk.model.api.MediaType -import org.jellyfin.sdk.model.api.SortOrder -import org.jellyfin.sdk.model.api.request.GetItemsRequest -import org.jellyfin.sdk.model.api.request.GetPersonsRequest -import org.jellyfin.sdk.model.serializer.toUUIDOrNull -import timber.log.Timber -import java.util.UUID import kotlin.time.Duration -@HiltViewModel(assistedFactory = CollectionFolderViewModel.Factory::class) -class CollectionFolderViewModel - @AssistedInject - constructor( - private val savedStateHandle: SavedStateHandle, - api: ApiClient, - @param:ApplicationContext private val context: Context, - val serverRepository: ServerRepository, - private val libraryDisplayInfoDao: LibraryDisplayInfoDao, - private val favoriteWatchManager: FavoriteWatchManager, - private val backdropService: BackdropService, - private val navigationManager: NavigationManager, - private val themeSongPlayer: ThemeSongPlayer, - private val userPreferencesService: UserPreferencesService, - private val mediaManagementService: MediaManagementService, - private val musicService: MusicService, - val streamChoiceService: StreamChoiceService, - val mediaReportService: MediaReportService, - @Assisted itemId: String, - @Assisted initialSortAndDirection: SortAndDirection?, - @Assisted("recursive") private val recursive: Boolean, - @Assisted private val collectionFilter: CollectionFolderFilter, - @Assisted("useSeriesForPrimary") private val useSeriesForPrimary: Boolean, - @Assisted defaultViewOptions: ViewOptions, - ) : ItemViewModel(api) { - @AssistedFactory - interface Factory { - fun create( - itemId: String, - initialSortAndDirection: SortAndDirection?, - @Assisted("recursive") recursive: Boolean, - collectionFilter: CollectionFolderFilter, - @Assisted("useSeriesForPrimary") useSeriesForPrimary: Boolean, - defaultViewOptions: ViewOptions, - ): CollectionFolderViewModel - } - - val loading = MutableLiveData<DataLoadingState<List<BaseItem?>>>(DataLoadingState.Loading) - val backgroundLoading = MutableLiveData<LoadingState>(LoadingState.Loading) - val sortAndDirection = MutableLiveData<SortAndDirection>() - val filter = MutableLiveData<GetItemsFilter>(GetItemsFilter()) - val viewOptions = MutableLiveData<ViewOptions>() - - var position: Int - get() = savedStateHandle.get<Int>("position") ?: 0 - set(value) { - savedStateHandle["position"] = value - } - - init { - viewModelScope.launchIO { - super.itemId = itemId - try { - val item = - itemId.toUUIDOrNull()?.let { - fetchItem(it) - } - - val libraryDisplayInfo = - serverRepository.currentUser.value?.let { user -> - libraryDisplayInfoDao.getItem(user, itemId) - } - this@CollectionFolderViewModel.viewOptions.setValueOnMain( - libraryDisplayInfo?.viewOptions ?: defaultViewOptions, - ) - - val sortAndDirection = - if (collectionFilter.useSavedLibraryDisplayInfo) { - libraryDisplayInfo?.sortAndDirection - } else { - null - } ?: initialSortAndDirection ?: SortAndDirection.DEFAULT - - val filterToUse = - if (collectionFilter.useSavedLibraryDisplayInfo && libraryDisplayInfo?.filter != null) { - collectionFilter.filter.merge(libraryDisplayInfo.filter) - } else { - collectionFilter.filter - } - - loadResults(true, sortAndDirection, recursive, filterToUse, useSeriesForPrimary) - .join() -// onResumePage() - } catch (ex: Exception) { - Timber.e(ex, "Error during init") - loading.setValueOnMain(DataLoadingState.Error(ex)) - } - } - mediaManagementService.deletedItemFlow - .onEach { deletedItem -> - refreshAfterDelete(position, deletedItem.item) - }.catch { ex -> - Timber.e(ex, "Error refreshing after deleted item") - }.launchIn(viewModelScope) - } - - private suspend fun refreshAfterDelete( - position: Int, - deletedItem: BaseItem, - ) { - try { - val pager = - ((loading.value as? DataLoadingState.Success)?.data as? ApiRequestPager<*>) - position.let { - Timber.v("Item deleted: position=%s, id=%s", it, itemId) - val item = pager?.get(it) - // Exact item deleted (eg a movie) or deleted item was within the series - if (item?.id == deletedItem.id || - equalsNotNull(item?.data?.id, deletedItem.data.seriesId) - ) { - pager?.refreshPagesAfter(position) - } - } - } catch (ex: Exception) { - Timber.e(ex, "Error refreshing after deleted item %s", itemId) - showToast(context, "Error refreshing after item deleted") - } - } - - private fun saveLibraryDisplayInfo( - newFilter: GetItemsFilter = this.filter.value!!, - newSort: SortAndDirection = this.sortAndDirection.value!!, - viewOptions: ViewOptions? = this.viewOptions.value, - ) { - if (collectionFilter.useSavedLibraryDisplayInfo) { - serverRepository.currentUser.value?.let { user -> - viewModelScope.launchIO { - val libraryDisplayInfo = - LibraryDisplayInfo( - userId = user.rowId, - itemId = itemId, - sort = newSort.sort, - direction = newSort.direction, - filter = newFilter, - viewOptions = viewOptions, - ) - libraryDisplayInfoDao.saveItem(libraryDisplayInfo) - } - } - } - } - - fun saveViewOptions(viewOptions: ViewOptions) { - this.viewOptions.value = viewOptions - viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { - saveLibraryDisplayInfo(viewOptions = viewOptions) - if (!viewOptions.showDetails) { - backdropService.clearBackdrop() - } - } - } - - fun onFilterChange( - newFilter: GetItemsFilter, - recursive: Boolean, - ) { - Timber.v("onFilterChange: filter=%s", newFilter) - saveLibraryDisplayInfo(newFilter, sortAndDirection.value!!) - loadResults(false, sortAndDirection.value!!, recursive, newFilter, useSeriesForPrimary) - } - - fun onSortChange( - sortAndDirection: SortAndDirection, - recursive: Boolean, - filter: GetItemsFilter, - ) { - Timber.v( - "onSortChange: sort=%s, recursive=%s, filter=%s", - sortAndDirection, - recursive, - filter, - ) - saveLibraryDisplayInfo(filter, sortAndDirection) - loadResults(true, sortAndDirection, recursive, filter, useSeriesForPrimary) - } - - private fun loadResults( - resetState: Boolean, - sortAndDirection: SortAndDirection, - recursive: Boolean, - filter: GetItemsFilter, - useSeriesForPrimary: Boolean, - ) = viewModelScope.launch(Dispatchers.IO) { - withContext(Dispatchers.Main) { - if (resetState) { - loading.value = DataLoadingState.Loading - } - backgroundLoading.value = LoadingState.Loading - this@CollectionFolderViewModel.sortAndDirection.value = sortAndDirection - this@CollectionFolderViewModel.filter.value = filter - } - try { - val newPager = - createPager(sortAndDirection, recursive, filter, useSeriesForPrimary).init() - if (newPager.isNotEmpty()) newPager.getBlocking(0) - withContext(Dispatchers.Main) { - loading.value = DataLoadingState.Success(newPager) - backgroundLoading.value = LoadingState.Success - } - } catch (ex: Exception) { - Timber.e( - ex, - "Exception while loading data: sort=%s, filter=%s", - sortAndDirection, - filter, - ) - withContext(Dispatchers.Main) { - loading.value = DataLoadingState.Error(ex) - } - } - } - - private fun createPager( - sortAndDirection: SortAndDirection, - recursive: Boolean, - filter: GetItemsFilter, - useSeriesForPrimary: Boolean, - ): ApiRequestPager<out Any> = - when (filter.override) { - GetItemsFilterOverride.NONE -> { - val request = - createGetItemsRequest( - sortAndDirection = sortAndDirection, - recursive = recursive, - filter = filter, - ) - val newPager = - ApiRequestPager( - api, - request, - GetItemsRequestHandler, - viewModelScope, - useSeriesForPrimary = useSeriesForPrimary, - ) - newPager - } - - GetItemsFilterOverride.PERSON -> { - val request = - filter.applyTo( - GetPersonsRequest( - enableImageTypes = listOf(ImageType.PRIMARY, ImageType.THUMB), - ), - ) - val newPager = - ApiRequestPager( - api, - request, - GetPersonsHandler, - viewModelScope, - useSeriesForPrimary = useSeriesForPrimary, - ) - newPager - } - } - - private fun createGetItemsRequest( - sortAndDirection: SortAndDirection, - recursive: Boolean, - filter: GetItemsFilter, - ): GetItemsRequest { - val item = item.value - val includeItemTypes = - item - ?.data - ?.collectionType - ?.baseItemKinds - .orEmpty() - val request = - filter.applyTo( - GetItemsRequest( - parentId = item?.id, - enableImageTypes = - listOf( - ImageType.PRIMARY, - ImageType.THUMB, - ImageType.BACKDROP, - ImageType.LOGO, - ), - includeItemTypes = includeItemTypes, - recursive = recursive, - excludeItemIds = item?.let { listOf(item.id) }, - sortBy = - buildList { - if (sortAndDirection.sort != ItemSortBy.DEFAULT) { - add(sortAndDirection.sort) - if (sortAndDirection.sort != ItemSortBy.SORT_NAME) { - add(ItemSortBy.SORT_NAME) - } - if (item?.data?.collectionType == CollectionType.MOVIES) { - add(ItemSortBy.PRODUCTION_YEAR) - } - } - }, - sortOrder = - buildList { - if (sortAndDirection.sort != ItemSortBy.DEFAULT) { - add(sortAndDirection.direction) - if (sortAndDirection.sort != ItemSortBy.SORT_NAME) { - add(SortOrder.ASCENDING) - } - if (item?.data?.collectionType == CollectionType.MOVIES) { - add(SortOrder.ASCENDING) - } - } - }, - fields = SlimItemFields, - ), - ) - return request - } - - suspend fun getFilterOptionValues(filterOption: ItemFilterBy<*>): List<FilterValueOption> = - FilterUtils.getFilterOptionValues( - api, - serverRepository.currentUser.value?.id, - itemUuid, - filterOption, - ) - - suspend fun positionOfLetter(letter: Char): Int? = - withContext(Dispatchers.IO) { - val sort = sortAndDirection.value - val filter = filter.value - if (sort == null || filter == null) { - return@withContext null - } - val request = - createGetItemsRequest( - sortAndDirection = sort, - recursive = recursive, - filter = filter, - ).copy( - enableImageTypes = null, - fields = null, - nameLessThan = letter.toString(), - limit = 0, - enableTotalRecordCount = true, - ) - val result by GetItemsRequestHandler.execute(api, request) - result.totalRecordCount - } - - fun setWatched( - position: Int, - itemId: UUID, - played: Boolean, - ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { - favoriteWatchManager.setWatched(itemId, played) - (loading.value as? DataLoadingState.Success)?.let { - (it.data as? ApiRequestPager<*>)?.refreshItem(position, itemId) - } - } - - fun setFavorite( - position: Int, - itemId: UUID, - favorite: Boolean, - ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { - favoriteWatchManager.setFavorite(itemId, favorite) - (loading.value as? DataLoadingState.Success)?.let { - (it.data as? ApiRequestPager<*>)?.refreshItem(position, itemId) - } - } - - fun updateBackdrop(item: BaseItem) { - viewModelScope.launchIO { - backdropService.submit(item) - } - } - - fun navigateTo(destination: Destination) { - release() - navigationManager.navigateTo(destination) - } - - fun release() { - themeSongPlayer.stop() - } - - fun onResumePage() { - viewModelScope.launchIO { - item.value?.let { - Timber.v("onResumePage: %s", loading.value!!::class) - if (it.type == BaseItemKind.BOX_SET && loading.value !is DataLoadingState.Error) { - val volume = - userPreferencesService - .getCurrent() - .appPreferences.interfacePreferences.playThemeSongs - themeSongPlayer.playThemeFor(it.id, volume) - } - } - } - } - - fun deleteItem( - index: Int, - item: BaseItem, - ) { - deleteItem(context, mediaManagementService, item) { - viewModelScope.launchDefault { - refreshAfterDelete(index, item) - } - } - } - - fun canDelete( - item: BaseItem, - appPreferences: AppPreferences, - ): Boolean = mediaManagementService.canDelete(item, appPreferences) - - fun addToQueue( - item: BaseItem, - index: Int, - ) = addToQueue(api, musicService, item, index) - } - -/** - * Shows a collection folder as a grid - * - * This is the "Library" tab for Movies or TV shows - */ -@Composable -fun CollectionFolderGrid( - preferences: UserPreferences, - itemId: UUID, - initialFilter: CollectionFolderFilter, - recursive: Boolean, - onClickItem: (Int, BaseItem) -> Unit, - sortOptions: List<ItemSortBy>, - playEnabled: Boolean, - defaultViewOptions: ViewOptions, - modifier: Modifier = Modifier, - viewModelKey: String? = itemId.toServerString(), - initialSortAndDirection: SortAndDirection? = null, - showTitle: Boolean = true, - positionCallback: ((columns: Int, position: Int) -> Unit)? = null, - useSeriesForPrimary: Boolean = true, - filterOptions: List<ItemFilterBy<*>> = DefaultFilterOptions, - focusRequesterOnEmpty: FocusRequester? = null, -) = CollectionFolderGrid( - preferences, - itemId.toServerString(), - initialFilter, - recursive, - GridClickActions(onClickItem = onClickItem), - sortOptions, - playEnabled, - viewModelKey = viewModelKey, - defaultViewOptions = defaultViewOptions, - modifier = modifier, - initialSortAndDirection = initialSortAndDirection, - showTitle = showTitle, - positionCallback = positionCallback, - useSeriesForPrimary = useSeriesForPrimary, - filterOptions = filterOptions, - focusRequesterOnEmpty = focusRequesterOnEmpty, -) - -@Composable -fun CollectionFolderGrid( - preferences: UserPreferences, - itemId: UUID, - initialFilter: CollectionFolderFilter, - recursive: Boolean, - actions: GridClickActions, - sortOptions: List<ItemSortBy>, - playEnabled: Boolean, - defaultViewOptions: ViewOptions, - modifier: Modifier = Modifier, - viewModelKey: String? = itemId.toServerString(), - initialSortAndDirection: SortAndDirection? = null, - showTitle: Boolean = true, - positionCallback: ((columns: Int, position: Int) -> Unit)? = null, - useSeriesForPrimary: Boolean = true, - filterOptions: List<ItemFilterBy<*>> = DefaultFilterOptions, - focusRequesterOnEmpty: FocusRequester? = null, -) = CollectionFolderGrid( - preferences, - itemId.toServerString(), - initialFilter, - recursive, - actions, - sortOptions, - playEnabled, - viewModelKey = viewModelKey, - defaultViewOptions = defaultViewOptions, - modifier = modifier, - initialSortAndDirection = initialSortAndDirection, - showTitle = showTitle, - positionCallback = positionCallback, - useSeriesForPrimary = useSeriesForPrimary, - filterOptions = filterOptions, - focusRequesterOnEmpty = focusRequesterOnEmpty, -) - -@Composable -fun CollectionFolderGrid( - preferences: UserPreferences, - itemId: String, - initialFilter: CollectionFolderFilter, - recursive: Boolean, - actions: GridClickActions, - sortOptions: List<ItemSortBy>, - playEnabled: Boolean, - defaultViewOptions: ViewOptions, - modifier: Modifier = Modifier, - viewModelKey: String? = itemId, - initialSortAndDirection: SortAndDirection? = null, - showTitle: Boolean = true, - positionCallback: ((columns: Int, position: Int) -> Unit)? = null, - useSeriesForPrimary: Boolean = true, - filterOptions: List<ItemFilterBy<*>> = DefaultFilterOptions, - focusRequesterOnEmpty: FocusRequester? = null, - playlistViewModel: AddPlaylistViewModel = hiltViewModel(), - viewModel: CollectionFolderViewModel = - hiltViewModel<CollectionFolderViewModel, CollectionFolderViewModel.Factory>( - key = viewModelKey, - ) { - it.create( - itemId = itemId, - initialSortAndDirection = initialSortAndDirection, - recursive = recursive, - collectionFilter = initialFilter, - useSeriesForPrimary = useSeriesForPrimary, - defaultViewOptions = defaultViewOptions, - ) - }, -) { - val context = LocalContext.current - val sortAndDirection by viewModel.sortAndDirection.observeAsState(SortAndDirection.DEFAULT) - val filter by viewModel.filter.observeAsState(initialFilter.filter) - val loading by viewModel.loading.observeAsState(LoadingState.Loading) - val backgroundLoading by viewModel.backgroundLoading.observeAsState(LoadingState.Loading) - val item by viewModel.item.observeAsState() - val viewOptions by viewModel.viewOptions.observeAsState(defaultViewOptions) - - var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) } - var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) } - var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) } - val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) - - val contextActions = - remember { - ContextMenuActions( - navigateTo = viewModel::navigateTo, - onClickWatch = { itemId, watched -> - viewModel.setWatched(viewModel.position, itemId, watched) - }, - onClickFavorite = { itemId, favorite -> - viewModel.setFavorite(viewModel.position, itemId, favorite) - }, - onClickAddPlaylist = { itemId -> - playlistViewModel.loadPlaylists(MediaType.VIDEO) - showPlaylistDialog.makePresent(itemId) - }, - onSendMediaInfo = viewModel.mediaReportService::sendReportFor, - onDeleteItem = { viewModel.deleteItem(viewModel.position, it) }, - onShowOverview = { overviewDialog = ItemDetailsDialogInfo(it) }, - onChooseVersion = { _, _ -> - // Not supported on this page - }, - onChooseTracks = { result -> - // Not supported on this page - }, - onClearChosenStreams = { - // Not supported on this page - }, - ) - } - - val gridActions = - remember(actions) { - GridClickActions( - onClickItem = actions.onClickItem, - onLongClickItem = - actions.onLongClickItem ?: { position, item -> - showContextMenu = - ContextMenu.ForBaseItem( - fromLongClick = true, - item = item, - chosenStreams = null, - showGoTo = true, - showStreamChoices = false, - canDelete = viewModel.canDelete(item, preferences.appPreferences), - canRemoveContinueWatching = false, - canRemoveNextUp = false, - actions = contextActions, - ) - }, - onClickPlayAll = - actions.onClickPlayAll ?: { shuffle -> - itemId.toUUIDOrNull()?.let { - val destination = - if (item?.type == BaseItemKind.PHOTO_ALBUM) { - Destination.Slideshow( - parentId = it, - index = 0, - filter = CollectionFolderFilter(filter = filter), - sortAndDirection = sortAndDirection, - recursive = true, - startSlideshow = true, - ) - } else { - Destination.PlaybackList( - itemId = it, - startIndex = 0, - shuffle = shuffle, - recursive = recursive, - sortAndDirection = sortAndDirection, - filter = filter, - ) - } - viewModel.navigateTo(destination) - } - Unit - }, - onClickPlayRemoteButton = - actions.onClickPlayRemoteButton ?: { index, item -> - val destination = - if (item.type == BaseItemKind.PHOTO_ALBUM) { - Destination.Slideshow( - parentId = item.id, - index = index, - filter = CollectionFolderFilter(filter = filter), - sortAndDirection = sortAndDirection, - recursive = true, - startSlideshow = true, - ) - } else { - Destination.Playback(item) - } - viewModel.navigateTo(destination) - }, - ) - } - - when (val state = loading) { - DataLoadingState.Loading, - DataLoadingState.Pending, - -> { - LoadingPage(modifier) - } - - is DataLoadingState.Error, - is DataLoadingState.Success<*>, - -> { - val title = - initialFilter.nameOverride - ?: item?.name - ?: item?.data?.collectionType?.name - ?: stringResource(R.string.collection) - Box(modifier = modifier) { - LifecycleResumeEffect(itemId) { - viewModel.onResumePage() - - onPauseOrDispose { - viewModel.release() - } - } - CollectionFolderGridContent( - preferences = preferences, - initialPosition = viewModel.position, - item = item, - title = title, - loadingState = state as DataLoadingState<List<BaseItem?>>, - sortAndDirection = sortAndDirection!!, - modifier = Modifier.fillMaxSize(), - focusRequesterOnEmpty = focusRequesterOnEmpty, - onClickItem = gridActions.onClickItem, - onLongClickItem = gridActions.onLongClickItem!!, - onSortChange = { - viewModel.onSortChange(it, recursive, filter) - }, - filterOptions = filterOptions, - currentFilter = filter, - onFilterChange = { - viewModel.onFilterChange(it, recursive) - }, - getPossibleFilterValues = { - viewModel.getFilterOptionValues(it) - }, - showTitle = showTitle, - sortOptions = sortOptions, - positionCallback = { columns, position -> - viewModel.position = position - positionCallback?.invoke(columns, position) - }, - letterPosition = { viewModel.positionOfLetter(it) ?: -1 }, - viewOptions = viewOptions, - defaultViewOptions = defaultViewOptions, - onSaveViewOptions = { viewModel.saveViewOptions(it) }, - onChangeBackdrop = viewModel::updateBackdrop, - playEnabled = playEnabled, - onClickPlay = gridActions.onClickPlayRemoteButton!!, - onClickPlayAll = gridActions.onClickPlayAll!!, - ) - - AnimatedVisibility( - backgroundLoading == LoadingState.Loading, - modifier = - Modifier - .align(Alignment.Center) - .padding(16.dp), - ) { - CircularProgress( - Modifier - .background( - MaterialTheme.colorScheme.background.copy(alpha = .25f), - shape = CircleShape, - ).size(64.dp) - .padding(4.dp), - ) - } - } - } - } - overviewDialog?.let { info -> - ItemDetailsDialog( - info = info, - showFilePath = - viewModel.serverRepository.currentUserDto.value - ?.policy - ?.isAdministrator == true, - onDismissRequest = { overviewDialog = null }, - ) - } - showContextMenu?.let { contextMenu -> - ContextMenuDialog( - onDismissRequest = { showContextMenu = null }, - getMediaSource = null, - contextMenu = contextMenu, - preferredSubtitleLanguage = null, - ) - } - showPlaylistDialog.compose { itemId -> - PlaylistDialog( - title = stringResource(R.string.add_to_playlist), - state = playlistState, - onDismissRequest = { showPlaylistDialog.makeAbsent() }, - onClick = { - playlistViewModel.addToPlaylist(it.id, itemId) - showPlaylistDialog.makeAbsent() - }, - createEnabled = true, - onCreatePlaylist = { - playlistViewModel.createPlaylistAndAddItem(it, itemId) - showPlaylistDialog.makeAbsent() - }, - elevation = 3.dp, - ) - } -} - @OptIn(ExperimentalFoundationApi::class) @Composable -fun CollectionFolderGridContent( +fun CollectionFolderViewScope.CollectionFolderGrid( preferences: UserPreferences, - item: BaseItem?, - title: String, - loadingState: DataLoadingState<List<BaseItem?>>, + focusedItem: BaseItem?, + items: List<BaseItem?>, sortAndDirection: SortAndDirection, onClickItem: (Int, BaseItem) -> Unit, onLongClickItem: (Int, BaseItem) -> Unit, - onSortChange: (SortAndDirection) -> Unit, letterPosition: suspend (Char) -> Int, - sortOptions: List<ItemSortBy>, - playEnabled: Boolean, - getPossibleFilterValues: suspend (ItemFilterBy<*>) -> List<FilterValueOption>, - defaultViewOptions: ViewOptions, - onSaveViewOptions: (ViewOptions) -> Unit, - viewOptions: ViewOptions, - onClickPlayAll: (shuffle: Boolean) -> Unit, onClickPlay: (Int, BaseItem) -> Unit, - onChangeBackdrop: (BaseItem) -> Unit, initialPosition: Int, + gridFocusRequester: FocusRequester, + cardContent: @Composable CollectionFolderViewScope.(GridItemDetails<BaseItem>) -> Unit, modifier: Modifier = Modifier, - showTitle: Boolean = true, positionCallback: ((columns: Int, position: Int) -> Unit)? = null, - currentFilter: GetItemsFilter = GetItemsFilter(), - filterOptions: List<ItemFilterBy<*>> = listOf(), - onFilterChange: (GetItemsFilter) -> Unit = {}, - focusRequesterOnEmpty: FocusRequester? = null, ) { - val context = LocalContext.current - - val pager = (loadingState as? DataLoadingState.Success)?.data - var showHeader by rememberSaveable { mutableStateOf(true) } - var showViewOptions by rememberSaveable { mutableStateOf(false) } - var viewOptions by remember { mutableStateOf(viewOptions) } - val headerRowFocusRequester = remember { FocusRequester() } - - val gridFocusRequester = remember { FocusRequester() } - if (pager?.isNotEmpty() == true) { - RequestOrRestoreFocus(gridFocusRequester) - } else { - LaunchedEffect(Unit) { - (focusRequesterOnEmpty ?: headerRowFocusRequester).tryRequestFocus() - } - } - var backdropImageUrl by remember { mutableStateOf<String?>(null) } - - var position by rememberInt(initialPosition) - val focusedItem = pager?.getOrNull(position) - if (viewOptions.showDetails) { - LaunchedEffect(focusedItem) { - focusedItem?.let(onChangeBackdrop) - } - } - Box(modifier = modifier) { Column( verticalArrangement = Arrangement.spacedBy(0.dp), modifier = Modifier.fillMaxSize(), ) { - AnimatedVisibility( - showHeader || loadingState !is DataLoadingState.Success, - enter = expandVertically(), - exit = shrinkVertically(), - ) { - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.fillMaxWidth(), - ) { - if (showTitle) { - GridTitle(title) - } - val endPadding = - 16.dp + if (sortAndDirection.sort == ItemSortBy.SORT_NAME) 24.dp else 0.dp - Row( - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - modifier = - Modifier - .padding(start = 16.dp, end = endPadding) - .fillMaxWidth() - .focusRequester(headerRowFocusRequester), - ) { - if (sortOptions.isNotEmpty() || filterOptions.isNotEmpty()) { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier, - ) { - if (sortOptions.isNotEmpty()) { - SortByButton( - sortOptions = sortOptions, - current = sortAndDirection, - onSortChange = onSortChange, - modifier = Modifier, - ) - } - if (filterOptions.isNotEmpty()) { - FilterByButton( - filterOptions = filterOptions, - current = currentFilter, - onFilterChange = onFilterChange, - getPossibleValues = getPossibleFilterValues, - modifier = Modifier, - ) - } - ExpandableFaButton( - title = R.string.view_options, - iconStringRes = R.string.fa_sliders, - onClick = { showViewOptions = true }, - modifier = Modifier, - ) - } - } - if (playEnabled && pager?.isNotEmpty() == true) { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier, - ) { - ExpandablePlayButton( - title = R.string.play, - resume = Duration.ZERO, - icon = Icons.Default.PlayArrow, - onClick = { onClickPlayAll.invoke(false) }, - ) - ExpandableFaButton( - title = R.string.shuffle, - iconStringRes = R.string.fa_shuffle, - onClick = { onClickPlayAll.invoke(true) }, - ) - } - } - } - } - } val defaultBringIntoViewSpec = LocalBringIntoViewSpec.current val density = LocalDensity.current AnimatedVisibility(viewOptions.showDetails) { @@ -1032,73 +85,33 @@ fun CollectionFolderGridContent( .padding(HeaderUtils.padding), ) } - when (val state = loadingState) { - DataLoadingState.Pending, - DataLoadingState.Loading, - -> { - // This shouldn't happen, so just show placeholder - Text(stringResource(R.string.loading)) - } - - is DataLoadingState.Error -> { - ErrorMessage(state.message, state.exception) - } - - is DataLoadingState.Success<List<BaseItem?>> -> { - CardGrid( - pager = state.data, - onClickItem = onClickItem, - onLongClickItem = onLongClickItem, - onClickPlay = onClickPlay, - letterPosition = letterPosition, - gridFocusRequester = gridFocusRequester, - showJumpButtons = false, // TODO add preference - showLetterButtons = sortAndDirection.sort == ItemSortBy.SORT_NAME, - modifier = Modifier.fillMaxSize(), - initialPosition = initialPosition, - positionCallback = { columns, newPosition -> - showHeader = newPosition < columns - position = newPosition - positionCallback?.invoke(columns, newPosition) - }, - cardContent = { (item, index, onClick, onLongClick, widthPx, mod) -> - GridCard( - item = item, - onClick = onClick, - onLongClick = onLongClick, - imageContentScale = viewOptions.contentScale.scale, - imageAspectRatio = viewOptions.aspectRatio.ratio, - imageType = viewOptions.imageType, - showTitle = viewOptions.showTitles, - fillWidth = widthPx, - modifier = mod, - ) - }, - columns = viewOptions.columns, - spacing = viewOptions.spacing.dp, - bringIntoViewSpec = - remember(viewOptions) { - val spacingPx = with(density) { viewOptions.spacing.dp.toPx() } - if (viewOptions.showDetails) { - ScrollToTopBringIntoViewSpec(spacingPx) - } else { - defaultBringIntoViewSpec - } - }, - ) - AnimatedVisibility(showViewOptions) { - ViewOptionsDialog( - viewOptions = viewOptions, - defaultViewOptions = defaultViewOptions, - onDismissRequest = { - showViewOptions = false - onSaveViewOptions.invoke(viewOptions) - }, - onViewOptionsChange = { viewOptions = it }, - ) - } - } - } + CardGrid( + pager = items, + onClickItem = onClickItem, + onLongClickItem = onLongClickItem, + onClickPlay = onClickPlay, + letterPosition = letterPosition, + gridFocusRequester = gridFocusRequester, + showJumpButtons = false, // TODO add preference + showLetterButtons = sortAndDirection.sort == ItemSortBy.SORT_NAME, + modifier = Modifier.fillMaxSize(), + initialPosition = initialPosition, + positionCallback = positionCallback, + cardContent = { details -> + cardContent.invoke(this@CollectionFolderGrid, details) + }, + columns = viewOptions.columns, + spacing = viewOptions.spacing.dp, + bringIntoViewSpec = + remember(viewOptions, density) { + val spacingPx = with(density) { viewOptions.spacing.dp.toPx() } + if (viewOptions.showDetails) { + ScrollToTopBringIntoViewSpec(spacingPx) + } else { + defaultBringIntoViewSpec + } + }, + ) } } } @@ -1182,11 +195,13 @@ val CollectionType.baseItemKinds: List<BaseItemKind> } CollectionType.TVSHOWS -> { - listOf(BaseItemKind.SERIES) + listOf(BaseItemKind.SERIES, BaseItemKind.SEASON, BaseItemKind.EPISODE) } - CollectionType.HOMEVIDEOS -> { - listOf(BaseItemKind.VIDEO) + CollectionType.PHOTOS, + CollectionType.HOMEVIDEOS, + -> { + listOf(BaseItemKind.VIDEO, BaseItemKind.PHOTO, BaseItemKind.PHOTO_ALBUM) } CollectionType.MUSIC -> { @@ -1197,6 +212,10 @@ val CollectionType.baseItemKinds: List<BaseItemKind> ) } + CollectionType.MUSICVIDEOS -> { + listOf(BaseItemKind.MUSIC_VIDEO) + } + CollectionType.BOXSETS -> { listOf(BaseItemKind.BOX_SET) } @@ -1205,7 +224,29 @@ val CollectionType.baseItemKinds: List<BaseItemKind> listOf(BaseItemKind.PLAYLIST) } - else -> { + CollectionType.LIVETV -> { + listOf( + BaseItemKind.TV_CHANNEL, + BaseItemKind.LIVE_TV_CHANNEL, + BaseItemKind.PROGRAM, + BaseItemKind.TV_PROGRAM, + BaseItemKind.LIVE_TV_PROGRAM, + ) + } + + CollectionType.TRAILERS -> { + listOf(BaseItemKind.TRAILER) + } + + CollectionType.BOOKS -> { + listOf(BaseItemKind.BOOK) + } + + CollectionType.FOLDERS -> { + listOf(BaseItemKind.FOLDER) + } + + CollectionType.UNKNOWN -> { listOf() } } @@ -1217,7 +258,7 @@ fun GridTitle( modifier: Modifier = Modifier, ) = Text( text = title, - style = MaterialTheme.typography.displayMedium, + style = MaterialTheme.typography.displaySmall, color = MaterialTheme.colorScheme.onBackground, textAlign = TextAlign.Center, maxLines = 1, @@ -1231,3 +272,127 @@ data class GridClickActions( val onClickPlayAll: ((shuffle: Boolean) -> Unit)? = null, val onClickPlayRemoteButton: ((Int, BaseItem) -> Unit)? = null, ) + +@Composable +fun CollectionFolderHeader( + listIsNotEmpty: Boolean, + showHeader: Boolean, + showTitle: Boolean, + playEnabled: Boolean, + title: String, + sortAndDirection: SortAndDirection, + onSortChange: (SortAndDirection) -> Unit, + sortOptions: List<ItemSortBy>, + getPossibleFilterValues: suspend (ItemFilterBy<*>) -> List<FilterValueOption>, + onClickShowViewOptions: () -> Unit, + onClickPlayAll: (Boolean) -> Unit, + onClickRandom: () -> Unit, + modifier: Modifier = Modifier, + currentFilter: GetItemsFilter = GetItemsFilter(), + filterOptions: List<ItemFilterBy<*>> = listOf(), + onFilterChange: (GetItemsFilter) -> Unit = {}, + onShowFilterDropdown: ((Boolean) -> Unit)? = null, + filterButtonFocusRequester: FocusRequester = remember { FocusRequester() }, + randomButtonFocusRequester: FocusRequester = remember { FocusRequester() }, +) { + AnimatedVisibility( + showHeader, + enter = expandVertically(), + exit = shrinkVertically(), + modifier = modifier, + ) { + val focusRequester = remember { FocusRequester() } + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth(), + ) { + if (showTitle) { + GridTitle(title) + } + val endPadding = + 16.dp + if (sortAndDirection.sort == ItemSortBy.SORT_NAME) 24.dp else 0.dp + + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .padding(start = 16.dp, end = endPadding) + .fillMaxWidth(), + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier, + ) { + if (sortOptions.isNotEmpty()) { + SortByButton( + sortOptions = sortOptions, + current = sortAndDirection, + onSortChange = onSortChange, + modifier = Modifier.focusRequester(focusRequester), + ) + } + if (filterOptions.isNotEmpty()) { + FilterByButton( + filterOptions = filterOptions, + current = currentFilter, + onFilterChange = onFilterChange, + getPossibleValues = getPossibleFilterValues, + modifier = + Modifier + .focusRequester(filterButtonFocusRequester) + .ifElse( + sortOptions.isEmpty(), + Modifier.focusRequester(focusRequester), + ), + onShow = onShowFilterDropdown, + ) + } + ExpandableFaButton( + title = R.string.view_options, + iconStringRes = R.string.fa_sliders, + onClick = onClickShowViewOptions, + modifier = + Modifier.ifElse( + sortOptions.isEmpty() && filterOptions.isEmpty(), + Modifier.focusRequester(focusRequester), + ), + ) + } + + Spacer(Modifier.weight(1f)) + + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier, + ) { + ExpandableFaButton( + title = R.string.sort_by_random, + iconStringRes = R.string.fa_dice, + onClick = onClickRandom, + modifier = Modifier.focusRequester(randomButtonFocusRequester), + enabled = listIsNotEmpty, + ) + + if (playEnabled) { + ExpandablePlayButton( + title = R.string.play, + resume = Duration.ZERO, + icon = Icons.Default.PlayArrow, + onClick = { onClickPlayAll.invoke(false) }, + enabled = listIsNotEmpty, + ) + ExpandableFaButton( + title = R.string.shuffle, + iconStringRes = R.string.fa_shuffle, + onClick = { onClickPlayAll.invoke(true) }, + enabled = listIsNotEmpty, + ) + } + } + } + } + } +} 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 new file mode 100644 index 00000000..57ff8957 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderList.kt @@ -0,0 +1,623 @@ +package com.github.damontecres.wholphin.ui.components + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.expandHorizontally +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkHorizontally +import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +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.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.rememberSaveable +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.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusRestorer +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalResources +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.tv.material3.ListItemDefaults +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import androidx.tv.material3.surfaceColorAtElevation +import coil3.annotation.ExperimentalCoilApi +import coil3.compose.AsyncImage +import coil3.compose.useExistingImageAsPlaceholder +import coil3.request.ImageRequest +import coil3.request.crossfade +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.ui.LocalImageUrlService +import com.github.damontecres.wholphin.ui.cards.FavoriteIndicator +import com.github.damontecres.wholphin.ui.cards.WatchedIcon +import com.github.damontecres.wholphin.ui.data.SortAndDirection +import com.github.damontecres.wholphin.ui.detail.AlphabetButtons +import com.github.damontecres.wholphin.ui.detail.GridItemDetails +import com.github.damontecres.wholphin.ui.enableMarquee +import com.github.damontecres.wholphin.ui.formatDuration +import com.github.damontecres.wholphin.ui.ifElse +import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.ui.rememberInt +import com.github.damontecres.wholphin.ui.roundMinutes +import com.github.damontecres.wholphin.ui.tryRequestFocus +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.CollectionType +import org.jellyfin.sdk.model.api.ImageType +import org.jellyfin.sdk.model.api.ItemSortBy +import org.jellyfin.sdk.model.extensions.ticks + +@Composable +fun CollectionFolderViewScope.CollectionFolderList( + preferences: UserPreferences, + collectionType: CollectionType?, + focusedItem: BaseItem?, + items: List<BaseItem?>, + sortAndDirection: SortAndDirection, + onClickItem: (Int, BaseItem) -> Unit, + onLongClickItem: (Int, BaseItem) -> Unit, + letterPosition: suspend (Char) -> Int, + onClickPlay: (Int, BaseItem) -> Unit, + initialPosition: Int, + gridFocusRequester: FocusRequester, + itemContent: @Composable CollectionFolderViewScope.(GridItemDetails<BaseItem>) -> Unit, + modifier: Modifier = Modifier, + positionCallback: ((columns: Int, position: Int) -> Unit)? = null, +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val listState = rememberLazyListState() + + var contentHashFocus by remember { mutableStateOf(false) } + var position by rememberInt(initialPosition) + val positionFocusRequester = remember { FocusRequester() } + val showLetterButtons = sortAndDirection.sort == ItemSortBy.SORT_NAME + + var showHeader by rememberSaveable { mutableStateOf(true) } + + fun jumpTo(newPosition: Int) { + scope.launch { + if (newPosition >= 0) { + position = newPosition + listState.animateScrollToItem(newPosition) + positionFocusRequester.tryRequestFocus() + } + } + } + + BackHandler(contentHashFocus && position > 0) { + jumpTo(0) + } + + Column(modifier = modifier) { + val topPadding by animateDpAsState( + targetValue = + when { + showHeader -> 8.dp + + // showClock-> TODO() + else -> 48.dp + }, + ) + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = + Modifier + .padding(top = topPadding, bottom = 16.dp) + .onFocusChanged { + contentHashFocus = it.hasFocus + }, + ) { + AnimatedVisibility( + visible = viewOptions.showDetails, + enter = fadeIn() + expandHorizontally(expandFrom = Alignment.Start), + exit = fadeOut() + shrinkHorizontally(shrinkTowards = Alignment.Start), + ) { + CollectionFolderListDetails( + item = focusedItem, + showLogo = true, + modifier = + Modifier + .fillMaxWidth(.33f) + .fillMaxHeight() + .background( + color = Color.Transparent, // MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp), + shape = RoundedCornerShape(16.dp), + ).padding(8.dp), + ) + } + this@CollectionFolderList.CollectionFolderListContent( + items = items, + listState = listState, + position = position, + positionFocusRequester = positionFocusRequester, + onPositionChange = { + showHeader = it < 1 + positionCallback?.invoke(1, it) + position = it + }, + onClickItem = onClickItem, + onLongClickItem = onLongClickItem, + itemContent = itemContent, + modifier = + Modifier + .weight(1f) + .focusRequester(gridFocusRequester), + ) + val letters = stringResource(R.string.jump_letters) + // Letters + val currentLetter = + remember(focusedItem) { + focusedItem + ?.sortName + ?.firstOrNull() + ?.uppercaseChar() + ?.let { + when (it) { + in '0'..'9' -> '#' + in 'A'..'Z' -> it + else -> null + } + } + ?: letters[0] + } + if (showLetterButtons && items.isNotEmpty()) { + AlphabetButtons( + letters = letters, + currentLetter = currentLetter, + modifier = + Modifier + .align(Alignment.CenterVertically) + .padding(start = 16.dp), + letterClicked = { + scope.launchIO { + val position = letterPosition.invoke(it) + jumpTo(position) + } + }, + ) + } + } + } +} + +@OptIn(ExperimentalCoilApi::class) +@Composable +fun CollectionFolderListDetails( + item: BaseItem?, + showLogo: Boolean, + modifier: Modifier, +) { + val imageUrlService = LocalImageUrlService.current + val imageUrl = + remember(item) { + item?.imageUrlOverride ?: imageUrlService.getItemImageUrl(item, ImageType.PRIMARY, useSeriesForPrimary = true) + } + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier, + ) { + SimpleTitleOrLogo( + item, + showLogo, + Modifier + .height(HeaderUtils.logoHeight) + .fillMaxWidth(), + ) + + AsyncImage( + model = + ImageRequest + .Builder(LocalContext.current) + .data(imageUrl) + .crossfade(300) + .useExistingImageAsPlaceholder(true) + .build(), + contentDescription = null, + modifier = + Modifier + .clip(shape = RoundedCornerShape(8.dp)) + .weight(1f), + ) + + item?.let { + QuickDetails(item, null) + } + + OverviewText( + overview = item?.data?.overview ?: "", + maxLines = 5, + onClick = {}, + enabled = false, + modifier = Modifier, + ) + + // index? + } +} + +@Composable +fun CollectionFolderViewScope.CollectionFolderListContent( + items: List<BaseItem?>, + listState: LazyListState, + position: Int, + positionFocusRequester: FocusRequester, + onClickItem: (Int, BaseItem) -> Unit, + onLongClickItem: (Int, BaseItem) -> Unit, + onPositionChange: (Int) -> Unit, + itemContent: @Composable CollectionFolderViewScope.(GridItemDetails<BaseItem>) -> Unit, + modifier: Modifier = Modifier, +) { + val currentPosition by rememberUpdatedState(position) + LazyColumn( + state = listState, + verticalArrangement = Arrangement.spacedBy(viewOptions.spacing.dp), + modifier = modifier.focusRestorer(positionFocusRequester), + ) { + itemsIndexed(items) { index, item -> + val details = + remember(item, index, onClickItem, onLongClickItem, onPositionChange) { + GridItemDetails( + item = item, + index = index, + onClick = { item?.let { onClickItem.invoke(index, item) } }, + onLongClick = { item?.let { onLongClickItem.invoke(index, item) } }, + widthPx = -1, + mod = + Modifier + .onFocusChanged { + if (it.isFocused) onPositionChange.invoke(index) + }.ifElse( + index == currentPosition, + Modifier.focusRequester(positionFocusRequester), + ), + ) + } + itemContent.invoke(this@CollectionFolderListContent, details) + } + } +} + +@Composable +fun CollectionFolderListItem( + item: BaseItem?, + dense: Boolean, + collectionType: CollectionType?, + onClick: () -> Unit, + onLongClick: () -> Unit, + modifier: Modifier = Modifier, +) { + when (collectionType) { + CollectionType.MOVIES -> ListItemMovie(dense, item, onClick, onLongClick, modifier) + CollectionType.TVSHOWS -> ListItemSeries(dense, item, onClick, onLongClick, modifier) + CollectionType.MUSIC -> ListItemAlbum(dense, item, onClick, onLongClick, modifier) + else -> ListItemGeneric(dense, item, onClick, onLongClick, modifier) + } +} + +@Composable +fun ListItemGeneric( + dense: Boolean, + item: BaseItem?, + onClick: () -> Unit, + onLongClick: () -> Unit, + modifier: Modifier = Modifier, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, +) { + ListItemWrapper( + dense = dense, + selected = false, + enabled = true, + onClick = onClick, + onLongClick = onLongClick, + leadingContent = { + if (item?.favorite == true) { + FavoriteIndicator() + } + }, + headlineContent = { TitleContent(item?.title, interactionSource) }, + supportingContent = + item?.subtitle?.let { subtitle -> + { + Text( + text = subtitle, + ) + } + }, + trailingContent = { + Text( + text = + item + ?.data + ?.runTimeTicks + ?.ticks + ?.roundMinutes + ?.let { LocalResources.current.formatDuration(it) } ?: "", + ) + }, + scale = scale, + colors = listItemColors(), + interactionSource = interactionSource, + modifier = modifier, + ) +} + +@Composable +fun ListItemMovie( + dense: Boolean, + item: BaseItem?, + onClick: () -> Unit, + onLongClick: () -> Unit, + modifier: Modifier = Modifier, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, +) { + val title = + remember(item, dense) { + if (dense && item != null && item.title != null && item.data.productionYear != null) { + "${item.title} (${ item.data.productionYear})" + } else { + item?.title ?: "" + } + } + val supportingContent = + remember(item, dense) { + if (!dense) { + @Composable { + Text(item?.subtitle ?: "") + } + } else { + null + } + } + ListItemWrapper( + dense = dense, + selected = false, + enabled = true, + onClick = onClick, + onLongClick = onLongClick, + leadingContent = { + if (item?.favorite == true) { + FavoriteIndicator() + } + }, + headlineContent = { TitleContent(title, interactionSource) }, + supportingContent = supportingContent, + trailingContent = { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = + item + ?.data + ?.runTimeTicks + ?.ticks + ?.roundMinutes + ?.let { LocalResources.current.formatDuration(it) } ?: "", + ) + if (item?.played == true) { + WatchedIcon(padding = 0.dp) + } + } + }, + scale = scale, + colors = listItemColors(), + interactionSource = interactionSource, + modifier = modifier, + ) +} + +@Composable +fun ListItemSeries( + dense: Boolean, + item: BaseItem?, + onClick: () -> Unit, + onLongClick: () -> Unit, + modifier: Modifier = Modifier, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, +) { + val title = + remember(item, dense) { + if (item != null) { + if (dense) { + "${item.title} (${item.subtitle})" + } else { + item.title ?: "" + } + } else { + "" + } + } + val supportingContent = + remember(item, dense) { + if (!dense) { + @Composable { + Text(item?.subtitle ?: "") + } + } else { + null + } + } + val trailingContent = + remember(item) { + val unplayedItemCount = item?.data?.userData?.unplayedItemCount + if (unplayedItemCount != null && unplayedItemCount > 0) { + @Composable { Text(unplayedItemCount.toString()) } + } else if (item?.played == true) { + @Composable { WatchedIcon(padding = 0.dp) } + } else { + null + } + } + ListItemWrapper( + dense = dense, + selected = false, + enabled = true, + onClick = onClick, + onLongClick = onLongClick, + leadingContent = { + if (item?.favorite == true) { + FavoriteIndicator() + } + }, + headlineContent = { + TitleContent(title, interactionSource) + }, + supportingContent = supportingContent, + trailingContent = trailingContent, + scale = scale, + colors = listItemColors(), + interactionSource = interactionSource, + modifier = modifier, + ) +} + +@Composable +fun ListItemAlbum( + dense: Boolean, + item: BaseItem?, + onClick: () -> Unit, + onLongClick: () -> Unit, + modifier: Modifier = Modifier, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, +) { + val title = + remember(item, dense) { + if (dense && item != null && item.title != null && item.data.productionYear != null) { + "${item.title} (${item.data.productionYear})" + } else { + item?.title ?: "" + } + } + val supportingContent = + remember(item, dense) { + // Album artist is already shown as the overline + val subtitle = + if (item?.type == BaseItemKind.MUSIC_ALBUM) item.data.productionYear?.toString() else item?.subtitle + if (!dense && subtitle != null) { + @Composable { + Text(subtitle) + } + } else { + null + } + } + val overlineContent = + remember(item, dense) { + if (!dense && item?.data?.albumArtist != null) { + @Composable { + Text(item.data.albumArtist ?: "") + } + } else { + null + } + } + val trailingContent = + remember(item) { + if (item?.type == BaseItemKind.MUSIC_ALBUM) { + @Composable { + Text( + text = + item + .data + .runTimeTicks + ?.ticks + ?.roundMinutes + ?.let { LocalResources.current.formatDuration(it) } ?: "", + ) + } + } else { + null + } + } + ListItemWrapper( + dense = dense, + selected = false, + enabled = true, + onClick = onClick, + onLongClick = onLongClick, + leadingContent = { + if (item?.favorite == true) { + FavoriteIndicator() + } + }, + headlineContent = { TitleContent(title, interactionSource) }, + supportingContent = supportingContent, + trailingContent = trailingContent, + overlineContent = overlineContent, + scale = scale, + colors = listItemColors(), + interactionSource = interactionSource, + modifier = modifier, + ) +} + +@Composable +private fun TitleContent( + title: String?, + interactionSource: MutableInteractionSource, + modifier: Modifier = Modifier, +) { + val focused by interactionSource.collectIsFocusedAsState() + var focusedAfterDelay by remember { mutableStateOf(false) } + + val hideOverlayDelay = 500L + if (focused) { + LaunchedEffect(Unit) { + delay(hideOverlayDelay) + if (focused) { + focusedAfterDelay = true + } else { + focusedAfterDelay = false + } + } + } else { + focusedAfterDelay = false + } + Text( + text = title ?: "", + modifier = modifier.enableMarquee(focusedAfterDelay), + ) +} + +@Composable +fun listItemColors() = + ListItemDefaults.colors( + containerColor = + MaterialTheme.colorScheme + .surfaceColorAtElevation(1.dp) + .copy(alpha = .5f), + ) + +private val scale = ListItemDefaults.scale(focusedScale = 1f, pressedScale = .95f) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt new file mode 100644 index 00000000..8445dfa9 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt @@ -0,0 +1,1207 @@ +package com.github.damontecres.wholphin.ui.components + +import android.content.Context +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.compose.LifecycleResumeEffect +import androidx.lifecycle.viewModelScope +import androidx.tv.material3.MaterialTheme +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.filter.DefaultFilterOptions +import com.github.damontecres.wholphin.data.filter.FilterValueOption +import com.github.damontecres.wholphin.data.filter.ItemFilterBy +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.CollectionFolderFilter +import com.github.damontecres.wholphin.data.model.GetItemsFilter +import com.github.damontecres.wholphin.data.model.GetItemsFilterOverride +import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.services.BackdropService +import com.github.damontecres.wholphin.services.FavoriteWatchManager +import com.github.damontecres.wholphin.services.FilterOptionCache +import com.github.damontecres.wholphin.services.MediaManagementService +import com.github.damontecres.wholphin.services.MusicService +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.ServerReportService +import com.github.damontecres.wholphin.services.StreamChoiceService +import com.github.damontecres.wholphin.services.ThemeSongPlayer +import com.github.damontecres.wholphin.services.UserPreferencesService +import com.github.damontecres.wholphin.services.deleteItem +import com.github.damontecres.wholphin.ui.SlimItemFields +import com.github.damontecres.wholphin.ui.cards.GridCard +import com.github.damontecres.wholphin.ui.data.SortAndDirection +import com.github.damontecres.wholphin.ui.detail.GridItemDetails +import com.github.damontecres.wholphin.ui.detail.music.addToQueue +import com.github.damontecres.wholphin.ui.equalsNotNull +import com.github.damontecres.wholphin.ui.launchDefault +import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.playback.scale +import com.github.damontecres.wholphin.ui.rememberInt +import com.github.damontecres.wholphin.ui.showToast +import com.github.damontecres.wholphin.ui.toServerString +import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.ui.util.goToRandom +import com.github.damontecres.wholphin.util.ApiRequestPager +import com.github.damontecres.wholphin.util.BlockingList +import com.github.damontecres.wholphin.util.DataLoadingState +import com.github.damontecres.wholphin.util.ExceptionHandler +import com.github.damontecres.wholphin.util.GetAlbumArtistsHandler +import com.github.damontecres.wholphin.util.GetArtistsHandler +import com.github.damontecres.wholphin.util.GetItemsRequestHandler +import com.github.damontecres.wholphin.util.GetPersonsHandler +import com.github.damontecres.wholphin.util.LoadingState +import com.github.damontecres.wholphin.util.WholphinDispatchers +import com.github.damontecres.wholphin.util.successValue +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.libraryApi +import org.jellyfin.sdk.api.client.extensions.userLibraryApi +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.CollectionType +import org.jellyfin.sdk.model.api.ImageType +import org.jellyfin.sdk.model.api.ItemSortBy +import org.jellyfin.sdk.model.api.SortOrder +import org.jellyfin.sdk.model.api.request.GetAlbumArtistsRequest +import org.jellyfin.sdk.model.api.request.GetArtistsRequest +import org.jellyfin.sdk.model.api.request.GetItemsRequest +import org.jellyfin.sdk.model.api.request.GetPersonsRequest +import org.jellyfin.sdk.model.serializer.toUUID +import org.jellyfin.sdk.model.serializer.toUUIDOrNull +import timber.log.Timber +import java.util.UUID + +@HiltViewModel(assistedFactory = CollectionFolderViewModel.Factory::class) +class CollectionFolderViewModel + @AssistedInject + constructor( + private val savedStateHandle: SavedStateHandle, + private val api: ApiClient, + @param:ApplicationContext private val context: Context, + val serverRepository: ServerRepository, + private val libraryDisplayInfoDao: LibraryDisplayInfoDao, + private val favoriteWatchManager: FavoriteWatchManager, + private val backdropService: BackdropService, + private val navigationManager: NavigationManager, + private val themeSongPlayer: ThemeSongPlayer, + private val userPreferencesService: UserPreferencesService, + private val mediaManagementService: MediaManagementService, + private val musicService: MusicService, + val streamChoiceService: StreamChoiceService, + val serverReportService: ServerReportService, + private val filterOptionCache: FilterOptionCache, + @Assisted val itemId: String, + @Assisted initialSortAndDirection: SortAndDirection?, + @Assisted("recursive") private val recursive: Boolean, + @Assisted private val collectionFilter: CollectionFolderFilter, + @Assisted("useSeriesForPrimary") private val useSeriesForPrimary: Boolean, + @Assisted defaultViewOptions: ViewOptions, + ) : ViewModel(), + ContextMenuProvider, + CollectionFolderViewActions { + @AssistedFactory + interface Factory { + fun create( + itemId: String, + initialSortAndDirection: SortAndDirection?, + @Assisted("recursive") recursive: Boolean, + collectionFilter: CollectionFolderFilter, + @Assisted("useSeriesForPrimary") useSeriesForPrimary: Boolean, + defaultViewOptions: ViewOptions, + ): CollectionFolderViewModel + } + + private val _state = MutableStateFlow(CollectionFolderState(viewOptions = defaultViewOptions)) + val state: StateFlow<CollectionFolderState> = _state + + private val artistSource: ArtistSource + get() = state.value.viewOptions.artistSource + + var position: Int + get() = savedStateHandle.get<Int>("position") ?: 0 + set(value) { + savedStateHandle["position"] = value + } + + init { + viewModelScope.launchIO { + try { + val item = + itemId.toUUIDOrNull()?.let { + api.userLibraryApi + .getItem(it) + .content + .let(::BaseItem) + } + + val libraryDisplayInfo = + serverRepository.currentUser?.let { user -> + val id = collectionFilter.libraryDisplayInfoIdOverride ?: itemId + libraryDisplayInfoDao.getItem(user, id) + } + val ancestorDisplayInfo = + if (libraryDisplayInfo?.viewOptions == null && item != null) { + getAncestorDisplayInfo(item) + } else { + null + } + _state.update { + it.copy( + viewOptions = + libraryDisplayInfo?.viewOptions + ?: ancestorDisplayInfo?.viewOptions + ?: defaultViewOptions, + ) + } + + val sortAndDirection = + if (collectionFilter.useSavedLibraryDisplayInfo) { + libraryDisplayInfo?.sortAndDirection + ?: ancestorDisplayInfo?.sortAndDirection + } else { + null + } ?: initialSortAndDirection ?: SortAndDirection.DEFAULT + + val filterToUse = + if (collectionFilter.useSavedLibraryDisplayInfo && libraryDisplayInfo?.filter != null) { + collectionFilter.filter.merge(libraryDisplayInfo.filter) + } else { + collectionFilter.filter + } + + _state.update { it.copy(item = DataLoadingState.Success(item)) } + loadResults(true, sortAndDirection, recursive, filterToUse, useSeriesForPrimary) + .join() +// onResumePage() + } catch (ex: Exception) { + Timber.e(ex, "Error during init") + _state.update { + it.copy( + item = DataLoadingState.Error(ex), + ) + } + } + } + mediaManagementService.deletedItemFlow + .onEach { deletedItem -> + refreshAfterDelete(position, deletedItem.item) + }.catch { ex -> + Timber.e(ex, "Error refreshing after deleted item") + }.launchIn(viewModelScope) + } + + private suspend fun refreshAfterDelete( + position: Int, + deletedItem: BaseItem, + ) { + try { + val pager = + ((state.value.items as? DataLoadingState.Success)?.data as? ApiRequestPager<*>) + position.let { + Timber.v("Item deleted: position=%s, id=%s", it, itemId) + val item = pager?.get(it) + // Exact item deleted (eg a movie) or deleted item was within the series + if (item?.id == deletedItem.id || + equalsNotNull(item?.data?.id, deletedItem.data.seriesId) + ) { + pager?.refreshPagesAfter(position) + } + } + } catch (ex: Exception) { + Timber.e(ex, "Error refreshing after deleted item %s", itemId) + showToast(context, "Error refreshing after item deleted") + } + } + + /** + * For a sub-folder, returns the saved display info of the closest ancestor that has one + */ + private suspend fun getAncestorDisplayInfo(item: BaseItem): LibraryDisplayInfo? { + if (item.type != BaseItemKind.FOLDER || collectionFilter.libraryDisplayInfoIdOverride != null) { + return null + } + val user = serverRepository.currentUser ?: return null + return try { + api.libraryApi + .getAncestors(item.id, user.id) + .content + .firstNotNullOfOrNull { ancestor -> libraryDisplayInfoDao.getItem(user, ancestor.id) } + } catch (ex: Exception) { + Timber.w(ex, "Error getting ancestors for %s", item.id) + null + } + } + + private fun saveLibraryDisplayInfo( + newFilter: GetItemsFilter = state.value.filter, + newSort: SortAndDirection = state.value.sortAndDirection, + viewOptions: ViewOptions? = state.value.viewOptions, + ) { + if (collectionFilter.useSavedLibraryDisplayInfo) { + serverRepository.currentUser?.let { user -> + viewModelScope.launchIO { + val libraryDisplayInfo = + LibraryDisplayInfo( + userId = user.rowId, + itemId = collectionFilter.libraryDisplayInfoIdOverride ?: itemId, + sort = newSort.sort, + direction = newSort.direction, + filter = newFilter, + viewOptions = viewOptions, + ) + libraryDisplayInfoDao.saveItem(libraryDisplayInfo) + } + } + } + } + + override fun saveViewOptions(viewOptions: ViewOptions) { + position = 0 + val artistSourceChanged = viewOptions.artistSource != artistSource + _state.update { it.copy(viewOptions = viewOptions) } + if (artistSourceChanged && state.value.filter.override == GetItemsFilterOverride.ARTIST) { + loadResults( + true, + state.value.sortAndDirection, + recursive, + state.value.filter, + useSeriesForPrimary, + ) + } + viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { + saveLibraryDisplayInfo(viewOptions = viewOptions) + if (!viewOptions.showBackdrop) { + backdropService.clearBackdrop() + } + } + } + + override fun onClickRandom() { + viewModelScope.launchIO { + try { + val state = state.value + val list = state.items.successValue as? BlockingList<BaseItem?> + if (list != null) { + goToRandom( + navigateTo = ::navigateTo, + parentId = itemId, + filter = state.filter, + sortAndDirection = state.sortAndDirection, + recursive = recursive, + list = list, + ) + } else { + Timber.w("state.value.items is not successful: %s", state.items) + } + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Error getting random") + showToast(context, "Error: ${ex.localizedMessage}}") + } + } + } + + override fun onFilterChange( + newFilter: GetItemsFilter, + recursive: Boolean, + ) { + Timber.v("onFilterChange: filter=%s", newFilter) + saveLibraryDisplayInfo(newFilter) + loadResults( + false, + state.value.sortAndDirection, + recursive, + newFilter, + useSeriesForPrimary, + ) + } + + override fun onSortChange( + sortAndDirection: SortAndDirection, + recursive: Boolean, + filter: GetItemsFilter, + ) { + Timber.v( + "onSortChange: sort=%s, recursive=%s, filter=%s", + sortAndDirection, + recursive, + filter, + ) + saveLibraryDisplayInfo(filter, sortAndDirection) + loadResults(true, sortAndDirection, recursive, filter, useSeriesForPrimary) + } + + private fun loadResults( + resetState: Boolean, + sortAndDirection: SortAndDirection, + recursive: Boolean, + filter: GetItemsFilter, + useSeriesForPrimary: Boolean, + ) = viewModelScope.launch(WholphinDispatchers.IO) { + _state.update { + it.copy( + items = DataLoadingState.Loading, + backgroundLoading = LoadingState.Loading, + sortAndDirection = sortAndDirection, + filter = filter, + ) + } + try { + val newPager = + createPager(sortAndDirection, recursive, filter, useSeriesForPrimary).init() + if (newPager.isNotEmpty()) newPager.getBlocking(0) + _state.update { + it.copy( + items = DataLoadingState.Success(newPager), + backgroundLoading = LoadingState.Success, + ) + } + } catch (ex: Exception) { + Timber.e( + ex, + "Exception while loading data: sort=%s, filter=%s", + sortAndDirection, + filter, + ) + _state.update { + it.copy( + items = DataLoadingState.Error(ex), + backgroundLoading = LoadingState.Pending, + ) + } + } + } + + private fun createPager( + sortAndDirection: SortAndDirection, + recursive: Boolean, + filter: GetItemsFilter, + useSeriesForPrimary: Boolean, + ): ApiRequestPager<out Any> = + when (filter.override) { + GetItemsFilterOverride.NONE -> { + val request = + createGetItemsRequest( + sortAndDirection = sortAndDirection, + recursive = recursive, + filter = filter, + ) + val newPager = + ApiRequestPager( + api, + request, + GetItemsRequestHandler, + viewModelScope, + useSeriesForPrimary = useSeriesForPrimary, + ) + newPager + } + + GetItemsFilterOverride.PERSON -> { + val request = + filter.applyTo( + GetPersonsRequest( + enableImageTypes = listOf(ImageType.PRIMARY, ImageType.THUMB), + ), + ) + val newPager = + ApiRequestPager( + api, + request, + GetPersonsHandler, + viewModelScope, + useSeriesForPrimary = useSeriesForPrimary, + ) + newPager + } + + GetItemsFilterOverride.ARTIST -> { + when (artistSource) { + ArtistSource.ARTIST -> { + ApiRequestPager( + api, + createGetArtistsRequest(filter), + GetArtistsHandler, + viewModelScope, + useSeriesForPrimary = useSeriesForPrimary, + ) + } + + ArtistSource.ALBUM_ARTIST -> { + ApiRequestPager( + api, + createGetAlbumArtistsRequest(filter), + GetAlbumArtistsHandler, + viewModelScope, + useSeriesForPrimary = useSeriesForPrimary, + ) + } + } + } + } + + /** + * Shared with [positionOfLetter] so that the alphabet jump counts the same artists that are + * actually paged through + */ + private fun createGetArtistsRequest(filter: GetItemsFilter): GetArtistsRequest { + val item = state.value.item.successValue + return filter.applyTo( + GetArtistsRequest( + parentId = item?.id, + enableImageTypes = listOf(ImageType.PRIMARY, ImageType.THUMB), + fields = SlimItemFields, + ), + ) + } + + private fun createGetAlbumArtistsRequest(filter: GetItemsFilter): GetAlbumArtistsRequest { + val item = state.value.item.successValue + return filter.applyTo( + GetAlbumArtistsRequest( + parentId = item?.id, + enableImageTypes = listOf(ImageType.PRIMARY, ImageType.THUMB), + fields = SlimItemFields, + ), + ) + } + + private fun createGetItemsRequest( + sortAndDirection: SortAndDirection, + recursive: Boolean, + filter: GetItemsFilter, + ): GetItemsRequest { + val item = state.value.item.successValue + val includeItemTypes = + item + ?.data + ?.collectionType + ?.baseItemKinds + .orEmpty() + val request = + filter.applyTo( + GetItemsRequest( + parentId = item?.id, + enableImageTypes = + listOf( + ImageType.PRIMARY, + ImageType.THUMB, + ImageType.BACKDROP, + ImageType.LOGO, + ), + includeItemTypes = includeItemTypes, + recursive = recursive, + excludeItemIds = item?.let { listOf(item.id) }, + sortBy = + buildList { + if (sortAndDirection.sort != ItemSortBy.DEFAULT) { + add(sortAndDirection.sort) + if (sortAndDirection.sort != ItemSortBy.SORT_NAME) { + add(ItemSortBy.SORT_NAME) + } + if (item?.data?.collectionType == CollectionType.MOVIES) { + add(ItemSortBy.PRODUCTION_YEAR) + } + } + }, + sortOrder = + buildList { + if (sortAndDirection.sort != ItemSortBy.DEFAULT) { + add(sortAndDirection.direction) + if (sortAndDirection.sort != ItemSortBy.SORT_NAME) { + add(SortOrder.ASCENDING) + } + if (item?.data?.collectionType == CollectionType.MOVIES) { + add(SortOrder.ASCENDING) + } + } + }, + fields = SlimItemFields, + ), + ) + return request + } + + override suspend fun getFilterOptionValues(filterOption: ItemFilterBy<*>): List<FilterValueOption> = + filterOptionCache.getFilterOptionValues( + itemId.toUUID(), + filterOption, + ) + + /** + * The count must come from the same endpoint that [createPager] pages through, otherwise + * the index refers to a different result set and lands past the end of the list. + */ + override suspend fun positionOfLetter(letter: Char): Int? = + withContext(WholphinDispatchers.IO) { + val filter = state.value.filter + when (filter.override) { + GetItemsFilterOverride.ARTIST -> { + when (artistSource) { + ArtistSource.ARTIST -> { + GetArtistsHandler.countMatching( + api = api, + request = + createGetArtistsRequest(filter).copy( + enableImageTypes = null, + fields = null, + nameLessThan = letter.toString(), + limit = 0, + enableTotalRecordCount = true, + enableUserData = false, + ), + ) + } + + ArtistSource.ALBUM_ARTIST -> { + GetAlbumArtistsHandler.countMatching( + api = api, + request = + createGetAlbumArtistsRequest(filter).copy( + enableImageTypes = null, + fields = null, + nameLessThan = letter.toString(), + limit = 0, + enableTotalRecordCount = true, + enableUserData = false, + ), + ) + } + } + } + + // GetPersonsRequest has no nameLessThan or startIndex, so /Persons cannot be + // counted up to a letter at all + GetItemsFilterOverride.PERSON -> { + null + } + + GetItemsFilterOverride.NONE -> { + GetItemsRequestHandler.countMatching( + api = api, + request = + createGetItemsRequest( + sortAndDirection = state.value.sortAndDirection, + recursive = recursive, + filter = filter, + ).copy( + enableImageTypes = null, + fields = null, + nameLessThan = letter.toString(), + limit = 0, + enableTotalRecordCount = true, + enableUserData = false, + ), + ) + } + } + } + + override fun setWatched( + position: Int, + itemId: UUID, + played: Boolean, + ) { + viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { + favoriteWatchManager.setWatched(itemId, played) + (state.value.items as? DataLoadingState.Success)?.let { + (it.data as? ApiRequestPager<*>)?.refreshItem(position, itemId) + } + } + } + + override fun setFavorite( + position: Int, + itemId: UUID, + favorite: Boolean, + ) { + viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { + favoriteWatchManager.setFavorite(itemId, favorite) + (state.value.items as? DataLoadingState.Success)?.let { + (it.data as? ApiRequestPager<*>)?.refreshItem(position, itemId) + } + } + } + + override fun updateBackdrop(item: BaseItem) { + viewModelScope.launchIO { + backdropService.submit(item) + } + } + + override fun navigateTo(destination: Destination) { + release() + navigationManager.navigateTo(destination) + } + + fun release() { + themeSongPlayer.stop() + } + + fun onResumePage() { + viewModelScope.launchIO { + state.value.item.successValue?.let { + Timber.v("onResumePage: %s", state.value.items::class) + if (it.type == BaseItemKind.BOX_SET && state.value.items !is DataLoadingState.Error) { + themeSongPlayer.playThemeFor(it.id) + } + } + } + } + + override fun deleteItem( + index: Int, + item: BaseItem, + ) { + deleteItem(context, mediaManagementService, item) { + viewModelScope.launchDefault { + refreshAfterDelete(index, item) + } + } + } + + override fun canDelete( + item: BaseItem, + appPreferences: AppPreferences, + ): Boolean = mediaManagementService.canDelete(item, appPreferences) + + fun addToQueue( + item: BaseItem, + index: Int, + ) = addToQueue(api, musicService, item, index) + + override fun sendReportFor(itemId: UUID) = serverReportService.sendMediaReportFor(itemId) + + override fun isAdministrator(): Boolean = serverRepository.currentUserDto?.policy?.isAdministrator == true + } + +data class CollectionFolderState( + val item: DataLoadingState<BaseItem?> = DataLoadingState.Loading, + val items: DataLoadingState<List<BaseItem?>> = DataLoadingState.Loading, + val backgroundLoading: LoadingState = LoadingState.Loading, + val sortAndDirection: SortAndDirection = SortAndDirection.DEFAULT, + val filter: GetItemsFilter = GetItemsFilter(), + val viewOptions: ViewOptions, +) + +/** + * Shows a collection folder as a grid + * + * This is the "Library" tab for Movies or TV shows + */ +@Composable +fun CollectionFolderView( + preferences: UserPreferences, + itemId: UUID, + initialFilter: CollectionFolderFilter, + recursive: Boolean, + onClickItem: (Int, BaseItem) -> Unit, + sortOptions: List<ItemSortBy>, + playEnabled: Boolean, + defaultViewOptions: ViewOptions, + modifier: Modifier = Modifier, + viewModelKey: String? = itemId.toServerString(), + initialSortAndDirection: SortAndDirection? = null, + showTitle: Boolean = true, + positionCallback: ((columns: Int, position: Int) -> Unit)? = null, + useSeriesForPrimary: Boolean = true, + filterOptions: List<ItemFilterBy<*>> = DefaultFilterOptions, + focusRequesterOnEmpty: FocusRequester? = null, +) = CollectionFolderView( + preferences, + itemId.toServerString(), + initialFilter, + recursive, + GridClickActions(onClickItem = onClickItem), + sortOptions, + playEnabled, + viewModelKey = viewModelKey, + defaultViewOptions = defaultViewOptions, + modifier = modifier, + initialSortAndDirection = initialSortAndDirection, + showTitle = showTitle, + positionCallback = positionCallback, + useSeriesForPrimary = useSeriesForPrimary, + filterOptions = filterOptions, + focusRequesterOnEmpty = focusRequesterOnEmpty, +) + +@Composable +fun CollectionFolderView( + preferences: UserPreferences, + itemId: UUID, + initialFilter: CollectionFolderFilter, + recursive: Boolean, + actions: GridClickActions, + sortOptions: List<ItemSortBy>, + playEnabled: Boolean, + defaultViewOptions: ViewOptions, + modifier: Modifier = Modifier, + viewModelKey: String? = itemId.toServerString(), + initialSortAndDirection: SortAndDirection? = null, + showTitle: Boolean = true, + positionCallback: ((columns: Int, position: Int) -> Unit)? = null, + useSeriesForPrimary: Boolean = true, + filterOptions: List<ItemFilterBy<*>> = DefaultFilterOptions, + focusRequesterOnEmpty: FocusRequester? = null, +) = CollectionFolderView( + preferences, + itemId.toServerString(), + initialFilter, + recursive, + actions, + sortOptions, + playEnabled, + viewModelKey = viewModelKey, + defaultViewOptions = defaultViewOptions, + modifier = modifier, + initialSortAndDirection = initialSortAndDirection, + showTitle = showTitle, + positionCallback = positionCallback, + useSeriesForPrimary = useSeriesForPrimary, + filterOptions = filterOptions, + focusRequesterOnEmpty = focusRequesterOnEmpty, +) + +@Composable +fun CollectionFolderView( + preferences: UserPreferences, + itemId: String, + initialFilter: CollectionFolderFilter, + recursive: Boolean, + actions: GridClickActions, + sortOptions: List<ItemSortBy>, + playEnabled: Boolean, + defaultViewOptions: ViewOptions, + modifier: Modifier = Modifier, + viewModelKey: String? = itemId, + initialSortAndDirection: SortAndDirection? = null, + showTitle: Boolean = true, + positionCallback: ((columns: Int, position: Int) -> Unit)? = null, + useSeriesForPrimary: Boolean = true, + filterOptions: List<ItemFilterBy<*>> = DefaultFilterOptions, + focusRequesterOnEmpty: FocusRequester? = null, + viewModel: CollectionFolderViewModel = + hiltViewModel<CollectionFolderViewModel, CollectionFolderViewModel.Factory>( + key = viewModelKey, + ) { + it.create( + itemId = itemId, + initialSortAndDirection = initialSortAndDirection, + recursive = recursive, + collectionFilter = initialFilter, + useSeriesForPrimary = useSeriesForPrimary, + defaultViewOptions = defaultViewOptions, + ) + }, +) { + val state by viewModel.state.collectAsState() + LifecycleResumeEffect(itemId) { + viewModel.onResumePage() + + onPauseOrDispose { + viewModel.release() + } + } + CollectionFolderViewContent( + preferences = preferences, + state = state, + savedPosition = viewModel.position, + itemId = itemId, + initialFilter = initialFilter, + recursive = recursive, + actions = actions, + sortOptions = sortOptions, + playEnabled = playEnabled, + defaultViewOptions = defaultViewOptions, + viewActions = viewModel, + modifier = modifier, + provider = viewModel, + showTitle = showTitle, + positionCallback = { columns, index -> + viewModel.position = index + positionCallback?.invoke(columns, index) + }, + filterOptions = filterOptions, + focusRequesterOnEmpty = focusRequesterOnEmpty, + ) +} + +interface CollectionFolderViewActions { + fun navigateTo(destination: Destination) + + fun updateBackdrop(item: BaseItem) + + fun onSortChange( + sortAndDirection: SortAndDirection, + recursive: Boolean, + filter: GetItemsFilter, + ) + + fun onFilterChange( + newFilter: GetItemsFilter, + recursive: Boolean, + ) + + suspend fun getFilterOptionValues(filterOption: ItemFilterBy<*>): List<FilterValueOption> + + suspend fun positionOfLetter(letter: Char): Int? + + fun saveViewOptions(viewOptions: ViewOptions) + + /** + * Navigate to a random item + */ + fun onClickRandom() +} + +@Composable +fun CollectionFolderViewContent( + preferences: UserPreferences, + state: CollectionFolderState, + savedPosition: Int, + itemId: String, + initialFilter: CollectionFolderFilter, + recursive: Boolean, + actions: GridClickActions, + sortOptions: List<ItemSortBy>, + playEnabled: Boolean, + defaultViewOptions: ViewOptions, + viewActions: CollectionFolderViewActions, + modifier: Modifier = Modifier, + provider: ContextMenuProvider, + showTitle: Boolean = true, + positionCallback: ((columns: Int, position: Int) -> Unit)? = null, + filterOptions: List<ItemFilterBy<*>> = DefaultFilterOptions, + focusRequesterOnEmpty: FocusRequester? = null, + gridCardContent: @Composable CollectionFolderViewScope.( + GridItemDetails<BaseItem>, + ) -> Unit = { (item, index, onClick, onLongClick, widthPx, mod) -> + GridCard( + item = item, + onClick = onClick, + onLongClick = onLongClick, + imageContentScale = viewOptions.contentScale.scale, + imageAspectRatio = viewOptions.aspectRatio.ratio, + imageType = viewOptions.imageType, + showTitle = viewOptions.showTitles, + fillWidth = widthPx, + modifier = mod, + ) + }, + listItemContent: @Composable CollectionFolderViewScope.( + GridItemDetails<BaseItem>, + ) -> Unit = { (item, index, onClick, onLongClick, _, mod) -> + CollectionFolderListItem( + item = item, + dense = viewOptions.type == ViewOptionsType.DENSE_LIST, + collectionType = collectionType, + onClick = onClick, + onLongClick = onLongClick, + modifier = mod, + ) + }, +) { + var position by rememberInt(savedPosition) + + // Track if leaving page due to clicking the random button so focus can be restored there + var clickedRandom by rememberSaveable { mutableStateOf(false) } + + val contextMenu = rememberContextMenu(preferences, provider) + var showViewOptions by rememberSaveable { mutableStateOf(false) } + var filterDropdownShowing by remember { mutableStateOf(false) } + val headerRowFocusRequester = remember { FocusRequester() } + val filterButtonFocusRequester = remember { FocusRequester() } + val randomButtonFocusRequester = remember { FocusRequester() } + + val gridActions = + remember(actions) { + GridClickActions( + onClickItem = { index, item -> + position = index + actions.onClickItem.invoke(index, item) + }, + onLongClickItem = { index, item -> + position = index + if (actions.onLongClickItem != null) { + actions.onLongClickItem.invoke(index, item) + } else { + contextMenu.showContextMenu(index, item) + } + }, + onClickPlayAll = + actions.onClickPlayAll ?: { shuffle -> + itemId.toUUIDOrNull()?.let { + val destination = + if (state.item.successValue?.type == BaseItemKind.PHOTO_ALBUM) { + Destination.Slideshow( + parentId = it, + index = 0, + filter = CollectionFolderFilter(filter = state.filter), + sortAndDirection = state.sortAndDirection, + recursive = true, + startSlideshow = true, + ) + } else { + Destination.PlaybackList( + itemId = it, + startIndex = 0, + shuffle = shuffle, + recursive = recursive, + sortAndDirection = state.sortAndDirection, + filter = state.filter, + ) + } + viewActions.navigateTo(destination) + } + Unit + }, + onClickPlayRemoteButton = + actions.onClickPlayRemoteButton ?: { index, item -> + val destination = + if (item.type == BaseItemKind.PHOTO_ALBUM) { + Destination.Slideshow( + parentId = item.id, + index = index, + filter = CollectionFolderFilter(filter = state.filter), + sortAndDirection = state.sortAndDirection, + recursive = true, + startSlideshow = true, + ) + } else { + Destination.Playback(item) + } + viewActions.navigateTo(destination) + }, + ) + } + Box(modifier = modifier) { + when (val st = state.item) { + DataLoadingState.Loading, + DataLoadingState.Pending, + -> { + LoadingPage(Modifier.fillMaxSize()) + } + + is DataLoadingState.Error -> { + ErrorMessage(st, Modifier.fillMaxSize()) + } + + is DataLoadingState.Success<BaseItem?> -> { + val item = st.successValue + val title = + initialFilter.nameOverride + ?: item?.name + ?: item?.data?.collectionType?.name + ?: stringResource(R.string.collection) + Column(modifier = Modifier.fillMaxSize()) { + var showHeader by rememberSaveable { mutableStateOf(true) } + val gridFocusRequester = remember { FocusRequester() } + val pager = remember(state.items) { state.items.successValue } + + val focusedItem = pager?.getOrNull(position) + if (state.viewOptions.showBackdrop) { + LaunchedEffect(focusedItem) { + focusedItem?.let(viewActions::updateBackdrop) + } + } + CollectionFolderHeader( + listIsNotEmpty = state.items.successValue?.isNotEmpty() == true, + showHeader = showHeader || state.items !is DataLoadingState.Success, + showTitle = showTitle, + playEnabled = playEnabled, + title = title, + sortAndDirection = state.sortAndDirection, + onSortChange = { + viewActions.onSortChange(it, recursive, state.filter) + }, + sortOptions = sortOptions, + currentFilter = state.filter, + onFilterChange = { + viewActions.onFilterChange(it, recursive) + }, + getPossibleFilterValues = { + viewActions.getFilterOptionValues(it) + }, + filterOptions = filterOptions, + onClickPlayAll = gridActions.onClickPlayAll!!, + onClickShowViewOptions = { showViewOptions = true }, + onClickRandom = { + clickedRandom = true + viewActions.onClickRandom() + }, + modifier = Modifier.focusRequester(headerRowFocusRequester), + onShowFilterDropdown = { filterDropdownShowing = it }, + filterButtonFocusRequester = filterButtonFocusRequester, + randomButtonFocusRequester = randomButtonFocusRequester, + ) + + when (val pager = state.items) { + is DataLoadingState.Error -> { + LaunchedEffect(Unit) { + (focusRequesterOnEmpty ?: headerRowFocusRequester).tryRequestFocus() + } + ErrorMessage(pager, Modifier.fillMaxSize()) + } + + DataLoadingState.Loading, + DataLoadingState.Pending, + -> { + LoadingPage(Modifier.fillMaxSize()) + } + + is DataLoadingState.Success<List<BaseItem?>> -> { + LaunchedEffect(Unit) { + val focusRequester = + when { + contextMenu.isShowing -> null + filterDropdownShowing -> filterButtonFocusRequester + clickedRandom -> randomButtonFocusRequester + pager.data.isNotEmpty() -> gridFocusRequester + else -> focusRequesterOnEmpty ?: headerRowFocusRequester + } + focusRequester?.tryRequestFocus() + clickedRandom = false + } + val scope = + object : CollectionFolderViewScope { + override val viewOptions: ViewOptions + get() = state.viewOptions + override val collectionType: CollectionType? + get() = item?.data?.collectionType + override val sortAndDirection: SortAndDirection + get() = state.sortAndDirection + override val collectionSize: Int + get() = pager.data.size + } + + Box(Modifier.fillMaxSize()) { + if (state.viewOptions.type == ViewOptionsType.GRID) { + scope.CollectionFolderGrid( + preferences = preferences, + initialPosition = savedPosition, + items = pager.data, + sortAndDirection = state.sortAndDirection, + modifier = Modifier.fillMaxSize(), + gridFocusRequester = gridFocusRequester, + onClickItem = gridActions.onClickItem, + onLongClickItem = gridActions.onLongClickItem!!, + positionCallback = { columns, pos -> + showHeader = pos < columns + position = pos + positionCallback?.invoke(columns, pos) + }, + letterPosition = { viewActions.positionOfLetter(it) ?: -1 }, + onClickPlay = gridActions.onClickPlayRemoteButton!!, + focusedItem = focusedItem, + cardContent = gridCardContent, + ) + } else { + scope.CollectionFolderList( + preferences = preferences, + collectionType = item?.data?.collectionType, + initialPosition = savedPosition, + items = pager.data, + sortAndDirection = state.sortAndDirection, + modifier = Modifier.fillMaxSize(), + gridFocusRequester = gridFocusRequester, + onClickItem = gridActions.onClickItem, + onLongClickItem = gridActions.onLongClickItem!!, + positionCallback = { columns, pos -> + showHeader = pos < columns + position = pos + positionCallback?.invoke(columns, pos) + }, + letterPosition = { viewActions.positionOfLetter(it) ?: -1 }, + onClickPlay = gridActions.onClickPlayRemoteButton!!, + focusedItem = focusedItem, + itemContent = listItemContent, + ) + } + androidx.compose.animation.AnimatedVisibility( + state.backgroundLoading == LoadingState.Loading, + modifier = + Modifier + .align(Alignment.Center) + .padding(16.dp), + ) { + CircularProgress( + Modifier + .background( + MaterialTheme.colorScheme.background.copy(alpha = .25f), + shape = CircleShape, + ).size(64.dp) + .padding(4.dp), + ) + } + } + } + } + } + } + } + } + contextMenu.Compose() + AnimatedVisibility(showViewOptions) { + ViewOptionsDialog( + viewOptions = state.viewOptions, + defaultViewOptions = defaultViewOptions, + showArtistSource = state.filter.override == GetItemsFilterOverride.ARTIST, + onDismissRequest = { + showViewOptions = false + viewActions.saveViewOptions(state.viewOptions) + }, + onViewOptionsChange = viewActions::saveViewOptions, + ) + } +} + +/** + * Scope with global data for [CollectionFolderView], [CollectionFolderGrid], [CollectionFolderList], and individual items + */ +interface CollectionFolderViewScope { + val viewOptions: ViewOptions + val collectionType: CollectionType? + val sortAndDirection: SortAndDirection + val collectionSize: Int +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ContextMenu.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ContextMenu.kt index e2111637..f87010d9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ContextMenu.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ContextMenu.kt @@ -1,6 +1,6 @@ package com.github.damontecres.wholphin.ui.components -import android.content.Context +import android.content.res.Resources import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowForward import androidx.compose.material.icons.filled.Add @@ -15,7 +15,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.github.damontecres.wholphin.R @@ -28,6 +28,7 @@ import com.github.damontecres.wholphin.preferences.PlayerBackend import com.github.damontecres.wholphin.ui.letNotEmpty import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.util.supportedPlayableTypes +import com.github.damontecres.wholphin.util.supportedShufflableTypes import org.jellyfin.sdk.model.UUID import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemKind @@ -40,6 +41,7 @@ sealed interface ContextMenu { data class ForBaseItem( val fromLongClick: Boolean, val item: BaseItem, + val index: Int = UNSPECIFIED_INDEX, val chosenStreams: ChosenStreams?, val showGoTo: Boolean, val showStreamChoices: Boolean, @@ -47,6 +49,7 @@ sealed interface ContextMenu { val canRemoveContinueWatching: Boolean, val canRemoveNextUp: Boolean, val actions: ContextMenuActions, + val showRemoveFromPlaylist: Boolean = false, ) : ContextMenu data class ForPerson( @@ -62,6 +65,8 @@ sealed interface ContextMenu { val canDelete: Boolean, val canRemoveFromQueue: Boolean, val actions: MusicContextActions, + val showRemoveFromPlaylist: Boolean = false, + val showGoTo: Boolean = false, ) : ContextMenu data class ForQueue( @@ -70,6 +75,10 @@ sealed interface ContextMenu { val index: Int, val actions: QueueContextActions, ) : ContextMenu + + companion object { + const val UNSPECIFIED_INDEX = -12 + } } data class ContextMenuActions( @@ -86,6 +95,7 @@ data class ContextMenuActions( val onClickGoTo: (BaseItem) -> Unit = { navigateTo(it.destination()) }, val onClickRemoveFromNextUp: (BaseItem) -> Unit = {}, val onClickAddToQueue: (BaseItem) -> Unit = {}, + val onRemoveFromPlaylist: (Int, UUID) -> Unit = { _, _ -> }, ) data class PersonContextActions( @@ -108,6 +118,7 @@ data class MusicContextActions( val onClickGoToArtist: (UUID) -> Unit = { navigateTo.invoke(Destination.MediaItem(itemId = it, type = BaseItemKind.MUSIC_ARTIST)) }, + val onRemoveFromPlaylist: (Int, UUID) -> Unit = { _, _ -> }, ) data class QueueContextActions( @@ -184,31 +195,32 @@ fun ContextMenu( ) { val item = contextMenu.item val chosenStreams = contextMenu.chosenStreams - val context = LocalContext.current + val resources = LocalResources.current var chooseVersion by remember { mutableStateOf<DialogParams?>(null) } var showDeleteDialog by remember { mutableStateOf(false) } var showPlayWithDialog by remember { mutableStateOf(false) } val dialogItems = - remember(context, item, chosenStreams) { + remember(resources, item, chosenStreams) { buildContextMenuItems( - context = context, + resources = resources, item = item, watched = item.played, favorite = item.favorite, seriesId = item.data.seriesId, sourceId = chosenStreams?.source?.id?.toUUIDOrNull(), - canClearChosenStreams = chosenStreams.let { it?.itemPlayback != null || it?.plc != null }, - showGoTo = contextMenu.showGoTo, - showStreamChoices = contextMenu.showStreamChoices, - canDelete = contextMenu.canDelete, - canRemoveContinueWatching = contextMenu.canRemoveContinueWatching, - canRemoveNextUp = contextMenu.canRemoveNextUp, + canClearChosenStreams = + chosenStreams.let { + it?.itemPlayback != null || + it?.audioStc?.isNotEmpty() == true || + it?.subtitleStc?.isNotEmpty() == true + }, + contextMenu = contextMenu, actions = actions, onChooseVersion = { chooseVersion = chooseVersionParams( - context, + resources, item.data.mediaSources.orEmpty(), chosenStreams?.source?.id?.toUUIDOrNull(), ) { idx -> @@ -225,14 +237,14 @@ fun ContextMenu( )?.let { source -> chooseVersion = chooseStream( - context = context, + resources = resources, streams = source.mediaStreams.orEmpty(), type = type, - currentIndex = + streamChoice = if (type == MediaStreamType.AUDIO) { - chosenStreams?.audioStream?.index + chosenStreams?.audioStream } else { - chosenStreams?.subtitleStream?.index + chosenStreams?.subtitleStream }, onClick = { trackIndex -> actions.onChooseTracks.invoke( @@ -274,6 +286,7 @@ fun ContextMenu( onDismissRequest = { chooseVersion = null }, dismissOnClick = true, waitToLoad = params.fromLongClick, + elevation = 3.dp, ) } } @@ -285,12 +298,13 @@ fun ContextMenu( actions.onDeleteItem.invoke(item) onDismissRequest.invoke() }, + elevation = 3.dp, ) } if (showPlayWithDialog) { val dialogItems = remember { - buildPlayWith(context) { transcode, backend -> + buildPlayWith(resources) { transcode, backend -> onDismissRequest.invoke() actions.navigateTo( Destination.Playback( @@ -315,18 +329,14 @@ fun ContextMenu( } private fun buildContextMenuItems( - context: Context, + resources: Resources, + contextMenu: ContextMenu.ForBaseItem, item: BaseItem, seriesId: UUID?, sourceId: UUID?, watched: Boolean, favorite: Boolean, canClearChosenStreams: Boolean, - showGoTo: Boolean, - showStreamChoices: Boolean, - canDelete: Boolean, - canRemoveContinueWatching: Boolean, - canRemoveNextUp: Boolean, actions: ContextMenuActions, onChooseVersion: () -> Unit, onChooseTracks: (MediaStreamType) -> Unit, @@ -336,10 +346,11 @@ private fun buildContextMenuItems( onClickPlayWith: () -> Unit, ): List<DialogItem> = buildList { - if (showGoTo) { + // Songs should not show Go to + if (contextMenu.showGoTo && item.type != BaseItemKind.AUDIO) { add( DialogItem( - context.getString(R.string.go_to), + resources.getString(R.string.go_to), Icons.Default.ArrowForward, dismissOnClick = true, ) { @@ -351,9 +362,8 @@ private fun buildContextMenuItems( if (item.playbackPosition > Duration.ZERO) { add( DialogItem( - context.getString(R.string.resume), + resources.getString(R.string.resume), Icons.Default.PlayArrow, - iconColor = Color.Green.copy(alpha = .8f), dismissOnClick = true, ) { actions.navigateTo( @@ -366,7 +376,7 @@ private fun buildContextMenuItems( ) add( DialogItem( - context.getString(R.string.restart), + resources.getString(R.string.restart), Icons.Default.Refresh, dismissOnClick = true, ) { @@ -381,9 +391,8 @@ private fun buildContextMenuItems( } else { add( DialogItem( - context.getString(R.string.play), + resources.getString(R.string.play), Icons.Default.PlayArrow, - iconColor = Color.Green.copy(alpha = .8f), dismissOnClick = true, ) { actions.navigateTo( @@ -396,7 +405,23 @@ private fun buildContextMenuItems( ) } } - if (showStreamChoices) { + if (item.type in supportedShufflableTypes) { + add( + DialogItem( + resources.getString(R.string.shuffle), + R.string.fa_shuffle, + dismissOnClick = true, + ) { + actions.navigateTo( + Destination.PlaybackList( + itemId = item.id, + shuffle = true, + ), + ) + }, + ) + } + if (contextMenu.showStreamChoices) { item.data.mediaSources?.letNotEmpty { sources -> val source = sourceId?.let { sources.firstOrNull { it.id?.toUUIDOrNull() == sourceId } } @@ -407,9 +432,9 @@ private fun buildContextMenuItems( if (audioCount > 1) { add( DialogItem( - context.getString( + resources.getString( R.string.choose_stream, - context.getString(R.string.audio), + resources.getString(R.string.audio), ), R.string.fa_volume_low, dismissOnClick = false, @@ -421,9 +446,9 @@ private fun buildContextMenuItems( if (subtitleCount > 0) { add( DialogItem( - context.getString( + resources.getString( R.string.choose_stream, - context.getString(R.string.subtitles), + resources.getString(R.string.subtitles), ), R.string.fa_closed_captioning, dismissOnClick = false, @@ -436,9 +461,9 @@ private fun buildContextMenuItems( if (sources.size > 1) { add( DialogItem( - context.getString( + resources.getString( R.string.choose_stream, - context.getString(R.string.version), + resources.getString(R.string.version), ), R.string.fa_file_video, dismissOnClick = false, @@ -449,10 +474,10 @@ private fun buildContextMenuItems( } } } - if (item.type == BaseItemKind.MUSIC_ALBUM) { + if (item.type == BaseItemKind.MUSIC_ALBUM || item.type == BaseItemKind.AUDIO) { add( DialogItem( - context.getString(R.string.add_to_queue), + resources.getString(R.string.add_to_queue), Icons.Default.Add, dismissOnClick = true, ) { @@ -460,6 +485,17 @@ private fun buildContextMenuItems( }, ) } + if (contextMenu.showRemoveFromPlaylist) { + add( + DialogItem( + text = R.string.remove_from_playlist, + iconStringRes = R.string.fa_circle_xmark, + dismissOnClick = true, + ) { + actions.onRemoveFromPlaylist.invoke(contextMenu.index, item.id) + }, + ) + } add( DialogItem( text = R.string.add_to_playlist, @@ -469,10 +505,10 @@ private fun buildContextMenuItems( actions.onClickAddPlaylist.invoke(item.id) }, ) - if (canDelete) { + if (contextMenu.canDelete) { add( DialogItem( - context.getString(R.string.delete), + resources.getString(R.string.delete), Icons.Default.Delete, iconColor = Color.Red.copy(alpha = .8f), dismissOnClick = false, @@ -481,7 +517,7 @@ private fun buildContextMenuItems( }, ) } - if (canRemoveContinueWatching && !watched && item.playbackPosition > Duration.ZERO) { + if (contextMenu.canRemoveContinueWatching && !watched && item.playbackPosition > Duration.ZERO) { add( DialogItem( text = R.string.remove_continue_watching, @@ -492,7 +528,7 @@ private fun buildContextMenuItems( }, ) } - if (canRemoveNextUp && item.type == BaseItemKind.EPISODE && item.data.seriesId != null) { + if (contextMenu.canRemoveNextUp && item.type == BaseItemKind.EPISODE && item.data.seriesId != null) { add( DialogItem( text = R.string.remove_next_up, @@ -525,7 +561,7 @@ private fun buildContextMenuItems( item.data.albumId?.let { albumId -> add( DialogItem( - context.getString(R.string.go_to_album), + resources.getString(R.string.go_to_album), R.string.fa_compact_disc, dismissOnClick = true, ) { @@ -542,7 +578,7 @@ private fun buildContextMenuItems( item.data.artistItems?.firstOrNull()?.id?.let { artistId -> add( DialogItem( - context.getString(R.string.go_to_artist), + resources.getString(R.string.go_to_artist), R.string.fa_user, dismissOnClick = true, ) { @@ -559,7 +595,7 @@ private fun buildContextMenuItems( seriesId?.let { add( DialogItem( - context.getString(R.string.go_to_series), + resources.getString(R.string.go_to_series), Icons.AutoMirrored.Filled.ArrowForward, dismissOnClick = true, ) { @@ -576,7 +612,7 @@ private fun buildContextMenuItems( if (item.data.mediaSources?.isNotEmpty() == true) { add( DialogItem( - context.getString(R.string.media_information), + resources.getString(R.string.media_information), Icons.Default.Info, dismissOnClick = true, ) { @@ -584,10 +620,10 @@ private fun buildContextMenuItems( }, ) } - if (showStreamChoices && canClearChosenStreams) { + if (contextMenu.showStreamChoices && canClearChosenStreams) { add( DialogItem( - context.getString(R.string.clear_track_choices), + resources.getString(R.string.clear_track_choices), Icons.Default.Delete, dismissOnClick = true, ) { @@ -598,7 +634,7 @@ private fun buildContextMenuItems( if (item.type in supportedPlayableTypes) { add( DialogItem( - context.getString(R.string.play_with), + resources.getString(R.string.play_with), Icons.Default.PlayArrow, dismissOnClick = false, onClick = onClickPlayWith, @@ -619,13 +655,13 @@ private fun buildContextMenuItems( } private fun buildPlayWith( - context: Context, + resources: Resources, onClick: (Boolean, PlayerBackend?) -> Unit, ) = buildList { val entries = PlayerBackend.entries .filterNot { it == PlayerBackend.UNRECOGNIZED } - .zip(context.resources.getStringArray(R.array.player_backend_options)) + .zip(resources.getStringArray(R.array.player_backend_options)) .filterNot { it.first == PlayerBackend.PREFER_MPV } entries.forEach { (backend, title) -> add( @@ -639,7 +675,7 @@ private fun buildPlayWith( } add( DialogItem( - context.getString(R.string.transcoding), + resources.getString(R.string.transcoding), dismissOnClick = true, ) { onClick.invoke(true, null) @@ -691,11 +727,11 @@ fun ContextMenu( item: ContextMenu.ForMusic, actions: MusicContextActions, ) { - val context = LocalContext.current + val resources = LocalResources.current var showDeleteDialog by remember { mutableStateOf(false) } val dialogItems = - remember(context, item, actions) { - buildContextForMusic(context, item, actions, onClickDelete = { + remember(resources, item, actions) { + buildContextForMusic(resources, item, actions, onClickDelete = { showDeleteDialog = true }) } @@ -720,7 +756,7 @@ fun ContextMenu( } fun buildContextForMusic( - context: Context, + resources: Resources, music: ContextMenu.ForMusic, actions: MusicContextActions, onClickDelete: () -> Unit, @@ -728,11 +764,22 @@ fun buildContextForMusic( buildList { val item = music.item val index = music.index + // Songs should not show Go to + if (music.showGoTo && item.type != BaseItemKind.AUDIO) { + add( + DialogItem( + resources.getString(R.string.go_to), + Icons.Default.ArrowForward, + dismissOnClick = true, + ) { + actions.navigateTo(item.destination()) + }, + ) + } add( DialogItem( - context.getString(R.string.play), + resources.getString(R.string.play), Icons.Default.PlayArrow, - iconColor = Color.Green.copy(alpha = .8f), dismissOnClick = true, ) { actions.onClickPlay(index, item) @@ -740,9 +787,8 @@ fun buildContextForMusic( ) add( DialogItem( - context.getString(R.string.play_next), + resources.getString(R.string.play_next), Icons.Default.PlayArrow, - iconColor = Color.Green.copy(alpha = .8f), dismissOnClick = true, ) { actions.onClickPlayNext(index, item) @@ -751,7 +797,7 @@ fun buildContextForMusic( if (music.canRemoveFromQueue) { add( DialogItem( - context.getString(R.string.remove_from_queue), + resources.getString(R.string.remove_from_queue), Icons.Default.Delete, dismissOnClick = true, ) { @@ -761,7 +807,7 @@ fun buildContextForMusic( } else { add( DialogItem( - context.getString(R.string.add_to_queue), + resources.getString(R.string.add_to_queue), Icons.Default.Add, dismissOnClick = true, ) { @@ -769,6 +815,17 @@ fun buildContextForMusic( }, ) } + if (music.showRemoveFromPlaylist) { + add( + DialogItem( + text = R.string.remove_from_playlist, + iconStringRes = R.string.fa_circle_xmark, + dismissOnClick = true, + ) { + actions.onRemoveFromPlaylist.invoke(index, item.id) + }, + ) + } add( DialogItem( text = R.string.add_to_playlist, @@ -781,7 +838,7 @@ fun buildContextForMusic( if (music.canDelete) { add( DialogItem( - context.getString(R.string.delete), + resources.getString(R.string.delete), Icons.Default.Delete, iconColor = Color.Red.copy(alpha = .8f), dismissOnClick = false, @@ -803,7 +860,7 @@ fun buildContextForMusic( if (item.type == BaseItemKind.AUDIO && item.data.albumId != null) { add( DialogItem( - context.getString(R.string.go_to_album), + resources.getString(R.string.go_to_album), R.string.fa_compact_disc, dismissOnClick = true, ) { @@ -814,7 +871,7 @@ fun buildContextForMusic( if ((item.type == BaseItemKind.AUDIO || item.type == BaseItemKind.MUSIC_ALBUM) && item.data.artistItems?.isNotEmpty() == true) { add( DialogItem( - context.getString(R.string.go_to_artist), + resources.getString(R.string.go_to_artist), R.string.fa_user, dismissOnClick = true, ) { @@ -834,11 +891,11 @@ fun ContextMenu( item: ContextMenu.ForQueue, actions: QueueContextActions, ) { - val context = LocalContext.current + val resources = LocalResources.current val dialogItems = - remember(context, item, actions) { + remember(resources, item, actions) { buildContextForMusicQueue( - context, + resources, item.item, item.index, actions, @@ -855,7 +912,7 @@ fun ContextMenu( } fun buildContextForMusicQueue( - context: Context, + resources: Resources, item: AudioItem, index: Int, actions: QueueContextActions, @@ -863,9 +920,8 @@ fun buildContextForMusicQueue( buildList { add( DialogItem( - context.getString(R.string.play), + resources.getString(R.string.play), Icons.Default.PlayArrow, - iconColor = Color.Green.copy(alpha = .8f), dismissOnClick = true, ) { actions.onClickPlay(index, item) @@ -873,9 +929,8 @@ fun buildContextForMusicQueue( ) add( DialogItem( - context.getString(R.string.play_next), + resources.getString(R.string.play_next), Icons.Default.PlayArrow, - iconColor = Color.Green.copy(alpha = .8f), dismissOnClick = true, ) { actions.onClickPlayNext(index, item) @@ -883,7 +938,7 @@ fun buildContextForMusicQueue( ) add( DialogItem( - context.getString(R.string.remove_from_queue), + resources.getString(R.string.remove_from_queue), Icons.Default.Delete, dismissOnClick = true, ) { @@ -893,7 +948,7 @@ fun buildContextForMusicQueue( if (item.albumId != null) { add( DialogItem( - context.getString(R.string.go_to_album), + resources.getString(R.string.go_to_album), Icons.Default.ArrowForward, dismissOnClick = true, ) { @@ -904,7 +959,7 @@ fun buildContextForMusicQueue( if (item.artistId != null) { add( DialogItem( - context.getString(R.string.go_to_artist), + resources.getString(R.string.go_to_artist), Icons.Default.ArrowForward, dismissOnClick = true, ) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ContextMenuUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ContextMenuUtils.kt new file mode 100644 index 00000000..2261e0ac --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ContextMenuUtils.kt @@ -0,0 +1,166 @@ +package com.github.damontecres.wholphin.ui.components + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel +import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog +import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo +import com.github.damontecres.wholphin.ui.detail.PlaylistDialog +import com.github.damontecres.wholphin.ui.nav.Destination +import java.util.UUID + +/** + * Simplifies showing a basic context menu + */ +@Composable +fun rememberContextMenu( + preferences: UserPreferences, + provider: ContextMenuProvider, +): ContextMenuUtils = remember(preferences, provider) { ContextMenuUtils(preferences, provider) } + +class ContextMenuUtils( + private val preferences: UserPreferences, + private val provider: ContextMenuProvider, +) { + private var showContextMenu by mutableStateOf<Pair<Int, BaseItem>?>(null) + + val isShowing: Boolean get() = showContextMenu != null + + /** + * Show the context menu, typically from a long click + */ + fun showContextMenu( + position: Int, + item: BaseItem, + ) { + showContextMenu = Pair(position, item) + } + + /** + * Composes the context menu when needed + */ + @Composable + fun Compose(playlistViewModel: AddPlaylistViewModel = hiltViewModel()) { + var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) } + var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) } + val playlistState by playlistViewModel.playlistState.collectAsState() + + overviewDialog?.let { info -> + ItemDetailsDialog( + info = info, + showFilePath = provider.isAdministrator(), + onDismissRequest = { overviewDialog = null }, + ) + } + showContextMenu?.let { (position, item) -> + val contextActions = + remember { + ContextMenuActions( + navigateTo = provider::navigateTo, + onClickWatch = { itemId, watched -> + provider.setWatched(position, itemId, watched) + }, + onClickFavorite = { itemId, favorite -> + provider.setFavorite(position, itemId, favorite) + }, + onClickAddPlaylist = { itemId -> + playlistViewModel.loadPlaylists() + showPlaylistDialog.makePresent(itemId) + }, + onSendMediaInfo = provider::sendReportFor, + onDeleteItem = { provider.deleteItem(position, it) }, + onShowOverview = { overviewDialog = ItemDetailsDialogInfo(it) }, + onChooseVersion = { _, _ -> + // Not supported on this page + }, + onChooseTracks = { result -> + // Not supported on this page + }, + onClearChosenStreams = { + // Not supported on this page + }, + onClickAddToQueue = playlistViewModel::addToQueue, + ) + } + val contextMenu = + remember { + ContextMenu.ForBaseItem( + fromLongClick = true, + item = item, + chosenStreams = null, + showGoTo = true, + showStreamChoices = false, + canDelete = provider.canDelete(item, preferences.appPreferences), + canRemoveContinueWatching = false, + canRemoveNextUp = false, + actions = contextActions, + ) + } + ContextMenuDialog( + onDismissRequest = { showContextMenu = null }, + getMediaSource = null, + contextMenu = contextMenu, + preferredSubtitleLanguage = null, + ) + } + showPlaylistDialog.compose { itemId -> + PlaylistDialog( + title = stringResource(R.string.add_to_playlist), + state = playlistState, + onDismissRequest = { showPlaylistDialog.makeAbsent() }, + onClick = { + playlistViewModel.addToPlaylist(it.id, itemId) + showPlaylistDialog.makeAbsent() + }, + createEnabled = true, + onCreatePlaylist = { + playlistViewModel.createPlaylistAndAddItem(it, itemId) + showPlaylistDialog.makeAbsent() + }, + onSearch = playlistViewModel::loadPlaylists, + elevation = 3.dp, + ) + } + } +} + +interface ContextMenuProvider { + fun isAdministrator(): Boolean + + fun navigateTo(destination: Destination) + + fun canDelete( + item: BaseItem, + appPreferences: AppPreferences, + ): Boolean + + fun deleteItem( + index: Int, + item: BaseItem, + ) + + fun setWatched( + position: Int, + itemId: UUID, + played: Boolean, + ) + + fun setFavorite( + position: Int, + itemId: UUID, + favorite: Boolean, + ) + + fun sendReportFor(itemId: UUID) +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt index 58bcde20..76b7dc77 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt @@ -1,6 +1,6 @@ package com.github.damontecres.wholphin.ui.components -import android.content.Context +import android.content.res.Resources import android.view.KeyEvent import androidx.annotation.StringRes import androidx.compose.foundation.background @@ -63,19 +63,20 @@ import androidx.tv.material3.Text import androidx.tv.material3.surfaceColorAtElevation import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.TrackIndex +import com.github.damontecres.wholphin.preferences.lazyListWrapScrolling +import com.github.damontecres.wholphin.services.StreamChoiceResult import com.github.damontecres.wholphin.ui.FontAwesome -import com.github.damontecres.wholphin.ui.ifElse +import com.github.damontecres.wholphin.ui.formatBitrate import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.playback.SimpleMediaStream -import com.github.damontecres.wholphin.ui.playback.isDown -import com.github.damontecres.wholphin.ui.playback.isUp -import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.ui.roundMinutes import com.github.damontecres.wholphin.util.ExceptionHandler import kotlinx.coroutines.delay import kotlinx.coroutines.launch import org.jellyfin.sdk.model.api.MediaSourceInfo import org.jellyfin.sdk.model.api.MediaStream import org.jellyfin.sdk.model.api.MediaStreamType +import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.serializer.toUUIDOrNull import java.util.UUID @@ -85,7 +86,7 @@ import java.util.UUID data class DialogParams( val fromLongClick: Boolean, val title: String, - val items: List<DialogItem>, + val items: List<DialogItemEntry>, ) sealed interface DialogItemEntry @@ -230,28 +231,35 @@ fun DialogPopup( onDismissRequest = onDismissRequest, properties = properties, ) { + val elevatedContainerColor = + MaterialTheme.colorScheme.surfaceColorAtElevation(elevation) DialogPopupContent( title = title, dialogItems = dialogItems, waiting = waiting, onDismissRequest = onDismissRequest, dismissOnClick = dismissOnClick, - elevation = elevation, modifier = - Modifier.onKeyEvent { event -> - val code = event.nativeKeyEvent.keyCode - if (event.nativeKeyEvent.action == KeyEvent.ACTION_UP && - code in - setOf( - KeyEvent.KEYCODE_ENTER, - KeyEvent.KEYCODE_DPAD_CENTER, - KeyEvent.KEYCODE_NUMPAD_ENTER, - ) - ) { - waiting = false - } - false - }, + Modifier + .onKeyEvent { event -> + val code = event.nativeKeyEvent.keyCode + if (event.nativeKeyEvent.action == KeyEvent.ACTION_UP && + code in + setOf( + KeyEvent.KEYCODE_ENTER, + KeyEvent.KEYCODE_DPAD_CENTER, + KeyEvent.KEYCODE_NUMPAD_ENTER, + ) + ) { + waiting = false + } + false + }.shadow(elevation = elevation, shape = RoundedCornerShape(28.0.dp)) + .graphicsLayer { + this.clip = true + this.shape = RoundedCornerShape(28.0.dp) + }.drawBehind { drawRect(color = elevatedContainerColor) } + .padding(PaddingValues(24.dp)), ) } } @@ -265,20 +273,10 @@ fun DialogPopupContent( onDismissRequest: () -> Unit, modifier: Modifier = Modifier, dismissOnClick: Boolean = true, - elevation: Dp = 8.dp, ) { - val elevatedContainerColor = - MaterialTheme.colorScheme.surfaceColorAtElevation(elevation) Column( verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = - modifier - .shadow(elevation = elevation, shape = RoundedCornerShape(28.0.dp)) - .graphicsLayer { - this.clip = true - this.shape = RoundedCornerShape(28.0.dp) - }.drawBehind { drawRect(color = elevatedContainerColor) } - .padding(PaddingValues(24.dp)), + modifier = modifier, ) { Text( text = title, @@ -319,30 +317,13 @@ fun DialogPopupContent( modifier = Modifier .focusRequester(focusRequesters[index]) - .ifElse( + .lazyListWrapScrolling( + listState, + focused, index == 0, - Modifier.onKeyEvent { - if (focused && isUp(it) && it.type == KeyEventType.KeyDown) { - scope.launch { - listState.animateScrollToItem(dialogItems.lastIndex) - focusRequesters[dialogItems.lastIndex].tryRequestFocus() - } - return@onKeyEvent true - } - false - }, - ).ifElse( index == dialogItems.lastIndex, - Modifier.onKeyEvent { - if (focused && isDown(it) && it.type == KeyEventType.KeyDown) { - scope.launch { - listState.animateScrollToItem(0) - focusRequesters[0].tryRequestFocus() - } - return@onKeyEvent true - } - false - }, + focusRequesters[0], + focusRequesters[dialogItems.lastIndex], ), ) } @@ -469,12 +450,14 @@ fun ConfirmDialog( properties: DialogProperties = DialogProperties(), elevation: Dp = 1.dp, bodyColor: Color = MaterialTheme.colorScheme.onSurface, + @StringRes cancelStringRes: Int = R.string.cancel, + @StringRes confirmStringRes: Int = R.string.confirm, ) = BasicDialog( onDismissRequest = onCancel, properties = properties, elevation = elevation, content = { - ConfirmDialogContent(title, body, onCancel, onConfirm, Modifier, bodyColor) + ConfirmDialogContent(title, body, onCancel, onConfirm, Modifier, bodyColor, cancelStringRes, confirmStringRes) }, ) @@ -489,6 +472,8 @@ fun ConfirmDialogContent( onConfirm: () -> Unit, modifier: Modifier = Modifier, bodyColor: Color = MaterialTheme.colorScheme.onSurface, + @StringRes cancelStringRes: Int = R.string.cancel, + @StringRes confirmStringRes: Int = R.string.confirm, ) { LazyColumn( verticalArrangement = Arrangement.spacedBy(8.dp), @@ -519,11 +504,11 @@ fun ConfirmDialogContent( modifier = Modifier.fillMaxWidth(), ) { TextButton( - stringRes = R.string.cancel, + stringRes = cancelStringRes, onClick = onCancel, ) TextButton( - stringRes = R.string.confirm, + stringRes = confirmStringRes, onClick = onConfirm, ) } @@ -608,14 +593,14 @@ fun ConfirmDeleteDialog( } fun chooseVersionParams( - context: Context, + resources: Resources, sources: List<MediaSourceInfo>, chosenSourceId: UUID?, onClick: (Int) -> Unit, ): DialogParams = DialogParams( fromLongClick = false, - title = context.getString(R.string.choose_stream, context.getString(R.string.version)), + title = resources.getString(R.string.choose_stream, resources.getString(R.string.version)), items = sources.filter { it.id.isNotNullOrBlank() }.mapIndexed { index, source -> val uuid = source.id?.toUUIDOrNull() @@ -630,7 +615,24 @@ fun chooseVersionParams( SelectedLeadingContent(uuid != null && uuid == chosenSourceId) }, supportingContent = { - videoStream?.displayTitle?.let { Text(text = it) } + val text = + remember { + buildList { + videoStream?.displayTitle?.let(::add) + source.bitrate?.let(::formatBitrate)?.let(::add) + }.joinToString(", ") + } + Text(text) + }, + trailingContent = { + val runtime = + remember { + source.runTimeTicks + ?.ticks + ?.roundMinutes + .toString() + } + Text(runtime) }, onClick = { onClick.invoke(index) }, ) @@ -649,16 +651,18 @@ fun resourceFor(type: MediaStreamType): Int = } fun chooseStream( - context: Context, + resources: Resources, streams: List<MediaStream>, - currentIndex: Int?, + streamChoice: StreamChoiceResult?, type: MediaStreamType, preferredSubtitleLanguage: String?, onClick: (Int) -> Unit, -): DialogParams = - DialogParams( +): DialogParams { + val filteredStreams = streams.filter { it.type == type } + val currentIndex = streamChoice?.stream?.index + return DialogParams( fromLongClick = false, - title = context.getString(R.string.choose_stream, context.getString(resourceFor(type))), + title = resources.getString(R.string.choose_stream, resources.getString(resourceFor(type))), items = buildList { if (type == MediaStreamType.SUBTITLE) { @@ -673,6 +677,16 @@ fun chooseStream( }, supportingContent = { }, + trailingContent = + if (currentIndex == null && streamChoice != null) { + { + Text( + text = streamChoice.reason.description.getString(), + ) + } + } else { + null + }, onClick = { onClick.invoke(TrackIndex.DISABLED) }, ), ) @@ -689,10 +703,12 @@ fun chooseStream( onClick = { onClick.invoke(TrackIndex.ONLY_FORCED) }, ), ) + if (filteredStreams.isNotEmpty()) { + add(DialogItemDivider) + } } addAll( - streams - .filter { it.type == type } + filteredStreams .let { if (type == MediaStreamType.SUBTITLE && preferredSubtitleLanguage.isNotNullOrBlank()) { it.sortedByDescending { it.language != null && it.language == preferredSubtitleLanguage } @@ -700,7 +716,7 @@ fun chooseStream( it } }.mapIndexed { index, stream -> - val simpleStream = SimpleMediaStream.from(context, stream, true) + val simpleStream = SimpleMediaStream.from(resources, stream, true) DialogItem( selected = currentIndex == stream.index, leadingContent = { @@ -708,15 +724,28 @@ fun chooseStream( }, headlineContent = { Text( - text = simpleStream.streamTitle ?: simpleStream.displayTitle, + text = + simpleStream.streamTitle + ?: simpleStream.displayTitle, ) }, supportingContent = { if (simpleStream.streamTitle != null) Text(text = simpleStream.displayTitle) }, + trailingContent = + if (currentIndex == stream.index) { + { + Text( + text = streamChoice.reason.description.getString(), + ) + } + } else { + null + }, onClick = { onClick.invoke(stream.index) }, ) }, ) }, ) +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ErrorMessage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ErrorMessage.kt index 59bb456f..b1995b3d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ErrorMessage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ErrorMessage.kt @@ -1,6 +1,5 @@ package com.github.damontecres.wholphin.ui.components -import android.content.Context import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding @@ -10,30 +9,25 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.ui.detail.DebugViewModel.Companion.sendAppLogs +import com.github.damontecres.wholphin.services.ServerReportService +import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.LoadingState import dagger.hilt.android.lifecycle.HiltViewModel -import dagger.hilt.android.qualifiers.ApplicationContext -import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.model.ClientInfo -import org.jellyfin.sdk.model.DeviceInfo import javax.inject.Inject @HiltViewModel class ErrorViewModel @Inject constructor( - @param:ApplicationContext private val context: Context, - private val api: ApiClient, - private val clientInfo: ClientInfo, - private val deviceInfo: DeviceInfo, + private val serverReportService: ServerReportService, ) : ViewModel() { fun sendLogs() { - sendAppLogs(context, api, clientInfo, deviceInfo) + viewModelScope.launchIO { serverReportService.sendAppLogs() } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/FilterByButton.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/FilterByButton.kt index e881bb98..af64ef15 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/FilterByButton.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/FilterByButton.kt @@ -37,8 +37,20 @@ import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import androidx.tv.material3.surfaceColorAtElevation import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.api.seerr.model.TvDetails +import com.github.damontecres.wholphin.api.seerr.model.TvShowStatus import com.github.damontecres.wholphin.data.filter.CommunityRatingFilter import com.github.damontecres.wholphin.data.filter.DecadeFilter +import com.github.damontecres.wholphin.data.filter.DiscoverFilter +import com.github.damontecres.wholphin.data.filter.DiscoverFilterBy +import com.github.damontecres.wholphin.data.filter.DiscoverMovieContentRatingFilter +import com.github.damontecres.wholphin.data.filter.DiscoverMovieGenreFilter +import com.github.damontecres.wholphin.data.filter.DiscoverMovieStudiosFilter +import com.github.damontecres.wholphin.data.filter.DiscoverRatingFilter +import com.github.damontecres.wholphin.data.filter.DiscoverTvContentRatingFilter +import com.github.damontecres.wholphin.data.filter.DiscoverTvGenreFilter +import com.github.damontecres.wholphin.data.filter.DiscoverTvStatusFilter +import com.github.damontecres.wholphin.data.filter.DiscoverTvStudiosFilter import com.github.damontecres.wholphin.data.filter.FavoriteFilter import com.github.damontecres.wholphin.data.filter.FilterValueOption import com.github.damontecres.wholphin.data.filter.FilterVideoType @@ -71,6 +83,7 @@ fun FilterByButton( onFilterChange: (GetItemsFilter) -> Unit, getPossibleValues: suspend (ItemFilterBy<*>) -> List<FilterValueOption>, modifier: Modifier = Modifier, + onShow: ((Boolean) -> Unit)? = null, ) { var dropDown by remember { mutableStateOf(false) } var nestedDropDown by remember { mutableStateOf<ItemFilterBy<*>?>(null) } @@ -79,7 +92,10 @@ fun FilterByButton( Box(modifier = modifier) { ExpandableFilterButton( filterCount = filterCount, - onClick = { dropDown = true }, + onClick = { + onShow?.invoke(true) + dropDown = true + }, modifier = Modifier, ) @@ -87,6 +103,7 @@ fun FilterByButton( expanded = dropDown, containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp), onDismissRequest = { + onShow?.invoke(false) dropDown = false nestedDropDown = null }, @@ -422,6 +439,286 @@ fun ExpandableFilterButton( } } +// TODO this all duplicated code from FilterByButton +@Composable +fun DiscoverFilterByButton( + filterOptions: List<DiscoverFilterBy<*>>, + current: DiscoverFilter, + onFilterChange: (DiscoverFilter) -> Unit, + getPossibleValues: suspend (DiscoverFilterBy<*>) -> List<FilterValueOption>, + modifier: Modifier = Modifier, + onShow: ((Boolean) -> Unit)? = null, +) { + var dropDown by remember { mutableStateOf(false) } + var nestedDropDown by remember { mutableStateOf<DiscoverFilterBy<*>?>(null) } + val filterCount = remember(current, filterOptions) { current.countFilters(filterOptions) } + + Box(modifier = modifier) { + ExpandableFilterButton( + filterCount = filterCount, + onClick = { + onShow?.invoke(true) + dropDown = true + }, + modifier = Modifier, + ) + + DropdownMenu( + expanded = dropDown, + containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp), + onDismissRequest = { + onShow?.invoke(false) + dropDown = false + nestedDropDown = null + }, + ) { + if (filterCount > 0) { + val interactionSource = remember { MutableInteractionSource() } + val focused by interactionSource.collectIsFocusedAsState() + TvDropdownMenuItem( + leadingIcon = { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = null, + tint = Color.Red.copy(alpha = .8f), + ) + }, + text = { + Text( + text = stringResource(R.string.remove), + color = if (focused) MaterialTheme.colorScheme.onError else MaterialTheme.colorScheme.error, + ) + }, + onClick = { + onFilterChange.invoke(current.delete(filterOptions)) + dropDown = false + }, + interactionSource = interactionSource, + modifier = Modifier, + ) + HorizontalDivider() + } + filterOptions + .forEachIndexed { index, filterOption -> + val focusRequester = remember { FocusRequester() } + if (index == 0) { + LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + } + val currentValue = remember(current) { filterOption.get(current) } + TvDropdownMenuItem( + leadingIcon = { + if (currentValue != null) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = "Filter active", + ) + } + }, + text = { + Text( + text = stringResource(filterOption.stringRes), + ) + }, + onClick = { + nestedDropDown = filterOption + }, + modifier = Modifier.focusRequester(focusRequester), + ) + } + } + + DropdownMenu( + expanded = nestedDropDown != null, + containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(5.dp), + offset = DpOffset(80.dp, 16.dp), + onDismissRequest = { + nestedDropDown = null + }, + ) { + nestedDropDown?.let { filterOption -> + filterOption as DiscoverFilterBy<Any> + val currentValue = remember(current) { filterOption.get(current) } + + var possibleValues by remember { mutableStateOf<List<FilterValueOption>?>(null) } + LaunchedEffect(Unit) { + possibleValues = getPossibleValues.invoke(filterOption) + } + + if (currentValue != null) { + val interactionSource = remember { MutableInteractionSource() } + val focused by interactionSource.collectIsFocusedAsState() + TvDropdownMenuItem( + elevation = 5.dp, + leadingIcon = { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = null, + tint = Color.Red.copy(alpha = .8f), + ) + }, + text = { + Text( + text = stringResource(R.string.remove), + color = if (focused) MaterialTheme.colorScheme.onError else MaterialTheme.colorScheme.error, + ) + }, + onClick = { + onFilterChange.invoke(filterOption.set(null, current)) + nestedDropDown = null + }, + interactionSource = interactionSource, + modifier = Modifier, + ) + HorizontalDivider() + } + + if (possibleValues == null) { + CircularProgress(Modifier.size(48.dp)) + } else if (possibleValues?.isEmpty() == true) { + Text( + text = stringResource(R.string.no_results), + ) + } else { + possibleValues.orEmpty().forEachIndexed { index, value -> + val focusRequester = remember { FocusRequester() } + if (index == 0) { + LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + } + + val isSelected = + remember(currentValue) { + when (filterOption) { + DiscoverMovieGenreFilter, + DiscoverTvGenreFilter, + DiscoverMovieStudiosFilter, + DiscoverTvStudiosFilter, + -> { + (currentValue as? List<Int>) + .orEmpty() + .contains(value.value) + } + + DiscoverTvStatusFilter -> { + (currentValue as? List<TvShowStatus>) + .orEmpty() + .contains(value.value) + } + + DiscoverMovieContentRatingFilter, + DiscoverTvContentRatingFilter, + -> { + (currentValue as? List<String>) + .orEmpty() + .contains(value.value) + } + + DiscoverRatingFilter -> { + (currentValue as? Int) == value.value + } + } + } + val interactionSource = remember { MutableInteractionSource() } + val focused by interactionSource.collectIsFocusedAsState() + TvDropdownMenuItem( + elevation = 8.dp, + interactionSource = interactionSource, + leadingIcon = { + if (isSelected) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = "Filter active", + ) + } + }, + text = { + if (filterOption == DiscoverRatingFilter) { + SimpleStarRating( + "${value.name}+", + starColor = if (focused) EmptyStarColor else FilledStarColor, + ) + } else { + Text( + text = value.name, + ) + } + }, + onClick = { + val newFilter = + when (filterOption) { + DiscoverMovieGenreFilter, + DiscoverTvGenreFilter, + DiscoverMovieStudiosFilter, + DiscoverTvStudiosFilter, + -> { + val list = (currentValue as? List<Int>).orEmpty() + val newValue = + list + .toMutableList() + .apply { + if (isSelected) { + remove(value.value!!) + } else { + add(value.value!! as Int) + } + }.takeIf { it.isNotEmpty() } + filterOption.set(newValue, current) + } + + DiscoverTvStatusFilter -> { + val list = + (currentValue as? List<TvShowStatus>).orEmpty() + val newValue = + list + .toMutableList() + .apply { + if (isSelected) { + remove(value.value!!) + } else { + add(value.value!! as TvShowStatus) + } + }.takeIf { it.isNotEmpty() } + filterOption.set(newValue, current) + } + + DiscoverMovieContentRatingFilter, + DiscoverTvContentRatingFilter, + -> { + val list = (currentValue as? List<String>).orEmpty() + val newValue = + list + .toMutableList() + .apply { + if (isSelected) { + remove(value.value!!) + } else { + add(value.value!! as String) + } + }.takeIf { it.isNotEmpty() } + filterOption.set(newValue, current) + } + + DiscoverRatingFilter -> { + filterOption.set( + value.value as? Int, + current, + ) + } + } + + onFilterChange.invoke(newFilter) + if (!filterOption.supportMultiple) { + nestedDropDown = null + } + }, + modifier = Modifier.focusRequester(focusRequester), + ) + } + } + } + } + } +} + @PreviewTvSpec @Composable private fun ExpandableFilterButtonPreview() { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/FocusableItemRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/FocusableItemRow.kt index b04bf451..811b0051 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/FocusableItemRow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/FocusableItemRow.kt @@ -60,7 +60,7 @@ fun FocusableItemRow( if (focused) { MaterialTheme.colorScheme.border.copy(alpha = .25f) } else { - Color.Unspecified + Color.Transparent }, ) Column( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt index 4723627f..ecc643fa 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt @@ -6,8 +6,8 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.Stable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester @@ -16,11 +16,11 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.times import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.alphabetSortName import com.github.damontecres.wholphin.data.model.createGenreDestination import com.github.damontecres.wholphin.services.ImageUrlService import com.github.damontecres.wholphin.services.NavigationManager @@ -29,28 +29,30 @@ import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.cards.GenreCard import com.github.damontecres.wholphin.ui.detail.CardGrid import com.github.damontecres.wholphin.ui.detail.CardGridItem -import com.github.damontecres.wholphin.ui.setValueOnMain +import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.GetGenresRequestHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler -import com.github.damontecres.wholphin.util.LoadingExceptionHandler -import com.github.damontecres.wholphin.util.LoadingState +import com.github.damontecres.wholphin.util.WholphinDispatchers import com.mayakapps.kache.InMemoryKache import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.CollectionType import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.api.ItemFields import org.jellyfin.sdk.model.api.ItemSortBy @@ -80,72 +82,92 @@ class GenreViewModel ): GenreViewModel } - val item = MutableLiveData<BaseItem?>(null) - val loading = MutableLiveData<LoadingState>(LoadingState.Pending) - val genres = MutableLiveData<List<Genre>>(listOf()) + private val _state = MutableStateFlow(GenreGridState()) + val state: StateFlow<GenreGridState> = _state fun init(cardWidthPx: Int) { - loading.value = LoadingState.Loading - viewModelScope.launch(Dispatchers.IO + LoadingExceptionHandler(loading, "Failed to fetch genres")) { - val item = - api.userLibraryApi.getItem(itemId = itemId).content.let { - BaseItem(it, false) - } - this@GenreViewModel.item.setValueOnMain(item) - val request = - GetGenresRequest( - userId = serverRepository.currentUser.value?.id, - parentId = itemId, - fields = SlimItemFields, - includeItemTypes = includeItemTypes, - ) - val genres = - GetGenresRequestHandler - .execute(api, request) - .content.items - .map { - Genre(it.id, it.name ?: "", null) + _state.update { it.copy(item = DataLoadingState.Loading) } + viewModelScope.launchIO { + try { + val item = + api.userLibraryApi.getItem(itemId = itemId).content.let { + BaseItem(it, false) } - withContext(Dispatchers.Main) { - this@GenreViewModel.genres.value = genres - loading.value = LoadingState.Success - } - val genreToUrl = - getGenreImageMap( - api = api, - userId = serverRepository.currentUser.value?.id, - scope = viewModelScope, - imageUrlService = imageUrlService, - genres = genres.map { it.id }, - parentId = itemId, - includeItemTypes = includeItemTypes, - cardWidthPx = cardWidthPx, - ) - val genresWithImages = - genres.map { + val request = + GetGenresRequest( + userId = serverRepository.currentUser?.id, + parentId = itemId, + fields = SlimItemFields, + includeItemTypes = includeItemTypes, + ) + val genres = + GetGenresRequestHandler + .execute(api, request) + .content.items + .map { + Genre( + id = it.id, + name = it.name ?: "", + imageUrl = null, + sortName = it.alphabetSortName, + ) + } + _state.update { it.copy( - imageUrl = genreToUrl[it.id], + item = DataLoadingState.Success(item), + genres = genres, ) } - this@GenreViewModel.genres.setValueOnMain(genresWithImages) + val genreToUrl = + getGenreImageMap( + api = api, + userId = serverRepository.currentUser?.id, + scope = viewModelScope, + imageUrlService = imageUrlService, + genres = genres.map { it.id }, + parentId = itemId, + includeItemTypes = includeItemTypes, + cardWidthPx = cardWidthPx, + ) + val genresWithImages = + genres.map { + it.copy( + imageUrl = genreToUrl[it.id], + ) + } + _state.update { + it.copy( + genres = genresWithImages, + ) + } + } catch (ex: Exception) { + Timber.e(ex, "Error fetching genres") + _state.update { it.copy(item = DataLoadingState.Error(ex)) } + } } } suspend fun positionOfLetter(letter: Char): Int = - withContext(Dispatchers.IO) { - val request = - GetGenresRequest( - parentId = itemId, - nameLessThan = letter.toString(), - limit = 0, - enableTotalRecordCount = true, - includeItemTypes = includeItemTypes, - ) - val result by GetGenresRequestHandler.execute(api, request) - return@withContext result.totalRecordCount + withContext(WholphinDispatchers.IO) { + GetGenresRequestHandler.countMatching( + api = api, + request = + GetGenresRequest( + parentId = itemId, + nameLessThan = letter.toString(), + limit = 0, + enableTotalRecordCount = true, + includeItemTypes = includeItemTypes, + ), + ) } } +data class GenreGridState( + val item: DataLoadingState<BaseItem> = DataLoadingState.Pending, + val genres: List<Genre> = emptyList(), +) + data class GenreCacheKey( val userId: UUID?, val parentId: UUID, @@ -182,7 +204,7 @@ suspend fun getGenreImageMap( val semaphore = Semaphore(4) genres .map { genreId -> - scope.async(Dispatchers.IO) { + scope.async(WholphinDispatchers.IO) { semaphore.withPermit { val item = GetItemsRequestHandler @@ -228,10 +250,10 @@ data class Genre( val id: UUID, val name: String, val imageUrl: String?, + override val sortName: String = name, ) : CardGridItem { override val gridId: String get() = id.toString() override val playable: Boolean = false - override val sortName: String get() = name } /** @@ -241,7 +263,9 @@ data class Genre( fun GenreCardGrid( itemId: UUID, includeItemTypes: List<BaseItemKind>?, + collectionType: CollectionType, modifier: Modifier = Modifier, + initialPosition: Int = 0, viewModel: GenreViewModel = hiltViewModel<GenreViewModel, GenreViewModel.Factory>( creationCallback = { it.create(itemId, includeItemTypes) }, @@ -252,7 +276,7 @@ fun GenreCardGrid( val density = LocalDensity.current val configuration = LocalConfiguration.current val cardWidthPx = - remember { + remember(density) { with(density) { // Grid has 16dp padding on either side & 16dp spacing between 4 cards // This isn't exact though because it doesn't account for nav drawer or letters, but it's close and the calculation is much faster @@ -265,35 +289,35 @@ fun GenreCardGrid( OneTimeLaunchedEffect { viewModel.init(cardWidthPx) } - val loading by viewModel.loading.observeAsState(LoadingState.Pending) - val genres by viewModel.genres.observeAsState(listOf()) + val state by viewModel.state.collectAsState() val gridFocusRequester = remember { FocusRequester() } - when (val st = loading) { - LoadingState.Pending, - LoadingState.Loading, + when (val st = state.item) { + DataLoadingState.Pending, + DataLoadingState.Loading, -> { LoadingPage(modifier.focusable()) } - is LoadingState.Error -> { + is DataLoadingState.Error -> { ErrorMessage(st, modifier.focusable()) } - LoadingState.Success -> { + is DataLoadingState.Success<BaseItem> -> { Box(modifier = modifier) { LaunchedEffect(Unit) { gridFocusRequester.tryRequestFocus() } - val item by viewModel.item.observeAsState(null) + val item = st.data CardGrid( - pager = genres, + pager = state.genres, onClickItem = { _, genre -> viewModel.navigationManager.navigateTo( createGenreDestination( genreId = genre.id, genreName = genre.name, parentId = itemId, - parentName = item?.title, + parentName = item.title, includeItemTypes = includeItemTypes, + collectionType = collectionType, ), ) }, @@ -304,7 +328,7 @@ fun GenreCardGrid( showJumpButtons = false, showLetterButtons = true, modifier = Modifier.fillMaxSize(), - initialPosition = 0, + initialPosition = initialPosition, positionCallback = { columns, position -> }, columns = columns, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/HiddenFocusBox.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/HiddenFocusBox.kt index d74707e5..f86868bc 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/HiddenFocusBox.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/HiddenFocusBox.kt @@ -25,7 +25,7 @@ fun HiddenFocusBox( modifier = Modifier .fillMaxWidth() - .height(1.dp) + .height(0.dp) .focusRequester(focusRequester) .onFocusChanged { if (it.isFocused) onFocus.invoke() diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ItemGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ItemGrid.kt index dff6d505..3609cdfa 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ItemGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ItemGrid.kt @@ -1,138 +1,240 @@ package com.github.damontecres.wholphin.ui.components +import android.content.Context import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Text +import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.services.FavoriteWatchManager +import com.github.damontecres.wholphin.services.MediaManagementService import com.github.damontecres.wholphin.services.NavigationManager -import com.github.damontecres.wholphin.ui.AspectRatios +import com.github.damontecres.wholphin.services.ServerReportService +import com.github.damontecres.wholphin.services.deleteItem import com.github.damontecres.wholphin.ui.cards.GridCard +import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel import com.github.damontecres.wholphin.ui.detail.CardGrid +import com.github.damontecres.wholphin.ui.equalsNotNull +import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.rememberInt +import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.ApiRequestPager -import com.github.damontecres.wholphin.util.GetItemsRequestHandler -import com.github.damontecres.wholphin.util.LoadingExceptionHandler -import com.github.damontecres.wholphin.util.LoadingState +import com.github.damontecres.wholphin.util.DataLoadingState +import com.github.damontecres.wholphin.util.ExceptionHandler +import com.github.damontecres.wholphin.util.RequestHandler +import com.github.damontecres.wholphin.util.WholphinDispatchers import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.model.api.ItemSortBy -import org.jellyfin.sdk.model.api.SortOrder -import org.jellyfin.sdk.model.api.request.GetItemsRequest +import timber.log.Timber +import java.util.UUID @HiltViewModel(assistedFactory = ItemGridViewModel.Factory::class) class ItemGridViewModel @AssistedInject constructor( + @param:ApplicationContext private val context: Context, private val api: ApiClient, private val navigationManager: NavigationManager, - @Assisted private val destination: Destination.ItemGrid, - ) : ViewModel() { - val loading = MutableLiveData<LoadingState>(LoadingState.Loading) - val items = MutableLiveData<List<BaseItem?>>(listOf()) - + private val favoriteWatchManager: FavoriteWatchManager, + private val mediaManagementService: MediaManagementService, + val serverRepository: ServerRepository, + val serverReportService: ServerReportService, + @Assisted private val destination: Destination.ItemGrid<*>, + ) : ViewModel(), + ContextMenuProvider { @AssistedFactory interface Factory { - fun create(destination: Destination.ItemGrid): ItemGridViewModel + fun create(destination: Destination.ItemGrid<*>): ItemGridViewModel } + private val _state = MutableStateFlow(ItemGridState()) + val state: StateFlow<ItemGridState> = _state + init { - viewModelScope.launchIO(LoadingExceptionHandler(loading, "Error fetching items")) { - val request = - GetItemsRequest( - ids = destination.itemIds, - sortBy = listOf(ItemSortBy.SORT_NAME), - sortOrder = listOf(SortOrder.ASCENDING), - ) - val pager = ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope).init() - if (pager.isNotEmpty()) { - pager.getBlocking(0) - } - withContext(Dispatchers.Main) { - this@ItemGridViewModel.items.value = pager - this@ItemGridViewModel.loading.value = LoadingState.Success + viewModelScope.launchIO { + try { + val request = destination.request as Any + val pager = + ApiRequestPager( + api, + request, + destination.requestHandler as RequestHandler<Any>, + viewModelScope, + useSeriesForPrimary = true, + ).init() + if (pager.isNotEmpty()) { + pager.getBlocking(0) + } + _state.update { + it.copy(items = DataLoadingState.Success(pager)) + } + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Error fetching items") + _state.update { it.copy(items = DataLoadingState.Error(ex)) } } } } - fun navigateTo(destination: Destination) { + override fun navigateTo(destination: Destination) { navigationManager.navigateTo(destination) } + + override fun canDelete( + item: BaseItem, + appPreferences: AppPreferences, + ): Boolean = mediaManagementService.canDelete(item, appPreferences) + + override fun deleteItem( + index: Int, + item: BaseItem, + ) { + deleteItem(context, mediaManagementService, item) { + viewModelScope.launchDefault { + refreshAfterDelete(index, item) + } + } + } + + private suspend fun refreshAfterDelete( + position: Int, + deletedItem: BaseItem, + ) { + try { + val pager = + ((state.value.items as? DataLoadingState.Success)?.data as? ApiRequestPager<*>) + position.let { + Timber.v("Item deleted: position=%s", it) + val item = pager?.get(it) + // Exact item deleted (eg a movie) or deleted item was within the series + if (item?.id == deletedItem.id || + equalsNotNull(item?.data?.id, deletedItem.data.seriesId) + ) { + pager?.refreshPagesAfter(position) + } + } + } catch (ex: Exception) { + Timber.e(ex, "Error refreshing after deleted item %s", deletedItem.id) + showToast(context, "Error refreshing after item deleted") + } + } + + override fun setWatched( + position: Int, + itemId: UUID, + played: Boolean, + ) { + viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { + favoriteWatchManager.setWatched(itemId, played) + (state.value.items as? DataLoadingState.Success)?.let { + (it.data as? ApiRequestPager<*>)?.refreshItem(position, itemId) + } + } + } + + override fun setFavorite( + position: Int, + itemId: UUID, + favorite: Boolean, + ) { + viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { + favoriteWatchManager.setFavorite(itemId, favorite) + (state.value.items as? DataLoadingState.Success)?.let { + (it.data as? ApiRequestPager<*>)?.refreshItem(position, itemId) + } + } + } + + override fun isAdministrator(): Boolean = serverRepository.currentUserDto?.policy?.isAdministrator == true + + override fun sendReportFor(itemId: UUID) = serverReportService.sendMediaReportFor(itemId) } +data class ItemGridState( + val items: DataLoadingState<List<BaseItem?>> = DataLoadingState.Pending, +) + /** - * Display a grid of a list of arbitrary item IDs such as for [com.github.damontecres.wholphin.data.ExtrasItem] + * Display a grid of a list of arbitrary items [com.github.damontecres.wholphin.data.ExtrasItem] */ @Composable fun ItemGrid( - destination: Destination.ItemGrid, + preferences: UserPreferences, + destination: Destination.ItemGrid<*>, modifier: Modifier = Modifier, + playlistViewModel: AddPlaylistViewModel = hiltViewModel(), viewModel: ItemGridViewModel = hiltViewModel<ItemGridViewModel, ItemGridViewModel.Factory>( creationCallback = { it.create(destination) }, ), ) { - val loading by viewModel.loading.observeAsState(LoadingState.Loading) - val items by viewModel.items.observeAsState(listOf()) - when (val state = loading) { - is LoadingState.Error -> { - ErrorMessage(state, modifier) + val contextMenu = rememberContextMenu(preferences, viewModel) + + val state by viewModel.state.collectAsState() + when (val st = state.items) { + is DataLoadingState.Error -> { + ErrorMessage(st, modifier) } - LoadingState.Loading, - LoadingState.Pending, + DataLoadingState.Loading, + DataLoadingState.Pending, -> { LoadingPage(modifier) } - LoadingState.Success -> { + is DataLoadingState.Success<List<BaseItem?>> -> { + var position by rememberInt(destination.initialPosition) val focusRequester = remember { FocusRequester() } LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + Column(modifier = modifier) { - Text( - text = destination.title ?: destination.titleRes?.let { stringResource(it) } ?: "", - style = MaterialTheme.typography.displayMedium, - color = MaterialTheme.colorScheme.onBackground, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth(), - ) + GridTitle(destination.title.getString()) + CardGrid( - pager = items, + pager = st.data, onClickItem = { index: Int, item: BaseItem -> - // TODO handle more types - viewModel.navigateTo(Destination.Playback(item.id, 0)) + position = index + viewModel.navigateTo(item.destination(index)) + }, + onLongClickItem = { index: Int, item: BaseItem -> + position = index + contextMenu.showContextMenu(index, item) }, - onLongClickItem = { index: Int, item: BaseItem -> }, onClickPlay = { _, item -> viewModel.navigateTo(Destination.Playback(item)) }, letterPosition = { c: Char -> 0 }, gridFocusRequester = focusRequester, showJumpButtons = false, showLetterButtons = false, - spacing = 24.dp, + initialPosition = destination.initialPosition, + spacing = destination.viewOptions.spacing.dp, cardContent = @Composable { (item, index, onClick, onLongClick, widthPx, mod) -> GridCard( item = item, @@ -140,13 +242,14 @@ fun ItemGrid( onLongClick = onLongClick, fillWidth = widthPx, modifier = mod, - imageAspectRatio = AspectRatios.WIDE, // TODO + imageAspectRatio = destination.viewOptions.aspectRatio.ratio, ) }, - columns = 3, + columns = destination.viewOptions.columns, modifier = Modifier.fillMaxSize(), ) } } } + contextMenu.Compose() } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ListItemWrapper.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ListItemWrapper.kt new file mode 100644 index 00000000..bdfddb9e --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ListItemWrapper.kt @@ -0,0 +1,82 @@ +package com.github.damontecres.wholphin.ui.components + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.runtime.Composable +import androidx.compose.runtime.NonRestartableComposable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import androidx.tv.material3.DenseListItem +import androidx.tv.material3.ListItem +import androidx.tv.material3.ListItemBorder +import androidx.tv.material3.ListItemColors +import androidx.tv.material3.ListItemDefaults +import androidx.tv.material3.ListItemGlow +import androidx.tv.material3.ListItemScale +import androidx.tv.material3.ListItemShape + +/** + * Displays either a [ListItem] or [DenseListItem] based on the dense parameter + */ +@Composable +@NonRestartableComposable +fun ListItemWrapper( + dense: Boolean, + selected: Boolean, + onClick: () -> Unit, + headlineContent: @Composable () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + onLongClick: (() -> Unit)? = null, + overlineContent: (@Composable () -> Unit)? = null, + supportingContent: (@Composable () -> Unit)? = null, + leadingContent: (@Composable BoxScope.() -> Unit)? = null, + trailingContent: (@Composable () -> Unit)? = null, + tonalElevation: Dp = ListItemDefaults.TonalElevation, + shape: ListItemShape = ListItemDefaults.shape(), + colors: ListItemColors = ListItemDefaults.colors(), + scale: ListItemScale = ListItemDefaults.scale(), + border: ListItemBorder = ListItemDefaults.border(), + glow: ListItemGlow = ListItemDefaults.glow(), + interactionSource: MutableInteractionSource? = null, +) = if (dense) { + DenseListItem( + selected = selected, + onClick = onClick, + headlineContent = headlineContent, + modifier = modifier, + enabled = enabled, + onLongClick = onLongClick, + overlineContent = overlineContent, + supportingContent = supportingContent, + leadingContent = leadingContent, + trailingContent = trailingContent, + tonalElevation = tonalElevation, + shape = shape, + colors = colors, + scale = scale, + border = border, + glow = glow, + interactionSource = interactionSource, + ) +} else { + ListItem( + selected = selected, + onClick = onClick, + headlineContent = headlineContent, + modifier = modifier, + enabled = enabled, + onLongClick = onLongClick, + overlineContent = overlineContent, + supportingContent = supportingContent, + leadingContent = leadingContent, + trailingContent = trailingContent, + tonalElevation = tonalElevation, + shape = shape, + colors = colors, + scale = scale, + border = border, + glow = glow, + interactionSource = interactionSource, + ) +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/PlayButtons.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/PlayButtons.kt index ca4c11b8..4137759d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/PlayButtons.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/PlayButtons.kt @@ -26,6 +26,7 @@ import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.Refresh 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 @@ -42,6 +43,7 @@ import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.isSpecified import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign @@ -55,12 +57,15 @@ import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.Trailer import com.github.damontecres.wholphin.ui.FontAwesome import com.github.damontecres.wholphin.ui.PreviewTvSpec +import com.github.damontecres.wholphin.ui.data.ChooseVersionParams import com.github.damontecres.wholphin.ui.data.SortAndDirection import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.theme.PreviewInteractionSource import com.github.damontecres.wholphin.ui.theme.WholphinTheme +import com.github.damontecres.wholphin.ui.tryRequestFocus import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.SortOrder +import org.jellyfin.sdk.model.serializer.toUUIDOrNull import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds @@ -81,10 +86,23 @@ fun ExpandablePlayButtons( moreOnClick: () -> Unit, trailerOnClick: (Trailer) -> Unit, onConfirmDelete: () -> Unit, + chooseVersionParams: ChooseVersionParams?, buttonOnFocusChanged: (FocusState) -> Unit, modifier: Modifier = Modifier, ) { + val resources = LocalResources.current val firstFocus = remember { FocusRequester() } + val restartInteractionSource = remember { MutableInteractionSource() } + val restartButtonFocused by restartInteractionSource.collectIsFocusedAsState() + LaunchedEffect(resumePosition) { + // Reset focus if the restart button was focuses and it is going to disappear + if (restartButtonFocused && resumePosition == Duration.ZERO) { + firstFocus.tryRequestFocus() + } + } + + var chooseVersion by remember { mutableStateOf<DialogParams?>(null) } + LazyRow( horizontalArrangement = Arrangement.spacedBy(16.dp), contentPadding = PaddingValues(8.dp), @@ -93,19 +111,19 @@ fun ExpandablePlayButtons( .focusGroup() .focusRestorer(firstFocus), ) { + item("play") { + ExpandablePlayButton( + title = if (resumePosition > Duration.ZERO) R.string.resume else R.string.play, + resume = resumePosition, + icon = Icons.Default.PlayArrow, + onClick = playOnClick, + modifier = + Modifier + .onFocusChanged(buttonOnFocusChanged) + .focusRequester(firstFocus), + ) + } if (resumePosition > Duration.ZERO) { - item("play") { - ExpandablePlayButton( - title = R.string.resume, - resume = resumePosition, - icon = Icons.Default.PlayArrow, - onClick = playOnClick, - modifier = - Modifier - .onFocusChanged(buttonOnFocusChanged) - .focusRequester(firstFocus), - ) - } item("restart") { ExpandablePlayButton( title = R.string.restart, @@ -114,19 +132,31 @@ fun ExpandablePlayButtons( onClick = playOnClick, modifier = Modifier.onFocusChanged(buttonOnFocusChanged), mirrorIcon = true, + interactionSource = restartInteractionSource, ) } - } else { - item("play") { - ExpandablePlayButton( - title = R.string.play, - resume = Duration.ZERO, - icon = Icons.Default.PlayArrow, - onClick = playOnClick, - modifier = - Modifier - .onFocusChanged(buttonOnFocusChanged) - .focusRequester(firstFocus), + } + + if (chooseVersionParams != null && chooseVersionParams.mediaSources.size > 1) { + item { + ExpandableFaButton( + title = R.string.version, + iconStringRes = R.string.fa_file_video, + onClick = { + chooseVersion = + chooseVersionParams( + resources, + chooseVersionParams.mediaSources.orEmpty(), + chooseVersionParams.chosenStreams + ?.source + ?.id + ?.toUUIDOrNull(), + ) { idx -> + val source = chooseVersionParams.mediaSources[idx] + chooseVersionParams.onChooseVersion.invoke(source) + } + }, + modifier = Modifier.onFocusChanged(buttonOnFocusChanged), ) } } @@ -184,6 +214,16 @@ fun ExpandablePlayButtons( ) } } + chooseVersion?.let { params -> + DialogPopup( + showDialog = true, + title = params.title, + dialogItems = params.items, + onDismissRequest = { chooseVersion = null }, + dismissOnClick = true, + waitToLoad = params.fromLongClick, + ) + } } val MinButtonSize = 40.dp @@ -359,7 +399,7 @@ fun TrailerButton( } else if (trailers.size == 1) { R.string.play_trailer } else { - R.string.trailers + R.string.trailers_title }, iconStringRes = R.string.fa_film, enabled = trailers.isNotEmpty(), @@ -453,6 +493,7 @@ private fun ExpandablePlayButtonsPreview() { trailerOnClick = {}, canDelete = true, onConfirmDelete = {}, + chooseVersionParams = null, modifier = Modifier, ) } 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 d4931d77..902ce814 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/QuickDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/QuickDetails.kt @@ -1,116 +1,126 @@ 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.res.painterResource +import androidx.compose.ui.platform.LocalResources 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.getTimeFormatter +import com.github.damontecres.wholphin.ui.formatTime import com.github.damontecres.wholphin.ui.util.LocalClock +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: AnnotatedString, + details: QuickDetailsData?, timeRemaining: Duration?, modifier: Modifier = Modifier, textStyle: TextStyle = MaterialTheme.typography.titleSmall, + endsAt: DateTime? = null, + ratings: SourceRatings? = null, ) { - val inlineContentMap = - 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, - ) - }, - ) - } - + val enabled = LocalInterfaceCustomization.current.enabledDisplayToggles Row(modifier = modifier) { - Text( - text = details, - color = MaterialTheme.colorScheme.onSurface, - style = textStyle, - inlineContent = inlineContentMap, - maxLines = 1, - modifier = Modifier, - ) - timeRemaining?.let { TimeRemaining(it, textStyle = textStyle) } + if (details != null) { + QuickDetailsText(details.basic, Modifier, textStyle) + if (DisplayToggle.OFFICIAL_RATING in enabled) { + QuickDetailsText(details.officialRating, Modifier, textStyle) + } + SourceRatingsRow(ratings, Modifier, textStyle) + } + if (timeRemaining != null) { + TimeRemaining(timeRemaining, textStyle = textStyle) + } else if (endsAt != null) { + EndsAt(endsAt, textStyle = textStyle) + } } } +/** + * [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, +) = str?.let { + Text( + text = str, + color = MaterialTheme.colorScheme.onSurface, + style = textStyle, + maxLines = 1, + modifier = modifier, + ) +} + @Composable fun TimeRemaining( remaining: Duration, modifier: Modifier = Modifier, textStyle: TextStyle = MaterialTheme.typography.titleSmall, ) { - val context = LocalContext.current val now by LocalClock.current.now + EndsAt( + endsAt = now.plusSeconds(remaining.inWholeSeconds), + modifier = modifier, + textStyle = textStyle, + ) +} + +@Composable +fun EndsAt( + endsAt: DateTime, + modifier: Modifier = Modifier, + textStyle: TextStyle = MaterialTheme.typography.titleSmall, +) { + val resources = LocalResources.current + val context = LocalContext.current val remainingStr = - remember(remaining, now) { - val endTimeStr = getTimeFormatter().format(now.plusSeconds(remaining.inWholeSeconds)) + remember(endsAt, resources, context) { + val endTimeStr = formatTime(context, endsAt) buildAnnotatedString { dot() - append(context.getString(R.string.ends_at, endTimeStr)) + append(resources.getString(R.string.ends_at, endTimeStr)) } } Text( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/Rating.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/Rating.kt index c52fb52f..460eda05 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/Rating.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/Rating.kt @@ -1,93 +1,29 @@ package com.github.damontecres.wholphin.ui.components -import androidx.compose.foundation.background -import androidx.compose.foundation.focusGroup import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.selection.selectable -import androidx.compose.foundation.selection.selectableGroup -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Star import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.drawWithCache -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusProperties -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.tv.material3.Icon -import androidx.tv.material3.LocalContentColor import androidx.tv.material3.LocalTextStyle -import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text -import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.ui.AppColors -import com.github.damontecres.wholphin.ui.PreviewTvSpec import com.github.damontecres.wholphin.ui.isNotNullOrBlank -import com.github.damontecres.wholphin.ui.playOnClickSound -import com.github.damontecres.wholphin.ui.playSoundOnFocus -import com.github.damontecres.wholphin.ui.theme.WholphinTheme -import com.github.damontecres.wholphin.ui.tryRequestFocus -import java.util.Locale - -enum class StarRatingPrecision { - FULL, - HALF, - QUARTER, - ; - - companion object { - fun fromFloat(value: Float): StarRatingPrecision = - if (value <= .25f) { - QUARTER - } else if (value <= .5f) { - HALF - } else { - FULL - } - } -} val FilledStarColor = Color(0xFFFFC700) val EmptyStarColor = Color(0xFF5D4A0A) -val ratingBarHeight: Dp = 32.dp - -@Composable -fun SimpleStarRating( - communityRating: Float?, - modifier: Modifier = Modifier, -) = CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) { - SimpleStarRating( - text = communityRating?.let { String.format(Locale.getDefault(), "%.1f", it) }, - modifier = modifier, - ) -} - +/** + * A minimum rating such as "7+" with a star, for the rating filter menus. + * + * This is not for showing a title's rating, see [SourceRatingsRow] for that. + */ @Composable fun SimpleStarRating( text: String?, @@ -115,206 +51,3 @@ fun SimpleStarRating( } } } - -@Composable -fun TomatoRating( - rating: Float?, - modifier: Modifier = Modifier, - threshold: Float = 60f, -) { - Row( - horizontalArrangement = Arrangement.spacedBy(2.dp), - verticalAlignment = Alignment.CenterVertically, - modifier = modifier, - ) { - if (rating != null) { - CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) { - Text( - text = rating.toInt().toString() + "%", - maxLines = 1, - modifier = Modifier, - ) - } - val height = with(LocalDensity.current) { LocalTextStyle.current.fontSize.toDp() * .8f } - Icon( - painter = - painterResource( - if (rating >= threshold) { - R.drawable.ic_rotten_tomatoes_fresh - } else { - R.drawable.ic_rotten_tomatoes_rotten - }, - ), - contentDescription = null, - modifier = Modifier.height(height), - tint = Color.Unspecified, - ) - } - } -} - -/** - * Shows a rating out of 5 stars - */ -@Composable -fun StarRating( - rating100: Int, - onRatingChange: (Int) -> Unit, - precision: StarRatingPrecision, - enabled: Boolean, - playSoundOnFocus: Boolean, - modifier: Modifier = Modifier, - bgColor: Color = AppColors.TransparentBlack75, // MaterialTheme.colorScheme.background, -) { - val context = LocalContext.current - var tempRating by remember(rating100) { mutableIntStateOf(rating100) } - val percentage = (if (enabled) tempRating else rating100) / 100f - val focusRequesters = remember { List(5) { FocusRequester() } } - Box( - modifier = - modifier - .clip(RoundedCornerShape(16.dp)) - .background(bgColor), - ) { - LazyRow( - modifier = - Modifier - .selectableGroup() - .padding(4.dp) - .drawWithCache { - onDrawWithContent { - drawContent() - if (percentage in 0f..<1f) { - drawRect( - color = bgColor, - topLeft = Offset(size.width * percentage, 0f), - blendMode = BlendMode.SrcAtop, - ) - } - } - }.focusGroup() - .focusProperties { - onEnter = { - val index = - if (rating100 <= 20) { - 0 - } else if (rating100 <= 40) { - 1 - } else if (rating100 <= 60) { - 2 - } else if (rating100 <= 80) { - 3 - } else { - 4 - } - focusRequesters[index].tryRequestFocus() - } - }, - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceEvenly, - ) { - for (i in 1..5) { - item { - val isRated = (if (enabled) tempRating else rating100) >= (i * 20) - val icon = Icons.Filled.Star - var focused by remember { mutableStateOf(false) } - - val focusedColor = - if (focused) { - MaterialTheme.colorScheme.border - } else { - Color.Unspecified - } - Box( - modifier = - Modifier - .clip(RoundedCornerShape(16.dp)) - .background(focusedColor), - ) { - Icon( - imageVector = icon, - tint = FilledStarColor, - contentDescription = null, - modifier = - if (enabled) { - Modifier - .onFocusChanged { - focused = it.isFocused - if (it.isFocused) { - tempRating = i * 20 - } else { - tempRating = rating100 - } - }.playSoundOnFocus(playSoundOnFocus) - .focusRequester(focusRequesters[i - 1]) - .focusProperties { - left = - if (i == 1) focusRequesters.last() else FocusRequester.Default - right = - if (i == 5) focusRequesters.first() else FocusRequester.Default - }.selectable( - selected = isRated, - onClick = { - if (playSoundOnFocus) playOnClickSound(context) - val newRating100 = - when (precision) { - StarRatingPrecision.FULL -> { - if (i == 1 && rating100 > 0 && rating100 <= 20) 0 else i * 20 - } - - StarRatingPrecision.HALF -> { - if (rating100 > i * 20) { - i * 20 - } else if (rating100 == i * 20) { - i * 20 - 10 - } else if (rating100 == i * 20 - 10) { - (i - 1) * 20 - } else if (i == 1 && rating100 == 0) { - 20 - } else if (i == 1 && rating100 > 10) { - 10 - } else if (i == 1 && rating100 <= 10) { - 0 - } else { - (i) * 20 - } - } - - StarRatingPrecision.QUARTER -> { - // TODO - null - } - } - if (newRating100 != null) { - tempRating = newRating100 - onRatingChange(newRating100) - } - }, - ) - } else { - Modifier - }.fillMaxHeight() - .aspectRatio(1f), - ) - } - } - } - } - } -} - -@PreviewTvSpec -@Composable -private fun SimpleStarRatingPreview() { - WholphinTheme { - Column { - SimpleStarRating(7.5f, Modifier.height(32.dp)) - TomatoRating(75f) - SimpleStarRating(7.5f, Modifier.height(20.dp)) - SimpleStarRating("", Modifier.height(32.dp)) - TomatoRating(75f) - TomatoRating(40f) - TomatoRating(3f) - } - } -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedContent.kt index ad7010cf..ca4ae7ff 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedContent.kt @@ -1,11 +1,9 @@ package com.github.damontecres.wholphin.ui.components import android.content.Context -import androidx.annotation.StringRes import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -13,7 +11,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.R @@ -25,9 +22,12 @@ import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.MediaManagementService -import com.github.damontecres.wholphin.services.MediaReportService import com.github.damontecres.wholphin.services.MusicService import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.ServerReportService +import com.github.damontecres.wholphin.services.SuggestionService +import com.github.damontecres.wholphin.services.SuggestionsResource +import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.services.deleteItem import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel @@ -35,130 +35,315 @@ import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo import com.github.damontecres.wholphin.ui.data.RowColumn import com.github.damontecres.wholphin.ui.detail.PlaylistDialog -import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState import com.github.damontecres.wholphin.ui.detail.music.addToQueue import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.main.HomePageContent import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.rememberPosition +import com.github.damontecres.wholphin.ui.toBaseItems +import com.github.damontecres.wholphin.ui.util.ResStringProvider import com.github.damontecres.wholphin.util.ApiRequestPager +import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.HomeRowLoadingState import com.github.damontecres.wholphin.util.LoadingState +import com.github.damontecres.wholphin.util.RequestHandler +import com.github.damontecres.wholphin.util.WholphinDispatchers +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.Deferred -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.async +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.model.api.MediaType +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.request.GetItemsRequest +import timber.log.Timber import java.util.UUID -/** - * Abstract [ViewModel] for the "Recommended" tab for a library - */ -abstract class RecommendedViewModel( - @param:ApplicationContext val context: Context, - val api: ApiClient, - val serverRepository: ServerRepository, - val navigationManager: NavigationManager, - val favoriteWatchManager: FavoriteWatchManager, - val mediaReportService: MediaReportService, - private val musicService: MusicService, - private val backdropService: BackdropService, - private val mediaManagementService: MediaManagementService, -) : ViewModel() { - abstract fun init() - - abstract val rows: MutableStateFlow<List<HomeRowLoadingState>> - - val loading = MutableLiveData<LoadingState>(LoadingState.Loading) - - fun refreshItem( - position: RowColumn, - itemId: UUID, - ) { - viewModelScope.launchIO { - val row = rows.value.getOrNull(position.row) - if (row is HomeRowLoadingState.Success) { - (row.items as? ApiRequestPager<*>)?.refreshItem(position.column, itemId) - } - } - } - - fun setWatched( - position: RowColumn, - itemId: UUID, - watched: Boolean, - ) { - viewModelScope.launchIO { - favoriteWatchManager.setWatched(itemId, watched) - refreshItem(position, itemId) - } - } - - fun setFavorite( - position: RowColumn, - itemId: UUID, - watched: Boolean, - ) { - viewModelScope.launchIO { - favoriteWatchManager.setFavorite(itemId, watched) - refreshItem(position, itemId) - } - } - - fun updateBackdrop(item: BaseItem) { - viewModelScope.launchIO { - backdropService.submit(item) - } - } - - abstract fun update( - @StringRes title: Int, - row: HomeRowLoadingState, - ): HomeRowLoadingState - - fun update( - @StringRes title: Int, - viewOptions: HomeRowViewOptions = HomeRowViewOptions(), - block: suspend () -> List<BaseItem>, - ): Deferred<HomeRowLoadingState> = - viewModelScope.async(Dispatchers.IO) { - val titleStr = context.getString(title) - val row = - try { - HomeRowLoadingState.Success(titleStr, block.invoke(), viewOptions) - } catch (ex: Exception) { - HomeRowLoadingState.Error(titleStr, null, ex) - } - update(title, row) +@HiltViewModel(assistedFactory = RecommendedViewModel.Factory::class) +class RecommendedViewModel + @AssistedInject + constructor( + @param:ApplicationContext private val context: Context, + private val api: ApiClient, + val serverRepository: ServerRepository, + val navigationManager: NavigationManager, + private val userPreferencesService: UserPreferencesService, + private val favoriteWatchManager: FavoriteWatchManager, + private val musicService: MusicService, + private val backdropService: BackdropService, + private val mediaManagementService: MediaManagementService, + private val suggestionService: SuggestionService, + val serverReportService: ServerReportService, + @Assisted private val parentId: UUID, + @Assisted private val suggestionsType: BaseItemKind, + @Assisted private val recommendedRows: List<RecommendedRow<*>>, + @Assisted private val viewOptions: HomeRowViewOptions, + ) : ViewModel() { + @AssistedFactory + interface Factory { + fun create( + parentId: UUID, + suggestionsType: BaseItemKind, + recommendedRows: List<RecommendedRow<*>>, + viewOptions: HomeRowViewOptions, + ): RecommendedViewModel } - fun deleteItem( - position: RowColumn, - item: BaseItem, - ) { - deleteItem(context, mediaManagementService, item) { + private val _state = MutableStateFlow(RecommendedState()) + val state: StateFlow<RecommendedState> = _state + + fun init() { viewModelScope.launchDefault { - val row = rows.value.getOrNull(position.row) - if (row is HomeRowLoadingState.Success) { - (row.items as? ApiRequestPager<*>)?.refreshPagesAfter(position.column) + val limit = + userPreferencesService.flow + .first() + .appPreferences.homePagePreferences.maxItemsPerRow + _state.update { + it.copy( + loading = LoadingState.Loading, + rows = + recommendedRows.map { HomeRowLoadingState.Loading(ResStringProvider(it.title)) } + + listOf(HomeRowLoadingState.Loading(ResStringProvider(R.string.suggestions))), + ) } + val jobs = + recommendedRows.mapIndexed { index, row -> + val title = ResStringProvider(row.title) + viewModelScope.launchIO { + val result = + try { + val items = execute(row, limit) + HomeRowLoadingState.Success( + title, + items, + viewOptions, + showViewMore = items.size >= limit, + ) + } catch (ex: Exception) { + Timber.e(ex, "Exception fetching %s", title) + HomeRowLoadingState.Error(title, null, ex) + } + _state.update { + it.copy( + rows = + it.rows.toMutableList().apply { + set(index, result) + }, + ) + } + } + } + fetchSuggestions() + jobs.forEachIndexed { index, job -> + job.join() + val row = state.value.rows[index] + if (row is HomeRowLoadingState.Success && row.items.isNotEmpty()) { + // Report loading success once the first non-empty row is ready + _state.update { it.copy(loading = LoadingState.Success) } + } + } + } + } + + private suspend fun <T> execute( + row: RecommendedRow<T>, + limit: Int?, + ): List<BaseItem?> = + if (limit != null) { + val request = row.handler.prepare(row.request, 0, limit, false) + row.handler + .execute(api, request) + .toBaseItems(api, true) + } else { + ApiRequestPager( + api, + row.request, + row.handler, + viewModelScope, + useSeriesForPrimary = true, + ).init() + } + + private fun fetchSuggestions() { + viewModelScope.launch(WholphinDispatchers.IO) { + val limit = + userPreferencesService + .getCurrent() + .appPreferences.homePagePreferences.maxItemsPerRow + val title = ResStringProvider(R.string.suggestions) + try { + suggestionService + .getSuggestionsFlow(parentId, suggestionsType) + .collect { resource -> + val result = + when (resource) { + is SuggestionsResource.Loading -> { + HomeRowLoadingState.Loading(title) + } + + is SuggestionsResource.Success -> { + HomeRowLoadingState.Success( + title, + resource.items, + viewOptions, + showViewMore = resource.items.size >= limit, + ) + } + + is SuggestionsResource.Empty -> { + HomeRowLoadingState.Success( + title, + emptyList(), + viewOptions, + showViewMore = false, + ) + } + } + _state.update { + it.copy( + rows = + it.rows.toMutableList().apply { + set(lastIndex, result) + }, + ) + } + } + } catch (_: CancellationException) { + // no-op + } catch (ex: Exception) { + Timber.e(ex, "Failed to fetch suggestions") + _state.update { + it.copy( + rows = + it.rows.toMutableList().apply { + set(lastIndex, HomeRowLoadingState.Error(title, null, ex)) + }, + ) + } + } + } + } + + fun refreshItem( + position: RowColumn, + itemId: UUID, + ) { + viewModelScope.launchIO { + val row = state.value.rows.getOrNull(position.row) + if (row is HomeRowLoadingState.Success) { + (row.items as? ApiRequestPager<*>)?.refreshItem(position.column, itemId) + } + } + } + + fun setWatched( + position: RowColumn, + itemId: UUID, + watched: Boolean, + ) { + viewModelScope.launchIO { + favoriteWatchManager.setWatched(itemId, watched) + refreshItem(position, itemId) + } + } + + fun setFavorite( + position: RowColumn, + itemId: UUID, + watched: Boolean, + ) { + viewModelScope.launchIO { + favoriteWatchManager.setFavorite(itemId, watched) + refreshItem(position, itemId) + } + } + + fun updateBackdrop(item: BaseItem) { + viewModelScope.launchIO { + backdropService.submit(item) + } + } + + fun deleteItem( + position: RowColumn, + item: BaseItem, + ) { + deleteItem(context, mediaManagementService, item) { + viewModelScope.launchDefault { + val row = state.value.rows.getOrNull(position.row) + if (row is HomeRowLoadingState.Success) { + (row.items as? ApiRequestPager<*>)?.refreshPagesAfter(position.column) + } + } + } + } + + fun canDelete( + item: BaseItem, + appPreferences: AppPreferences, + ): Boolean = mediaManagementService.canDelete(item, appPreferences) + + fun addToQueue( + item: BaseItem, + index: Int, + ) = addToQueue(api, musicService, item, index) + + fun onClickViewMore( + position: RowColumn, + row: HomeRowLoadingState.Success, + ) { + if (position.row in recommendedRows.indices) { + val recommendedRow = recommendedRows[position.row] as RecommendedRow<Any> + navigationManager.navigateTo( + Destination.ItemGrid( + title = row.title, + request = recommendedRow.request, + requestHandler = recommendedRow.handler, + initialPosition = row.items.size, + viewOptions = + ViewOptions( + aspectRatio = viewOptions.aspectRatio, + ), + ), + ) + } else { + // Suggestions + navigationManager.navigateTo( + Destination.ItemGrid( + title = row.title, + request = + GetItemsRequest( + ids = row.items.mapNotNull { it?.id }, + ), + requestHandler = GetItemsRequestHandler, + initialPosition = row.items.size, + viewOptions = + ViewOptions( + aspectRatio = viewOptions.aspectRatio, + ), + ), + ) } } } - fun canDelete( - item: BaseItem, - appPreferences: AppPreferences, - ): Boolean = mediaManagementService.canDelete(item, appPreferences) +data class RecommendedState( + val loading: LoadingState = LoadingState.Pending, + val rows: List<HomeRowLoadingState> = emptyList(), +) - fun addToQueue( - item: BaseItem, - index: Int, - ) = addToQueue(api, musicService, item, index) -} +data class RecommendedRow<T>( + val title: Int, + val handler: RequestHandler<T>, + val request: T, +) @Composable fun RecommendedContent( @@ -171,17 +356,16 @@ fun RecommendedContent( var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) } var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) } var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) } - val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) + val playlistState by playlistViewModel.playlistState.collectAsState() OneTimeLaunchedEffect { viewModel.init() } - val loading by viewModel.loading.observeAsState(LoadingState.Loading) - val rows by viewModel.rows.collectAsState() + val state by viewModel.state.collectAsState() - when (val state = loading) { + when (val st = state.loading) { is LoadingState.Error -> { - ErrorMessage(state, modifier) + ErrorMessage(st, modifier) } LoadingState.Loading, @@ -203,10 +387,10 @@ fun RecommendedContent( viewModel.setFavorite(position, itemId, favorite) }, onClickAddPlaylist = { itemId -> - playlistViewModel.loadPlaylists(MediaType.VIDEO) + playlistViewModel.loadPlaylists() showPlaylistDialog.makePresent(itemId) }, - onSendMediaInfo = viewModel.mediaReportService::sendReportFor, + onSendMediaInfo = viewModel.serverReportService::sendMediaReportFor, onDeleteItem = { viewModel.deleteItem(position, it) }, onShowOverview = { overviewDialog = ItemDetailsDialogInfo(it) }, onChooseVersion = { _, _ -> @@ -222,7 +406,7 @@ fun RecommendedContent( } HomePageContent( - homeRows = rows, + homeRows = state.rows, position = position, onClickItem = { _, item -> viewModel.navigationManager.navigateTo(item.destination()) @@ -247,7 +431,7 @@ fun RecommendedContent( onFocusPosition = { position = it val nonEmptyRowBefore = - rows + state.rows .subList(0, it.row) .count { it is HomeRowLoadingState.Success && it.items.isEmpty() @@ -262,6 +446,8 @@ fun RecommendedContent( showClock = preferences.appPreferences.interfacePreferences.showClock, onUpdateBackdrop = viewModel::updateBackdrop, showLogo = preferences.appPreferences.interfacePreferences.showLogos, + showViewMore = true, + onClickViewMore = viewModel::onClickViewMore, modifier = modifier, ) } @@ -270,7 +456,7 @@ fun RecommendedContent( ItemDetailsDialog( info = info, showFilePath = - viewModel.serverRepository.currentUserDto.value + viewModel.serverRepository.currentUserDto ?.policy ?.isAdministrator == true, onDismissRequest = { overviewDialog = null }, @@ -298,12 +484,8 @@ fun RecommendedContent( playlistViewModel.createPlaylistAndAddItem(it, itemId) showPlaylistDialog.makeAbsent() }, + onSearch = playlistViewModel::loadPlaylists, elevation = 3.dp, ) } } - -data class RowColumnItem( - val position: RowColumn, - val item: BaseItem, -) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMovie.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMovie.kt index 71b762f0..febe0199 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMovie.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMovie.kt @@ -1,271 +1,93 @@ package com.github.damontecres.wholphin.ui.components -import android.content.Context -import androidx.annotation.StringRes import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.datastore.core.DataStore import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.data.ServerRepository -import com.github.damontecres.wholphin.preferences.AppPreference -import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.data.model.HomeRowViewOptions import com.github.damontecres.wholphin.preferences.UserPreferences -import com.github.damontecres.wholphin.services.BackdropService -import com.github.damontecres.wholphin.services.FavoriteWatchManager -import com.github.damontecres.wholphin.services.MediaManagementService -import com.github.damontecres.wholphin.services.MediaReportService -import com.github.damontecres.wholphin.services.MusicService -import com.github.damontecres.wholphin.services.NavigationManager -import com.github.damontecres.wholphin.services.SuggestionService -import com.github.damontecres.wholphin.services.SuggestionsResource import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.data.RowColumn -import com.github.damontecres.wholphin.ui.setValueOnMain -import com.github.damontecres.wholphin.ui.toBaseItems -import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.GetResumeItemsRequestHandler -import com.github.damontecres.wholphin.util.HomeRowLoadingState -import com.github.damontecres.wholphin.util.LoadingState -import dagger.assisted.Assisted -import dagger.assisted.AssistedFactory -import dagger.assisted.AssistedInject -import dagger.hilt.android.lifecycle.HiltViewModel -import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.Deferred -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.SortOrder import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest -import timber.log.Timber +import java.time.LocalDateTime import java.util.UUID -@HiltViewModel(assistedFactory = RecommendedMovieViewModel.Factory::class) -class RecommendedMovieViewModel - @AssistedInject - constructor( - @ApplicationContext context: Context, - api: ApiClient, - musicService: MusicService, - serverRepository: ServerRepository, - private val preferencesDataStore: DataStore<AppPreferences>, - private val suggestionService: SuggestionService, - @Assisted val parentId: UUID, - navigationManager: NavigationManager, - favoriteWatchManager: FavoriteWatchManager, - mediaReportService: MediaReportService, - backdropService: BackdropService, - mediaManagementService: MediaManagementService, - ) : RecommendedViewModel( - context, - api, - serverRepository, - navigationManager, - favoriteWatchManager, - mediaReportService, - musicService, - backdropService, - mediaManagementService, - ) { - @AssistedFactory - interface Factory { - fun create(parentId: UUID): RecommendedMovieViewModel - } - - override val rows = - MutableStateFlow<List<HomeRowLoadingState>>( - rowTitles.keys.map { - HomeRowLoadingState.Pending( - context.getString(it), - ) - }, - ) - - override fun init() { - viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) { - val itemsPerRow = - preferencesDataStore.data - .firstOrNull() - ?.homePagePreferences - ?.maxItemsPerRow - ?: AppPreference.HomePageItems.defaultValue.toInt() - try { - val resumeItemsRequest = - GetResumeItemsRequest( - parentId = parentId, - fields = SlimItemFields, - includeItemTypes = listOf(BaseItemKind.MOVIE), - enableUserData = true, - startIndex = 0, - limit = itemsPerRow, - enableTotalRecordCount = false, - ) - val resumeItems = - GetResumeItemsRequestHandler - .execute(api, resumeItemsRequest) - .toBaseItems(api, false) - update( - R.string.continue_watching, - HomeRowLoadingState.Success( - context.getString(R.string.continue_watching), - resumeItems, +private fun getRecommendedRows(parentId: UUID) = + listOf( + RecommendedRow( + title = R.string.continue_watching, + handler = GetResumeItemsRequestHandler, + request = + GetResumeItemsRequest( + parentId = parentId, + fields = SlimItemFields, + includeItemTypes = listOf(BaseItemKind.MOVIE), + enableUserData = true, + enableTotalRecordCount = false, + ), + ), + RecommendedRow( + title = R.string.recently_released, + handler = GetItemsRequestHandler, + request = + GetItemsRequest( + parentId = parentId, + fields = SlimItemFields, + includeItemTypes = listOf(BaseItemKind.MOVIE), + recursive = true, + enableUserData = true, + sortBy = + listOf( + ItemSortBy.PREMIERE_DATE, + ItemSortBy.SORT_NAME, ), - ) - - if (resumeItems.isNotEmpty()) { - loading.setValueOnMain(LoadingState.Success) - } - } catch (ex: Exception) { - Timber.e(ex, "Exception fetching movie recommendations") - withContext(Dispatchers.Main) { - loading.value = LoadingState.Error(ex) - } - } - - val jobs = mutableListOf<Deferred<HomeRowLoadingState>>() - - update(R.string.recently_released) { - val request = - GetItemsRequest( - parentId = parentId, - fields = SlimItemFields, - includeItemTypes = listOf(BaseItemKind.MOVIE), - recursive = true, - enableUserData = true, - sortBy = listOf(ItemSortBy.PREMIERE_DATE), - sortOrder = listOf(SortOrder.DESCENDING), - startIndex = 0, - limit = itemsPerRow, - enableTotalRecordCount = false, - ) - GetItemsRequestHandler.execute(api, request).toBaseItems(api, false) - }.also(jobs::add) - - update(R.string.recently_added) { - val request = - GetItemsRequest( - parentId = parentId, - fields = SlimItemFields, - includeItemTypes = listOf(BaseItemKind.MOVIE), - recursive = true, - enableUserData = true, - sortBy = listOf(ItemSortBy.DATE_CREATED), - sortOrder = listOf(SortOrder.DESCENDING), - startIndex = 0, - limit = itemsPerRow, - enableTotalRecordCount = false, - ) - GetItemsRequestHandler.execute(api, request).toBaseItems(api, false) - }.also(jobs::add) - - update(R.string.top_unwatched) { - val request = - GetItemsRequest( - parentId = parentId, - fields = SlimItemFields, - includeItemTypes = listOf(BaseItemKind.MOVIE), - recursive = true, - enableUserData = true, - isPlayed = false, - sortBy = listOf(ItemSortBy.COMMUNITY_RATING), - sortOrder = listOf(SortOrder.DESCENDING), - startIndex = 0, - limit = itemsPerRow, - enableTotalRecordCount = false, - ) - GetItemsRequestHandler.execute(api, request).toBaseItems(api, false) - }.also(jobs::add) - - viewModelScope.launch(Dispatchers.IO) { - try { - suggestionService - .getSuggestionsFlow(parentId, BaseItemKind.MOVIE) - .collect { resource -> - val state = - when (resource) { - is SuggestionsResource.Loading -> { - HomeRowLoadingState.Loading( - context.getString(R.string.suggestions), - ) - } - - is SuggestionsResource.Success -> { - HomeRowLoadingState.Success( - context.getString(R.string.suggestions), - resource.items, - ) - } - - is SuggestionsResource.Empty -> { - HomeRowLoadingState.Success( - context.getString(R.string.suggestions), - emptyList(), - ) - } - } - update(R.string.suggestions, state) - } - } catch (ex: CancellationException) { - throw ex - } catch (ex: Exception) { - Timber.e(ex, "Failed to fetch suggestions") - update( - R.string.suggestions, - HomeRowLoadingState.Error( - title = context.getString(R.string.suggestions), - exception = ex, - ), - ) - } - } - - // If the continue watching row is empty, then wait until the first successful row - // is loaded before telling the UI that the page is loaded - if (loading.value == LoadingState.Loading || loading.value == LoadingState.Pending) { - for (i in 0..<jobs.size) { - val result = jobs[i].await() - if (result.completed) { - Timber.v("First success") - loading.setValueOnMain(LoadingState.Success) - } - break - } - } - } - } - - override fun update( - @StringRes title: Int, - row: HomeRowLoadingState, - ): HomeRowLoadingState { - rows.update { current -> - current.toMutableList().apply { set(rowTitles[title]!!, row) } - } - return row - } - - companion object { - private val rowTitles = - listOf( - R.string.continue_watching, - R.string.recently_released, - R.string.recently_added, - R.string.suggestions, - R.string.top_unwatched, - ).mapIndexed { index, i -> i to index }.toMap() - } - } + sortOrder = + listOf( + SortOrder.DESCENDING, + SortOrder.ASCENDING, + ), + enableTotalRecordCount = false, + maxPremiereDate = LocalDateTime.now(), + ), + ), + RecommendedRow( + title = R.string.recently_added, + handler = GetItemsRequestHandler, + request = + GetItemsRequest( + parentId = parentId, + fields = SlimItemFields, + includeItemTypes = listOf(BaseItemKind.MOVIE), + recursive = true, + enableUserData = true, + sortBy = listOf(ItemSortBy.DATE_CREATED), + sortOrder = listOf(SortOrder.DESCENDING), + enableTotalRecordCount = false, + ), + ), + RecommendedRow( + title = R.string.top_unwatched, + handler = GetItemsRequestHandler, + request = + GetItemsRequest( + parentId = parentId, + fields = SlimItemFields, + includeItemTypes = listOf(BaseItemKind.MOVIE), + recursive = true, + enableUserData = true, + isPlayed = false, + sortBy = listOf(ItemSortBy.COMMUNITY_RATING), + sortOrder = listOf(SortOrder.DESCENDING), + enableTotalRecordCount = false, + ), + ), + ) /** * The "recommended" tab of a movie library @@ -276,9 +98,16 @@ fun RecommendedMovie( parentId: UUID, onFocusPosition: (RowColumn) -> Unit, modifier: Modifier = Modifier, - viewModel: RecommendedMovieViewModel = - hiltViewModel<RecommendedMovieViewModel, RecommendedMovieViewModel.Factory>( - creationCallback = { it.create(parentId) }, + viewModel: RecommendedViewModel = + hiltViewModel<RecommendedViewModel, RecommendedViewModel.Factory>( + creationCallback = { + it.create( + parentId = parentId, + suggestionsType = BaseItemKind.MOVIE, + recommendedRows = getRecommendedRows(parentId), + viewOptions = HomeRowViewOptions(), + ) + }, ), ) { RecommendedContent( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMusic.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMusic.kt index 30576e7b..73e9af44 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMusic.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMusic.kt @@ -1,237 +1,80 @@ package com.github.damontecres.wholphin.ui.components -import android.content.Context -import androidx.annotation.StringRes import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.datastore.core.DataStore import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.HomeRowViewOptions -import com.github.damontecres.wholphin.preferences.AppPreference -import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.UserPreferences -import com.github.damontecres.wholphin.services.BackdropService -import com.github.damontecres.wholphin.services.FavoriteWatchManager -import com.github.damontecres.wholphin.services.MediaManagementService -import com.github.damontecres.wholphin.services.MediaReportService -import com.github.damontecres.wholphin.services.MusicService -import com.github.damontecres.wholphin.services.NavigationManager -import com.github.damontecres.wholphin.services.SuggestionService -import com.github.damontecres.wholphin.services.SuggestionsResource import com.github.damontecres.wholphin.ui.AspectRatio import com.github.damontecres.wholphin.ui.Cards import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.data.RowColumn -import com.github.damontecres.wholphin.ui.setValueOnMain -import com.github.damontecres.wholphin.ui.toBaseItems -import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler -import com.github.damontecres.wholphin.util.HomeRowLoadingState -import com.github.damontecres.wholphin.util.LoadingState -import dagger.assisted.Assisted -import dagger.assisted.AssistedFactory -import dagger.assisted.AssistedInject -import dagger.hilt.android.lifecycle.HiltViewModel -import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.Deferred -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.SortOrder import org.jellyfin.sdk.model.api.request.GetItemsRequest -import timber.log.Timber +import java.time.LocalDateTime import java.util.UUID -@HiltViewModel(assistedFactory = RecommendedMusicViewModel.Factory::class) -class RecommendedMusicViewModel - @AssistedInject - constructor( - @ApplicationContext context: Context, - api: ApiClient, - musicService: MusicService, - serverRepository: ServerRepository, - private val preferencesDataStore: DataStore<AppPreferences>, - private val suggestionService: SuggestionService, - @Assisted val parentId: UUID, - navigationManager: NavigationManager, - favoriteWatchManager: FavoriteWatchManager, - mediaReportService: MediaReportService, - backdropService: BackdropService, - mediaManagementService: MediaManagementService, - ) : RecommendedViewModel( - context, - api, - serverRepository, - navigationManager, - favoriteWatchManager, - mediaReportService, - musicService, - backdropService, - mediaManagementService, - ) { - @AssistedFactory - interface Factory { - fun create(parentId: UUID): RecommendedMusicViewModel - } - - override val rows = - MutableStateFlow<List<HomeRowLoadingState>>( - rowTitles.keys.map { - HomeRowLoadingState.Pending( - context.getString(it), - ) - }, - ) - - override fun init() { - viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) { - val itemsPerRow = - preferencesDataStore.data - .firstOrNull() - ?.homePagePreferences - ?.maxItemsPerRow - ?: AppPreference.HomePageItems.defaultValue.toInt() - - val jobs = mutableListOf<Deferred<HomeRowLoadingState>>() - val viewOptions = - HomeRowViewOptions( - aspectRatio = AspectRatio.SQUARE, - heightDp = Cards.HEIGHT_EPISODE, - showTitles = true, - ) - - update(R.string.recently_released, viewOptions) { - val request = - GetItemsRequest( - parentId = parentId, - fields = SlimItemFields, - includeItemTypes = listOf(BaseItemKind.MUSIC_ALBUM), - recursive = true, - enableUserData = true, - sortBy = listOf(ItemSortBy.PREMIERE_DATE), - sortOrder = listOf(SortOrder.DESCENDING), - startIndex = 0, - limit = itemsPerRow, - enableTotalRecordCount = false, - ) - GetItemsRequestHandler.execute(api, request).toBaseItems(api, false) - }.also(jobs::add) - - update(R.string.recently_added, viewOptions) { - val request = - GetItemsRequest( - parentId = parentId, - fields = SlimItemFields, - includeItemTypes = listOf(BaseItemKind.MUSIC_ALBUM), - recursive = true, - enableUserData = true, - sortBy = listOf(ItemSortBy.DATE_CREATED), - sortOrder = listOf(SortOrder.DESCENDING), - startIndex = 0, - limit = itemsPerRow, - enableTotalRecordCount = false, - ) - GetItemsRequestHandler.execute(api, request).toBaseItems(api, false) - }.also(jobs::add) - - update(R.string.top_unwatched, viewOptions) { - val request = - GetItemsRequest( - parentId = parentId, - fields = SlimItemFields, - includeItemTypes = listOf(BaseItemKind.MUSIC_ALBUM), - recursive = true, - enableUserData = true, - isPlayed = false, - sortBy = listOf(ItemSortBy.COMMUNITY_RATING), - sortOrder = listOf(SortOrder.DESCENDING), - startIndex = 0, - limit = itemsPerRow, - enableTotalRecordCount = false, - ) - GetItemsRequestHandler.execute(api, request).toBaseItems(api, false) - }.also(jobs::add) - - viewModelScope.launch(Dispatchers.IO) { - try { - suggestionService - .getSuggestionsFlow(parentId, BaseItemKind.MUSIC_ALBUM) - .collect { resource -> - val state = - when (resource) { - is SuggestionsResource.Loading -> { - HomeRowLoadingState.Loading( - context.getString(R.string.suggestions), - ) - } - - is SuggestionsResource.Success -> { - HomeRowLoadingState.Success( - context.getString(R.string.suggestions), - resource.items, - ) - } - - is SuggestionsResource.Empty -> { - HomeRowLoadingState.Success( - context.getString(R.string.suggestions), - emptyList(), - ) - } - } - update(R.string.suggestions, state) - } - } catch (ex: Exception) { - Timber.e(ex, "Failed to fetch suggestions") - update( - R.string.suggestions, - HomeRowLoadingState.Error( - title = context.getString(R.string.suggestions), - exception = ex, - ), - ) - } - } - - for (i in 0..<jobs.size) { - val result = jobs[i].await() - if (result.completed) { - Timber.v("First success") - loading.setValueOnMain(LoadingState.Success) - } - break - } - } - } - - override fun update( - @StringRes title: Int, - row: HomeRowLoadingState, - ): HomeRowLoadingState { - rows.update { current -> - current.toMutableList().apply { set(rowTitles[title]!!, row) } - } - return row - } - - companion object { - private val rowTitles = - listOf( - R.string.recently_released, - R.string.recently_added, - R.string.suggestions, - R.string.top_unwatched, - ).mapIndexed { index, i -> i to index }.toMap() - } - } +private fun getRecommendedRows(parentId: UUID) = + listOf( + RecommendedRow( + title = R.string.recently_released, + handler = GetItemsRequestHandler, + request = + GetItemsRequest( + parentId = parentId, + fields = SlimItemFields, + includeItemTypes = listOf(BaseItemKind.MUSIC_ALBUM), + recursive = true, + enableUserData = true, + sortBy = + listOf( + ItemSortBy.PREMIERE_DATE, + ItemSortBy.SORT_NAME, + ), + sortOrder = + listOf( + SortOrder.DESCENDING, + SortOrder.ASCENDING, + ), + enableTotalRecordCount = false, + maxPremiereDate = LocalDateTime.now(), + ), + ), + RecommendedRow( + title = R.string.recently_added, + handler = GetItemsRequestHandler, + request = + GetItemsRequest( + parentId = parentId, + fields = SlimItemFields, + includeItemTypes = listOf(BaseItemKind.MUSIC_ALBUM), + recursive = true, + enableUserData = true, + sortBy = listOf(ItemSortBy.DATE_CREATED), + sortOrder = listOf(SortOrder.DESCENDING), + enableTotalRecordCount = false, + ), + ), + RecommendedRow( + title = R.string.top_rated, + handler = GetItemsRequestHandler, + request = + GetItemsRequest( + parentId = parentId, + fields = SlimItemFields, + includeItemTypes = listOf(BaseItemKind.MUSIC_ALBUM), + recursive = true, + enableUserData = true, + sortBy = listOf(ItemSortBy.COMMUNITY_RATING), + sortOrder = listOf(SortOrder.DESCENDING), + enableTotalRecordCount = false, + ), + ), + ) @Composable fun RecommendedMusic( @@ -239,9 +82,21 @@ fun RecommendedMusic( parentId: UUID, onFocusPosition: (RowColumn) -> Unit, modifier: Modifier = Modifier, - viewModel: RecommendedMusicViewModel = - hiltViewModel<RecommendedMusicViewModel, RecommendedMusicViewModel.Factory>( - creationCallback = { it.create(parentId) }, + viewModel: RecommendedViewModel = + hiltViewModel<RecommendedViewModel, RecommendedViewModel.Factory>( + creationCallback = { + it.create( + parentId = parentId, + suggestionsType = BaseItemKind.MUSIC_ALBUM, + recommendedRows = getRecommendedRows(parentId), + viewOptions = + HomeRowViewOptions( + aspectRatio = AspectRatio.SQUARE, + heightDp = Cards.HEIGHT_EPISODE, + showTitles = true, + ), + ) + }, ), ) { RecommendedContent( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt index ce50249e..78caf524 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt @@ -1,317 +1,113 @@ package com.github.damontecres.wholphin.ui.components -import android.content.Context -import androidx.annotation.StringRes import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.datastore.core.DataStore import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.data.ServerRepository -import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.data.model.HomeRowViewOptions import com.github.damontecres.wholphin.preferences.UserPreferences -import com.github.damontecres.wholphin.services.BackdropService -import com.github.damontecres.wholphin.services.FavoriteWatchManager -import com.github.damontecres.wholphin.services.LatestNextUpService -import com.github.damontecres.wholphin.services.MediaManagementService -import com.github.damontecres.wholphin.services.MediaReportService -import com.github.damontecres.wholphin.services.MusicService -import com.github.damontecres.wholphin.services.NavigationManager -import com.github.damontecres.wholphin.services.SuggestionService -import com.github.damontecres.wholphin.services.SuggestionsResource import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.data.RowColumn -import com.github.damontecres.wholphin.ui.setValueOnMain -import com.github.damontecres.wholphin.ui.toBaseItems -import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.GetNextUpRequestHandler import com.github.damontecres.wholphin.util.GetResumeItemsRequestHandler -import com.github.damontecres.wholphin.util.HomeRowLoadingState -import com.github.damontecres.wholphin.util.LoadingState -import dagger.assisted.Assisted -import dagger.assisted.AssistedFactory -import dagger.assisted.AssistedInject -import dagger.hilt.android.lifecycle.HiltViewModel -import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.Deferred -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.async -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.SortOrder import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.jellyfin.sdk.model.api.request.GetNextUpRequest import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest -import timber.log.Timber +import java.time.LocalDateTime import java.util.UUID -@HiltViewModel(assistedFactory = RecommendedTvShowViewModel.Factory::class) -class RecommendedTvShowViewModel - @AssistedInject - constructor( - @ApplicationContext context: Context, - api: ApiClient, - musicService: MusicService, - serverRepository: ServerRepository, - private val preferencesDataStore: DataStore<AppPreferences>, - private val lastestNextUpService: LatestNextUpService, - private val suggestionService: SuggestionService, - @Assisted val parentId: UUID, - navigationManager: NavigationManager, - favoriteWatchManager: FavoriteWatchManager, - mediaReportService: MediaReportService, - backdropService: BackdropService, - mediaManagementService: MediaManagementService, - ) : RecommendedViewModel( - context, - api, - serverRepository, - navigationManager, - favoriteWatchManager, - mediaReportService, - musicService, - backdropService, - mediaManagementService, - ) { - @AssistedFactory - interface Factory { - fun create(parentId: UUID): RecommendedTvShowViewModel - } - - override val rows = - MutableStateFlow<List<HomeRowLoadingState>>( - rowTitles.keys.map { - HomeRowLoadingState.Pending( - context.getString(it), - ) - }, - ) - - override fun init() { - viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) { - val preferences = - preferencesDataStore.data.firstOrNull() ?: AppPreferences.getDefaultInstance() - val combineNextUp = preferences.homePagePreferences.combineContinueNext - val itemsPerRow = preferences.homePagePreferences.maxItemsPerRow - val userId = serverRepository.currentUser.value?.id - try { - val resumeItemsDeferred = - async(Dispatchers.IO) { - val resumeItemsRequest = - GetResumeItemsRequest( - userId = userId, - parentId = parentId, - fields = SlimItemFields, - includeItemTypes = listOf(BaseItemKind.EPISODE), - enableUserData = true, - startIndex = 0, - limit = itemsPerRow, - enableTotalRecordCount = false, - ) - GetResumeItemsRequestHandler - .execute(api, resumeItemsRequest) - .toBaseItems(api, true) - } - - val nextUpItemsDeferred = - async(Dispatchers.IO) { - val nextUpRequest = - GetNextUpRequest( - userId = userId, - fields = SlimItemFields, - imageTypeLimit = 1, - parentId = parentId, - limit = itemsPerRow, - enableResumable = false, - enableUserData = true, - enableRewatching = preferences.homePagePreferences.enableRewatchingNextUp, - ) - GetNextUpRequestHandler - .execute(api, nextUpRequest) - .toBaseItems(api, true) - } - - val resumeItems = resumeItemsDeferred.await() - val nextUpItems = nextUpItemsDeferred.await() - - if (combineNextUp) { - val combined = lastestNextUpService.buildCombined(resumeItems, nextUpItems) - update( - R.string.continue_watching, - HomeRowLoadingState.Success( - context.getString(R.string.continue_watching), - combined, - ), - ) - update( - R.string.next_up, - HomeRowLoadingState.Success(context.getString(R.string.next_up), listOf()), - ) - } else { - update( - R.string.continue_watching, - HomeRowLoadingState.Success( - context.getString(R.string.continue_watching), - resumeItems, - ), - ) - update( - R.string.next_up, - HomeRowLoadingState.Success(context.getString(R.string.next_up), nextUpItems), - ) - } - - if (resumeItems.isNotEmpty() || nextUpItems.isNotEmpty()) { - loading.setValueOnMain(LoadingState.Success) - } - } catch (ex: Exception) { - Timber.e(ex, "Exception fetching tv recommendations") - withContext(Dispatchers.Main) { - loading.value = LoadingState.Error(ex) - } - } - - val jobs = mutableListOf<Deferred<HomeRowLoadingState>>() - - update(R.string.recently_released) { - val request = - GetItemsRequest( - parentId = parentId, - fields = SlimItemFields, - includeItemTypes = listOf(BaseItemKind.EPISODE), - recursive = true, - enableUserData = true, - sortBy = listOf(ItemSortBy.PREMIERE_DATE), - sortOrder = listOf(SortOrder.DESCENDING), - startIndex = 0, - limit = itemsPerRow, - enableTotalRecordCount = false, - ) - GetItemsRequestHandler.execute(api, request).toBaseItems(api, true) - }.also(jobs::add) - - update(R.string.recently_added) { - val request = - GetItemsRequest( - parentId = parentId, - fields = SlimItemFields, - includeItemTypes = listOf(BaseItemKind.EPISODE), - recursive = true, - enableUserData = true, - sortBy = listOf(ItemSortBy.DATE_CREATED), - sortOrder = listOf(SortOrder.DESCENDING), - startIndex = 0, - limit = itemsPerRow, - enableTotalRecordCount = false, - ) - GetItemsRequestHandler.execute(api, request).toBaseItems(api, true) - }.also(jobs::add) - - update(R.string.top_unwatched) { - val request = - GetItemsRequest( - parentId = parentId, - fields = SlimItemFields, - includeItemTypes = listOf(BaseItemKind.SERIES), - recursive = true, - enableUserData = true, - isPlayed = false, - sortBy = listOf(ItemSortBy.COMMUNITY_RATING), - sortOrder = listOf(SortOrder.DESCENDING), - startIndex = 0, - limit = itemsPerRow, - enableTotalRecordCount = false, - ) - GetItemsRequestHandler.execute(api, request).toBaseItems(api, true) - }.also(jobs::add) - - viewModelScope.launch(Dispatchers.IO) { - try { - suggestionService - .getSuggestionsFlow(parentId, BaseItemKind.SERIES) - .collect { resource -> - val state = - when (resource) { - is SuggestionsResource.Loading -> { - HomeRowLoadingState.Loading( - context.getString(R.string.suggestions), - ) - } - - is SuggestionsResource.Success -> { - HomeRowLoadingState.Success( - context.getString(R.string.suggestions), - resource.items, - ) - } - - is SuggestionsResource.Empty -> { - HomeRowLoadingState.Success( - context.getString(R.string.suggestions), - emptyList(), - ) - } - } - update(R.string.suggestions, state) - } - } catch (ex: CancellationException) { - throw ex - } catch (ex: Exception) { - Timber.e(ex, "Failed to fetch suggestions") - update( - R.string.suggestions, - HomeRowLoadingState.Error( - title = context.getString(R.string.suggestions), - exception = ex, - ), - ) - } - } - - if (loading.value == LoadingState.Loading || loading.value == LoadingState.Pending) { - for (i in 0..<jobs.size) { - val result = jobs[i].await() - if (result is HomeRowLoadingState.Success) { - Timber.v("First success") - loading.setValueOnMain(LoadingState.Success) - } - break - } - } - } - } - - override fun update( - @StringRes title: Int, - row: HomeRowLoadingState, - ): HomeRowLoadingState { - rows.update { current -> - current.toMutableList().apply { set(rowTitles[title]!!, row) } - } - return row - } - - companion object { - private val rowTitles = - listOf( - R.string.continue_watching, - R.string.next_up, - R.string.recently_released, - R.string.recently_added, - R.string.suggestions, - R.string.top_unwatched, - ).mapIndexed { index, i -> i to index }.toMap() - } - } +private fun getRecommendedRows( + parentId: UUID, + enableRewatching: Boolean, +) = listOf( + RecommendedRow( + title = R.string.continue_watching, + handler = GetResumeItemsRequestHandler, + request = + GetResumeItemsRequest( + parentId = parentId, + fields = SlimItemFields, + includeItemTypes = listOf(BaseItemKind.EPISODE), + enableUserData = true, + enableTotalRecordCount = false, + ), + ), + RecommendedRow( + title = R.string.next_up, + handler = GetNextUpRequestHandler, + request = + GetNextUpRequest( + fields = SlimItemFields, + imageTypeLimit = 1, + parentId = parentId, + enableResumable = false, + enableUserData = true, + enableRewatching = enableRewatching, + ), + ), + RecommendedRow( + title = R.string.recently_released, + handler = GetItemsRequestHandler, + request = + GetItemsRequest( + parentId = parentId, + fields = SlimItemFields, + includeItemTypes = listOf(BaseItemKind.EPISODE), + recursive = true, + enableUserData = true, + sortBy = + listOf( + ItemSortBy.PREMIERE_DATE, + ItemSortBy.SERIES_SORT_NAME, + ItemSortBy.AIRED_EPISODE_ORDER, + ), + sortOrder = + listOf( + SortOrder.DESCENDING, + SortOrder.ASCENDING, + SortOrder.DESCENDING, + ), + enableTotalRecordCount = false, + maxPremiereDate = LocalDateTime.now(), + isUnaired = false, + ), + ), + RecommendedRow( + title = R.string.recently_added, + handler = GetItemsRequestHandler, + request = + GetItemsRequest( + parentId = parentId, + fields = SlimItemFields, + includeItemTypes = listOf(BaseItemKind.EPISODE), + recursive = true, + enableUserData = true, + sortBy = listOf(ItemSortBy.DATE_CREATED), + sortOrder = listOf(SortOrder.DESCENDING), + enableTotalRecordCount = false, + ), + ), + RecommendedRow( + title = R.string.top_unwatched, + handler = GetItemsRequestHandler, + request = + GetItemsRequest( + parentId = parentId, + fields = SlimItemFields, + includeItemTypes = listOf(BaseItemKind.SERIES), + recursive = true, + enableUserData = true, + isPlayed = false, + sortBy = listOf(ItemSortBy.COMMUNITY_RATING), + sortOrder = listOf(SortOrder.DESCENDING), + enableTotalRecordCount = false, + ), + ), +) /** * The "recommended" tab of a TV show library @@ -322,9 +118,20 @@ fun RecommendedTvShow( parentId: UUID, onFocusPosition: (RowColumn) -> Unit, modifier: Modifier = Modifier, - viewModel: RecommendedTvShowViewModel = - hiltViewModel<RecommendedTvShowViewModel, RecommendedTvShowViewModel.Factory>( - creationCallback = { it.create(parentId) }, + viewModel: RecommendedViewModel = + hiltViewModel<RecommendedViewModel, RecommendedViewModel.Factory>( + creationCallback = { + it.create( + parentId = parentId, + suggestionsType = BaseItemKind.SERIES, + recommendedRows = + getRecommendedRows( + parentId, + preferences.appPreferences.homePagePreferences.enableRewatchingNextUp, + ), + viewOptions = HomeRowViewOptions(), + ) + }, ), ) { RecommendedContent( 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 f87f5c98..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 @@ -146,6 +146,8 @@ fun sliderActiveColor(focused: Boolean): Color { AppThemeColors.BLUE, AppThemeColors.GREEN, AppThemeColors.ORANGE, + AppThemeColors.RED, + AppThemeColors.BROWN, -> { MaterialTheme.colorScheme.border } @@ -188,6 +190,8 @@ fun sliderInactiveColor(focused: Boolean): Color { AppThemeColors.GREEN, AppThemeColors.ORANGE, AppThemeColors.BOLD_BLUE, + AppThemeColors.RED, + AppThemeColors.BROWN, -> { MaterialTheme.colorScheme.secondaryContainer.copy(alpha = .66f) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/SortByButton.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/SortByButton.kt index 405e0fb6..3e2555db 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/SortByButton.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/SortByButton.kt @@ -8,19 +8,18 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.withStyle import androidx.tv.material3.Text import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.filter.DiscoverSort +import com.github.damontecres.wholphin.data.filter.DiscoverSortAndDirection import com.github.damontecres.wholphin.ui.FontAwesome -import com.github.damontecres.wholphin.ui.PreviewTvSpec import com.github.damontecres.wholphin.ui.data.SortAndDirection import com.github.damontecres.wholphin.ui.data.flip import com.github.damontecres.wholphin.ui.data.getStringRes -import com.github.damontecres.wholphin.ui.theme.WholphinTheme import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.SortOrder @@ -37,10 +36,8 @@ fun SortByButton( modifier: Modifier = Modifier, ) { val currentSort = current.sort - val name = stringResource(getStringRes(currentSort)) val currentDirection = current.direction var sortByDropDown by remember { mutableStateOf(false) } - val context = LocalContext.current Box(modifier = modifier) { TextButton( @@ -50,22 +47,7 @@ fun SortByButton( }, ) { Text( - text = - buildAnnotatedString { - withStyle(SpanStyle(fontFamily = FontAwesome)) { - append( - stringResource( - if (currentDirection == SortOrder.ASCENDING) { - R.string.fa_caret_up - } else { - R.string.fa_caret_down - }, - ), - ) - } - append(" ") - append(name) - }, + text = current.toAnnotatedString(), ) } @@ -117,3 +99,112 @@ fun SortByButton( } } } + +@Composable +fun SortAndDirection.toAnnotatedString() = + buildAnnotatedString { + withStyle(SpanStyle(fontFamily = FontAwesome)) { + append( + stringResource( + if (direction == SortOrder.ASCENDING) { + R.string.fa_caret_up + } else { + R.string.fa_caret_down + }, + ), + ) + } + append(" ") + append(stringResource(getStringRes(sort))) + } + +// TODO code duplicated from SortByButton +@Composable +fun DiscoverSortByButton( + sortOptions: List<DiscoverSort>, + current: DiscoverSortAndDirection, + onSortChange: (DiscoverSortAndDirection) -> Unit, + modifier: Modifier = Modifier, +) { + val currentSort = current.sort + val currentDirection = current.direction + var sortByDropDown by remember { mutableStateOf(false) } + + Box(modifier = modifier) { + TextButton( + onClick = { sortByDropDown = true }, + onLongClick = { + onSortChange.invoke(current.flip()) + }, + ) { + Text( + text = current.toAnnotatedString(), + ) + } + + DropdownMenu( + expanded = sortByDropDown, + onDismissRequest = { sortByDropDown = false }, + ) { + sortOptions +// .sortedBy { it.name } + .forEach { sortOption -> + TvDropdownMenuItem( + leadingIcon = { + if (sortOption == currentSort) { + if (currentDirection == SortOrder.ASCENDING) { + Text( + text = stringResource(R.string.fa_caret_up), + fontFamily = FontAwesome, + ) + } else { + Text( + text = stringResource(R.string.fa_caret_down), + fontFamily = FontAwesome, + ) + } + } + }, + text = { + Text( + text = stringResource(sortOption.stringRes), + ) + }, + onClick = { + sortByDropDown = false + val newDirection = + if (currentSort == sortOption) { + currentDirection.flip() + } else { + currentDirection + } + onSortChange.invoke( + DiscoverSortAndDirection( + sortOption, + newDirection, + ), + ) + }, + ) + } + } + } +} + +@Composable +fun DiscoverSortAndDirection.toAnnotatedString() = + buildAnnotatedString { + withStyle(SpanStyle(fontFamily = FontAwesome)) { + append( + stringResource( + if (direction == SortOrder.ASCENDING) { + R.string.fa_caret_up + } else { + R.string.fa_caret_down + }, + ), + ) + } + append(" ") + append(stringResource(sort.stringRes)) + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/SourceRatingsRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/SourceRatingsRow.kt new file mode 100644 index 00000000..19567883 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/SourceRatingsRow.kt @@ -0,0 +1,152 @@ +package com.github.damontecres.wholphin.ui.components + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.foundation.text.appendInlineContent +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.ColorMatrix +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.Placeholder +import androidx.compose.ui.text.PlaceholderVerticalAlign +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.RatingBadge +import com.github.damontecres.wholphin.data.model.RatingEntry +import com.github.damontecres.wholphin.data.model.SourceRatings +import com.github.damontecres.wholphin.preferences.DisplayToggle +import com.github.damontecres.wholphin.services.SeerrRatingsService +import com.github.damontecres.wholphin.ui.dot +import com.github.damontecres.wholphin.ui.util.LocalInterfaceCustomization + +/** + * Provides the [SeerrRatingsService] to [rememberSourceRatings] + */ +val LocalSeerrRatingsService = + staticCompositionLocalOf<SeerrRatingsService> { throw IllegalStateException("LocalSeerrRatingsService not set") } + +/** + * The ratings for an item from the same sources as the PWA. This is the only data path for ratings in the UI, every + * screen that shows ratings calls this and draws the result with [SourceRatingsRow] (usually via [QuickDetails]). + * + * Which items have ratings is decided by [SeerrRatingsService]. Returns null while loading, or if there are none, and + * never the previous item's ratings. + */ +@Composable +fun rememberSourceRatings(item: BaseItem?): SourceRatings? { + val service = LocalSeerrRatingsService.current + val key = item?.id + var ratings by remember(key) { mutableStateOf(item?.let { service.cachedFor(it) }) } + LaunchedEffect(key) { + // Leaving the composition or changing the item cancels this collection, and so a Seerr request that is still waiting or running + service.forItem(item).collect { ratings = it } + } + return ratings +} + +@DrawableRes +fun RatingBadge.drawable(): Int = + when (this) { + RatingBadge.RT_FRESH -> R.drawable.ic_rotten_tomatoes_fresh + RatingBadge.RT_ROTTEN -> R.drawable.ic_rotten_tomatoes_rotten + RatingBadge.RT_UPRIGHT -> R.drawable.ic_rt_upright + RatingBadge.RT_SPILLED -> R.drawable.ic_rt_spilled + RatingBadge.RT_UNKNOWN -> R.drawable.ic_rotten_tomatoes_fresh + RatingBadge.IMDB -> R.drawable.ic_rating_imdb + RatingBadge.TMDB -> R.drawable.ic_rating_tmdb + } + +/** + * The badges for the ratings as inline text content. IMDb and TMDB are wordmarks, wider than tall like in the PWA. + */ +@Composable +private fun rememberBadges(textStyle: TextStyle): Map<String, InlineTextContent> = + remember(textStyle) { + val square = Placeholder(textStyle.fontSize, textStyle.fontSize, PlaceholderVerticalAlign.TextCenter) + val wide = Placeholder(textStyle.fontSize * 1.8f, textStyle.fontSize, PlaceholderVerticalAlign.TextCenter) + RatingBadge.entries.associate { badge -> + val muted = badge == RatingBadge.RT_UNKNOWN + val placeholder = if (badge == RatingBadge.IMDB || badge == RatingBadge.TMDB) wide else square + badge.name to + InlineTextContent(placeholder) { + Image( + painter = painterResource(badge.drawable()), + contentDescription = null, + contentScale = ContentScale.Fit, + alpha = if (muted) 0.55f else 1f, + colorFilter = if (muted) ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }) else null, + modifier = Modifier.fillMaxSize(), + ) + } + } + } + +/** + * The text for the ratings: each as a badge and its number, separated by dots + * + * @param leadingDot whether a dot is put in front of the first rating because something precedes it + * @param episodeLabel put in front of an episode's own score, "Ep" so it isn't mistaken for the series' score + */ +fun ratingsText( + entries: List<RatingEntry>, + leadingDot: Boolean, + episodeLabel: String, +): AnnotatedString = + buildAnnotatedString { + entries.forEachIndexed { index, entry -> + if (index > 0 || leadingDot) dot() + appendInlineContent(entry.badge.name) + append(" ") + if (entry.isEpisode) append("$episodeLabel ") + append(entry.text) + } + } + +/** + * The one way ratings are drawn: badge and number pairs from [SourceRatings] in the PWA's order, Rotten Tomatoes then IMDb or TMDB. + * + * Respects the community rating (IMDb/TMDB) and critic rating (Rotten Tomatoes) display settings. Draws nothing if + * there is nothing to show. + */ +@Composable +fun SourceRatingsRow( + ratings: SourceRatings?, + modifier: Modifier = Modifier, + textStyle: TextStyle = MaterialTheme.typography.titleSmall, + leadingDot: Boolean = true, +) { + if (ratings == null) return + val enabled = LocalInterfaceCustomization.current.enabledDisplayToggles + val community = DisplayToggle.COMMUNITY_RATING in enabled + val critic = DisplayToggle.CRITIC_RATING in enabled + val episodeLabel = stringResource(R.string.rating_episode_label) + val text = + remember(ratings, community, critic, leadingDot, episodeLabel) { + ratings.visibleEntries(community, critic).takeIf { it.isNotEmpty() }?.let { ratingsText(it, leadingDot, episodeLabel) } + } ?: return + val badges = rememberBadges(textStyle) + Text( + text = text, + color = MaterialTheme.colorScheme.onSurface, + style = textStyle, + inlineContent = badges, + maxLines = 1, + modifier = modifier, + ) +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/StudioCardGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/StudioCardGrid.kt index 92605dfc..b69e1126 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/StudioCardGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/StudioCardGrid.kt @@ -6,8 +6,8 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.Stable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester @@ -16,11 +16,11 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.times import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.alphabetSortName import com.github.damontecres.wholphin.data.model.createStudioDestination import com.github.damontecres.wholphin.services.ImageUrlService import com.github.damontecres.wholphin.services.NavigationManager @@ -29,23 +29,25 @@ import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.cards.StudioCard import com.github.damontecres.wholphin.ui.detail.CardGrid import com.github.damontecres.wholphin.ui.detail.CardGridItem -import com.github.damontecres.wholphin.ui.setValueOnMain +import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.GetStudiosRequestHandler -import com.github.damontecres.wholphin.util.LoadingExceptionHandler -import com.github.damontecres.wholphin.util.LoadingState +import com.github.damontecres.wholphin.util.WholphinDispatchers import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.api.request.GetStudiosRequest +import timber.log.Timber import java.util.UUID @HiltViewModel(assistedFactory = StudioViewModel.Factory::class) @@ -67,58 +69,70 @@ class StudioViewModel ): StudioViewModel } - val item = MutableLiveData<BaseItem?>(null) - val loading = MutableLiveData<LoadingState>(LoadingState.Pending) - val studios = MutableLiveData<List<Studio>>(listOf()) + private val _state = MutableStateFlow(StudioGridState()) + val state: StateFlow<StudioGridState> = _state fun init(cardWidthPx: Int) { - loading.value = LoadingState.Loading - viewModelScope.launch(Dispatchers.IO + LoadingExceptionHandler(loading, "Failed to fetch genres")) { - val item = - api.userLibraryApi.getItem(itemId = itemId).content.let { - BaseItem(it, false) - } - this@StudioViewModel.item.setValueOnMain(item) - val request = - GetStudiosRequest( - userId = serverRepository.currentUser.value?.id, - parentId = itemId, - fields = SlimItemFields, - includeItemTypes = includeItemTypes, - ) - val studios = - GetStudiosRequestHandler - .execute(api, request) - .content.items - .map { - val imageUrl = - imageUrlService.getItemImageUrl( - itemId = it.id, - imageType = ImageType.THUMB, - fillWidth = cardWidthPx, - ) - Studio(it.id, it.name ?: "", imageUrl) + _state.update { it.copy(item = DataLoadingState.Loading) } + viewModelScope.launchIO { + try { + val item = + api.userLibraryApi.getItem(itemId = itemId).content.let { + BaseItem(it, false) } - withContext(Dispatchers.Main) { - this@StudioViewModel.studios.value = studios - loading.value = LoadingState.Success + val request = + GetStudiosRequest( + userId = serverRepository.currentUser?.id, + parentId = itemId, + fields = SlimItemFields, + includeItemTypes = includeItemTypes, + ) + val studios = + GetStudiosRequestHandler + .execute(api, request) + .content.items + .map { + val imageUrl = + imageUrlService.getItemImageUrl( + itemId = it.id, + imageType = ImageType.THUMB, + fillWidth = cardWidthPx, + tag = it.imageTags?.get(ImageType.THUMB), + ) + Studio( + id = it.id, + name = it.name ?: "", + imageUrl = imageUrl, + sortName = it.alphabetSortName, + ) + } + _state.update { + it.copy( + item = DataLoadingState.Success(item), + studios = studios, + ) + } + } catch (ex: Exception) { + Timber.e(ex, "Error fetching studios") + _state.update { it.copy(item = DataLoadingState.Error(ex)) } } } } suspend fun positionOfLetter(letter: Char): Int = - withContext(Dispatchers.IO) { - val request = - GetStudiosRequest( - userId = serverRepository.currentUser.value?.id, - parentId = itemId, - nameLessThan = letter.toString(), - limit = 0, - enableTotalRecordCount = true, - includeItemTypes = includeItemTypes, - ) - val result by GetStudiosRequestHandler.execute(api, request) - return@withContext result.totalRecordCount + withContext(WholphinDispatchers.IO) { + GetStudiosRequestHandler.countMatching( + api = api, + request = + GetStudiosRequest( + userId = serverRepository.currentUser?.id, + parentId = itemId, + nameLessThan = letter.toString(), + limit = 0, + enableTotalRecordCount = true, + includeItemTypes = includeItemTypes, + ), + ) } } @@ -127,17 +141,23 @@ data class Studio( val id: UUID, val name: String, val imageUrl: String?, + override val sortName: String = name, ) : CardGridItem { override val gridId: String get() = id.toString() override val playable: Boolean = false - override val sortName: String get() = name } +data class StudioGridState( + val item: DataLoadingState<BaseItem> = DataLoadingState.Pending, + val studios: List<Studio> = emptyList(), +) + @Composable fun StudioCardGrid( itemId: UUID, includeItemTypes: List<BaseItemKind>?, modifier: Modifier = Modifier, + initialPosition: Int = 0, viewModel: StudioViewModel = hiltViewModel<StudioViewModel, StudioViewModel.Factory>( creationCallback = { it.create(itemId, includeItemTypes) }, @@ -148,7 +168,7 @@ fun StudioCardGrid( val density = LocalDensity.current val configuration = LocalConfiguration.current val cardWidthPx = - remember { + remember(density) { with(density) { // Grid has 16dp padding on either side & 16dp spacing between 4 cards // This isn't exact though because it doesn't account for nav drawer or letters, but it's close and the calculation is much faster @@ -161,34 +181,32 @@ fun StudioCardGrid( OneTimeLaunchedEffect { viewModel.init(cardWidthPx) } - val loading by viewModel.loading.observeAsState(LoadingState.Pending) - val studios by viewModel.studios.observeAsState(listOf()) + val state by viewModel.state.collectAsState() val gridFocusRequester = remember { FocusRequester() } - when (val st = loading) { - LoadingState.Pending, - LoadingState.Loading, + when (val st = state.item) { + DataLoadingState.Pending, + DataLoadingState.Loading, -> { LoadingPage(modifier.focusable()) } - is LoadingState.Error -> { + is DataLoadingState.Error -> { ErrorMessage(st, modifier.focusable()) } - LoadingState.Success -> { + is DataLoadingState.Success<BaseItem> -> { Box(modifier = modifier) { LaunchedEffect(Unit) { gridFocusRequester.tryRequestFocus() } - val item by viewModel.item.observeAsState(null) CardGrid( - pager = studios, + pager = state.studios, onClickItem = { _, studio -> viewModel.navigationManager.navigateTo( createStudioDestination( studioId = studio.id, name = studio.name, parentId = itemId, - parentName = item?.title, + parentName = st.data.title, includeItemTypes = includeItemTypes, ), ) @@ -200,7 +218,7 @@ fun StudioCardGrid( showJumpButtons = false, showLetterButtons = true, modifier = Modifier.fillMaxSize(), - initialPosition = 0, + initialPosition = initialPosition, positionCallback = { columns, position -> }, columns = columns, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/TabRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/TabRow.kt index f54fda5b..f2f60ad8 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/TabRow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/TabRow.kt @@ -14,8 +14,10 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -29,7 +31,6 @@ import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.onGloballyPositioned @@ -42,26 +43,27 @@ import androidx.tv.material3.Text import com.github.damontecres.wholphin.ui.PreviewTvSpec import com.github.damontecres.wholphin.ui.theme.WholphinTheme import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.ui.util.StringStringProvider import timber.log.Timber +import java.util.SortedMap @Composable fun TabRow( selectedTabIndex: Int, - tabs: List<String>, - focusRequesters: List<FocusRequester>, + tabs: List<TabDetails>, onClick: (Int) -> Unit, modifier: Modifier = Modifier, ) { val state = rememberLazyListState() LaunchedEffect(selectedTabIndex) { - if (selectedTabIndex >= 0) { + if (selectedTabIndex in tabs.indices) { state.animateScrollToItem(selectedTabIndex, -(state.layoutInfo.viewportSize.width / 3.5).toInt()) } } var rowHasFocus by remember { mutableStateOf(false) } val currentSelectedTabIndex by rememberUpdatedState(selectedTabIndex) - val currentFocusRequesters by rememberUpdatedState(focusRequesters) +// val currentFocusRequesters by rememberUpdatedState(focusRequesters) val currentOnClick by rememberUpdatedState(onClick) LazyRow( @@ -76,21 +78,21 @@ fun TabRow( // If entering from left or right, use last or first tab // Otherwise use the selected tab val index = currentSelectedTabIndex - val requesters = currentFocusRequesters +// val requesters = currentFocusRequesters Timber.v("onEnter requestedFocusDirection=$requestedFocusDirection, selectedTabIndex=$index") - val focusRequester = + val tab = if (requestedFocusDirection == FocusDirection.Left) { - requesters.lastOrNull() + tabs.lastOrNull() } else if (requestedFocusDirection == FocusDirection.Right) { - requesters.firstOrNull() + tabs.firstOrNull() } else { - requesters.getOrNull(index) + tabs.getOrNull(index) } - (focusRequester ?: FocusRequester.Default).tryRequestFocus() + (tab?.tabFocusRequester ?: FocusRequester.Default).tryRequestFocus() } }, ) { - itemsIndexed(tabs) { index, tabTitle -> + itemsIndexed(tabs) { index, tab -> val interactionSource = remember { MutableInteractionSource() } val onTabClick = remember(index) { @@ -99,17 +101,90 @@ fun TabRow( } } Tab( - title = tabTitle, + title = tab.title.getString(), selected = index == selectedTabIndex, rowActive = rowHasFocus, interactionSource = interactionSource, onClick = onTabClick, - modifier = Modifier.focusRequester(focusRequesters.getOrElse(index) { FocusRequester() }), + modifier = Modifier.focusRequester(tab.tabFocusRequester), ) } } } +@Composable +fun <T> KeyedTabRow( + selectedTabKey: T, + tabs: SortedMap<T, TabDetails>, + onClick: (T) -> Unit, + modifier: Modifier = Modifier, +) { + val state = rememberLazyListState() + LaunchedEffect(selectedTabKey) { + tabs[selectedTabKey]?.bringIntoViewRequester?.bringIntoView() + } + var rowHasFocus by remember { mutableStateOf(false) } + val currentSelectedTabKey by rememberUpdatedState(selectedTabKey) + val currentOnClick by rememberUpdatedState(onClick) + + val tabKeyList = remember(tabs) { tabs.keys.filterNotNull().toList() } + + LazyRow( + state = state, + modifier = + modifier + .onFocusChanged { + rowHasFocus = it.hasFocus + }.focusGroup() + .focusProperties { + onEnter = { + // If entering from left or right, use last or first tab + // Otherwise use the selected tab + val currentKey = currentSelectedTabKey +// val requesters = currentFocusRequesters + Timber.v( + "onEnter requestedFocusDirection=%s, currentSelectedTabKey=%s", + requestedFocusDirection, + currentSelectedTabKey, + ) + val key = + if (requestedFocusDirection == FocusDirection.Left) { + tabs.lastKey() + } else if (requestedFocusDirection == FocusDirection.Right) { + tabs.firstKey() + } else { + currentKey + } + val tab = tabs[key] + (tab?.tabFocusRequester ?: FocusRequester.Default).tryRequestFocus() + } + }, + ) { + items(tabKeyList, key = { it }) { tabKey -> + tabs[tabKey]?.let { tab -> + val interactionSource = remember { MutableInteractionSource() } + val onTabClick = + remember(tabKey) { + { + currentOnClick(tabKey) + } + } + Tab( + title = tab.title.getString(), + selected = tabKey == selectedTabKey, + rowActive = rowHasFocus, + interactionSource = interactionSource, + onClick = onTabClick, + modifier = + Modifier + .focusRequester(tab.tabFocusRequester) + .bringIntoViewRequester(tab.bringIntoViewRequester), + ) + } + } + } +} + @Composable fun Tab( title: String, @@ -195,9 +270,11 @@ private fun TabRowPreview() { WholphinTheme { Column(modifier = Modifier.background(MaterialTheme.colorScheme.background)) { TabRow( - selectedTabIndex = 1, - tabs = listOf("Tab 1", "Tab 2", "Tab 3"), - focusRequesters = listOf(), + selectedTabIndex = -1, + tabs = + remember { + listOf("Tab 1", "Tab 2", "Tab 3").map { TabDetails(StringStringProvider(it)) } + }, onClick = {}, ) Tab( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/TabbedPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/TabbedPage.kt new file mode 100644 index 00000000..0749c068 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/TabbedPage.kt @@ -0,0 +1,241 @@ +package com.github.damontecres.wholphin.ui.components + +import androidx.annotation.StringRes +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.services.BackdropService +import com.github.damontecres.wholphin.services.RememberedTabService +import com.github.damontecres.wholphin.services.UserPreferencesService +import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.ui.util.ResStringProvider +import com.github.damontecres.wholphin.ui.util.StringProvider +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import timber.log.Timber +import java.util.SortedMap + +@HiltViewModel(assistedFactory = TabViewModel.Factory::class) +class TabViewModel + @AssistedInject + constructor( + val userPreferencesService: UserPreferencesService, + private val rememberedTabService: RememberedTabService, + private val backdropService: BackdropService, + @param:Assisted private val itemId: String, + @param:Assisted private val tabCount: Int, + ) : ViewModel() { + @AssistedFactory + interface Factory { + fun create( + itemId: String, + tabCount: Int, + ): TabViewModel + } + + private val _state = MutableStateFlow<Int>(UNSET) + val state: StateFlow<Int> = _state + + val isShowClock = + userPreferencesService.flow.map { it.appPreferences.interfacePreferences.showClock } + + init { + viewModelScope.launchIO { + val startingTab = + if (shouldRememberTabs()) { + Timber.v("Getting remembered tab for %s", itemId) + try { + rememberedTabService + .getRememberedTab(itemId) + ?.coerceIn(0..<tabCount) + } catch (ex: Exception) { + Timber.e(ex, "Error getting tab for %s", itemId) + null + } ?: 0 + } else { + 0 + } + _state.value = startingTab + } + } + + private suspend fun shouldRememberTabs(): Boolean = + userPreferencesService + .getCurrent() + .appPreferences.interfacePreferences.rememberSelectedTab + + fun updateSelectedTabIndex(tabIndex: Int) { + _state.value = tabIndex + viewModelScope.launchIO { + backdropService.clearBackdrop() + if (shouldRememberTabs()) { + Timber.v("Saving remembered tab for %s: %s", itemId, tabIndex) + rememberedTabService.saveRememberedTab(itemId, tabIndex) + } + } + } + } + +data class TabDetails( + val title: StringProvider, + val tabFocusRequester: FocusRequester = FocusRequester(), + val contentFocusRequester: FocusRequester = FocusRequester(), + val bringIntoViewRequester: BringIntoViewRequester = BringIntoViewRequester(), +) { + constructor( + @StringRes stringResId: Int, + ) : this(ResStringProvider(stringResId)) +} + +const val UNSET = -1234 + +/** + * A page showing multiple tabs + * + * This handles remembering the selected tabs via [TabViewModel] if needed + */ +@Composable +fun TabbedPage( + itemId: String, + tabs: List<TabDetails>, + modifier: Modifier = Modifier, + showTabs: Boolean = true, + viewModel: TabViewModel = + hiltViewModel<TabViewModel, TabViewModel.Factory>( + key = "$itemId-${tabs.size}", + creationCallback = { it.create(itemId, tabs.size) }, + ), + tabContent: @Composable (Int, TabDetails) -> Unit, +) { + val selectedTabIndex by viewModel.state.collectAsState() + val isShowClock by viewModel.isShowClock.collectAsState(true) + TabbedPage( + selectedTabIndex = selectedTabIndex, + tabs = tabs, + updateSelectedTabIndex = viewModel::updateSelectedTabIndex, + isShowClock = isShowClock, + modifier = modifier, + showTabs = showTabs, + tabContent = tabContent, + ) +} + +@Composable +fun TabbedPage( + selectedTabIndex: Int, + tabs: List<TabDetails>, + updateSelectedTabIndex: (Int) -> Unit, + isShowClock: Boolean, + modifier: Modifier = Modifier, + showTabs: Boolean = true, + tabContent: @Composable (Int, TabDetails) -> Unit, +) { + val endPadding = + remember(isShowClock) { + if (isShowClock) { + 184.dp + } else { + 0.dp + } + } + Column( + modifier = modifier, + ) { + AnimatedVisibility( + showTabs, + enter = expandVertically(), + exit = shrinkVertically(), + ) { + TabRow( + selectedTabIndex = selectedTabIndex, + modifier = + Modifier + .padding(top = 16.dp, bottom = 16.dp, end = endPadding), + tabs = tabs, + onClick = updateSelectedTabIndex, + ) + } + selectedTabIndex.let { tabIndex -> + if (tabIndex in tabs.indices) { + tabContent.invoke(tabIndex, tabs[tabIndex]) + } else { + DelayedLoadingPage(focusEnabled = false) + } + } + } +} + +@Composable +fun <T> KeyedTabbedPage( + selectedTabKey: T, + tabs: SortedMap<T, TabDetails>, + updateSelectedTabKey: (T) -> Unit, + isShowClock: Boolean, + modifier: Modifier = Modifier, + showTabs: Boolean = true, + tabContent: @Composable (T, TabDetails) -> Unit, +) { + val endPadding = + remember(isShowClock) { + if (isShowClock) { + 184.dp + } else { + 0.dp + } + } + Column( + modifier = modifier, + ) { + AnimatedVisibility( + showTabs, + enter = expandVertically(), + exit = shrinkVertically(), + ) { + KeyedTabRow( + selectedTabKey = selectedTabKey, + modifier = + Modifier + .padding(top = 16.dp, bottom = 16.dp, end = endPadding), + tabs = tabs, + onClick = updateSelectedTabKey, + ) + } + AnimatedContent( + targetState = selectedTabKey, + transitionSpec = { + (fadeIn(animationSpec = tween(300, delayMillis = 100))) + .togetherWith(fadeOut(animationSpec = tween(200))) + }, + ) { tabKey -> + val tab = tabs[tabKey] + if (tab != null) { + tabContent.invoke(tabKey, tab) + } else { + DelayedLoadingPage(focusEnabled = false) + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/TitleOrLogo.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/TitleOrLogo.kt index 5af10b8b..4268f2d9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/TitleOrLogo.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/TitleOrLogo.kt @@ -1,6 +1,7 @@ package com.github.damontecres.wholphin.ui.components import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.widthIn @@ -9,6 +10,7 @@ import androidx.compose.runtime.getValue 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.layout.ContentScale import androidx.compose.ui.text.font.FontWeight @@ -19,6 +21,7 @@ import androidx.tv.material3.Text import coil3.compose.AsyncImage import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.ui.LocalImageUrlService +import com.github.damontecres.wholphin.ui.logCoilError import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.ImageType @@ -38,6 +41,10 @@ fun TitleOrLogo( model = logoImageUrl, contentDescription = title, contentScale = ContentScale.Fit, + onError = { + logCoilError(logoImageUrl, it.result) + imageError = true + }, modifier = Modifier .height(HeaderUtils.logoHeight) @@ -84,8 +91,15 @@ fun TitleOrLogo( fun rememberLogoUrl(item: BaseItem?): String? { val imageUrlService = LocalImageUrlService.current return remember(item?.id) { - if (item?.type == BaseItemKind.EPISODE && item.data.seriesId != null && item.data.parentLogoImageTag != null) { - imageUrlService.getItemImageUrl(item.data.seriesId!!, ImageType.LOGO) + if ((item?.type == BaseItemKind.EPISODE || item?.type == BaseItemKind.SEASON) && + item.data.seriesId != null && + item.data.parentLogoImageTag != null + ) { + imageUrlService.getItemImageUrl( + itemId = item.data.seriesId!!, + imageType = ImageType.LOGO, + tag = item.data.parentLogoImageTag.takeIf { item.data.parentLogoItemId == item.data.seriesId }, + ) } else if (ImageType.LOGO in item?.data?.imageTags.orEmpty()) { imageUrlService.getItemImageUrl(item, ImageType.LOGO) } else { @@ -93,3 +107,32 @@ fun rememberLogoUrl(item: BaseItem?): String? { } } } + +@Composable +fun SimpleTitleOrLogo( + item: BaseItem?, + showLogo: Boolean, + modifier: Modifier = Modifier, +) { + val logoImageUrl = rememberLogoUrl(item) + var imageError by remember { mutableStateOf(false) } + Box( + contentAlignment = Alignment.Center, + modifier = modifier, + ) { + if (showLogo && logoImageUrl != null && !imageError) { + AsyncImage( + model = logoImageUrl, + contentDescription = item?.title, + contentScale = ContentScale.Fit, + onError = { + logCoilError(logoImageUrl, it.result) + imageError = true + }, + modifier = Modifier.fillMaxSize(), + ) + } else { + Title(item?.title, Modifier) + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/VideoStreamDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/VideoStreamDetails.kt index 7805a95a..94842ef5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/VideoStreamDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/VideoStreamDetails.kt @@ -3,7 +3,9 @@ package com.github.damontecres.wholphin.ui.components import androidx.annotation.StringRes import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.RoundedCornerShape @@ -15,7 +17,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight @@ -52,8 +54,8 @@ fun VideoStreamDetails( ) = VideoStreamDetails( chosenStreams?.source, chosenStreams?.videoStream, - chosenStreams?.audioStream, - chosenStreams?.subtitleStream, + chosenStreams?.audioStream?.stream, + chosenStreams?.subtitleStream?.stream, numberOfVersions, modifier, ) @@ -67,9 +69,9 @@ fun VideoStreamDetails( numberOfVersions: Int = 0, modifier: Modifier = Modifier, ) { - val context = LocalContext.current + val resources = LocalResources.current Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), modifier = modifier, ) { val video = @@ -88,7 +90,13 @@ fun VideoStreamDetails( } else { null } - val range = formatVideoRange(context, it.videoRange, it.videoRangeType, it.videoDoViTitle) + val range = + formatVideoRange( + resources, + it.videoRange, + it.videoRangeType, + it.videoDoViTitle, + ) resName.concatWithSpace(range) } } @@ -106,11 +114,11 @@ fun VideoStreamDetails( val audio = remember(audioCount, audioStream) { if (audioCount == 0 || audioStream == null) { - context.getString(R.string.none) + resources.getString(R.string.none) } else { listOfNotNull( languageName(audioStream.language), - formatAudioCodec(context, audioStream.codec, audioStream.profile), + formatAudioCodec(resources, audioStream.codec, audioStream.profile), audioStream.channelLayout, ).joinToString(" ") } @@ -131,7 +139,7 @@ fun VideoStreamDetails( remember(subtitleCount, subtitleStream) { if (subtitleCount > 0 && subtitleStream == null) { disabled = true - context.getString(R.string.disabled) + resources.getString(R.string.none) } else if (subtitleCount == 0 || subtitleStream == null) { null } else { @@ -188,20 +196,21 @@ fun StreamLabel( modifier = Modifier, ) } + val string = + remember(text, disabled, count) { + val countToUse = if (disabled) count else count - 1 + if (countToUse > 0) { + "$text (+$countToUse)" + } else { + text + } + } Text( - text = text, + text = string, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier, ) - val countToUse = if (disabled) count else count - 1 - if (countToUse > 0) { - Text( - text = "(+$countToUse)", - maxLines = 1, - modifier = Modifier, - ) - } } } } @@ -210,21 +219,38 @@ fun StreamLabel( @Composable private fun StreamLabelPreview() { WholphinTheme(appThemeColors = AppThemeColors.PURPLE) { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = - Modifier - .background(MaterialTheme.colorScheme.background) - .padding(8.dp), + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), ) { - StreamLabel("1080p") - StreamLabel("HDR") - StreamLabel("H264") - StreamLabel("AC3 5.1", icon = R.string.fa_volume_high, count = 2) + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = + Modifier + .background(MaterialTheme.colorScheme.background) + .padding(8.dp), + ) { + StreamLabel("1080p") + StreamLabel("HDR") + StreamLabel("H264") + StreamLabel("AC3 5.1", icon = R.string.fa_volume_high, count = 2) - StreamLabel("PGS", count = 1) - StreamLabel("PGS", count = 1, disabled = true) - StreamLabel("PGS", count = 2) + StreamLabel("PGS", count = 1) + StreamLabel("PGS", count = 1, disabled = true) + StreamLabel("PGS", count = 2) + } + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = + Modifier + .background(MaterialTheme.colorScheme.background) + .padding(8.dp) + .fillMaxWidth(.7f), + ) { + StreamLabel("1080p") + StreamLabel("HDR") + StreamLabel("English Dolby Atmos 5.1", icon = R.string.fa_volume_high, count = 2) + StreamLabel("English SDH SRT", icon = R.string.fa_closed_captioning, count = 35) + } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ViewOptionsDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ViewOptionsDialog.kt index 74c00328..df923a55 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ViewOptionsDialog.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ViewOptionsDialog.kt @@ -4,8 +4,11 @@ import android.view.Gravity import androidx.compose.foundation.background import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items @@ -19,6 +22,7 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties @@ -37,12 +41,13 @@ import com.github.damontecres.wholphin.ui.AspectRatio import com.github.damontecres.wholphin.ui.preferences.ComposablePreference import com.github.damontecres.wholphin.ui.tryRequestFocus import kotlinx.serialization.Serializable +import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.ImageType /** * A dialog that shows the controls for making changes to a [ViewOptions] object. The caller must manage the state of the [ViewOptions]. * - * It displays the [AppPreference] objects from [ViewOptions.OPTIONS] + * It displays the [AppPreference] objects from [ViewOptions.GRID_OPTIONS] */ @Composable fun ViewOptionsDialog( @@ -50,6 +55,7 @@ fun ViewOptionsDialog( onDismissRequest: () -> Unit, onViewOptionsChange: (ViewOptions) -> Unit, defaultViewOptions: ViewOptions = ViewOptions(), + showArtistSource: Boolean = false, ) { val focusRequester = remember { FocusRequester() } LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } @@ -66,50 +72,71 @@ fun ViewOptionsDialog( window.setGravity(Gravity.END) window.setDimAmount(0f) } - LazyColumn( - state = columnState, - contentPadding = PaddingValues(16.dp), + val options = + remember(viewOptions.type, showArtistSource) { + when (viewOptions.type) { + ViewOptionsType.GRID -> ViewOptions.buildGridOptions(showArtistSource) + ViewOptionsType.LIST -> ViewOptions.buildListOptions(showArtistSource) + ViewOptionsType.DENSE_LIST -> ViewOptions.buildListOptions(showArtistSource) + } + } + Column( verticalArrangement = Arrangement.spacedBy(0.dp), modifier = Modifier .width(256.dp) .heightIn(max = 380.dp) - .focusRequester(focusRequester) .background( MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp), shape = RoundedCornerShape(8.dp), ), ) { - stickyHeader { - Text( - text = stringResource(R.string.view_options), - style = MaterialTheme.typography.titleMedium, - ) - } - items(ViewOptions.OPTIONS) { pref -> - pref as AppPreference<ViewOptions, Any> - val interactionSource = remember { MutableInteractionSource() } - val value = pref.getter.invoke(viewOptions) - ComposablePreference( - preference = pref, - value = value, - onNavigate = {}, - onValueChange = { newValue -> - onViewOptionsChange.invoke(pref.setter(viewOptions, newValue)) - }, - interactionSource = interactionSource, - modifier = Modifier, - onClickPreference = { pref -> - if (pref == ViewOptions.ViewOptionsReset) { - onViewOptionsChange.invoke(defaultViewOptions) - } - }, - ) + Text( + text = stringResource(R.string.view_options), + style = MaterialTheme.typography.titleMedium, + textAlign = TextAlign.Center, + modifier = + Modifier + .padding(vertical = 8.dp) + .fillMaxWidth(), + ) + LazyColumn( + state = columnState, + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(0.dp), + modifier = Modifier.focusRequester(focusRequester), + ) { + items(options, key = { it.title }) { pref -> + pref as AppPreference<ViewOptions, Any> + val interactionSource = remember { MutableInteractionSource() } + val value = pref.getter.invoke(viewOptions) + ComposablePreference( + preference = pref, + value = value, + onNavigate = {}, + onValueChange = { newValue -> + onViewOptionsChange.invoke(pref.setter(viewOptions, newValue)) + }, + interactionSource = interactionSource, + modifier = Modifier, + onClickPreference = { pref -> + if (pref == ViewOptions.ViewOptionsReset) { + onViewOptionsChange.invoke(defaultViewOptions) + } + }, + ) + } } } } } +/** Which Jellyfin artists list backs the Music > Artists tab */ +enum class ArtistSource { + ARTIST, + ALBUM_ARTIST, +} + /** * Stores the customizable view changes from the user * @@ -122,8 +149,11 @@ data class ViewOptions( val contentScale: PrefContentScale = PrefContentScale.FIT, val aspectRatio: AspectRatio = AspectRatio.TALL, val showDetails: Boolean = false, + val showBackdrop: Boolean = false, val imageType: ViewOptionImageType = ViewOptionImageType.PRIMARY, val showTitles: Boolean = true, + val type: ViewOptionsType = ViewOptionsType.GRID, + val extras: ViewOptionsExtras = ViewOptionsExtras.None, ) { companion object { val ViewOptionsColumns = @@ -176,6 +206,13 @@ data class ViewOptions( getter = { it.showDetails }, setter = { vo, value -> vo.copy(showDetails = value) }, ) + val ViewOptionsBackdrop = + AppSwitchPreference<ViewOptions>( + title = R.string.show_backdrop, + defaultValue = false, + getter = { it.showBackdrop }, + setter = { vo, value -> vo.copy(showBackdrop = value) }, + ) val ViewOptionsShowTitles = AppSwitchPreference<ViewOptions>( title = R.string.show_titles, @@ -202,22 +239,83 @@ data class ViewOptions( valueToIndex = { it.ordinal }, ) + val ViewOptionsTypePref = + AppChoicePreference<ViewOptions, ViewOptionsType>( + title = R.string.layout, + defaultValue = ViewOptionsType.GRID, + displayValues = R.array.view_options_types, + getter = { it.type }, + setter = { viewOptions, value -> + val spacing = + when (value) { + ViewOptionsType.GRID -> 16 + ViewOptionsType.LIST -> 4 + ViewOptionsType.DENSE_LIST -> 2 + } + viewOptions.copy(type = value, spacing = spacing) + }, + indexToValue = { ViewOptionsType.entries[it] }, + valueToIndex = { it.ordinal }, + ) + + val ViewOptionsArtistSource = + AppChoicePreference<ViewOptions, ArtistSource>( + title = R.string.artist_source, + defaultValue = ArtistSource.ARTIST, + displayValues = R.array.artist_sources, + getter = { it.artistSource }, + setter = { viewOptions, value -> + viewOptions.copy(extras = ViewOptionsExtras.MusicLibrary(value)) + }, + indexToValue = { ArtistSource.entries[it] }, + valueToIndex = { it.ordinal }, + ) + val ViewOptionsReset = AppClickablePreference<ViewOptions>( title = R.string.reset, ) - val OPTIONS = + /** + * Get the options for a grid [ViewOptions] + */ + fun buildGridOptions(showArtistSource: Boolean) = + buildList { + if (showArtistSource) add(ViewOptionsArtistSource) + addAll(GRID_OPTIONS) + } + + private val GRID_OPTIONS = listOf( + ViewOptionsTypePref, ViewOptionsImageType, ViewOptionsAspectRatio, ViewOptionsDetailHeader, + ViewOptionsBackdrop, ViewOptionsShowTitles, ViewOptionsColumns, ViewOptionsSpacing, ViewOptionsContentScale, ViewOptionsReset, ) + + /** + * Get the options for a list [ViewOptions] + */ + fun buildListOptions(showArtistSource: Boolean) = + buildList { + if (showArtistSource) add(ViewOptionsArtistSource) + addAll(LIST_OPTIONS) + } + + private val LIST_OPTIONS = + listOf( + ViewOptionsTypePref, + ViewOptionsDetailHeader, + ViewOptionsBackdrop, + ViewOptionsSpacing, + ViewOptionsReset, + ) } } @@ -249,3 +347,52 @@ enum class ViewOptionImageType( THUMB(ImageType.THUMB), // BANNER(ImageType.BANNER), } + +enum class ViewOptionsType { + GRID, + LIST, + DENSE_LIST, +} + +val BaseItemKind.defaultViewOptions: ViewOptions + get() = + when (this) { + BaseItemKind.AUDIO, + BaseItemKind.MUSIC_ALBUM, + BaseItemKind.MUSIC_ARTIST, + BaseItemKind.PLAYLIST, + -> ViewOptionsSquare + + BaseItemKind.EPISODE, + BaseItemKind.VIDEO, + BaseItemKind.MUSIC_VIDEO, + BaseItemKind.PHOTO, + BaseItemKind.PHOTO_ALBUM, + BaseItemKind.TV_CHANNEL, + BaseItemKind.LIVE_TV_CHANNEL, + BaseItemKind.PROGRAM, + BaseItemKind.TV_PROGRAM, + BaseItemKind.LIVE_TV_PROGRAM, + -> ViewOptionsWide + + else -> ViewOptionsPoster + } + +/** + * Extras [ViewOptions] info that is context or library specific + */ +@Serializable +sealed interface ViewOptionsExtras { + @Serializable + data object None : ViewOptionsExtras + + @Serializable + data class MusicLibrary( + val artistSource: ArtistSource = ArtistSource.ARTIST, + ) : ViewOptionsExtras +} + +/** + * The [ArtistSource] if the [ViewOptions.extras] is a [ViewOptionsExtras.MusicLibrary] + */ +val ViewOptions.artistSource: ArtistSource get() = (extras as? ViewOptionsExtras.MusicLibrary)?.artistSource ?: ArtistSource.ARTIST diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/VoiceInputManager.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/VoiceInputManager.kt index d2d6fa32..57874b0c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/VoiceInputManager.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/VoiceInputManager.kt @@ -17,10 +17,10 @@ import android.speech.RecognizerIntent import android.speech.SpeechRecognizer import androidx.core.content.ContextCompat import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.util.WholphinDispatchers import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.android.asCoroutineDispatcher @@ -175,7 +175,7 @@ class VoiceInputManager private fun provideMainDispatcher(): CoroutineDispatcher = try { - Dispatchers.Main.immediate + WholphinDispatchers.Main.immediate } catch (_: IllegalStateException) { // Fallback for unit tests where Main dispatcher is not installed handler.asCoroutineDispatcher() diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/data/AddPlaylistViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/data/AddPlaylistViewModel.kt index 5c4721c5..4dc1dd16 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/data/AddPlaylistViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/data/AddPlaylistViewModel.kt @@ -2,19 +2,22 @@ package com.github.damontecres.wholphin.ui.data import android.content.Context import android.widget.Toast -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.services.MusicService import com.github.damontecres.wholphin.services.PlaylistCreator import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState +import com.github.damontecres.wholphin.ui.detail.music.addToQueue +import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO -import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.util.ExceptionHandler import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext -import org.jellyfin.sdk.model.api.MediaType +import kotlinx.coroutines.flow.MutableStateFlow +import org.jellyfin.sdk.api.client.ApiClient import timber.log.Timber import java.util.UUID import javax.inject.Inject @@ -28,18 +31,21 @@ class AddPlaylistViewModel @Inject constructor( @param:ApplicationContext private val context: Context, + private val api: ApiClient, private val playlistCreator: PlaylistCreator, + private val musicService: MusicService, ) : ViewModel() { - val playlistState = MutableLiveData<PlaylistLoadingState>(PlaylistLoadingState.Pending) + val playlistState = MutableStateFlow<PlaylistLoadingState>(PlaylistLoadingState.Pending) - fun loadPlaylists(mediaType: MediaType?) { + fun loadPlaylists(query: String = "") { viewModelScope.launchIO { - this@AddPlaylistViewModel.playlistState.setValueOnMain(PlaylistLoadingState.Loading) + this@AddPlaylistViewModel.playlistState.value = PlaylistLoadingState.Loading try { - val playlists = playlistCreator.getServerPlaylists(mediaType, viewModelScope) - this@AddPlaylistViewModel.playlistState.setValueOnMain(PlaylistLoadingState.Success(playlists)) + val playlists = playlistCreator.getServerPlaylists(query, null) + this@AddPlaylistViewModel.playlistState.value = + PlaylistLoadingState.Success(playlists, query) } catch (ex: Exception) { - playlistState.setValueOnMain(PlaylistLoadingState.Error(ex)) + playlistState.value = PlaylistLoadingState.Error(ex) } } } @@ -72,4 +78,10 @@ class AddPlaylistViewModel } } } + + fun addToQueue(item: BaseItem) { + viewModelScope.launchDefault { + addToQueue(api, musicService, item, -1) + } + } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/data/ChooseVersionParams.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/data/ChooseVersionParams.kt new file mode 100644 index 00000000..397a2126 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/data/ChooseVersionParams.kt @@ -0,0 +1,13 @@ +package com.github.damontecres.wholphin.ui.data + +import com.github.damontecres.wholphin.data.ChosenStreams +import org.jellyfin.sdk.model.api.MediaSourceInfo + +/** + * Necessary details & action for choosing a version of some content + */ +data class ChooseVersionParams( + val chosenStreams: ChosenStreams?, + val mediaSources: List<MediaSourceInfo>, + val onChooseVersion: (MediaSourceInfo) -> Unit, +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/data/ItemDetailsDialogInfo.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/data/ItemDetailsDialogInfo.kt index 67e4cc3e..08bd0c55 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/data/ItemDetailsDialogInfo.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/data/ItemDetailsDialogInfo.kt @@ -1,6 +1,6 @@ package com.github.damontecres.wholphin.ui.data -import android.content.Context +import android.content.res.Resources import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -13,7 +13,7 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.tv.material3.MaterialTheme @@ -25,6 +25,7 @@ import com.github.damontecres.wholphin.ui.components.ScrollableDialog import com.github.damontecres.wholphin.ui.formatBitrate import com.github.damontecres.wholphin.ui.formatBytes import com.github.damontecres.wholphin.ui.formatDate +import com.github.damontecres.wholphin.ui.formatDuration import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.letNotEmpty import com.github.damontecres.wholphin.ui.util.StreamFormatting.formatAudioCodec @@ -66,7 +67,7 @@ fun ItemDetailsDialog( showFilePath: Boolean, onDismissRequest: () -> Unit, ) { - val context = LocalContext.current + val resources = LocalResources.current // Extract stringResource calls outside of ScrollableDialog's non-composable lambda val pathLabel = stringResource(R.string.path) val fileSizeLabel = stringResource(R.string.file_size) @@ -153,7 +154,7 @@ fun ItemDetailsDialog( ) } source.runTimeTicks?.let { - add(runtimeLabel to it.ticks.toString()) + add(runtimeLabel to resources.formatDuration(it.ticks)) } }, ) @@ -164,8 +165,8 @@ fun ItemDetailsDialog( itemsIndexed(videoStreams) { index, stream -> MediaInfoSection( title = titleIndex(videoLabel, index, videoStreams.size), - items = remember { buildVideoStreamInfo(context, stream) }, - additional = remember { buildVideoStreamInfoAdditional(context, stream) }, + items = remember { buildVideoStreamInfo(resources, stream) }, + additional = remember { buildVideoStreamInfoAdditional(resources, stream) }, ) } @@ -184,7 +185,7 @@ fun ItemDetailsDialog( groupIndex * 3 + index, audioStreams.size, ), - items = buildAudioStreamInfo(context, stream), + items = buildAudioStreamInfo(resources, stream), modifier = Modifier.weight(1f), ) } @@ -210,7 +211,7 @@ fun ItemDetailsDialog( groupIndex * 3 + index, subtitleStreams.size, ), - items = buildSubtitleStreamInfo(context, stream, showFilePath), + items = buildSubtitleStreamInfo(resources, stream, showFilePath), modifier = Modifier.weight(1f), ) } @@ -301,25 +302,25 @@ fun titleIndex( } private fun buildVideoStreamInfo( - context: Context, + resources: Resources, stream: MediaStream, ): List<Pair<String, String>> = buildList { - val yesStr = context.getString(R.string.yes) - val noStr = context.getString(R.string.no) + val yesStr = resources.getString(R.string.yes) + val noStr = resources.getString(R.string.no) - val titleLabel = context.getString(R.string.title) - val codecLabel = context.getString(R.string.codec) - val resolutionLabel = context.getString(R.string.resolution) - val aspectRatioLabel = context.getString(R.string.aspect_ratio) - val framerateLabel = context.getString(R.string.framerate) - val bitrateLabel = context.getString(R.string.bitrate) - val profileLabel = context.getString(R.string.profile) - val levelLabel = context.getString(R.string.level) - val interlacedLabel = context.getString(R.string.interlaced) - val videoRangeLabel = context.getString(R.string.video_range) - val sdrStr = context.getString(R.string.sdr) - val hdrStr = context.getString(R.string.hdr) + val titleLabel = resources.getString(R.string.title) + val codecLabel = resources.getString(R.string.codec) + val resolutionLabel = resources.getString(R.string.resolution) + val aspectRatioLabel = resources.getString(R.string.aspect_ratio) + val framerateLabel = resources.getString(R.string.framerate) + val bitrateLabel = resources.getString(R.string.bitrate) + val profileLabel = resources.getString(R.string.profile) + val levelLabel = resources.getString(R.string.level) + val interlacedLabel = resources.getString(R.string.interlaced) + val videoRangeLabel = resources.getString(R.string.video_range) + val sdrStr = resources.getString(R.string.sdr) + val hdrStr = resources.getString(R.string.hdr) stream.title?.let { add(titleLabel to it) } stream.codec?.let { add(codecLabel to it.uppercase()) } @@ -350,31 +351,31 @@ private fun buildVideoStreamInfo( } private fun buildVideoStreamInfoAdditional( - context: Context, + resources: Resources, stream: MediaStream, ): List<Pair<String, String>> = buildList { - val yesStr = context.getString(R.string.yes) - val noStr = context.getString(R.string.no) + val yesStr = resources.getString(R.string.yes) + val noStr = resources.getString(R.string.no) - val avcLabel = context.getString(R.string.avc) - val anamorphicLabel = context.getString(R.string.anamorphic) - val bitDepthLabel = context.getString(R.string.bit_depth) + val avcLabel = resources.getString(R.string.avc) + val anamorphicLabel = resources.getString(R.string.anamorphic) + val bitDepthLabel = resources.getString(R.string.bit_depth) - val videoRangeTypeLabel = context.getString(R.string.video_range_type) - val colorSpaceLabel = context.getString(R.string.color_space) - val colorTransferLabel = context.getString(R.string.color_transfer) - val colorPrimariesLabel = context.getString(R.string.color_primaries) - val pixelFormatLabel = context.getString(R.string.pixel_format) - val refFramesLabel = context.getString(R.string.ref_frames) - val nalLabel = context.getString(R.string.nal) - val dolbyVisionLabel = context.getString(R.string.dolby_vision) + val videoRangeTypeLabel = resources.getString(R.string.video_range_type) + val colorSpaceLabel = resources.getString(R.string.color_space) + val colorTransferLabel = resources.getString(R.string.color_transfer) + val colorPrimariesLabel = resources.getString(R.string.color_primaries) + val pixelFormatLabel = resources.getString(R.string.pixel_format) + val refFramesLabel = resources.getString(R.string.ref_frames) + val nalLabel = resources.getString(R.string.nal) + val dolbyVisionLabel = resources.getString(R.string.dolby_vision) - val sdrStr = context.getString(R.string.sdr) - val hdr10Str = context.getString(R.string.hdr10) - val hdr10PlusStr = context.getString(R.string.hdr10_plus) - val hlgStr = context.getString(R.string.hlg) - val bitUnit = context.getString(R.string.bit_unit) + val sdrStr = resources.getString(R.string.sdr) + val hdr10Str = resources.getString(R.string.hdr10) + val hdr10PlusStr = resources.getString(R.string.hdr10_plus) + val hlgStr = resources.getString(R.string.hlg) + val bitUnit = resources.getString(R.string.bit_unit) stream.isAvc?.let { add(avcLabel to if (it) yesStr else noStr) } stream.isAnamorphic?.let { add(anamorphicLabel to if (it) yesStr else noStr) } @@ -394,7 +395,7 @@ private fun buildVideoStreamInfoAdditional( VideoRangeType.DOVI_WITH_HDR10, VideoRangeType.DOVI_WITH_HLG, VideoRangeType.DOVI_WITH_SDR, - -> context.getString(R.string.dolby_vision) + -> resources.getString(R.string.dolby_vision) VideoRangeType.UNKNOWN -> null @@ -412,27 +413,27 @@ private fun buildVideoStreamInfoAdditional( } private fun buildAudioStreamInfo( - context: Context, + resources: android.content.res.Resources, stream: MediaStream, ): List<Pair<String, String>> = buildList { - val titleLabel = context.getString(R.string.title) - val languageLabel = context.getString(R.string.language) - val codecLabel = context.getString(R.string.codec) - val layoutLabel = context.getString(R.string.layout) - val channelsLabel = context.getString(R.string.channels) - val bitrateLabel = context.getString(R.string.bitrate) - val sampleRateLabel = context.getString(R.string.sample_rate) - val defaultLabel = context.getString(R.string.default_track) - val profileLabel = context.getString(R.string.profile) - val yesStr = context.getString(R.string.yes) - val noStr = context.getString(R.string.no) - val sampleRateUnit = context.getString(R.string.sample_rate_unit) + val titleLabel = resources.getString(R.string.title) + val languageLabel = resources.getString(R.string.language) + val codecLabel = resources.getString(R.string.codec) + val layoutLabel = resources.getString(R.string.layout) + val channelsLabel = resources.getString(R.string.channels) + val bitrateLabel = resources.getString(R.string.bitrate) + val sampleRateLabel = resources.getString(R.string.sample_rate) + val defaultLabel = resources.getString(R.string.default_track) + val profileLabel = resources.getString(R.string.profile) + val yesStr = resources.getString(R.string.yes) + val noStr = resources.getString(R.string.no) + val sampleRateUnit = resources.getString(R.string.sample_rate_unit) stream.title?.let { add(titleLabel to it) } stream.language?.let { add(languageLabel to languageName(it)) } stream.codec?.let { - val formattedCodec = formatAudioCodec(context, it, stream.profile) + " ($it)" + val formattedCodec = formatAudioCodec(resources, it, stream.profile) + " ($it)" add(codecLabel to formattedCodec) } stream.channelLayout?.let { add(layoutLabel to it) } @@ -444,20 +445,20 @@ private fun buildAudioStreamInfo( } private fun buildSubtitleStreamInfo( - context: Context, + resources: Resources, stream: MediaStream, showPath: Boolean, ): List<Pair<String, String>> = buildList { - val titleLabel = context.getString(R.string.title) - val languageLabel = context.getString(R.string.language) - val codecLabel = context.getString(R.string.codec) - val defaultLabel = context.getString(R.string.default_track) - val forcedLabel = context.getString(R.string.forced_track) - val externalLabel = context.getString(R.string.external_track) - val yesStr = context.getString(R.string.yes) - val noStr = context.getString(R.string.no) - val pathLabel = context.getString(R.string.path) + val titleLabel = resources.getString(R.string.title) + val languageLabel = resources.getString(R.string.language) + val codecLabel = resources.getString(R.string.codec) + val defaultLabel = resources.getString(R.string.default_track) + val forcedLabel = resources.getString(R.string.forced_track) + val externalLabel = resources.getString(R.string.external_track) + val yesStr = resources.getString(R.string.yes) + val noStr = resources.getString(R.string.no) + val pathLabel = resources.getString(R.string.path) stream.title?.let { add(titleLabel to it) } stream.language?.let { add(languageLabel to languageName(it)) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/data/SortAndDirection.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/data/SortAndDirection.kt index ee4b8b26..36b4e5c7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/data/SortAndDirection.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/data/SortAndDirection.kt @@ -1,8 +1,12 @@ package com.github.damontecres.wholphin.ui.data import androidx.annotation.StringRes +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import com.github.damontecres.wholphin.R import kotlinx.serialization.Serializable +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.CollectionType import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.SortOrder @@ -44,8 +48,9 @@ val SeriesSortOptions = ItemSortBy.PREMIERE_DATE, ItemSortBy.DATE_CREATED, ItemSortBy.DATE_LAST_CONTENT_ADDED, - ItemSortBy.DATE_PLAYED, + ItemSortBy.SERIES_DATE_PLAYED, ItemSortBy.COMMUNITY_RATING, + ItemSortBy.CRITIC_RATING, ItemSortBy.OFFICIAL_RATING, ItemSortBy.RANDOM, ) @@ -53,25 +58,19 @@ val SeriesSortOptions = val EpisodeSortOptions = listOf( ItemSortBy.SORT_NAME, + ItemSortBy.PREMIERE_DATE, ItemSortBy.DATE_CREATED, ItemSortBy.DATE_PLAYED, ItemSortBy.AIRED_EPISODE_ORDER, + ItemSortBy.COMMUNITY_RATING, + ItemSortBy.CRITIC_RATING, ItemSortBy.OFFICIAL_RATING, ItemSortBy.RUNTIME, - ItemSortBy.RANDOM, - ) - -val VideoSortOptions = - listOf( - ItemSortBy.SORT_NAME, - ItemSortBy.DATE_CREATED, - ItemSortBy.DATE_PLAYED, - ItemSortBy.RUNTIME, ItemSortBy.PLAY_COUNT, ItemSortBy.RANDOM, ) -val PlaylistSortOptions = +val VideoSortOptions = listOf( ItemSortBy.SORT_NAME, ItemSortBy.PREMIERE_DATE, @@ -79,6 +78,22 @@ val PlaylistSortOptions = ItemSortBy.DATE_PLAYED, ItemSortBy.COMMUNITY_RATING, ItemSortBy.CRITIC_RATING, + ItemSortBy.OFFICIAL_RATING, + ItemSortBy.RUNTIME, + ItemSortBy.PLAY_COUNT, + ItemSortBy.RANDOM, + ) + +val PlaylistSortOptions = + listOf( + ItemSortBy.DEFAULT, + ItemSortBy.SORT_NAME, + ItemSortBy.PREMIERE_DATE, + ItemSortBy.DATE_CREATED, + ItemSortBy.DATE_PLAYED, + ItemSortBy.COMMUNITY_RATING, + ItemSortBy.CRITIC_RATING, + ItemSortBy.OFFICIAL_RATING, ItemSortBy.RUNTIME, ItemSortBy.PLAY_COUNT, ItemSortBy.RANDOM, @@ -179,3 +194,31 @@ fun getStringRes(sort: ItemSortBy): Int = else -> throw IllegalArgumentException("Unsupported sort option: $sort") } + +@Composable +fun rememberSortOptions(collectionType: CollectionType): List<ItemSortBy> = + remember(collectionType) { + when (collectionType) { + CollectionType.MOVIES -> MovieSortOptions + CollectionType.TVSHOWS -> SeriesSortOptions + CollectionType.MUSIC -> AlbumSortOptions + CollectionType.BOXSETS -> BoxSetSortOptions + else -> VideoSortOptions + } + } + +@Composable +fun rememberSortOptions(type: BaseItemKind): List<ItemSortBy> = + remember(type) { + when (type) { + BaseItemKind.MOVIE -> MovieSortOptions + BaseItemKind.SERIES -> SeriesSortOptions + BaseItemKind.SEASON -> SeriesSortOptions + BaseItemKind.EPISODE -> EpisodeSortOptions + BaseItemKind.AUDIO -> SongSortOptions + BaseItemKind.MUSIC_ALBUM -> AlbumSortOptions + BaseItemKind.MUSIC_ARTIST -> ArtistSortOptions + BaseItemKind.BOX_SET -> BoxSetSortOptions + else -> VideoSortOptions + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt index 2c13c0ca..00713ddb 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt @@ -73,7 +73,7 @@ import com.github.damontecres.wholphin.ui.playback.isForwardButton import com.github.damontecres.wholphin.ui.playback.isPlayKeyUp import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.ExceptionHandler -import kotlinx.coroutines.Dispatchers +import com.github.damontecres.wholphin.util.WholphinDispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import timber.log.Timber @@ -123,7 +123,7 @@ fun <T : CardGridItem> CardGrid( initialPosition.coerceIn(0, (pager.size - 1).coerceAtLeast(0)) } - var focusedIndex by rememberSaveable { mutableIntStateOf(initialPosition) } + var focusedIndex by rememberSaveable { mutableIntStateOf(startPosition) } val currentFocusedIndex by rememberUpdatedState(focusedIndex) val gridState = rememberLazyGridState( @@ -141,7 +141,7 @@ fun <T : CardGridItem> CardGrid( { index: Int -> if (DEBUG) Timber.v("focusOn: focusedIndex=$currentFocusedIndex, index=$index") if (index != currentFocusedIndex) { - previouslyFocusedIndex = focusedIndex + previouslyFocusedIndex = currentFocusedIndex } focusedIndex = index } @@ -217,7 +217,7 @@ fun <T : CardGridItem> CardGrid( { letter: Char -> scope.launch(ExceptionHandler()) { val jumpPosition = - withContext(Dispatchers.IO) { + withContext(WholphinDispatchers.IO) { letterPosition.invoke(letter) } Timber.d("Alphabet jump to $jumpPosition") @@ -257,10 +257,11 @@ fun <T : CardGridItem> CardGrid( val newPosition = previouslyFocusedIndex if (DEBUG) Timber.d("Back long pressed: newPosition=$newPosition") if (newPosition > 0) { - focusOn(newPosition) scope.launch(ExceptionHandler()) { - gridState.scrollToItem(newPosition, -columns) - firstFocus.tryRequestFocus() + pager.getOrNull(newPosition) + gridState.scrollToItem(newPosition) + focusOn(newPosition) + alphabetFocus = true } } return@onKeyEvent true @@ -338,13 +339,13 @@ fun <T : CardGridItem> CardGrid( val cardWidth = ceil((width - (spacingPx * (columns - 1))) / columns) cardWidthPx = cardWidth.toInt() - Timber.v("cardWidthPx=%s", cardWidthPx) +// Timber.v("cardWidthPx=%s", cardWidthPx) }, ) { items(pager.size) { index -> val item = pager[index] val details = - remember(index, item) { + remember(index, item, cardWidthPx, columns) { val mod = if ((index == currentFocusedIndex) or (currentFocusedIndex < 0 && index == 0)) { if (DEBUG) Timber.d("Adding firstFocus to focusedIndex $index") @@ -514,7 +515,7 @@ fun AlphabetButtons( val scope = rememberCoroutineScope() val listState = rememberLazyListState() - val index = letters.indexOf(currentLetter) + val index = remember(letters, currentLetter) { letters.indexOf(currentLetter) } LaunchedEffect(currentLetter) { scope.launch(ExceptionHandler()) { val firstVisibleItemIndex = listState.firstVisibleItemIndex @@ -522,7 +523,7 @@ fun AlphabetButtons( listState.layoutInfo.visibleItemsInfo .lastOrNull() ?.index ?: -1 - if (index !in firstVisibleItemIndex..lastVisibleItemIndex) { + if (index >= 0 && index !in firstVisibleItemIndex..lastVisibleItemIndex) { listState.animateScrollToItem(index) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderGeneric.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderGeneric.kt index e0c15c26..ccd7e556 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderGeneric.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderGeneric.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @@ -11,7 +12,7 @@ import com.github.damontecres.wholphin.data.filter.DefaultFilterOptions import com.github.damontecres.wholphin.data.filter.ItemFilterBy import com.github.damontecres.wholphin.data.model.CollectionFolderFilter import com.github.damontecres.wholphin.preferences.UserPreferences -import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid +import com.github.damontecres.wholphin.ui.components.CollectionFolderView import com.github.damontecres.wholphin.ui.components.ViewOptionsPoster import com.github.damontecres.wholphin.ui.components.ViewOptionsWide import com.github.damontecres.wholphin.ui.data.VideoSortOptions @@ -32,7 +33,7 @@ fun CollectionFolderGeneric( sortOptions: List<ItemSortBy> = VideoSortOptions, preferencesViewModel: PreferencesViewModel = hiltViewModel(), ) { - var showHeader by remember { mutableStateOf(true) } + var showHeader by rememberSaveable { mutableStateOf(true) } val viewOptions = remember(usePosters) { if (usePosters) { @@ -41,7 +42,7 @@ fun CollectionFolderGeneric( ViewOptionsWide } } - CollectionFolderGrid( + CollectionFolderView( preferences = preferences, onClickItem = { index, item -> preferencesViewModel.navigationManager.navigateTo(item.destination(index)) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderLiveTv.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderLiveTv.kt index 0d9bd338..18af298b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderLiveTv.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderLiveTv.kt @@ -1,27 +1,16 @@ package com.github.damontecres.wholphin.ui.detail -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.expandVertically -import androidx.compose.animation.shrinkVertically -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.R @@ -31,22 +20,25 @@ import com.github.damontecres.wholphin.data.model.CollectionFolderFilter 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.ui.components.CollectionFolderGrid +import com.github.damontecres.wholphin.ui.components.CollectionFolderView import com.github.damontecres.wholphin.ui.components.ErrorMessage -import com.github.damontecres.wholphin.ui.components.TabRow +import com.github.damontecres.wholphin.ui.components.LoadingPage +import com.github.damontecres.wholphin.ui.components.TabDetails +import com.github.damontecres.wholphin.ui.components.TabbedPage import com.github.damontecres.wholphin.ui.components.ViewOptions import com.github.damontecres.wholphin.ui.data.VideoSortOptions import com.github.damontecres.wholphin.ui.detail.livetv.DvrSchedule import com.github.damontecres.wholphin.ui.detail.livetv.TvGuideGrid import com.github.damontecres.wholphin.ui.launchIO -import com.github.damontecres.wholphin.ui.logTab import com.github.damontecres.wholphin.ui.nav.Destination -import com.github.damontecres.wholphin.ui.setValueOnMain -import com.github.damontecres.wholphin.ui.tryRequestFocus -import com.github.damontecres.wholphin.util.RememberTabManager +import com.github.damontecres.wholphin.ui.util.StringStringProvider +import com.github.damontecres.wholphin.util.DataLoadingState import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.MutableStateFlow import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.liveTvApi +import timber.log.Timber import java.util.UUID import javax.inject.Inject @@ -57,24 +49,35 @@ class LiveTvCollectionViewModel val api: ApiClient, val serverRepository: ServerRepository, val navigationManager: NavigationManager, - val rememberTabManager: RememberTabManager, val backdropService: BackdropService, - ) : ViewModel(), - RememberTabManager by rememberTabManager { - val recordingFolders = MutableLiveData<List<TabId>>() + ) : ViewModel() { + val state = MutableStateFlow(LiveTvRecordingFolderState()) init { viewModelScope.launchIO { - val folders = - api.liveTvApi - .getRecordingFolders(userId = serverRepository.currentUser.value?.id) - .content.items - .map { TabId(it.name ?: "Recordings", it.id) } - this@LiveTvCollectionViewModel.recordingFolders.setValueOnMain(folders) + try { + val folders = + api.liveTvApi + .getRecordingFolders(userId = serverRepository.currentUser?.id) + .content.items + .map { TabId(it.name ?: "Recordings", it.id) } + state.value = + LiveTvRecordingFolderState(DataLoadingState.Success(folders)) + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Error fetching recording folders") + state.value = + LiveTvRecordingFolderState(DataLoadingState.Error(ex)) + } } } } +data class LiveTvRecordingFolderState( + val folders: DataLoadingState<List<TabId>> = DataLoadingState.Pending, +) + data class TabId( val title: String, val id: UUID, @@ -87,103 +90,91 @@ fun CollectionFolderLiveTv( modifier: Modifier = Modifier, viewModel: LiveTvCollectionViewModel = hiltViewModel(), ) { - val rememberedTabIndex = - remember { viewModel.getRememberedTab(preferences, destination.itemId, 0) } - val folders by viewModel.recordingFolders.observeAsState(listOf()) + val state by viewModel.state.collectAsState() - val tvGuideStr = stringResource(R.string.tv_guide) - val tvDvrStr = stringResource(R.string.tv_dvr_schedule) - val tabs = - remember(folders) { - listOf( - TabId(tvGuideStr, UUID.randomUUID()), - TabId(tvDvrStr, UUID.randomUUID()), - ) + folders + when (val st = state.folders) { + is DataLoadingState.Error -> { + ErrorMessage(st, modifier) } - var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) } - val focusRequester = remember { FocusRequester() } - val tabFocusRequesters = remember(tabs.size) { List(tabs.size) { FocusRequester() } } - - val firstTabFocusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { firstTabFocusRequester.tryRequestFocus() } - - LaunchedEffect(selectedTabIndex) { - logTab("livetv", selectedTabIndex) - viewModel.saveRememberedTab(preferences, destination.itemId, selectedTabIndex) - viewModel.backdropService.clearBackdrop() - } - val onClickItem = { position: Int, item: BaseItem -> - viewModel.navigationManager.navigateTo(item.destination()) - } - - var showHeader by rememberSaveable { mutableStateOf(true) } - - LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } - Column( - modifier = modifier, - ) { - AnimatedVisibility( - showHeader, - enter = expandVertically(), - exit = shrinkVertically(), - ) { - TabRow( - selectedTabIndex = selectedTabIndex, - modifier = - Modifier - .padding(vertical = 16.dp) - .focusRequester(firstTabFocusRequester), - tabs = tabs.map { it.title }, - onClick = { selectedTabIndex = it }, - focusRequesters = tabFocusRequesters, - ) + DataLoadingState.Loading, + DataLoadingState.Pending, + -> { + LoadingPage(modifier) } - when (selectedTabIndex) { - 0 -> { - TvGuideGrid( - true, - onRowPosition = { - showHeader = it <= 0 - }, - Modifier - .fillMaxSize() - .focusRequester(focusRequester), - ) - } - 1 -> { - DvrSchedule( - requestFocusAfterLoading = true, - focusRequesterOnEmpty = tabFocusRequesters[1], - modifier = - Modifier - .fillMaxSize() - .focusRequester(focusRequester), - ) - } + is DataLoadingState.Success<List<TabId>> -> { + val tabs = + remember { + listOf( + TabDetails(R.string.tv_guide), + TabDetails(R.string.tv_dvr_schedule), + ) + + st.data.map { + TabDetails(StringStringProvider(it.title)) + } + } + val onClickItem = + remember { + { position: Int, item: BaseItem -> + viewModel.navigationManager.navigateTo(item.destination()) + } + } - else -> { - val folderIndex = selectedTabIndex - 2 - if (folderIndex in folders.indices) { - CollectionFolderGrid( - preferences = preferences, - onClickItem = onClickItem, - itemId = folders[folderIndex].id, - initialFilter = CollectionFolderFilter(), - showTitle = false, - recursive = false, - sortOptions = VideoSortOptions, - modifier = Modifier, - positionCallback = { columns, position -> - showHeader = position < columns - }, - playEnabled = false, - defaultViewOptions = ViewOptions(), - focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex), - ) - } else { - ErrorMessage("Invalid tab index $selectedTabIndex", null) + var showHeader by rememberSaveable { mutableStateOf(true) } + TabbedPage( + itemId = destination.itemId.toString(), + tabs = tabs, + modifier = modifier, + showTabs = showHeader, + ) { tabIndex, tabDetails -> + when (tabIndex) { + 0 -> { + TvGuideGrid( + preferences = preferences, + onRowPosition = { + showHeader = it <= 0 + }, + Modifier + .fillMaxSize() + .focusRequester(tabDetails.contentFocusRequester), + ) + } + + 1 -> { + DvrSchedule( + requestFocusAfterLoading = true, + focusRequesterOnEmpty = tabDetails.tabFocusRequester, + modifier = + Modifier + .fillMaxSize() + .focusRequester(tabDetails.contentFocusRequester), + ) + } + + else -> { + val folderIndex = tabIndex - 2 + if (folderIndex in st.data.indices) { + CollectionFolderView( + preferences = preferences, + onClickItem = onClickItem, + itemId = st.data[folderIndex].id, + initialFilter = CollectionFolderFilter(), + showTitle = false, + recursive = false, + sortOptions = VideoSortOptions, + modifier = Modifier.focusRequester(tabDetails.contentFocusRequester), + positionCallback = { columns, position -> + showHeader = position < columns + }, + playEnabled = false, + defaultViewOptions = ViewOptions(), + focusRequesterOnEmpty = tabDetails.tabFocusRequester, + ) + } else { + ErrorMessage("Invalid tab index $tabIndex", null) + } + } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt index 6f67b011..cb1d1812 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt @@ -1,41 +1,32 @@ package com.github.damontecres.wholphin.ui.detail -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.expandVertically -import androidx.compose.animation.shrinkVertically -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.CollectionFolderFilter import com.github.damontecres.wholphin.data.model.GetItemsFilter import com.github.damontecres.wholphin.preferences.UserPreferences -import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid +import com.github.damontecres.wholphin.ui.components.CollectionFolderView import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.GenreCardGrid import com.github.damontecres.wholphin.ui.components.RecommendedMovie -import com.github.damontecres.wholphin.ui.components.TabRow +import com.github.damontecres.wholphin.ui.components.TabDetails +import com.github.damontecres.wholphin.ui.components.TabbedPage import com.github.damontecres.wholphin.ui.components.ViewOptionsPoster import com.github.damontecres.wholphin.ui.data.MovieSortOptions import com.github.damontecres.wholphin.ui.data.VideoSortOptions -import com.github.damontecres.wholphin.ui.logTab import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.CollectionType @Composable fun CollectionFolderMovie( @@ -44,51 +35,25 @@ fun CollectionFolderMovie( modifier: Modifier = Modifier, preferencesViewModel: PreferencesViewModel = hiltViewModel(), ) { - val rememberedTabIndex = - remember { preferencesViewModel.getRememberedTab(preferences, destination.itemId, 0) } - val tabs = - listOf( - stringResource(R.string.recommended), - stringResource(R.string.library), - stringResource(R.string.collections), - stringResource(R.string.genres), - ) - var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) } - val focusRequester = remember { FocusRequester() } - val tabFocusRequesters = remember { List(tabs.size) { FocusRequester() } } - - val firstTabFocusRequester = remember { FocusRequester() } -// LaunchedEffect(Unit) { firstTabFocusRequester.tryRequestFocus() } - - LaunchedEffect(selectedTabIndex) { - logTab("movie", selectedTabIndex) - preferencesViewModel.saveRememberedTab(preferences, destination.itemId, selectedTabIndex) - preferencesViewModel.backdropService.clearBackdrop() - } + remember { + listOf( + TabDetails(R.string.recommended), + TabDetails(R.string.library), + TabDetails(R.string.collections), + TabDetails(R.string.genres), + ) + } var showHeader by rememberSaveable { mutableStateOf(true) } - Column( + TabbedPage( + itemId = destination.itemId.toString(), + tabs = tabs, modifier = modifier, - ) { - AnimatedVisibility( - showHeader, - enter = expandVertically(), - exit = shrinkVertically(), - ) { - TabRow( - selectedTabIndex = selectedTabIndex, - modifier = - Modifier - .padding(vertical = 16.dp) - .focusRequester(firstTabFocusRequester), - tabs = tabs, - onClick = { selectedTabIndex = it }, - focusRequesters = tabFocusRequesters, - ) - } - when (selectedTabIndex) { + showTabs = showHeader, + ) { tabIndex, tabDetails -> + when (tabIndex) { // Recommended 0 -> { RecommendedMovie( @@ -100,13 +65,13 @@ fun CollectionFolderMovie( modifier = Modifier .fillMaxSize() - .focusRequester(focusRequester), + .focusRequester(tabDetails.contentFocusRequester), ) } // Library 1 -> { - CollectionFolderGrid( + CollectionFolderView( preferences = preferences, onClickItem = { _, item -> preferencesViewModel.navigationManager.navigateTo(item.destination()) @@ -127,18 +92,18 @@ fun CollectionFolderMovie( modifier = Modifier .fillMaxSize() - .focusRequester(focusRequester), + .focusRequester(tabDetails.contentFocusRequester), positionCallback = { columns, position -> showHeader = position < columns }, playEnabled = true, - focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex), + focusRequesterOnEmpty = tabDetails.tabFocusRequester, ) } // Collections 2 -> { - CollectionFolderGrid( + CollectionFolderView( preferences = preferences, onClickItem = { _, item -> preferencesViewModel.navigationManager.navigateTo(item.destination()) @@ -159,12 +124,12 @@ fun CollectionFolderMovie( modifier = Modifier .fillMaxSize() - .focusRequester(focusRequester), + .focusRequester(tabDetails.contentFocusRequester), positionCallback = { columns, position -> showHeader = position < columns }, playEnabled = false, - focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex), + focusRequesterOnEmpty = tabDetails.tabFocusRequester, ) } @@ -173,15 +138,16 @@ fun CollectionFolderMovie( GenreCardGrid( itemId = destination.itemId, includeItemTypes = listOf(BaseItemKind.MOVIE), + collectionType = CollectionType.MOVIES, modifier = Modifier .fillMaxSize() - .focusRequester(focusRequester), + .focusRequester(tabDetails.contentFocusRequester), ) } else -> { - ErrorMessage("Invalid tab index $selectedTabIndex", null) + ErrorMessage("Invalid tab index $tabIndex", null) } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMusic.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMusic.kt index 1911fc7a..9ab7e33f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMusic.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMusic.kt @@ -1,24 +1,14 @@ package com.github.damontecres.wholphin.ui.detail -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.expandVertically -import androidx.compose.animation.shrinkVertically -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -27,32 +17,33 @@ import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.CollectionFolderFilter import com.github.damontecres.wholphin.data.model.GetItemsFilter +import com.github.damontecres.wholphin.data.model.GetItemsFilterOverride import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.MusicService import com.github.damontecres.wholphin.services.NavigationManager -import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid +import com.github.damontecres.wholphin.ui.components.CollectionFolderView import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.GenreCardGrid import com.github.damontecres.wholphin.ui.components.GridClickActions import com.github.damontecres.wholphin.ui.components.RecommendedMusic -import com.github.damontecres.wholphin.ui.components.TabRow +import com.github.damontecres.wholphin.ui.components.TabDetails +import com.github.damontecres.wholphin.ui.components.TabbedPage import com.github.damontecres.wholphin.ui.components.ViewOptionsSquare import com.github.damontecres.wholphin.ui.data.AlbumSortOptions import com.github.damontecres.wholphin.ui.data.ArtistSortOptions import com.github.damontecres.wholphin.ui.data.SongSortOptions import com.github.damontecres.wholphin.ui.launchDefault -import com.github.damontecres.wholphin.ui.logTab import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.GetItemsRequestHandler -import com.github.damontecres.wholphin.util.RememberTabManager import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.CollectionType import org.jellyfin.sdk.model.api.request.GetItemsRequest import java.util.UUID @@ -65,10 +56,8 @@ class CollectionFolderMusicViewModel private val musicService: MusicService, private val navigationManager: NavigationManager, val backdropService: BackdropService, - private val rememberTabManager: RememberTabManager, @Assisted private val itemId: UUID, - ) : ViewModel(), - RememberTabManager by rememberTabManager { + ) : ViewModel() { @AssistedFactory interface Factory { fun create(itemId: UUID): CollectionFolderMusicViewModel @@ -86,20 +75,14 @@ class CollectionFolderMusicViewModel index: Int, item: BaseItem, ) { - if (item.type == BaseItemKind.AUDIO) { - viewModelScope.launchDefault { - musicService.setQueue(listOf(item), false) - } - } else { - navigationManager.navigateTo(item.destination()) - } + navigationManager.navigateTo(item.destination()) } fun onClickPlayAll(shuffle: Boolean) { viewModelScope.launchDefault { val request = GetItemsRequest( - userId = serverRepository.currentUser.value?.id, + userId = serverRepository.currentUser?.id, parentId = itemId, includeItemTypes = listOf(BaseItemKind.AUDIO), recursive = true, @@ -131,30 +114,16 @@ fun CollectionFolderMusic( creationCallback = { it.create(destination.itemId) }, ), ) { - val rememberedTabIndex = - remember { viewModel.getRememberedTab(preferences, destination.itemId, 0) } - val tabs = - listOf( - stringResource(R.string.recommended), - stringResource(R.string.albums), - stringResource(R.string.artists), - stringResource(R.string.genres), - stringResource(R.string.songs), - ) - var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) } - val focusRequester = remember { FocusRequester() } - val tabFocusRequesters = remember { List(tabs.size) { FocusRequester() } } - - val firstTabFocusRequester = remember { FocusRequester() } -// LaunchedEffect(Unit) { firstTabFocusRequester.tryRequestFocus() } - - LaunchedEffect(selectedTabIndex) { - logTab("music", selectedTabIndex) - viewModel.saveRememberedTab(preferences, destination.itemId, selectedTabIndex) - viewModel.backdropService.clearBackdrop() - } - + remember { + listOf( + TabDetails(R.string.recommended), + TabDetails(R.string.albums), + TabDetails(R.string.artists), + TabDetails(R.string.genres), + TabDetails(R.string.songs), + ) + } var showHeader by rememberSaveable { mutableStateOf(true) } val actions = @@ -166,27 +135,13 @@ fun CollectionFolderMusic( onClickPlayRemoteButton = viewModel::onClickPlayRemoteButton, ) } - - Column( + TabbedPage( + itemId = destination.itemId.toString(), + tabs = tabs, modifier = modifier, - ) { - AnimatedVisibility( - showHeader, - enter = expandVertically(), - exit = shrinkVertically(), - ) { - TabRow( - selectedTabIndex = selectedTabIndex, - modifier = - Modifier - .padding(vertical = 16.dp) - .focusRequester(firstTabFocusRequester), - tabs = tabs, - onClick = { selectedTabIndex = it }, - focusRequesters = tabFocusRequesters, - ) - } - when (selectedTabIndex) { + showTabs = showHeader, + ) { tabIndex, tabDetails -> + when (tabIndex) { // Recommended 0 -> { RecommendedMusic( @@ -198,13 +153,13 @@ fun CollectionFolderMusic( modifier = Modifier .fillMaxSize() - .focusRequester(focusRequester), + .focusRequester(tabDetails.contentFocusRequester), ) } // Albums 1 -> { - CollectionFolderGrid( + CollectionFolderView( preferences = preferences, actions = actions, itemId = destination.itemId, @@ -223,18 +178,18 @@ fun CollectionFolderMusic( modifier = Modifier .fillMaxSize() - .focusRequester(focusRequester), + .focusRequester(tabDetails.contentFocusRequester), positionCallback = { columns, position -> showHeader = position < columns }, playEnabled = true, - focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex), + focusRequesterOnEmpty = tabDetails.tabFocusRequester, ) } // Artists 2 -> { - CollectionFolderGrid( + CollectionFolderView( preferences = preferences, actions = actions, itemId = destination.itemId, @@ -243,7 +198,7 @@ fun CollectionFolderMusic( CollectionFolderFilter( filter = GetItemsFilter( - includeItemTypes = listOf(BaseItemKind.MUSIC_ARTIST), + override = GetItemsFilterOverride.ARTIST, ), ), showTitle = false, @@ -253,12 +208,12 @@ fun CollectionFolderMusic( modifier = Modifier .fillMaxSize() - .focusRequester(focusRequester), + .focusRequester(tabDetails.contentFocusRequester), positionCallback = { columns, position -> showHeader = position < columns }, playEnabled = false, - focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex), + focusRequesterOnEmpty = tabDetails.tabFocusRequester, ) } @@ -267,16 +222,17 @@ fun CollectionFolderMusic( GenreCardGrid( itemId = destination.itemId, includeItemTypes = listOf(BaseItemKind.MUSIC_ALBUM), + collectionType = CollectionType.MUSIC, modifier = Modifier .fillMaxSize() - .focusRequester(focusRequester), + .focusRequester(tabDetails.contentFocusRequester), ) } // Songs 4 -> { - CollectionFolderGrid( + CollectionFolderView( preferences = preferences, actions = actions, itemId = destination.itemId, @@ -295,17 +251,17 @@ fun CollectionFolderMusic( modifier = Modifier .fillMaxSize() - .focusRequester(focusRequester), + .focusRequester(tabDetails.contentFocusRequester), positionCallback = { columns, position -> showHeader = position < columns }, playEnabled = true, - focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex), + focusRequesterOnEmpty = tabDetails.tabFocusRequester, ) } else -> { - ErrorMessage("Invalid tab index $selectedTabIndex", null) + ErrorMessage("Invalid tab index $tabIndex", null) } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPhotoAlbum.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPhotoAlbum.kt index 62186f55..02ecfd9f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPhotoAlbum.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPhotoAlbum.kt @@ -1,22 +1,22 @@ package com.github.damontecres.wholphin.ui.detail import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.github.damontecres.wholphin.data.filter.DefaultFilterOptions import com.github.damontecres.wholphin.data.filter.ItemFilterBy import com.github.damontecres.wholphin.data.model.CollectionFolderFilter -import com.github.damontecres.wholphin.data.model.GetItemsFilter import com.github.damontecres.wholphin.preferences.UserPreferences -import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid +import com.github.damontecres.wholphin.ui.components.CollectionFolderView import com.github.damontecres.wholphin.ui.components.CollectionFolderViewModel import com.github.damontecres.wholphin.ui.components.GridClickActions import com.github.damontecres.wholphin.ui.components.ViewOptionsWide -import com.github.damontecres.wholphin.ui.data.SortAndDirection import com.github.damontecres.wholphin.ui.data.VideoSortOptions import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.toServerString @@ -49,8 +49,9 @@ fun CollectionFolderPhotoAlbum( ) }, ) { - var showHeader by remember { mutableStateOf(true) } - CollectionFolderGrid( + var showHeader by rememberSaveable { mutableStateOf(true) } + val state by viewModel.state.collectAsState() + CollectionFolderView( preferences = preferences, actions = remember { @@ -63,11 +64,9 @@ fun CollectionFolderPhotoAlbum( index = index, filter = CollectionFolderFilter( - filter = viewModel.filter.value ?: GetItemsFilter(), + filter = state.filter, ), - sortAndDirection = - viewModel.sortAndDirection.value - ?: SortAndDirection.DEFAULT, + sortAndDirection = state.sortAndDirection, recursive = recursive, startSlideshow = false, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPlaylist.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPlaylist.kt index e7e19066..70acd69b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPlaylist.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPlaylist.kt @@ -3,13 +3,13 @@ package com.github.damontecres.wholphin.ui.detail import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.github.damontecres.wholphin.data.model.CollectionFolderFilter import com.github.damontecres.wholphin.preferences.UserPreferences -import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid +import com.github.damontecres.wholphin.ui.components.CollectionFolderView import com.github.damontecres.wholphin.ui.components.ViewOptionsSquare import com.github.damontecres.wholphin.ui.data.PlaylistSortOptions import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel @@ -24,8 +24,8 @@ fun CollectionFolderPlaylist( filter: CollectionFolderFilter = CollectionFolderFilter(), preferencesViewModel: PreferencesViewModel = hiltViewModel(), ) { - var showHeader by remember { mutableStateOf(true) } - CollectionFolderGrid( + var showHeader by rememberSaveable { mutableStateOf(true) } + CollectionFolderView( preferences = preferences, onClickItem = { _, item -> preferencesViewModel.navigationManager.navigateTo(item.destination()) }, itemId = itemId, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderRecordings.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderRecordings.kt index a58db1b9..95fcaf00 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderRecordings.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderRecordings.kt @@ -3,13 +3,13 @@ package com.github.damontecres.wholphin.ui.detail import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.github.damontecres.wholphin.data.model.CollectionFolderFilter import com.github.damontecres.wholphin.preferences.UserPreferences -import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid +import com.github.damontecres.wholphin.ui.components.CollectionFolderView import com.github.damontecres.wholphin.ui.components.ViewOptionsPoster import com.github.damontecres.wholphin.ui.data.MovieSortOptions import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel @@ -24,8 +24,8 @@ fun CollectionFolderRecordings( filter: CollectionFolderFilter = CollectionFolderFilter(), preferencesViewModel: PreferencesViewModel = hiltViewModel(), ) { - var showHeader by remember { mutableStateOf(true) } - CollectionFolderGrid( + var showHeader by rememberSaveable { mutableStateOf(true) } + CollectionFolderView( preferences = preferences, onClickItem = { _, item -> preferencesViewModel.navigationManager.navigateTo(item.destination()) }, itemId = itemId, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt index cbc87bae..2f8fb909 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt @@ -1,101 +1,77 @@ package com.github.damontecres.wholphin.ui.detail -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.expandVertically -import androidx.compose.animation.shrinkVertically -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.ViewModel import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.filter.DefaultTvFilterOptions import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.CollectionFolderFilter import com.github.damontecres.wholphin.data.model.GetItemsFilter import com.github.damontecres.wholphin.preferences.UserPreferences -import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.ui.components.CollectionFolderView import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.GenreCardGrid import com.github.damontecres.wholphin.ui.components.RecommendedTvShow import com.github.damontecres.wholphin.ui.components.StudioCardGrid -import com.github.damontecres.wholphin.ui.components.TabRow +import com.github.damontecres.wholphin.ui.components.TabDetails +import com.github.damontecres.wholphin.ui.components.TabbedPage import com.github.damontecres.wholphin.ui.components.ViewOptionsPoster import com.github.damontecres.wholphin.ui.data.SeriesSortOptions -import com.github.damontecres.wholphin.ui.logTab import com.github.damontecres.wholphin.ui.nav.Destination -import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel -import com.github.damontecres.wholphin.ui.tryRequestFocus +import dagger.hilt.android.lifecycle.HiltViewModel import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.CollectionType +import javax.inject.Inject + +@HiltViewModel +class CollectionFolderTvViewModel + @Inject + constructor( + val navigationManager: NavigationManager, + ) : ViewModel() @Composable fun CollectionFolderTv( preferences: UserPreferences, destination: Destination.MediaItem, modifier: Modifier = Modifier, - preferencesViewModel: PreferencesViewModel = hiltViewModel(), + viewModel: CollectionFolderTvViewModel = hiltViewModel(), ) { - val rememberedTabIndex = - remember { preferencesViewModel.getRememberedTab(preferences, destination.itemId, 0) } - val tabs = - listOf( - stringResource(R.string.recommended), - stringResource(R.string.library), - stringResource(R.string.genres), - stringResource(R.string.studios), - ) - var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) } - val focusRequester = remember { FocusRequester() } - val tabFocusRequesters = remember { List(tabs.size) { FocusRequester() } } - - val firstTabFocusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { firstTabFocusRequester.tryRequestFocus() } - - LaunchedEffect(selectedTabIndex) { - logTab("tv", selectedTabIndex) - preferencesViewModel.saveRememberedTab(preferences, destination.itemId, selectedTabIndex) - preferencesViewModel.backdropService.clearBackdrop() - } - - val onClickItem = { item: BaseItem -> - preferencesViewModel.navigationManager.navigateTo(item.destination()) - } + remember { + listOf( + TabDetails(R.string.recommended), + TabDetails(R.string.library), + TabDetails(R.string.genres), + TabDetails(R.string.studios), + ) + } + val onClickItem = + remember { + { position: Int, item: BaseItem -> + viewModel.navigationManager.navigateTo(item.destination()) + } + } var showHeader by rememberSaveable { mutableStateOf(true) } - Column( + TabbedPage( + itemId = destination.itemId.toString(), + tabs = tabs, modifier = modifier, - ) { - AnimatedVisibility( - showHeader, - enter = expandVertically(), - exit = shrinkVertically(), - ) { - TabRow( - selectedTabIndex = selectedTabIndex, - modifier = - Modifier - .padding(vertical = 16.dp) - .focusRequester(firstTabFocusRequester), - tabs = tabs, - onClick = { selectedTabIndex = it }, - focusRequesters = tabFocusRequesters, - ) - } - when (selectedTabIndex) { + showTabs = showHeader, + ) { tabIndex, tabDetails -> + when (tabIndex) { // Recommended 0 -> { RecommendedTvShow( @@ -107,13 +83,13 @@ fun CollectionFolderTv( modifier = Modifier .fillMaxSize() - .focusRequester(focusRequester), + .focusRequester(tabDetails.contentFocusRequester), ) } // Library 1 -> { - CollectionFolderGrid( + CollectionFolderView( preferences = preferences, itemId = destination.itemId, initialFilter = @@ -131,15 +107,13 @@ fun CollectionFolderTv( modifier = Modifier .fillMaxSize() - .focusRequester(focusRequester), + .focusRequester(tabDetails.contentFocusRequester), positionCallback = { columns, position -> showHeader = position < columns }, - onClickItem = { _, item -> - preferencesViewModel.navigationManager.navigateTo(item.destination()) - }, + onClickItem = onClickItem, playEnabled = false, - focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex), + focusRequesterOnEmpty = tabDetails.tabFocusRequester, ) } @@ -148,10 +122,11 @@ fun CollectionFolderTv( GenreCardGrid( itemId = destination.itemId, includeItemTypes = listOf(BaseItemKind.SERIES), + collectionType = CollectionType.TVSHOWS, modifier = Modifier .fillMaxSize() - .focusRequester(focusRequester), + .focusRequester(tabDetails.contentFocusRequester), ) } @@ -163,12 +138,12 @@ fun CollectionFolderTv( modifier = Modifier .fillMaxSize() - .focusRequester(focusRequester), + .focusRequester(tabDetails.contentFocusRequester), ) } else -> { - ErrorMessage("Invalid tab index $selectedTabIndex", null) + ErrorMessage("Invalid tab index $tabIndex", null) } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt index 543d8e2c..fb4cdbdd 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt @@ -2,21 +2,33 @@ package com.github.damontecres.wholphin.ui.detail import android.content.Context import android.hardware.display.DisplayManager +import android.media.AudioDeviceCallback +import android.media.AudioDeviceInfo +import android.media.AudioFormat +import android.media.AudioManager import android.os.Build import android.util.Log import android.view.Display +import android.view.Display.HdrCapabilities import androidx.compose.foundation.background import androidx.compose.foundation.focusable import androidx.compose.foundation.gestures.scrollBy import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalGridApi +import androidx.compose.foundation.layout.Grid +import androidx.compose.foundation.layout.GridFlow +import androidx.compose.foundation.layout.GridTrackSize import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -25,55 +37,59 @@ import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.key.type -import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.github.damontecres.wholphin.BuildConfig -import com.github.damontecres.wholphin.MainActivity import com.github.damontecres.wholphin.data.ItemPlaybackDao +import com.github.damontecres.wholphin.data.SeriesTrackChoiceDao import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.services.DeviceProfileService +import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO -import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.util.ExceptionHandler +import com.github.damontecres.wholphin.util.WholphinDispatchers import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.acra.util.versionCodeLong -import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.api.client.extensions.clientLogApi import org.jellyfin.sdk.model.ClientInfo import org.jellyfin.sdk.model.DeviceInfo import timber.log.Timber import java.io.BufferedReader import java.io.InputStreamReader import java.util.Date +import java.util.Locale import javax.inject.Inject @HiltViewModel class DebugViewModel @Inject constructor( + @param:ApplicationContext private val context: Context, val serverRepository: ServerRepository, val itemPlaybackDao: ItemPlaybackDao, + val seriesTrackChoiceDao: SeriesTrackChoiceDao, val clientInfo: ClientInfo, val deviceInfo: DeviceInfo, + private val deviceProfileService: DeviceProfileService, ) : ViewModel() { - val itemPlaybacks = MutableLiveData<List<ItemPlayback>>(listOf()) - val logcat = MutableLiveData<List<LogcatLine>>(listOf()) + val state = MutableStateFlow(DebugState()) + val itemPlaybacks = MutableStateFlow<List<ItemPlayback>>(emptyList()) + val logcat = MutableStateFlow<List<LogcatLine>>(emptyList()) - val supportedModes by lazy { - val displayManager = - MainActivity.instance.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager - val display = displayManager.getDisplay(Display.DEFAULT_DISPLAY) - display.supportedModes.orEmpty() + val display: Display by lazy { + val displayManager = context.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager + displayManager.getDisplay(Display.DEFAULT_DISPLAY) } val av1Included by lazy { @@ -105,86 +121,168 @@ class DebugViewModel } init { - viewModelScope.launchIO { - serverRepository.currentUser.value?.rowId?.let { - val results = itemPlaybackDao.getItems(it) - withContext(Dispatchers.Main) { - itemPlaybacks.value = results + viewModelScope.launchDefault { + val buildTime = Date(BuildConfig.BUILD_TIME) + val pkgInfo = context.packageManager.getPackageInfo(context.packageName, 0) + val installInfo = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val installSource = + context.packageManager.getInstallSourceInfo(context.packageName) + buildList { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + add("Install source: ${installSource.packageSource}") + } + add("Installer: ${installSource.installingPackageName}") + add("Initiator: ${installSource.initiatingPackageName}") + } + } else { + listOf( + context.packageManager + .getInstallerPackageName(context.packageName) + .toString(), + ) } + + val appInfo = + listOf( + "Version Name: ${pkgInfo.versionName}", + "Version Code: ${pkgInfo.versionCodeLong}", + "ClientInfo: $clientInfo", + "Build type: ${BuildConfig.BUILD_TYPE}", + "Build flavor: ${BuildConfig.FLAVOR}", + "Build time: $buildTime", + "FFMPEG included: $ffmpegIncluded", + "AV1 included: $av1Included", + "libmpv loaded: $libMpvLoaded", + "Debug enabled: ${BuildConfig.DEBUG}", + "ABIs: ${Build.SUPPORTED_ABIS.toList()}", + ) + installInfo + state.update { it.copy(appInfo = appInfo) } + } + viewModelScope.launchDefault { + val deviceInfoList = + listOf( + "DeviceInfo: $deviceInfo", + "Manufacturer: ${Build.MANUFACTURER}", + "Model: ${Build.MODEL}", + "API Level: ${Build.VERSION.SDK_INT}", + ) + state.update { + it.copy( + deviceInfo = deviceInfoList, + displayModes = display.supportedModes.map { it.toDisplayMode(display) }, + ) } + } + viewModelScope.launchIO { + serverRepository.currentUser?.rowId?.let { + val results = itemPlaybackDao.getItems(it) + itemPlaybacks.value = results + } + } + viewModelScope.launchIO { + serverRepository.currentUser?.rowId?.let { + val results = seriesTrackChoiceDao.getAll(it) + Timber.v("Got %s SeriesTrackChoice", results.size) + results.forEach { Timber.v("%s", it) } + } + } + viewModelScope.launchIO { val logcat = getLogCatLines() - withContext(Dispatchers.Main) { - this@DebugViewModel.logcat.value = logcat - } + this@DebugViewModel.logcat.value = logcat + } + viewModelScope.launchDefault { + val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager + val callback = + object : AudioDeviceCallback() { + override fun onAudioDevicesAdded(addedDevices: Array<AudioDeviceInfo>) { + state.update { + it.copy( + audioInfo = + it.audioInfo.toMutableList().apply { + addAll( + addedDevices + .filter { it.isSink } + .map { it.details }, + ) + }, + ) + } + } + + override fun onAudioDevicesRemoved(removedDevices: Array<out AudioDeviceInfo>) { + state.update { + it.copy( + audioInfo = + it.audioInfo.toMutableList().apply { + removeAll(removedDevices.map { it.details }) + }, + ) + } + } + } + audioManager.registerAudioDeviceCallback(callback, null) + addCloseable { audioManager.unregisterAudioDeviceCallback(callback) } + } + viewModelScope.launchIO { + val hdrSupport = + buildMap { + put("HEVC HDR10", deviceProfileService.mediaCodecCapabilitiesTest.supportsHevcHDR10()) + put("HEVC HDR10+", deviceProfileService.mediaCodecCapabilitiesTest.supportsHevcHDR10Plus()) + put("DoVi single layer", deviceProfileService.mediaCodecCapabilitiesTest.supportsHevcDolbyVision()) + put("DoVi Profile 7 EL", deviceProfileService.mediaCodecCapabilitiesTest.supportsHevcDolbyVisionEL()) + put("DoVi Profile 8", deviceProfileService.mediaCodecCapabilitiesTest.supportsHevcDolbyVisionProfile8()) + put("AV1 HDR10", deviceProfileService.mediaCodecCapabilitiesTest.supportsAV1HDR10()) + put("AV1 HDR10+", deviceProfileService.mediaCodecCapabilitiesTest.supportsAV1HDR10Plus()) + put("AV1 DoVi", deviceProfileService.mediaCodecCapabilitiesTest.supportsAV1DolbyVision()) + } + state.update { it.copy(hdrSupport = hdrSupport) } } } companion object { - fun getLogCatLines(): List<LogcatLine> { - val lineCount = 500 - val args = - buildList { - add("logcat") - add("-d") - add("-t") - add(lineCount.toString()) - addAll(THIRD_PARTY_TAGS) - add("*:V") - } - val process = ProcessBuilder().command(args).redirectErrorStream(true).start() - val logLines = mutableListOf<LogcatLine>() - try { - val reader = BufferedReader(InputStreamReader(process.inputStream)) - var count = 0 - - while (count < lineCount) { - val line = reader.readLine() - if (line != null) { - val level = line.split(Regex("\\s+")).getOrNull(4) - val logLevel = - when (level?.uppercase()) { - "V" -> Log.VERBOSE - "D" -> Log.DEBUG - "I" -> Log.INFO - "W" -> Log.WARN - "E" -> Log.ERROR - else -> Log.VERBOSE - } - logLines.add(LogcatLine(logLevel, line)) - } else { - break + suspend fun getLogCatLines(): List<LogcatLine> = + withContext(WholphinDispatchers.IO) { + val lineCount = 500 + val args = + buildList { + add("logcat") + add("-d") + add("-t") + add(lineCount.toString()) + addAll(THIRD_PARTY_TAGS) + add("*:V") } - count++ + val process = ProcessBuilder().command(args).redirectErrorStream(true).start() + val logLines = mutableListOf<LogcatLine>() + try { + val reader = BufferedReader(InputStreamReader(process.inputStream)) + var count = 0 + + while (count < lineCount) { + val line = reader.readLine() + if (line != null) { + val level = line.split(Regex("\\s+")).getOrNull(4) + val logLevel = + when (level?.uppercase()) { + "V" -> Log.VERBOSE + "D" -> Log.DEBUG + "I" -> Log.INFO + "W" -> Log.WARN + "E" -> Log.ERROR + else -> Log.VERBOSE + } + logLines.add(LogcatLine(logLevel, line)) + } else { + break + } + count++ + } + } finally { + process.destroy() } - } finally { - process.destroy() + return@withContext logLines } - return logLines - } - - fun ViewModel.sendAppLogs( - context: Context, - api: ApiClient, - clientInfo: ClientInfo?, - deviceInfo: DeviceInfo?, - ) { - viewModelScope.launchIO(ExceptionHandler(true)) { - val logcat = getLogCatLines().joinToString("\n") { it.text } - val body = - """ - Send App Logs - clientInfo=$clientInfo - deviceInfo=$deviceInfo - manufacturer=${Build.MANUFACTURER} - model=${Build.MODEL} - apiLevel=${Build.VERSION.SDK_INT} - - """.trimIndent() - Timber.w(body) - val response by api.clientLogApi.logFile(body + logcat) - showToast(context, "Sent! Filename=${response.fileName}") - } - } } } @@ -193,30 +291,160 @@ data class LogcatLine( val text: String, ) +data class DebugState( + val appInfo: List<String> = emptyList(), + val deviceInfo: List<String> = emptyList(), + val displayModes: List<DisplayMode> = emptyList(), + val audioInfo: List<String> = emptyList(), + val hdrSupport: Map<String, Boolean> = emptyMap(), +) + +val AudioDeviceInfo.details: String + get() { + val typeName = + when (type) { + AudioDeviceInfo.TYPE_HDMI -> "HDMI" + AudioDeviceInfo.TYPE_HDMI_ARC -> "ARC" + AudioDeviceInfo.TYPE_HDMI_EARC -> "eARC" + AudioDeviceInfo.TYPE_BUILTIN_SPEAKER -> "Speaker" + AudioDeviceInfo.TYPE_BUILTIN_SPEAKER_SAFE -> "Speaker Safe" + else -> "N/A" + } + val addressStr = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + address + } else { + null + } + val encodings = + encodings.map { enc -> + when (enc) { + AudioFormat.ENCODING_INVALID -> "INVALID" + + AudioFormat.ENCODING_PCM_16BIT -> "PCM_16BIT" + + AudioFormat.ENCODING_PCM_8BIT -> "PCM_8BIT" + + AudioFormat.ENCODING_PCM_FLOAT -> "PCM_FLOAT" + + AudioFormat.ENCODING_AC3 -> "AC3" + + AudioFormat.ENCODING_E_AC3 -> "E_AC3" + + AudioFormat.ENCODING_DTS -> "DTS" + + AudioFormat.ENCODING_DTS_HD -> "DTS_HD" + + AudioFormat.ENCODING_MP3 -> "MP3" + + AudioFormat.ENCODING_AAC_LC -> "AAC_LC" + + AudioFormat.ENCODING_AAC_HE_V1 -> "AAC_HE_V1" + + AudioFormat.ENCODING_AAC_HE_V2 -> "AAC_HE_V2" + + AudioFormat.ENCODING_IEC61937 -> "IEC61937" + + AudioFormat.ENCODING_DOLBY_TRUEHD -> "DOLBY_TRUEHD" + + AudioFormat.ENCODING_AAC_ELD -> "AAC_ELD" + + AudioFormat.ENCODING_AAC_XHE -> "AAC_XHE" + + AudioFormat.ENCODING_AC4 -> "AC4" + + // AudioFormat.ENCODING_AC4_L4->"AC4_L4" + + AudioFormat.ENCODING_E_AC3_JOC -> "E_AC3_JOC" + + AudioFormat.ENCODING_DOLBY_MAT -> "DOLBY_MAT" + + AudioFormat.ENCODING_OPUS -> "OPUS" + + AudioFormat.ENCODING_PCM_24BIT_PACKED -> "PCM_24BIT_PACKED" + + AudioFormat.ENCODING_PCM_32BIT -> "PCM_32BIT" + + AudioFormat.ENCODING_MPEGH_BL_L3 -> "MPEGH_BL_L3" + + AudioFormat.ENCODING_MPEGH_BL_L4 -> "MPEGH_BL_L4" + + AudioFormat.ENCODING_MPEGH_LC_L3 -> "MPEGH_LC_L3" + + AudioFormat.ENCODING_MPEGH_LC_L4 -> "MPEGH_LC_L4" + + AudioFormat.ENCODING_DTS_UHD_P1 -> "DTS_UHD_P1" + + AudioFormat.ENCODING_DRA -> "DRA" + + AudioFormat.ENCODING_DTS_HD_MA -> "DTS_HD_MA" + + AudioFormat.ENCODING_DTS_UHD_P2 -> "DTS_UHD_P2" + + AudioFormat.ENCODING_DSD -> "DSD" + + AudioFormat.ENCODING_IAMF_BASE_ENHANCED_PROFILE_AAC -> "IAMF_BASE_ENHANCED_PROFILE_AAC" + + AudioFormat.ENCODING_IAMF_BASE_ENHANCED_PROFILE_FLAC -> "IAMF_BASE_ENHANCED_PROFILE_FLAC" + + AudioFormat.ENCODING_IAMF_BASE_ENHANCED_PROFILE_OPUS -> "IAMF_BASE_ENHANCED_PROFILE_OPUS" + + AudioFormat.ENCODING_IAMF_BASE_ENHANCED_PROFILE_PCM -> "IAMF_BASE_ENHANCED_PROFILE_PCM" + + AudioFormat.ENCODING_IAMF_BASE_PROFILE_AAC -> "IAMF_BASE_PROFILE_AAC" + + AudioFormat.ENCODING_IAMF_BASE_PROFILE_FLAC -> "IAMF_BASE_PROFILE_FLAC" + + AudioFormat.ENCODING_IAMF_BASE_PROFILE_OPUS -> "IAMF_BASE_PROFILE_OPUS" + + AudioFormat.ENCODING_IAMF_BASE_PROFILE_PCM -> "IAMF_BASE_PROFILE_PCM" + + AudioFormat.ENCODING_IAMF_SIMPLE_PROFILE_AAC -> "IAMF_SIMPLE_PROFILE_AAC" + + AudioFormat.ENCODING_IAMF_SIMPLE_PROFILE_FLAC -> "IAMF_SIMPLE_PROFILE_FLAC" + + AudioFormat.ENCODING_IAMF_SIMPLE_PROFILE_OPUS -> "IAMF_SIMPLE_PROFILE_OPUS" + + AudioFormat.ENCODING_IAMF_SIMPLE_PROFILE_PCM -> "IAMF_SIMPLE_PROFILE_PCM" + + else -> "invalid encoding $enc" + } + } + return "AudioDeviceInfo(id=$id, type=$type ($typeName), " + + "channelCounts=${channelCounts.contentToString()}, " + + "encodings=$encodings, " + + "productName=$productName, address=$addressStr)" + } + +@OptIn(ExperimentalGridApi::class) @Composable fun DebugPage( preferences: UserPreferences, modifier: Modifier = Modifier, viewModel: DebugViewModel = hiltViewModel(), ) { - val context = LocalContext.current - val scrollAmount = 100f val columnState = rememberLazyListState() val scope = rememberCoroutineScope() + val state by viewModel.state.collectAsState() - fun scroll(reverse: Boolean = false) { + fun scroll( + reverse: Boolean = false, + scrollAmount: Float = 100f, + ) { scope.launch(ExceptionHandler()) { columnState.scrollBy(if (reverse) -scrollAmount else scrollAmount) } } - val itemPlaybacks by viewModel.itemPlaybacks.observeAsState(listOf()) - val logcat by viewModel.logcat.observeAsState(listOf()) + val itemPlaybacks by viewModel.itemPlaybacks.collectAsState() + val logcat by viewModel.logcat.collectAsState() + + val padding = remember { PaddingValues(bottom = 32.dp) } LazyColumn( state = columnState, contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(32.dp), + verticalArrangement = Arrangement.spacedBy(0.dp), modifier = modifier .focusable() @@ -234,150 +462,133 @@ fun DebugPage( scroll(true) return@onKeyEvent true } + if (it.key == Key.MediaFastForward || it.key == Key.PageDown) { + scroll(false, 300f) + return@onKeyEvent true + } + if (it.key == Key.MediaSkipBackward || it.key == Key.PageUp) { + scroll(true, 300f) + return@onKeyEvent true + } return@onKeyEvent false }, ) { item { Column( verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.fillMaxWidth(), + modifier = + Modifier + .fillMaxWidth() + .padding(padding), ) { - Text( - text = "App Information", - style = MaterialTheme.typography.displaySmall, - color = MaterialTheme.colorScheme.onSurface, - ) - val buildTime = Date(BuildConfig.BUILD_TIME) - val pkgInfo = context.packageManager.getPackageInfo(context.packageName, 0) - val installInfo = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - val installSource = - context.packageManager.getInstallSourceInfo(context.packageName) - buildList { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - add("Install source: ${installSource.packageSource}") - } - add("Installer: ${installSource.installingPackageName}") - add("Initiator: ${installSource.initiatingPackageName}") - } - } else { - listOf(context.packageManager.getInstallerPackageName(context.packageName)) - } - ( - listOf( - "Version Name: ${pkgInfo.versionName}", - "Version Code: ${pkgInfo.versionCodeLong}", - "ClientInfo: ${viewModel.clientInfo}", - "Build type: ${BuildConfig.BUILD_TYPE}", - "Build flavor: ${BuildConfig.FLAVOR}", - "Build time: $buildTime", - "FFMPEG included: ${viewModel.ffmpegIncluded}", - "AV1 included: ${viewModel.av1Included}", - "libmpv loaded: ${viewModel.libMpvLoaded}", - "Debug enabled: ${BuildConfig.DEBUG}", - "ABIs: ${Build.SUPPORTED_ABIS.toList()}", - ) + installInfo - ).forEach { - Text( - text = it.toString(), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface, - ) + SectionTitle("App Information") + state.appInfo.forEach { + BodyText(it) } } } item { Column( verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.fillMaxWidth(), + modifier = + Modifier + .fillMaxWidth() + .padding(padding), ) { - Text( - text = "Device Information", - style = MaterialTheme.typography.displaySmall, - color = MaterialTheme.colorScheme.onSurface, - ) + SectionTitle("Device Information") + state.deviceInfo.forEach { + BodyText(it) + } - listOf( - "DeviceInfo: ${viewModel.deviceInfo}", - "Manufacturer: ${Build.MANUFACTURER}", - "Model: ${Build.MODEL}", - "API Level: ${Build.VERSION.SDK_INT}", - "Display Modes:", - *viewModel.supportedModes, - ).forEach { - Text( - text = it.toString(), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface, - ) + SubSectionTitle("Audio Devices") + state.audioInfo.forEach { + BodyText(it) + } + + SubSectionTitle("Display Modes") + Grid( + config = { + repeat(6) { column(GridTrackSize.Auto) } + flow = GridFlow.Row + columnGap(8.dp) + rowGap(8.dp) + }, + ) { + EmphasizedBodyText("ID") + EmphasizedBodyText("Width") + EmphasizedBodyText("Height") + EmphasizedBodyText("FPS") + EmphasizedBodyText("HDR") + EmphasizedBodyText("Alt FPS") + + state.displayModes.forEachIndexed { index, mode -> + BodyText(mode.id.toString()) + BodyText(mode.width.toString()) + BodyText(mode.height.toString()) + BodyText(mode.fps) + BodyText(mode.hdr) + BodyText(mode.altFps) + } + } + + SubSectionTitle("Detected HDR Support") + Grid( + config = { + column(GridTrackSize.Auto) + column(GridTrackSize.Auto) + flow = GridFlow.Row + columnGap(8.dp) + rowGap(4.dp) + }, + ) { + EmphasizedBodyText("HDR Format") + EmphasizedBodyText("Supported") + state.hdrSupport.forEach { (name, supported) -> + BodyText(name) + BodyText(supported.toString()) + } } } } item { Column( verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.fillMaxWidth(), + modifier = + Modifier + .fillMaxWidth() + .padding(padding), ) { - Text( - text = "AppPreferences", - style = MaterialTheme.typography.displaySmall, - color = MaterialTheme.colorScheme.onSurface, - ) - Text( - text = preferences.appPreferences.toString(), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface, - ) + SectionTitle("AppPreferences") + BodyText(preferences.appPreferences.toString()) } } item { Column( verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.fillMaxWidth(), + modifier = + Modifier + .fillMaxWidth() + .padding(padding), ) { - Text( - text = "User Information", - style = MaterialTheme.typography.displaySmall, - color = MaterialTheme.colorScheme.onSurface, - ) - Text( - text = "Current server: ${viewModel.serverRepository.currentServer.value}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface, - ) - Text( - text = "Current user: ${viewModel.serverRepository.currentUser.value}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface, - ) - Text( - text = "User server settings: ${viewModel.serverRepository.currentUserDto.value?.configuration}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface, - ) + SectionTitle("User Information") + BodyText("Current server: ${viewModel.serverRepository.currentServer}") + BodyText("Current user: ${viewModel.serverRepository.currentUser}") + BodyText("User server settings: ${viewModel.serverRepository.currentUserDto?.configuration}") } } item { Column( verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.fillMaxWidth(), + modifier = + Modifier + .fillMaxWidth() + .padding(padding), ) { - Text( - text = "Database", - style = MaterialTheme.typography.displaySmall, - color = MaterialTheme.colorScheme.onSurface, - ) - Text( - text = "ItemPlayback", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - ) + SectionTitle("Database") + + SubSectionTitle("ItemPlayback") itemPlaybacks.forEach { - Text( - text = it.toString(), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface, - ) + BodyText(it.toString()) } } } @@ -386,32 +597,73 @@ fun DebugPage( verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth(), ) { - Text( - text = "Logcat", - style = MaterialTheme.typography.displaySmall, - color = MaterialTheme.colorScheme.onSurface, - ) - logcat.forEach { (level, line) -> - val color = - when (level) { - Log.VERBOSE -> MaterialTheme.colorScheme.onSurface - Log.DEBUG -> Color(0xff2bc4cf) - Log.INFO -> Color(0xff2bcf8b) - Log.WARN -> Color(0xffdde663) - Log.ERROR -> Color(0xffe67063) - else -> MaterialTheme.colorScheme.onSurface - } - Text( - text = line, - style = MaterialTheme.typography.bodySmall, - color = color, - ) - } + SectionTitle("Logcat") } } + items(logcat) { (level, line) -> + val color = + when (level) { + Log.VERBOSE -> MaterialTheme.colorScheme.onSurface + Log.DEBUG -> Color(0xff2bc4cf) + Log.INFO -> Color(0xff2bcf8b) + Log.WARN -> Color(0xffdde663) + Log.ERROR -> Color(0xffe67063) + else -> MaterialTheme.colorScheme.onSurface + } + Text( + text = line, + style = MaterialTheme.typography.bodySmall, + color = color, + ) + } } } +@Composable +private fun SectionTitle(text: String) { + Text( + text = text, + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onSurface, + ) +} + +@Composable +private fun SubSectionTitle(text: String) { + Text( + text = text, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) +} + +@Composable +private fun BodyText( + text: String, + modifier: Modifier = Modifier, +) { + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + modifier = modifier, + ) +} + +@Composable +private fun EmphasizedBodyText( + text: String, + modifier: Modifier = Modifier, +) { + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + modifier = modifier, + ) +} + private val THIRD_PARTY_TAGS = listOf( "libc:F", @@ -440,3 +692,45 @@ private val THIRD_PARTY_TAGS = "HardwareConfig:W", "DfltImageHeaderParser:W", ) + +data class DisplayMode( + val id: Int, + val width: Int, + val height: Int, + val fps: String, + val hdr: String, + val altFps: String, +) + +fun Display.Mode.toDisplayMode(display: Display): DisplayMode { + val fps = String.format(Locale.getDefault(), "%.3f", refreshRate) + val altFps = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + alternativeRefreshRates.joinToString(", ") { + String.format(Locale.getDefault(), "%.3f", it) + } + } else { + "N/A" + } + val hdr = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + supportedHdrTypes.joinToString(", ") { hdrTypeToString(it) } + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + display.hdrCapabilities?.supportedHdrTypes?.joinToString(", ") { hdrTypeToString(it) } + ?: "None found" + } else { + "N/A" + } + return DisplayMode(modeId, physicalWidth, physicalHeight, fps, hdr, altFps) +} + +fun hdrTypeToString(type: Int): String = + when (type) { + HdrCapabilities.HDR_TYPE_HDR10 -> "HDR10" + HdrCapabilities.HDR_TYPE_HDR10_PLUS -> "HDR10+" + HdrCapabilities.HDR_TYPE_HLG -> "HLG" + HdrCapabilities.HDR_TYPE_HLG_PLUS -> "HLG+" + HdrCapabilities.HDR_TYPE_DOLBY_VISION -> "DoVi" + HdrCapabilities.HDR_TYPE_INVALID -> "Invalid" + else -> "Unknown ($type)" + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DetailUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DetailUtils.kt deleted file mode 100644 index 20172832..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DetailUtils.kt +++ /dev/null @@ -1,193 +0,0 @@ -package com.github.damontecres.wholphin.ui.detail - -import android.content.Context -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowForward -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.ArrowForward -import androidx.compose.material.icons.filled.Delete -import androidx.compose.material.icons.filled.PlayArrow -import androidx.compose.material.icons.filled.Refresh -import androidx.compose.ui.graphics.Color -import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.data.model.BaseItem -import com.github.damontecres.wholphin.ui.components.DialogItem -import com.github.damontecres.wholphin.ui.nav.Destination -import com.github.damontecres.wholphin.util.supportedPlayableTypes -import org.jellyfin.sdk.model.UUID -import org.jellyfin.sdk.model.api.BaseItemKind -import kotlin.time.Duration -import kotlin.time.Duration.Companion.seconds - -data class MoreDialogActions( - val navigateTo: (Destination) -> Unit, - val onClickWatch: (UUID, Boolean) -> Unit, - val onClickFavorite: (UUID, Boolean) -> Unit, - val onClickAddPlaylist: (UUID) -> Unit, - val onSendMediaInfo: (UUID) -> Unit, - val onClickDelete: (BaseItem) -> Unit, - val onClickGoTo: (BaseItem) -> Unit = { navigateTo(it.destination()) }, - val onClickRemoveFromNextUp: (BaseItem) -> Unit = {}, - val onClickAddToQueue: (BaseItem) -> Unit = {}, -) - -fun buildMoreDialogItemsForHome( - context: Context, - item: BaseItem, - seriesId: UUID?, - playbackPosition: Duration, - watched: Boolean, - favorite: Boolean, - canDelete: Boolean, - actions: MoreDialogActions, - canRemoveContinueWatching: Boolean = false, - canRemoveNextUp: Boolean = false, -): List<DialogItem> = - buildList { - val itemId = item.id - add( - DialogItem( - context.getString(R.string.go_to), - Icons.Default.ArrowForward, - ) { - actions.onClickGoTo(item) - }, - ) - if (item.type in supportedPlayableTypes) { - if (playbackPosition >= 1.seconds) { - add( - DialogItem( - context.getString(R.string.resume), - Icons.Default.PlayArrow, - iconColor = Color.Green.copy(alpha = .8f), - ) { - actions.navigateTo( - Destination.Playback( - itemId, - playbackPosition.inWholeMilliseconds, - ), - ) - }, - ) - add( - DialogItem( - context.getString(R.string.restart), - Icons.Default.Refresh, -// iconColor = Color.Green.copy(alpha = .8f), - ) { - actions.navigateTo( - Destination.Playback( - itemId, - 0L, - ), - ) - }, - ) - } else { - add( - DialogItem( - context.getString(R.string.play), - Icons.Default.PlayArrow, - iconColor = Color.Green.copy(alpha = .8f), - ) { - actions.navigateTo( - Destination.Playback( - itemId, - 0L, - ), - ) - }, - ) - } - } - if (item.type == BaseItemKind.MUSIC_ALBUM) { - add( - DialogItem( - context.getString(R.string.add_to_queue), - Icons.Default.Add, - ) { - actions.onClickAddToQueue(item) - }, - ) - } - add( - DialogItem( - text = R.string.add_to_playlist, - iconStringRes = R.string.fa_list_ul, - ) { - actions.onClickAddPlaylist.invoke(itemId) - }, - ) - if (canDelete) { - add( - DialogItem( - context.getString(R.string.delete), - Icons.Default.Delete, - iconColor = Color.Red.copy(alpha = .8f), - ) { - actions.onClickDelete.invoke(item) - }, - ) - } - if (canRemoveContinueWatching && !watched && playbackPosition > Duration.ZERO) { - add( - DialogItem( - text = R.string.remove_continue_watching, - iconStringRes = R.string.fa_eye, - ) { - actions.onClickWatch.invoke(itemId, false) - }, - ) - } - if (canRemoveNextUp && item.type == BaseItemKind.EPISODE && item.data.seriesId != null) { - add( - DialogItem( - text = R.string.remove_next_up, - iconStringRes = R.string.fa_tag, - ) { - actions.onClickRemoveFromNextUp.invoke(item) - }, - ) - } - add( - DialogItem( - text = if (watched) R.string.mark_unwatched else R.string.mark_watched, - iconStringRes = if (watched) R.string.fa_eye else R.string.fa_eye_slash, - ) { - actions.onClickWatch.invoke(itemId, !watched) - }, - ) - add( - DialogItem( - text = if (favorite) R.string.remove_favorite else R.string.add_favorite, - iconStringRes = R.string.fa_heart, - iconColor = if (favorite) Color.Red else Color.Unspecified, - ) { - actions.onClickFavorite.invoke(itemId, !favorite) - }, - ) - seriesId?.let { - add( - DialogItem( - context.getString(R.string.go_to_series), - Icons.AutoMirrored.Filled.ArrowForward, - ) { - actions.navigateTo( - Destination.MediaItem( - it, - BaseItemKind.SERIES, - null, - ), - ) - }, - ) - } - add( - DialogItem( - text = R.string.send_media_info_log_to_server, - iconStringRes = R.string.fa_file_video, - ) { - actions.onSendMediaInfo.invoke(itemId) - }, - ) - } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesPage.kt index 9b03aa0c..0e46999f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesPage.kt @@ -1,324 +1,129 @@ package com.github.damontecres.wholphin.ui.detail -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.expandVertically -import androidx.compose.animation.shrinkVertically -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Box import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.filter.DefaultForFavoritesFilterOptions -import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.CollectionFolderFilter -import com.github.damontecres.wholphin.data.model.GetItemsFilter -import com.github.damontecres.wholphin.data.model.GetItemsFilterOverride import com.github.damontecres.wholphin.preferences.UserPreferences -import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid +import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect +import com.github.damontecres.wholphin.ui.components.CollectionFolderViewContent import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.GridClickActions -import com.github.damontecres.wholphin.ui.components.TabRow -import com.github.damontecres.wholphin.ui.components.ViewOptionsPoster -import com.github.damontecres.wholphin.ui.components.ViewOptionsSquare -import com.github.damontecres.wholphin.ui.components.ViewOptionsWide -import com.github.damontecres.wholphin.ui.data.EpisodeSortOptions -import com.github.damontecres.wholphin.ui.data.MovieSortOptions -import com.github.damontecres.wholphin.ui.data.SeriesSortOptions -import com.github.damontecres.wholphin.ui.data.SortAndDirection -import com.github.damontecres.wholphin.ui.data.VideoSortOptions -import com.github.damontecres.wholphin.ui.logTab -import com.github.damontecres.wholphin.ui.nav.NavDrawerItem -import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel +import com.github.damontecres.wholphin.ui.components.KeyedTabbedPage +import com.github.damontecres.wholphin.ui.components.LoadingPage +import com.github.damontecres.wholphin.ui.components.defaultViewOptions +import com.github.damontecres.wholphin.ui.components.rememberContextMenu +import com.github.damontecres.wholphin.ui.data.rememberSortOptions import com.github.damontecres.wholphin.ui.tryRequestFocus -import org.jellyfin.sdk.model.api.BaseItemKind -import org.jellyfin.sdk.model.api.ItemSortBy -import org.jellyfin.sdk.model.api.SortOrder @Composable fun FavoritesPage( preferences: UserPreferences, modifier: Modifier = Modifier, - preferencesViewModel: PreferencesViewModel = hiltViewModel(), + viewModel: FavoritesViewModel = hiltViewModel(), ) { - val uiPrefs = preferences.appPreferences.interfacePreferences - val rememberedTabIndex = - remember { - preferencesViewModel.getRememberedTab( - preferences, - NavDrawerItem.Favorites.id, - 0, - ) + OneTimeLaunchedEffect { viewModel.init() } + val state by viewModel.state.collectAsState() + + var showTabs by rememberSaveable { mutableStateOf(true) } + + when (val s = state.loadingState) { + is FavoritesLoadingState.Error -> { + ErrorMessage(s.message, s.exception, modifier) } - val tabs = - listOf( - stringResource(R.string.movies), - stringResource(R.string.tv_shows), - stringResource(R.string.episodes), - stringResource(R.string.videos), - stringResource(R.string.playlists), - stringResource(R.string.people), - ) - val tabFocusRequesters = remember { List(tabs.size) { FocusRequester() } } - var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) } - val focusRequester = remember { FocusRequester() } + FavoritesLoadingState.Loading, + FavoritesLoadingState.Pending, + -> { + LoadingPage(modifier) + } - LaunchedEffect(selectedTabIndex) { - logTab("favorites", selectedTabIndex) - preferencesViewModel.saveRememberedTab( - preferences, - NavDrawerItem.Favorites.id, - selectedTabIndex, - ) - preferencesViewModel.backdropService.clearBackdrop() - } - var showHeader by rememberSaveable { mutableStateOf(true) } - - val onClickItem = { item: BaseItem -> - preferencesViewModel.navigationManager.navigateTo(item.destination()) - } - - LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } - Column( - modifier = modifier, - ) { - AnimatedVisibility( - showHeader, - enter = expandVertically(), - exit = shrinkVertically(), - ) { - TabRow( - selectedTabIndex = selectedTabIndex, + FavoritesLoadingState.NoFavorites -> { + val focusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + Box( + contentAlignment = Alignment.Center, modifier = - Modifier - .padding(start = 32.dp, top = 16.dp, bottom = 16.dp), - tabs = tabs, - onClick = { selectedTabIndex = it }, - focusRequesters = tabFocusRequesters, - ) + modifier + .focusRequester(focusRequester) + .focusable(), + ) { + Text( + text = stringResource(R.string.no_favorites_found), + style = MaterialTheme.typography.titleLarge, + modifier = Modifier, + ) + } } - val actions = - remember { - GridClickActions( - onClickItem = { _, item -> onClickItem.invoke(item) }, - ) - } - // TODO playEnabled = true for movies & episodes - when (selectedTabIndex) { - // Movies - 0 -> { - CollectionFolderGrid( - preferences = preferences, - actions = actions, - itemId = "${NavDrawerItem.Favorites.id}_movies", - initialFilter = - CollectionFolderFilter( - filter = - GetItemsFilter( - favorite = true, - includeItemTypes = listOf(BaseItemKind.MOVIE), - ), - ), - showTitle = false, - recursive = true, - sortOptions = MovieSortOptions, - defaultViewOptions = ViewOptionsPoster, - modifier = - Modifier - .padding(start = 16.dp) - .fillMaxSize() - .focusRequester(focusRequester), - positionCallback = { columns, position -> - showHeader = position < columns - }, - playEnabled = false, - filterOptions = DefaultForFavoritesFilterOptions, - focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex), - ) - } + FavoritesLoadingState.Success -> { +// LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + KeyedTabbedPage( + selectedTabKey = state.tabKey, + updateSelectedTabKey = viewModel::updateSelectedTabKey, + tabs = state.tabs, + showTabs = showTabs, + isShowClock = state.isShowClock, + modifier = modifier, + ) { type, tabDetails -> + val collectionState = state.favorites[type] + if (collectionState != null) { + val provider = remember(type) { viewModel.createTypedProvider(type) } + val contextMenu = rememberContextMenu(preferences, provider) + val actions = + remember(type) { + GridClickActions( + onClickItem = { _, item -> + viewModel.navigationManager.navigateTo( + item.destination(), + ) + }, + onLongClickItem = contextMenu::showContextMenu, + ) + } + CollectionFolderViewContent( + preferences = preferences, + state = collectionState, + savedPosition = 0, + itemId = remember { viewModel.libraryDisplayItemId(type) }, + initialFilter = CollectionFolderFilter(), + recursive = true, + actions = actions, + sortOptions = rememberSortOptions(type), + // TODO playEnabled = true for movies & episodes + playEnabled = false, + defaultViewOptions = type.defaultViewOptions, + viewActions = provider, + provider = provider, + showTitle = false, + positionCallback = { columns, index -> + showTabs = index < columns + }, + focusRequesterOnEmpty = null, + filterOptions = DefaultForFavoritesFilterOptions, + ) - // TV - 1 -> { - CollectionFolderGrid( - preferences = preferences, - actions = actions, - itemId = "${NavDrawerItem.Favorites.id}_series", - initialFilter = - CollectionFolderFilter( - filter = - GetItemsFilter( - favorite = true, - includeItemTypes = listOf(BaseItemKind.SERIES), - ), - ), - showTitle = false, - recursive = true, - sortOptions = SeriesSortOptions, - defaultViewOptions = ViewOptionsPoster, - modifier = - Modifier - .padding(start = 16.dp) - .fillMaxSize() - .focusRequester(focusRequester), - positionCallback = { columns, position -> - showHeader = position < columns - }, - playEnabled = false, - filterOptions = DefaultForFavoritesFilterOptions, - focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex), - ) - } - - // Episodes - 2 -> { - CollectionFolderGrid( - preferences = preferences, - actions = actions, - itemId = "${NavDrawerItem.Favorites.id}_episodes", - initialFilter = - CollectionFolderFilter( - filter = - GetItemsFilter( - favorite = true, - includeItemTypes = listOf(BaseItemKind.EPISODE), - ), - ), - showTitle = false, - recursive = true, - sortOptions = EpisodeSortOptions, - defaultViewOptions = ViewOptionsWide, - useSeriesForPrimary = false, - modifier = - Modifier - .padding(start = 16.dp) - .fillMaxSize() - .focusRequester(focusRequester), - positionCallback = { columns, position -> - showHeader = position < columns - }, - playEnabled = false, - filterOptions = DefaultForFavoritesFilterOptions, - focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex), - ) - } - - // Videos - 3 -> { - CollectionFolderGrid( - preferences = preferences, - actions = actions, - itemId = "${NavDrawerItem.Favorites.id}_videos", - initialFilter = - CollectionFolderFilter( - filter = - GetItemsFilter( - favorite = true, - includeItemTypes = listOf(BaseItemKind.VIDEO), - ), - ), - showTitle = false, - recursive = true, - sortOptions = VideoSortOptions, - defaultViewOptions = ViewOptionsWide, - modifier = - Modifier - .padding(start = 16.dp) - .fillMaxSize() - .focusRequester(focusRequester), - positionCallback = { columns, position -> - showHeader = position < columns - }, - playEnabled = false, - filterOptions = DefaultForFavoritesFilterOptions, - focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex), - ) - } - - // Playlists - 4 -> { - CollectionFolderGrid( - preferences = preferences, - actions = actions, - itemId = "${NavDrawerItem.Favorites.id}_playlists", - initialFilter = - CollectionFolderFilter( - filter = - GetItemsFilter( - favorite = true, - includeItemTypes = listOf(BaseItemKind.PLAYLIST), - ), - ), - showTitle = false, - recursive = true, - sortOptions = VideoSortOptions, - defaultViewOptions = ViewOptionsSquare, - modifier = - Modifier - .padding(start = 16.dp) - .fillMaxSize() - .focusRequester(focusRequester), - positionCallback = { columns, position -> - showHeader = position < columns - }, - playEnabled = false, - filterOptions = DefaultForFavoritesFilterOptions, - focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex), - ) - } - - // People - 5 -> { - CollectionFolderGrid( - preferences = preferences, - actions = actions, - itemId = "${NavDrawerItem.Favorites.id}_people", - initialFilter = - CollectionFolderFilter( - filter = - GetItemsFilter( - favorite = true, - override = GetItemsFilterOverride.PERSON, - ), - ), - initialSortAndDirection = - SortAndDirection( - ItemSortBy.DEFAULT, - SortOrder.ASCENDING, - ), - showTitle = false, - recursive = true, - sortOptions = listOf(), - defaultViewOptions = ViewOptionsPoster, - modifier = - Modifier - .padding(start = 16.dp) - .fillMaxSize() - .focusRequester(focusRequester), - positionCallback = { columns, position -> - showHeader = position < columns - }, - playEnabled = false, - filterOptions = listOf(), - focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex), - ) - } - - else -> { - ErrorMessage("Invalid tab index $selectedTabIndex", null) + contextMenu.Compose() + } else { + ErrorMessage("Invalid tab $type", null) + } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesViewModel.kt new file mode 100644 index 00000000..5690fadc --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesViewModel.kt @@ -0,0 +1,813 @@ +package com.github.damontecres.wholphin.ui.detail + +import android.content.Context +import androidx.annotation.VisibleForTesting +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.snapshots.SnapshotStateMap +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.filter.FilterValueOption +import com.github.damontecres.wholphin.data.filter.ItemFilterBy +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.GetItemsFilter +import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.services.BackdropService +import com.github.damontecres.wholphin.services.FavoriteWatchManager +import com.github.damontecres.wholphin.services.FilterOptionCache +import com.github.damontecres.wholphin.services.MediaManagementService +import com.github.damontecres.wholphin.services.NavDrawerService +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.RememberedTabService +import com.github.damontecres.wholphin.services.ServerReportService +import com.github.damontecres.wholphin.services.StreamChoiceService +import com.github.damontecres.wholphin.services.UserPreferencesService +import com.github.damontecres.wholphin.services.deleteItem +import com.github.damontecres.wholphin.ui.SlimItemFields +import com.github.damontecres.wholphin.ui.collectLatestIn +import com.github.damontecres.wholphin.ui.components.CollectionFolderState +import com.github.damontecres.wholphin.ui.components.CollectionFolderViewActions +import com.github.damontecres.wholphin.ui.components.ContextMenuProvider +import com.github.damontecres.wholphin.ui.components.TabDetails +import com.github.damontecres.wholphin.ui.components.ViewOptions +import com.github.damontecres.wholphin.ui.components.baseItemKinds +import com.github.damontecres.wholphin.ui.components.defaultViewOptions +import com.github.damontecres.wholphin.ui.data.SortAndDirection +import com.github.damontecres.wholphin.ui.equalsNotNull +import com.github.damontecres.wholphin.ui.formatTypeName +import com.github.damontecres.wholphin.ui.launchDefault +import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.ui.main.settings.Library +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.nav.NavDrawerItem +import com.github.damontecres.wholphin.ui.showToast +import com.github.damontecres.wholphin.ui.util.goToRandom +import com.github.damontecres.wholphin.util.ApiRequestPager +import com.github.damontecres.wholphin.util.BlockingList +import com.github.damontecres.wholphin.util.DataLoadingState +import com.github.damontecres.wholphin.util.ExceptionHandler +import com.github.damontecres.wholphin.util.GetArtistsHandler +import com.github.damontecres.wholphin.util.GetItemsRequestHandler +import com.github.damontecres.wholphin.util.GetPersonsHandler +import com.github.damontecres.wholphin.util.LoadingState +import com.github.damontecres.wholphin.util.WholphinDispatchers +import com.github.damontecres.wholphin.util.successValue +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.request.GetArtistsRequest +import org.jellyfin.sdk.model.api.request.GetItemsRequest +import org.jellyfin.sdk.model.api.request.GetPersonsRequest +import timber.log.Timber +import java.util.SortedMap +import java.util.TreeMap +import java.util.UUID +import javax.inject.Inject + +@HiltViewModel +class FavoritesViewModel + @Inject + constructor( + @param:ApplicationContext private val context: Context, + private val api: ApiClient, + val navigationManager: NavigationManager, + private val serverRepository: ServerRepository, + private val navDrawerService: NavDrawerService, + private val libraryDisplayInfoDao: LibraryDisplayInfoDao, + private val favoriteWatchManager: FavoriteWatchManager, + private val backdropService: BackdropService, + private val userPreferencesService: UserPreferencesService, + private val mediaManagementService: MediaManagementService, + val streamChoiceService: StreamChoiceService, + val serverReportService: ServerReportService, + private val filterOptionCache: FilterOptionCache, + private val rememberedTabService: RememberedTabService, + ) : ViewModel() { + private val _state = MutableStateFlow(FavoritesPageState()) + val state: StateFlow<FavoritesPageState> = _state + + fun init() { + viewModelScope.launchIO { + val rememberTabs = + userPreferencesService + .getCurrent() + .appPreferences.interfacePreferences.rememberSelectedTab + + setupPossibleTypes() + + val favoriteTypeOptions = state.value.favoriteTypeOptions + if (favoriteTypeOptions.isNotEmpty()) { + val tabKey = + if (rememberTabs) { + val rememberedTab = + rememberedTabService + .getRememberedTab(NavDrawerItem.Favorites.id) + ?.let { + BaseItemKind.entries[it] + } + if (rememberedTab != null && rememberedTab in favoriteTypeOptions) { + rememberedTab + } else { + favoriteTypeOptions.first() + } + } else { + favoriteTypeOptions.first() + } + val jobs = + favoriteTypeOptions.associateWith { type -> initialFetchForType(type) } + initialFetchAndWait(tabKey, jobs) + } else { + Timber.w("No available types to check for favorites!") + _state.update { + it.copy(loadingState = FavoritesLoadingState.NoFavorites) + } + } + + userPreferencesService.flow + .map { it.appPreferences.interfacePreferences.showClock } + .collectLatestIn(viewModelScope) { isShowClock -> + _state.update { it.copy(isShowClock = isShowClock) } + } + } + } + + @VisibleForTesting + internal fun setupPossibleTypes() { + val availableTypes = + navDrawerService.state.value.allLibraries + .possibleFavoriteTypes(false) + .toSet() + val favoriteTypeOptions = favoriteOptions.filter { it in availableTypes } + _state.update { + it.copy( + favoriteTypeOptions = favoriteTypeOptions, + ) + } + } + + /** + * Starts queries for favorites, waiting for [firstTabKey] to complete + * + * Exposed for testing + */ + internal suspend fun initialFetchAndWait( + firstTabKey: BaseItemKind, + jobs: Map<BaseItemKind, Deferred<DataLoadingState<List<BaseItem?>>>>, + ) { + val completed = + jobs[firstTabKey]?.let { + when (val result = it.await()) { + DataLoadingState.Loading, + DataLoadingState.Pending, + -> false + + is DataLoadingState.Error, + -> true + + is DataLoadingState.Success<List<BaseItem?>> -> result.data.isNotEmpty() + } + } == true + if (completed) { + Timber.v("Got completed for %s", firstTabKey) + _state.update { + it.copy( + loadingState = FavoritesLoadingState.Success, + tabKey = firstTabKey, + ) + } + } else { + // If the first key wasn't successful, wait in order until first successful & non-empty + val favoriteTypeOptions = state.value.favoriteTypeOptions + val firstCompleted = + favoriteTypeOptions.firstOrNull { type -> + val job = jobs[type] ?: return@firstOrNull false + val result = job.await() + result is DataLoadingState.Error || + (result is DataLoadingState.Success<List<BaseItem?>> && result.data.isNotEmpty()) + } + _state.update { + if (firstCompleted != null) { + it.copy( + loadingState = FavoritesLoadingState.Success, + tabKey = firstCompleted, + ) + } else { + // None were error or successful & non-empty + // Check if they were all empty which means the user has no favorites at all + val allEmpty = + jobs.all { (_, job) -> + val result = job.await() + result is DataLoadingState.Success<List<BaseItem?>> && result.data.isEmpty() + } + if (allEmpty) { + it.copy( + loadingState = FavoritesLoadingState.NoFavorites, + ) + } else { + // This means a least one failed + val firstError = + jobs.entries.firstOrNull { (_, job) -> + job.await() is DataLoadingState.Error + } + if (firstError != null) { + it.copy( + loadingState = FavoritesLoadingState.Success, + tabKey = firstError.key, + ) + } else { + // This shouldn't happen + it.copy( + loadingState = FavoritesLoadingState.Error("An unknown error occurred"), + ) + } + } + } + } + } + } + + /** + * Do the initial fetch for a [BaseItemKind] which will look up any existing saved sort and filter + */ + private fun initialFetchForType(type: BaseItemKind): Deferred<DataLoadingState<List<BaseItem?>>> = + viewModelScope.async(WholphinDispatchers.IO) { + try { + val collectionState = + CollectionFolderState( + item = DataLoadingState.Loading, + items = DataLoadingState.Loading, + backgroundLoading = LoadingState.Loading, + viewOptions = ViewOptions(), + ) + _state.value.favorites[type] = collectionState + + val libraryDisplayInfo = + serverRepository.currentUser?.let { user -> + libraryDisplayInfoDao.getItem(user, libraryDisplayItemId(type)) + } + val sortAndDirection = + libraryDisplayInfo?.sortAndDirection ?: SortAndDirection.DEFAULT + val filter = libraryDisplayInfo?.filter ?: GetItemsFilter(favorite = true) + val viewOptions = libraryDisplayInfo?.viewOptions ?: type.defaultViewOptions + fetchType(type, sortAndDirection, filter, viewOptions) + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Error fetching favorites for %s", type) + updateForException(type, ex) + DataLoadingState.Error(ex) + } + } + + /** + * Query for favorites of the specified [BaseItemKind] using the specified sort and filter + */ + @VisibleForTesting + internal suspend fun fetchType( + type: BaseItemKind, + sortAndDirection: SortAndDirection, + filter: GetItemsFilter, + viewOptions: ViewOptions, + ): DataLoadingState<List<BaseItem?>> { + val collectionState = + CollectionFolderState( + item = DataLoadingState.Loading, + items = DataLoadingState.Loading, + backgroundLoading = LoadingState.Loading, + viewOptions = ViewOptions(), + ) + _state.value.favorites[type] = collectionState + try { + val pager = createPager(type, filter, sortAndDirection) + _state.value.favorites[type] = + collectionState.copy( + item = DataLoadingState.Success(null), + items = DataLoadingState.Success(pager), + backgroundLoading = LoadingState.Success, + sortAndDirection = sortAndDirection, + filter = filter, + viewOptions = viewOptions, + ) + Timber.v("Got %s favorites for %s", pager.size, type) + _state.update { + val newTabs = + it.createMap { + if (pager.isNotEmpty()) { + put(type, TabDetails(formatTypeName(type))) + } else { + remove(type) + } + } + it.copy( + tabs = newTabs, + ) + } + return DataLoadingState.Success(pager) + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + updateForException(type, ex) + return DataLoadingState.Error(ex) + } + } + + /** + * If fetching data for a type fails, this should be called to update the state with the error + */ + private fun updateForException( + type: BaseItemKind, + ex: Exception, + ) { + _state.update { + // Want to store the error for the tab + _state.value.favorites[type] = + CollectionFolderState( + item = DataLoadingState.Success(null), + items = DataLoadingState.Error(ex), + backgroundLoading = LoadingState.Success, + viewOptions = type.defaultViewOptions, + ) + // And make sure the tab is included + val newTabs = + it.createMap { + put(type, TabDetails(formatTypeName(type))) + } + it.copy(tabs = newTabs) + } + } + + private fun createGetItemsRequest( + type: BaseItemKind, + filter: GetItemsFilter, + sortAndDirection: SortAndDirection, + ): GetItemsRequest = + filter.applyTo( + GetItemsRequest( + isFavorite = true, + recursive = true, + includeItemTypes = listOf(type), + fields = SlimItemFields, + sortBy = listOf(sortAndDirection.sort), + sortOrder = listOf(sortAndDirection.direction), + ), + overwriteIncludeTypes = false, + ) + + private fun createGetArtistsRequest( + filter: GetItemsFilter, + sortAndDirection: SortAndDirection, + ): GetArtistsRequest = + filter.applyTo( + GetArtistsRequest( + isFavorite = true, + fields = SlimItemFields, + sortBy = listOf(sortAndDirection.sort), + sortOrder = listOf(sortAndDirection.direction), + ), + ) + + private fun createGetPersonsRequest(filter: GetItemsFilter): GetPersonsRequest = + filter.applyTo( + GetPersonsRequest( + isFavorite = true, + fields = SlimItemFields, + ), + ) + + private suspend fun createPager( + type: BaseItemKind, + filter: GetItemsFilter, + sortAndDirection: SortAndDirection, + ): ApiRequestPager<*> = + when (type) { + BaseItemKind.MUSIC_ARTIST -> { + val request = createGetArtistsRequest(filter, sortAndDirection) + ApiRequestPager(api, request, GetArtistsHandler, viewModelScope, pageSize = 50) + } + + BaseItemKind.PERSON -> { + val request = createGetPersonsRequest(filter) + ApiRequestPager(api, request, GetPersonsHandler, viewModelScope, pageSize = 50) + } + + else -> { + val request = createGetItemsRequest(type, filter, sortAndDirection) + ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope, pageSize = 50) + } + }.init() + + fun libraryDisplayItemId(type: BaseItemKind): String { + // Previous version used hardcoded keys before more types were supported + // So override those type names so that user's settings are maintained + val typeKey = + when (type) { + BaseItemKind.MOVIE -> "movies" + BaseItemKind.SERIES -> "series" + BaseItemKind.EPISODE -> "episodes" + BaseItemKind.VIDEO -> "videos" + BaseItemKind.PLAYLIST -> "playlists" + BaseItemKind.PERSON -> "people" + else -> type.serialName + } + return "${NavDrawerItem.Favorites.id}_$typeKey" + } + + private fun collectionStateFor(type: BaseItemKind): CollectionFolderState? = state.value.favorites[type] + + fun updateSelectedTabKey(newKey: BaseItemKind) { + viewModelScope.launchDefault { backdropService.clearBackdrop() } + viewModelScope.launchIO { + val rememberTabs = + userPreferencesService + .getCurrent() + .appPreferences.interfacePreferences.rememberSelectedTab + if (rememberTabs) { + rememberedTabService.saveRememberedTab( + NavDrawerItem.Favorites.id, + newKey.ordinal, + ) + } + } + _state.update { it.copy(tabKey = newKey) } + } + + private suspend fun refreshAfterMutate( + type: BaseItemKind, + position: Int, + itemId: UUID, + ) { + try { + val pager = + collectionStateFor(type)?.let { + ((it.items as? DataLoadingState.Success)?.data as? ApiRequestPager<*>) + } + pager?.refreshItem(position, itemId) + } catch (ex: Exception) { + Timber.e(ex, "Error refreshing after mutate %s", itemId) + showToast(context, "Error refreshing after item mutate") + } + } + + private suspend fun refreshAfterDelete( + type: BaseItemKind, + position: Int, + deletedItem: BaseItem, + ) { + try { + val pager = + collectionStateFor(type)?.let { + ((it.items as? DataLoadingState.Success)?.data as? ApiRequestPager<*>) + } + + position.let { + Timber.v("Item deleted: position=%s, id=%s", it, deletedItem.id) + val item = pager?.get(it) + // Exact item deleted (eg a movie) or deleted item was within the series + if (item?.id == deletedItem.id || + equalsNotNull(item?.data?.id, deletedItem.data.seriesId) + ) { + pager?.refreshPagesAfter(position) + } + } + } catch (ex: Exception) { + Timber.e(ex, "Error refreshing after deleted item %s", deletedItem.id) + showToast(context, "Error refreshing after item deleted") + } + } + + private fun saveLibraryDisplayInfo( + type: BaseItemKind, + newFilter: GetItemsFilter, + newSort: SortAndDirection, + viewOptions: ViewOptions?, + ) { + serverRepository.currentUser?.let { user -> + viewModelScope.launchIO { + val libraryDisplayInfo = + LibraryDisplayInfo( + userId = user.rowId, + itemId = libraryDisplayItemId(type), + sort = newSort.sort, + direction = newSort.direction, + filter = newFilter, + viewOptions = viewOptions, + ) + libraryDisplayInfoDao.saveItem(libraryDisplayInfo) + } + } + } + + private fun saveViewOptions( + type: BaseItemKind, + viewOptions: ViewOptions, + ) { + viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { + collectionStateFor(type)?.let { collectionState -> + saveLibraryDisplayInfo( + type = type, + newFilter = collectionState.filter, + newSort = collectionState.sortAndDirection, + viewOptions = viewOptions, + ) + state.value.favorites[type] = + collectionState.copy( + viewOptions = viewOptions, + ) + if (!viewOptions.showBackdrop) { + backdropService.clearBackdrop() + } + } + } + } + + fun createTypedProvider(type: BaseItemKind) = TypedProvider(type) + + /** + * This provides context menu actions and other actions on a per [BaseItemKind] basis + */ + @Immutable + inner class TypedProvider( + val type: BaseItemKind, + ) : ContextMenuProvider, + CollectionFolderViewActions { + override fun isAdministrator(): Boolean = serverRepository.currentUserDto?.policy?.isAdministrator == true + + override fun navigateTo(destination: Destination) = navigationManager.navigateTo(destination) + + override fun canDelete( + item: BaseItem, + appPreferences: AppPreferences, + ): Boolean = mediaManagementService.canDelete(item, appPreferences) + + override fun deleteItem( + index: Int, + item: BaseItem, + ) { + deleteItem(context, mediaManagementService, item) { + viewModelScope.launchDefault { + refreshAfterDelete(type, index, item) + } + } + } + + override fun setWatched( + position: Int, + itemId: UUID, + played: Boolean, + ) { + viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { + favoriteWatchManager.setWatched(itemId, played) + refreshAfterMutate(type, position, itemId) + } + } + + override fun setFavorite( + position: Int, + itemId: UUID, + favorite: Boolean, + ) { + viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { + favoriteWatchManager.setWatched(itemId, favorite) + collectionStateFor(type)?.let { collectionState -> + // TODO use pager.refreshPagesAfter ?? + fetchType( + type, + collectionState.sortAndDirection, + collectionState.filter, + collectionState.viewOptions, + ) + } + } + } + + override fun sendReportFor(itemId: UUID) = serverReportService.sendMediaReportFor(itemId) + + override fun updateBackdrop(item: BaseItem) { + viewModelScope.launchIO { + backdropService.submit(item) + } + } + + override fun onSortChange( + sortAndDirection: SortAndDirection, + recursive: Boolean, + filter: GetItemsFilter, + ) { + viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { + Timber.v("onSortChange: type=%s, sortAndDirection=%s", type, sortAndDirection) + collectionStateFor(type)?.let { collectionState -> + saveLibraryDisplayInfo( + type = type, + newFilter = filter, + newSort = sortAndDirection, + viewOptions = collectionState.viewOptions, + ) + state.value.favorites[type] = + collectionState.copy( + filter = filter, + sortAndDirection = sortAndDirection, + ) + fetchType(type, sortAndDirection, filter, collectionState.viewOptions) + } + } + } + + override fun onFilterChange( + newFilter: GetItemsFilter, + recursive: Boolean, + ) { + viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { + Timber.v("onFilterChange: type=%s, newFilter=%s", type, newFilter) + collectionStateFor(type)?.let { collectionState -> + saveLibraryDisplayInfo( + type = type, + newFilter = newFilter, + newSort = collectionState.sortAndDirection, + viewOptions = collectionState.viewOptions, + ) + state.value.favorites[type] = + collectionState.copy( + filter = newFilter, + ) + fetchType( + type, + collectionState.sortAndDirection, + newFilter, + collectionState.viewOptions, + ) + } + } + } + + override suspend fun getFilterOptionValues(filterOption: ItemFilterBy<*>): List<FilterValueOption> = + // TODO use includeItemTypes? + filterOptionCache.getFilterOptionValues( + null, + filterOption, + ) + + override suspend fun positionOfLetter(letter: Char): Int? = + collectionStateFor(type)?.let { collectionState -> + when (type) { + BaseItemKind.MUSIC_ARTIST -> { + val request = + createGetArtistsRequest( + collectionState.filter, + collectionState.sortAndDirection, + ).copy( + enableUserData = false, + limit = 0, + enableTotalRecordCount = true, + ) + GetArtistsHandler.execute(api, request).content.totalRecordCount + } + + BaseItemKind.PERSON -> { + null + } + + else -> { + val request = + createGetItemsRequest( + type, + collectionState.filter, + collectionState.sortAndDirection, + ).copy( + enableUserData = false, + limit = 0, + enableTotalRecordCount = true, + ) + GetItemsRequestHandler.execute(api, request).content.totalRecordCount + } + } + } + + override fun saveViewOptions(viewOptions: ViewOptions) { + saveViewOptions(type, viewOptions) + } + + override fun onClickRandom() { + viewModelScope.launchIO { + try { + collectionStateFor(type)?.let { collectionState -> + val list = + collectionState.items.successValue as? BlockingList<BaseItem?> + if (list != null) { + goToRandom( + navigateTo = ::navigateTo, + parentId = libraryDisplayItemId(type), + filter = + collectionState.filter.copy( + includeItemTypes = listOf(type), + ), + sortAndDirection = collectionState.sortAndDirection, + recursive = true, + list = list, + ) + } else { + Timber.w( + "state.value.items is not successful: %s", + collectionState.items, + ) + } + } + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Error getting random") + showToast(context, "Error: ${ex.localizedMessage}}") + } + } + } + } + } + +data class FavoritesPageState( + val loadingState: FavoritesLoadingState = FavoritesLoadingState.Pending, + val favoriteTypeOptions: List<BaseItemKind> = emptyList(), + val favorites: SnapshotStateMap<BaseItemKind, CollectionFolderState> = SnapshotStateMap(), + val tabs: SortedMap<BaseItemKind, TabDetails> = TreeMap(compareBy { favoriteOptions.indexOf(it) }), + val tabKey: BaseItemKind = favoriteOptions.first(), + val isShowClock: Boolean = true, +) + +sealed interface FavoritesLoadingState { + data object Pending : FavoritesLoadingState + + data object Loading : FavoritesLoadingState + + data object Success : FavoritesLoadingState + + data object NoFavorites : FavoritesLoadingState + + data class Error( + val message: String? = null, + val exception: Throwable? = null, + ) : FavoritesLoadingState { + constructor(exception: Throwable) : this(null, exception) + + val localizedMessage: String = + listOfNotNull(message, exception?.localizedMessage).joinToString(" - ") + } +} + +/** + * List of supported favorite types + * + * This is also the order they will appear in the UI + */ +val favoriteOptions = + listOf( + BaseItemKind.MOVIE, + BaseItemKind.SERIES, + BaseItemKind.EPISODE, + BaseItemKind.BOX_SET, + BaseItemKind.PERSON, + BaseItemKind.TV_CHANNEL, + BaseItemKind.MUSIC_ALBUM, + BaseItemKind.MUSIC_ARTIST, + BaseItemKind.AUDIO, + BaseItemKind.MUSIC_VIDEO, + BaseItemKind.PLAYLIST, + BaseItemKind.VIDEO, + BaseItemKind.PHOTO, + BaseItemKind.PHOTO_ALBUM, + ) + +/** + * Creates a new tab map from the current state applying the supplied block which can contain mutations + */ +private inline fun FavoritesPageState.createMap(block: TreeMap<BaseItemKind, TabDetails>.() -> Unit) = + TreeMap<BaseItemKind, TabDetails>(compareBy { favoriteOptions.indexOf(it) }) + .apply { + putAll(this@createMap.tabs) + block.invoke(this) + } + +/** + * Given the list of libraries, return the possible favorite types + */ +fun List<Library>.possibleFavoriteTypes(sort: Boolean) = + flatMap { it.collectionType.baseItemKinds } + .distinct() + .filter { it in favoriteOptions } + .let { + // No library for persons, so always include it + val list = it + listOf(BaseItemKind.PERSON) + if (sort) { + list.sortedWith(compareBy { favoriteOptions.indexOf(it) }) + } else { + list + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/HomeRowGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/HomeRowGrid.kt new file mode 100644 index 00000000..4e49bd39 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/HomeRowGrid.kt @@ -0,0 +1,322 @@ +package com.github.damontecres.wholphin.ui.detail + +import android.content.Context +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.gestures.LocalBringIntoViewSpec +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.HomeRowConfig +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.services.BackdropService +import com.github.damontecres.wholphin.services.FavoriteWatchManager +import com.github.damontecres.wholphin.services.HomeDataService +import com.github.damontecres.wholphin.services.MediaManagementService +import com.github.damontecres.wholphin.services.MusicService +import com.github.damontecres.wholphin.services.NavDrawerService +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.ServerReportService +import com.github.damontecres.wholphin.services.StreamChoiceService +import com.github.damontecres.wholphin.services.ThemeSongPlayer +import com.github.damontecres.wholphin.services.UserPreferencesService +import com.github.damontecres.wholphin.services.deleteItem +import com.github.damontecres.wholphin.ui.cards.GridCard +import com.github.damontecres.wholphin.ui.components.ContextMenuProvider +import com.github.damontecres.wholphin.ui.components.ErrorMessage +import com.github.damontecres.wholphin.ui.components.GenreCardGrid +import com.github.damontecres.wholphin.ui.components.GridTitle +import com.github.damontecres.wholphin.ui.components.LoadingPage +import com.github.damontecres.wholphin.ui.components.StudioCardGrid +import com.github.damontecres.wholphin.ui.components.rememberContextMenu +import com.github.damontecres.wholphin.ui.launchDefault +import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.playback.scale +import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.ui.util.StringProvider +import com.github.damontecres.wholphin.ui.util.StringStringProvider +import com.github.damontecres.wholphin.util.ApiRequestPager +import com.github.damontecres.wholphin.util.ExceptionHandler +import com.github.damontecres.wholphin.util.HomeRowLoadingState +import com.github.damontecres.wholphin.util.WholphinDispatchers +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.jellyfin.sdk.model.api.CollectionType +import timber.log.Timber +import java.util.UUID + +@HiltViewModel(assistedFactory = HomeRowGridViewModel.Factory::class) +class HomeRowGridViewModel + @AssistedInject + constructor( + @param:ApplicationContext private val context: Context, + val serverRepository: ServerRepository, + private val userPreferencesService: UserPreferencesService, + private val navDrawerService: NavDrawerService, + private val homeDataService: HomeDataService, + private val favoriteWatchManager: FavoriteWatchManager, + private val backdropService: BackdropService, + private val navigationManager: NavigationManager, + private val themeSongPlayer: ThemeSongPlayer, + private val mediaManagementService: MediaManagementService, + private val musicService: MusicService, + val streamChoiceService: StreamChoiceService, + val serverReportService: ServerReportService, + @Assisted private val title: StringProvider, + @Assisted private val rowConfig: HomeRowConfig, + ) : ViewModel(), + ContextMenuProvider { + @AssistedFactory + interface Factory { + fun create( + title: StringProvider, + rowConfig: HomeRowConfig, + ): HomeRowGridViewModel + } + + private val _state = MutableStateFlow(HomeRowGridState()) + val state: StateFlow<HomeRowGridState> = _state + + init { + viewModelScope.launchDefault { + when (rowConfig) { + is HomeRowConfig.Genres, + is HomeRowConfig.Studios, + -> { + // Genres & Studios will be looked up by another ViewModel, so just return + _state.update { + it.copy( + loading = + HomeRowLoadingState.Success( + title, + emptyList(), + rowConfig.viewOptions, + ), + ) + } + return@launchDefault + } + + else -> {} + } + try { + val preferences = userPreferencesService.getCurrent() + val prefs = preferences.appPreferences.homePagePreferences + serverRepository.currentUserDto?.let { userDto -> + val libraries = + navDrawerService.getAllUserLibraries(userDto.id, userDto.tvAccess) + val result = + homeDataService.fetchDataForRow( + row = rowConfig, + scope = viewModelScope, + prefs = prefs, + userDto = userDto, + libraries = libraries, + isRefresh = true, + limit = 1_000, // TODO + usePaging = true, + ) + Timber.v( + "Got %s items for %s", + (result as? HomeRowLoadingState.Success)?.items?.size, + rowConfig, + ) + _state.update { it.copy(loading = result) } + } + } catch (ex: Exception) { + Timber.e(ex, "Error fetching: %s", rowConfig) + _state.update { + it.copy( + loading = + HomeRowLoadingState.Error( + title, + null, + ex, + ), + ) + } + } + } + } + + override fun setWatched( + position: Int, + itemId: UUID, + played: Boolean, + ) { + viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { + favoriteWatchManager.setWatched(itemId, played) + (state.value.loading as? HomeRowLoadingState.Success)?.let { + (it.items as? ApiRequestPager<*>)?.refreshItem(position, itemId) + } + } + } + + override fun setFavorite( + position: Int, + itemId: UUID, + favorite: Boolean, + ) { + viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { + favoriteWatchManager.setFavorite(itemId, favorite) + (state.value.loading as? HomeRowLoadingState.Success)?.let { + (it.items as? ApiRequestPager<*>)?.refreshItem(position, itemId) + } + } + } + + override fun sendReportFor(itemId: UUID) = serverReportService.sendMediaReportFor(itemId) + + fun updateBackdrop(item: BaseItem) { + viewModelScope.launchIO { + backdropService.submit(item) + } + } + + override fun isAdministrator(): Boolean = serverRepository.currentUserDto?.policy?.isAdministrator == true + + override fun navigateTo(destination: Destination) { + navigationManager.navigateTo(destination) + } + + override fun canDelete( + item: BaseItem, + appPreferences: AppPreferences, + ): Boolean = mediaManagementService.canDelete(item, appPreferences) + + override fun deleteItem( + index: Int, + item: BaseItem, + ) { + deleteItem(context, mediaManagementService, item) { + viewModelScope.launchDefault { + // TODO refresh + } + } + } + } + +data class HomeRowGridState( + val loading: HomeRowLoadingState = HomeRowLoadingState.Pending(StringStringProvider("")), +) + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun HomeRowGrid( + preferences: UserPreferences, + destination: Destination.MoreHomeRow, + modifier: Modifier = Modifier, + viewModel: HomeRowGridViewModel = + hiltViewModel<HomeRowGridViewModel, HomeRowGridViewModel.Factory>( + creationCallback = { it.create(destination.title, destination.config) }, + ), +) { + val state by viewModel.state.collectAsState() + val contextMenu = rememberContextMenu(preferences, viewModel) + val gridFocusRequester = remember { FocusRequester() } + val viewOptions = destination.config.viewOptions + + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier, + ) { + GridTitle(destination.title.getString()) + + when (val st = state.loading) { + is HomeRowLoadingState.Error -> { + ErrorMessage(st.message, st.exception, Modifier.fillMaxSize()) + } + + is HomeRowLoadingState.Loading, + is HomeRowLoadingState.Pending, + -> { + LoadingPage(Modifier.fillMaxSize()) + } + + is HomeRowLoadingState.Success -> { + when (destination.config) { + is HomeRowConfig.Genres -> { + GenreCardGrid( + itemId = destination.config.parentId, + includeItemTypes = null, + modifier = Modifier.fillMaxSize(), + initialPosition = destination.initialPosition, + collectionType = CollectionType.UNKNOWN, + ) + } + + is HomeRowConfig.Studios -> { + StudioCardGrid( + itemId = destination.config.parentId, + includeItemTypes = null, + modifier = Modifier.fillMaxSize(), + initialPosition = destination.initialPosition, + ) + } + + else -> { + val onClickItem = + remember { + { index: Int, item: BaseItem -> + viewModel.navigateTo(item.destination(index)) + } + } + LaunchedEffect(Unit) { gridFocusRequester.tryRequestFocus() } + CardGrid( + pager = st.items, + onClickItem = onClickItem, + onLongClickItem = contextMenu::showContextMenu, + onClickPlay = { _, _ -> }, + letterPosition = { -1 }, + gridFocusRequester = gridFocusRequester, + showJumpButtons = false, + showLetterButtons = false, + modifier = Modifier.fillMaxSize(), + initialPosition = destination.initialPosition, + positionCallback = { columns, newPosition -> + }, + cardContent = { (item, index, onClick, onLongClick, widthPx, mod) -> + GridCard( + item = item, + onClick = onClick, + onLongClick = onLongClick, + imageContentScale = viewOptions.contentScale.scale, + imageAspectRatio = viewOptions.aspectRatio.ratio, + imageType = viewOptions.imageType, + showTitle = true, // viewOptions.showTitles, + fillWidth = widthPx, + modifier = mod, + ) + }, + columns = if (viewOptions.aspectRatio.ratio > 1f) 4 else 6, + spacing = viewOptions.spacing.dp, + bringIntoViewSpec = LocalBringIntoViewSpec.current, + ) + } + } + } + } + } + contextMenu.Compose() +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/ItemViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/ItemViewModel.kt deleted file mode 100644 index 65c9a0e9..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/ItemViewModel.kt +++ /dev/null @@ -1,91 +0,0 @@ -package com.github.damontecres.wholphin.ui.detail - -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.github.damontecres.wholphin.data.model.BaseItem -import com.github.damontecres.wholphin.ui.setValueOnMain -import com.github.damontecres.wholphin.ui.toServerString -import com.github.damontecres.wholphin.util.LoadingExceptionHandler -import com.github.damontecres.wholphin.util.LoadingState -import kotlinx.coroutines.Deferred -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.async -import kotlinx.coroutines.withContext -import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.api.client.extensions.imageApi -import org.jellyfin.sdk.api.client.extensions.userLibraryApi -import org.jellyfin.sdk.model.api.ImageType -import timber.log.Timber -import java.util.UUID - -/** - * Basic [ViewModel] for a single fetchable item from the API - */ -abstract class ItemViewModel( - val api: ApiClient, -) : ViewModel() { - val item = MutableLiveData<BaseItem?>(null) - lateinit var itemId: String - var itemUuid: UUID? = null - - suspend fun fetchItem(itemId: UUID): BaseItem = - withContext(Dispatchers.IO) { - this@ItemViewModel.itemId = itemId.toServerString() - this@ItemViewModel.itemUuid = itemId - val it = api.userLibraryApi.getItem(itemId).content - val fetchedItem = BaseItem.from(it, api) - return@withContext fetchedItem.let { - withContext(Dispatchers.Main) { - item.value = fetchedItem - } - fetchedItem - } - } - - fun imageUrl( - itemId: UUID, - type: ImageType, - ): String? = api.imageApi.getItemImageUrl(itemId, type) -} - -/** - * Extends [ItemViewModel] to include a loading state tracking when the item has been fetched or if an error occurred - */ -abstract class LoadingItemViewModel( - api: ApiClient, -) : ItemViewModel(api) { - val loading = MutableLiveData<LoadingState>(LoadingState.Pending) - - open fun init( - itemId: UUID, - potential: BaseItem?, - ): Deferred<BaseItem?> = - viewModelScope.async( - LoadingExceptionHandler( - loading, - "Error loading item $itemId", - ) + Dispatchers.IO, - ) { - loading.setValueOnMain(LoadingState.Loading) - try { - fetchAndSetItem(itemId) - } catch (e: Exception) { - Timber.e(e, "Failed to load item $itemId") - withContext(Dispatchers.Main) { - item.value = null - loading.value = LoadingState.Error("Error loading item $itemId", e) - } - null - } - } - - open suspend fun fetchAndSetItem(itemId: UUID): BaseItem = - withContext(Dispatchers.IO) { - val item = fetchItem(itemId) - withContext(Dispatchers.Main) { - loading.value = LoadingState.Success - } - return@withContext item - } -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt index a6a77e23..64c48f0e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt @@ -18,7 +18,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -29,11 +28,12 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text @@ -46,11 +46,11 @@ import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.SeerrService +import com.github.damontecres.wholphin.ui.AspectRatios import com.github.damontecres.wholphin.ui.Cards +import com.github.damontecres.wholphin.ui.ItemRowFields import com.github.damontecres.wholphin.ui.LocalImageUrlService -import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect import com.github.damontecres.wholphin.ui.PreviewTvSpec -import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.cards.SeasonCard import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.ExpandableFaButton @@ -68,20 +68,23 @@ import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.rememberPosition -import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.theme.WholphinTheme import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.ui.util.ResStringProvider import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.DiscoverRequestType import com.github.damontecres.wholphin.util.GetItemsRequestHandler -import com.github.damontecres.wholphin.util.LoadingExceptionHandler -import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.RowLoadingState +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.api.ItemSortBy @@ -90,50 +93,77 @@ import org.jellyfin.sdk.model.api.request.GetItemsRequest import timber.log.Timber import java.time.LocalDate import java.util.UUID -import javax.inject.Inject -@HiltViewModel +@HiltViewModel(assistedFactory = PersonViewModel.Factory::class) class PersonViewModel - @Inject + @AssistedInject constructor( - api: ApiClient, + private val api: ApiClient, val navigationManager: NavigationManager, private val favoriteWatchManager: FavoriteWatchManager, private val seerrService: SeerrService, - ) : LoadingItemViewModel(api) { - val movies = MutableLiveData<RowLoadingState>(RowLoadingState.Pending) - val series = MutableLiveData<RowLoadingState>(RowLoadingState.Pending) - val episodes = MutableLiveData<RowLoadingState>(RowLoadingState.Pending) - val discovered = MutableStateFlow<List<DiscoverItem>>(listOf()) + @Assisted val itemId: UUID, + ) : ViewModel() { + @AssistedFactory + interface Factory { + fun create(itemId: UUID): PersonViewModel + } - fun init(itemId: UUID) { - viewModelScope.launchIO( - LoadingExceptionHandler( - loading, - "Error loading item $itemId", - ), - ) { - super.init(itemId, null).await()?.let { person -> + private val _state = MutableStateFlow(PersonState()) + val state: StateFlow<PersonState> = _state + + private suspend fun updatePerson(): BaseItem { + val person = + api.userLibraryApi + .getItem(itemId) + .content + .let { BaseItem(it) } + _state.update { + it.copy( + person = DataLoadingState.Success(person), + ) + } + return person + } + + init { + viewModelScope.launchIO { + try { + val person = updatePerson() val dto = person.data if ((dto.movieCount ?: 0) > 0) { - fetchRow(person.id, BaseItemKind.MOVIE, movies) + fetchRow(person.id, BaseItemKind.MOVIE) { state, row -> + state.copy(movies = row) + } } else { - movies.setValueOnMain(RowLoadingState.Success(listOf())) + _state.update { it.copy(movies = RowLoadingState.Success(emptyList())) } } if ((dto.seriesCount ?: 0) > 0) { - fetchRow(person.id, BaseItemKind.SERIES, series) + fetchRow(person.id, BaseItemKind.SERIES) { state, row -> + state.copy(series = row) + } } else { - series.setValueOnMain(RowLoadingState.Success(listOf())) + _state.update { it.copy(series = RowLoadingState.Success(emptyList())) } } if ((dto.episodeCount ?: 0) > 0) { - fetchRow(person.id, BaseItemKind.EPISODE, episodes) + fetchRow(person.id, BaseItemKind.EPISODE) { state, row -> + state.copy(episodes = row) + } } else { - episodes.setValueOnMain(RowLoadingState.Success(listOf())) + _state.update { it.copy(episodes = RowLoadingState.Success(emptyList())) } } viewModelScope.launchIO { val results = seerrService.similar(person).orEmpty() - discovered.update { results } + _state.update { + it.copy( + discovered = results, + discoverPerson = seerrService.discoverPerson(person), + ) + } } + } catch (ex: Exception) { + Timber.e(ex, "Error fetching person %s", itemId) + _state.update { it.copy(person = DataLoadingState.Error(ex)) } } } } @@ -141,16 +171,18 @@ class PersonViewModel private fun fetchRow( itemId: UUID, type: BaseItemKind, - target: MutableLiveData<RowLoadingState>, + update: (PersonState, RowLoadingState) -> PersonState, ) { viewModelScope.launchIO { - target.setValueOnMain(RowLoadingState.Loading) + _state.update { + update.invoke(it, RowLoadingState.Loading) + } try { val request = GetItemsRequest( personIds = listOf(itemId), includeItemTypes = listOf(type), - fields = SlimItemFields, + fields = ItemRowFields, recursive = true, sortBy = listOf(ItemSortBy.PREMIERE_DATE, ItemSortBy.PRODUCTION_YEAR, ItemSortBy.SORT_NAME), sortOrder = listOf(SortOrder.DESCENDING, SortOrder.DESCENDING, SortOrder.ASCENDING), @@ -164,96 +196,121 @@ class PersonViewModel pageSize = 15, useSeriesForPrimary = false, ).init() - target.setValueOnMain(RowLoadingState.Success(pager)) + _state.update { + update.invoke(it, RowLoadingState.Success(pager)) + } } catch (ex: Exception) { Timber.e(ex, "Error fetching $type for $itemId") - target.setValueOnMain(RowLoadingState.Error(ex)) + _state.update { + update.invoke(it, RowLoadingState.Error(ex)) + } } } } fun setFavorite(favorite: Boolean) { viewModelScope.launchIO { - itemUuid?.let { - favoriteWatchManager.setFavorite(it, favorite) - fetchAndSetItem(it) - } + favoriteWatchManager.setFavorite(itemId, favorite) + updatePerson() } } } +data class PersonState( + val person: DataLoadingState<BaseItem> = DataLoadingState.Pending, + val movies: RowLoadingState = RowLoadingState.Pending, + val series: RowLoadingState = RowLoadingState.Pending, + val episodes: RowLoadingState = RowLoadingState.Pending, + val discovered: List<DiscoverItem> = emptyList(), + val discoverPerson: DiscoverItem? = null, +) + @Composable fun PersonPage( preferences: UserPreferences, destination: Destination.MediaItem, modifier: Modifier = Modifier, - viewModel: PersonViewModel = hiltViewModel(), + viewModel: PersonViewModel = + hiltViewModel<PersonViewModel, PersonViewModel.Factory>( + creationCallback = { it.create(destination.itemId) }, + ), ) { - OneTimeLaunchedEffect { - viewModel.init(destination.itemId) - } - val person by viewModel.item.observeAsState() - val movies by viewModel.movies.observeAsState(RowLoadingState.Pending) - val series by viewModel.series.observeAsState(RowLoadingState.Pending) - val episodes by viewModel.episodes.observeAsState(RowLoadingState.Pending) - val discovered by viewModel.discovered.collectAsState() - - val loading by viewModel.loading.observeAsState(LoadingState.Loading) - when (val state = loading) { - is LoadingState.Error -> { - ErrorMessage(state, modifier) + val state by viewModel.state.collectAsState() + when (val st = state.person) { + is DataLoadingState.Error -> { + ErrorMessage(st, modifier) } - LoadingState.Loading, - LoadingState.Pending, + DataLoadingState.Loading, + DataLoadingState.Pending, -> { LoadingPage(modifier) } - LoadingState.Success -> { - person?.let { person -> - var showOverviewDialog by remember { mutableStateOf(false) } - val name = person.name ?: person.id.toString() - val imageUrlService = LocalImageUrlService.current - val imageUrl = remember { imageUrlService.getItemImageUrl(itemId = person.id, imageType = ImageType.PRIMARY) } - PersonPageContent( - preferences = preferences, - name = name, - overview = person.data.overview, - imageUrl = imageUrl, - birthdate = person.data.premiereDate?.toLocalDate(), - deathdate = person.data.endDate?.toLocalDate(), - birthPlace = person.data.productionLocations?.firstOrNull(), - favorite = person.favorite, - movies = movies, - series = series, - episodes = episodes, - onClickItem = { index, item -> - viewModel.navigationManager.navigateTo(item.destination()) - }, - overviewOnClick = { showOverviewDialog = true }, - favoriteOnClick = { - viewModel.setFavorite(!person.favorite) - }, - discovered = discovered, - onClickDiscover = { index, item -> - viewModel.navigationManager.navigateTo(item.destination) - }, - modifier = modifier, - ) - AnimatedVisibility(showOverviewDialog) { - ItemDetailsDialog( - info = - ItemDetailsDialogInfo( - title = name, - overview = person.data.overview, - genres = listOf(), - files = listOf(), - ), - showFilePath = false, - onDismissRequest = { showOverviewDialog = false }, + is DataLoadingState.Success<BaseItem> -> { + val person = st.data + var showOverviewDialog by remember { mutableStateOf(false) } + val name = remember(person) { person.name ?: person.id.toString() } + val imageUrlService = LocalImageUrlService.current + val imageUrl = + remember { + imageUrlService.getItemImageUrl( + itemId = person.id, + imageType = ImageType.PRIMARY, + tag = person.data.imageTags?.get(ImageType.PRIMARY), ) } + // A long filmography is hard to navigate as a row, so it is cut at the same + // limit as the home page rows and the rest is left to the full grid + val maxItems = preferences.appPreferences.homePagePreferences.maxItemsPerRow + val discovered = + remember(state.discovered, maxItems) { state.discovered.take(maxItems) } + + PersonPageContent( + preferences = preferences, + name = name, + overview = person.data.overview, + imageUrl = imageUrl, + birthdate = person.data.premiereDate?.toLocalDate(), + deathdate = person.data.endDate?.toLocalDate(), + birthPlace = person.data.productionLocations?.firstOrNull(), + favorite = person.favorite, + movies = state.movies, + series = state.series, + episodes = state.episodes, + onClickItem = { index, item -> + viewModel.navigationManager.navigateTo(item.destination()) + }, + overviewOnClick = { showOverviewDialog = true }, + favoriteOnClick = { + viewModel.setFavorite(!person.favorite) + }, + discovered = discovered, + onClickDiscover = { index, item -> + viewModel.navigationManager.navigateTo(item.destination) + }, + onClickViewMoreDiscover = { + state.discoverPerson?.let { + viewModel.navigationManager.navigateTo( + Destination.DiscoveredItem(it, maxItems), + ) + } + }, + enableViewMoreDiscover = state.discovered.size > discovered.size, + modifier = modifier, + ) + AnimatedVisibility(showOverviewDialog) { + ItemDetailsDialog( + info = + ItemDetailsDialogInfo( + title = name, + overview = person.data.overview, + genres = listOf(), + files = listOf(), + ), + showFilePath = false, + onDismissRequest = { showOverviewDialog = false }, + ) } } } @@ -283,6 +340,8 @@ fun PersonPageContent( overviewOnClick: () -> Unit, favoriteOnClick: () -> Unit, onClickDiscover: (Int, DiscoverItem) -> Unit, + onClickViewMoreDiscover: () -> Unit, + enableViewMoreDiscover: Boolean, modifier: Modifier = Modifier, ) { val focusRequester = remember { FocusRequester() } @@ -325,7 +384,7 @@ fun PersonPageContent( } item { LoadingRow( - title = rowTitle(stringResource(R.string.movies), movies), + title = rowTitle(stringResource(R.string.movies_title), movies), state = movies, rowIndex = MOVIE_ROW, position = position, @@ -338,7 +397,7 @@ fun PersonPageContent( } item { LoadingRow( - title = rowTitle(stringResource(R.string.tv_shows), series), + title = rowTitle(stringResource(R.string.tv_shows_title), series), state = series, rowIndex = SERIES_ROW, position = position, @@ -370,6 +429,7 @@ fun PersonPageContent( }, onLongClick = onLongClick, imageHeight = Cards.heightEpisode, + aspectRatio = item?.aspectRatio ?: AspectRatios.FOUR_THREE, modifier = mod .ifElse( @@ -385,7 +445,7 @@ fun PersonPageContent( DiscoverRow( row = DiscoverRowData( - stringResource(R.string.discover), + ResStringProvider(R.string.discover), DataLoadingState.Success(discovered), DiscoverRequestType.UNKNOWN, ), @@ -396,6 +456,11 @@ fun PersonPageContent( onLongClickItem = { _, _ -> }, onCardFocus = {}, focusRequester = focusRequester, + enableViewMore = enableViewMoreDiscover, + onClickViewMore = { + position = RowColumn(DISCOVER_ROW, discovered.size) + onClickViewMoreDiscover.invoke() + }, ) } } @@ -465,8 +530,9 @@ fun PersonHeader( val text = if (age != null) { stringResource(R.string.born) + ": ${formatDate(it)} (${ - stringResource( - R.string.years_old, + pluralStringResource( + R.plurals.years_old, + age, age, ) })" @@ -495,8 +561,9 @@ fun PersonHeader( val text = if (age != null) { stringResource(R.string.died) + ": ${formatDate(it)} (${ - stringResource( - R.string.years_old, + pluralStringResource( + R.plurals.years_old, + age, age, ) })" diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt index c69fc0e6..33960f83 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt @@ -1,254 +1,188 @@ package com.github.damontecres.wholphin.ui.detail import android.content.Context -import androidx.compose.foundation.background -import androidx.compose.foundation.focusGroup -import androidx.compose.foundation.focusable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.runtime.Composable -import androidx.compose.runtime.Immutable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusProperties -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.focusRestorer -import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.window.DialogProperties import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope -import androidx.tv.material3.ListItem +import androidx.tv.material3.LocalTextStyle import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text -import androidx.tv.material3.surfaceColorAtElevation import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao import com.github.damontecres.wholphin.data.ServerRepository -import com.github.damontecres.wholphin.data.filter.DefaultPlaylistItemsOptions -import com.github.damontecres.wholphin.data.filter.FilterValueOption -import com.github.damontecres.wholphin.data.filter.ItemFilterBy import com.github.damontecres.wholphin.data.model.BaseItem -import com.github.damontecres.wholphin.data.model.GetItemsFilter -import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo +import com.github.damontecres.wholphin.data.model.CollectionFolderFilter import com.github.damontecres.wholphin.preferences.UserPreferences -import com.github.damontecres.wholphin.services.BackdropService -import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.MediaManagementService -import com.github.damontecres.wholphin.services.MediaReportService import com.github.damontecres.wholphin.services.MusicService -import com.github.damontecres.wholphin.services.MusicServiceState import com.github.damontecres.wholphin.services.NavigationManager -import com.github.damontecres.wholphin.ui.DefaultItemFields -import com.github.damontecres.wholphin.ui.cards.ItemCardImage +import com.github.damontecres.wholphin.ui.FontAwesome +import com.github.damontecres.wholphin.ui.cards.FavoriteIndicator +import com.github.damontecres.wholphin.ui.cards.WatchedIcon +import com.github.damontecres.wholphin.ui.collectLatestIn import com.github.damontecres.wholphin.ui.components.BasicDialog +import com.github.damontecres.wholphin.ui.components.Button +import com.github.damontecres.wholphin.ui.components.CollectionFolderState +import com.github.damontecres.wholphin.ui.components.CollectionFolderViewContent +import com.github.damontecres.wholphin.ui.components.CollectionFolderViewModel +import com.github.damontecres.wholphin.ui.components.CollectionFolderViewScope import com.github.damontecres.wholphin.ui.components.ContextMenu import com.github.damontecres.wholphin.ui.components.ContextMenuActions import com.github.damontecres.wholphin.ui.components.ContextMenuDialog -import com.github.damontecres.wholphin.ui.components.ErrorMessage -import com.github.damontecres.wholphin.ui.components.ExpandableFaButton -import com.github.damontecres.wholphin.ui.components.ExpandablePlayButton -import com.github.damontecres.wholphin.ui.components.FilterByButton -import com.github.damontecres.wholphin.ui.components.LoadingPage +import com.github.damontecres.wholphin.ui.components.GridClickActions +import com.github.damontecres.wholphin.ui.components.ListItemWrapper import com.github.damontecres.wholphin.ui.components.MusicContextActions import com.github.damontecres.wholphin.ui.components.Optional -import com.github.damontecres.wholphin.ui.components.OverviewText -import com.github.damontecres.wholphin.ui.components.SortByButton import com.github.damontecres.wholphin.ui.components.TextButton +import com.github.damontecres.wholphin.ui.components.ViewOptions +import com.github.damontecres.wholphin.ui.components.ViewOptionsType import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel -import com.github.damontecres.wholphin.ui.data.BoxSetSortOptions +import com.github.damontecres.wholphin.ui.data.PlaylistSortOptions import com.github.damontecres.wholphin.ui.data.SortAndDirection import com.github.damontecres.wholphin.ui.detail.music.MusicQueueMarker import com.github.damontecres.wholphin.ui.detail.music.MusicViewModel import com.github.damontecres.wholphin.ui.enableMarquee import com.github.damontecres.wholphin.ui.equalsNotNull -import com.github.damontecres.wholphin.ui.formatDateTime -import com.github.damontecres.wholphin.ui.getTimeFormatter -import com.github.damontecres.wholphin.ui.ifElse +import com.github.damontecres.wholphin.ui.formatDuration +import com.github.damontecres.wholphin.ui.formatTime +import com.github.damontecres.wholphin.ui.gt import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.ui.main.settings.MoveDirection import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.roundMinutes +import com.github.damontecres.wholphin.ui.roundSeconds +import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.ui.toServerString -import com.github.damontecres.wholphin.ui.tryRequestFocus -import com.github.damontecres.wholphin.ui.util.FilterUtils import com.github.damontecres.wholphin.ui.util.LocalClock import com.github.damontecres.wholphin.util.ApiRequestPager -import com.github.damontecres.wholphin.util.ExceptionHandler -import com.github.damontecres.wholphin.util.GetItemsRequestHandler -import com.github.damontecres.wholphin.util.LoadingState +import com.github.damontecres.wholphin.util.successValue import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.api.client.extensions.userLibraryApi +import org.jellyfin.sdk.api.client.exception.InvalidStatusException +import org.jellyfin.sdk.api.client.extensions.playlistsApi import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.MediaType import org.jellyfin.sdk.model.api.SortOrder -import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.jellyfin.sdk.model.extensions.ticks import timber.log.Timber import java.util.UUID -import kotlin.time.Duration @HiltViewModel(assistedFactory = PlaylistViewModel.Factory::class) class PlaylistViewModel @AssistedInject constructor( + private val savedStateHandle: SavedStateHandle, @ApplicationContext context: Context, api: ApiClient, navigationManager: NavigationManager, musicService: MusicService, mediaManagementService: MediaManagementService, - private val backdropService: BackdropService, private val serverRepository: ServerRepository, - private val libraryDisplayInfoDao: LibraryDisplayInfoDao, - private val favoriteWatchManager: FavoriteWatchManager, - private val mediaReportService: MediaReportService, @Assisted itemId: UUID, + @Assisted private val collectionState: StateFlow<CollectionFolderState>, ) : MusicViewModel(itemId, context, api, musicService, navigationManager, mediaManagementService) { @AssistedFactory interface Factory { - fun create(itemId: UUID): PlaylistViewModel + fun create( + itemId: UUID, + collectionState: StateFlow<CollectionFolderState>, + ): PlaylistViewModel } - val state = MutableStateFlow(PlaylistDetailsState()) + private val _state = MutableStateFlow(PlaylistDetailsState()) + val state: StateFlow<PlaylistDetailsState> = _state + val musicState = musicService.state + var position: Int + get() = savedStateHandle.get<Int>("position") ?: 0 + set(value) { + savedStateHandle["position"] = value + } + init { init() } override fun init() { - state.update { it.copy(loading = LoadingState.Loading) } viewModelScope.launchDefault { try { - val playlist = - api.userLibraryApi - .getItem(itemId) - .content - .let { BaseItem(it, false) } - state.update { it.copy(playlist = playlist) } - val libraryDisplayInfo = - serverRepository.currentUser.value?.let { user -> - libraryDisplayInfoDao.getItem(user, itemId) - } - val filter = libraryDisplayInfo?.filter ?: GetItemsFilter() - val sortAndDirection = - libraryDisplayInfo?.sortAndDirection ?: SortAndDirection( - ItemSortBy.DEFAULT, - SortOrder.ASCENDING, - ) - loadItems(filter, sortAndDirection).join() + val user = serverRepository.currentUser + val canEdit = + user?.let { user -> + try { + val permission by api.playlistsApi.getPlaylistUser(itemId, user.id) + permission.canEdit + } catch (ex: CancellationException) { + throw ex + } catch (ex: InvalidStatusException) { + if (ex.status == 404) { + // Server will return this if no permission exists + Timber.w( + "User doesn't have permission to edit playlist %s", + itemId, + ) + } else { + Timber.e( + ex, + "Error checking user permission for playlist %s", + itemId, + ) + } + false + } catch (ex: Exception) { + Timber.e( + ex, + "Error checking user permission for playlist %s", + itemId, + ) + false + } + } ?: false + + _state.update { it.copy(canEdit = canEdit) } determineMediaType() } catch (ex: Exception) { - Timber.e(ex, "Error fetching playlist %s", itemId) - state.update { it.copy(loading = LoadingState.Error(ex)) } - } - } - } - - fun loadItems( - filter: GetItemsFilter, - sortAndDirection: SortAndDirection, - ) = viewModelScope.launchIO { - backdropService.clearBackdrop() - state.update { - it.copy( - loading = LoadingState.Loading, - filterAndSort = FilterAndSort(filter, sortAndDirection), - ) - } - - serverRepository.currentUser.value?.let { user -> - viewModelScope.launchIO { - val libraryDisplayInfo = - libraryDisplayInfoDao.getItem(user, itemId)?.copy( - filter = filter, - sort = sortAndDirection.sort, - direction = sortAndDirection.direction, - ) - ?: LibraryDisplayInfo( - userId = user.rowId, - itemId = itemId.toServerString(), - sort = sortAndDirection.sort, - direction = sortAndDirection.direction, - filter = filter, - viewOptions = null, - ) - libraryDisplayInfoDao.saveItem(libraryDisplayInfo) - } - - val request = - filter.applyTo( - GetItemsRequest( - parentId = itemId, - userId = user.id, - fields = DefaultItemFields, - sortBy = listOf(sortAndDirection.sort), - sortOrder = listOf(sortAndDirection.direction), - ), - ) - try { - val pager = - ApiRequestPager( - api, - request, - GetItemsRequestHandler, - viewModelScope, - ).init() - state.update { - it.copy( - items = pager, - loading = LoadingState.Success, - ) - } - } catch (ex: Exception) { - Timber.e(ex, "Error fetching playlist %s", itemId) - state.update { - it.copy( - items = emptyList(), - loading = LoadingState.Error(ex), - ) - } + Timber.e(ex, "Error fetching playlist info for %s", itemId) +// _state.update { it.copy(item = DataLoadingState.Error(ex)) } } } } @@ -258,102 +192,99 @@ class PlaylistViewModel * * In theory, the server will set the type, but sometimes it doesn't */ - private suspend fun determineMediaType() { - // Use the type the server says - var mediaType = - state.value.playlist - ?.data - ?.mediaType ?: MediaType.UNKNOWN - mediaType = - if (mediaType == MediaType.UNKNOWN) { - // Otherwise, if a most of the list is one type, we can assume that type - val pager = (state.value.items as? ApiRequestPager<*>) - if (pager != null && pager.size <= 50) { - val types = - (0..<50.coerceAtMost(pager.size)).groupBy { index -> - val pagerItem = pager.getBlocking(index) - when (pagerItem?.type) { - BaseItemKind.AUDIO -> MediaType.AUDIO + private fun determineMediaType() { + collectionState + .map { Pair(it.item, it.items) } + .distinctUntilChanged() + .catch { ex -> Timber.e(ex, "Error in determineMediaType") } + .collectLatestIn(viewModelScope) { (item, items) -> + var mediaType = item.successValue?.data?.mediaType ?: MediaType.UNKNOWN + mediaType = + if (mediaType == MediaType.UNKNOWN) { + // Otherwise, if a most of the list is one type, we can assume that type + val pager = (items.successValue as? ApiRequestPager<*>) + if (pager != null && pager.size <= 50) { + val types = + (0..<50.coerceAtMost(pager.size)).groupBy { index -> + val pagerItem = pager.getBlocking(index) + when (pagerItem?.type) { + BaseItemKind.AUDIO -> MediaType.AUDIO - BaseItemKind.VIDEO, - BaseItemKind.EPISODE, - BaseItemKind.MOVIE, - BaseItemKind.BOX_SET, - -> MediaType.VIDEO + BaseItemKind.VIDEO, + BaseItemKind.EPISODE, + BaseItemKind.MOVIE, + BaseItemKind.BOX_SET, + -> MediaType.VIDEO - else -> MediaType.UNKNOWN + else -> MediaType.UNKNOWN + } + } + if (types.keys.size == 1) { + types.keys.first() + } else { + MediaType.UNKNOWN } + } else { + MediaType.UNKNOWN } - if (types.keys.size == 1) { - types.keys.first() } else { - MediaType.UNKNOWN + mediaType } - } else { - MediaType.UNKNOWN + Timber.d("mediaType=%s", mediaType) + _state.update { + it.copy(mediaType = mediaType) } - } else { - mediaType } - Timber.d("mediaType=%s", mediaType) - state.update { - it.copy(mediaType = mediaType) - } } - suspend fun getFilterOptionValues(filterOption: ItemFilterBy<*>): List<FilterValueOption> = - FilterUtils.getFilterOptionValues( - api, - serverRepository.currentUser.value?.id, - itemId, - filterOption, - ) - - fun updateBackdrop(item: BaseItem) { + fun removeFromPlaylist( + index: Int, + itemId: UUID, + ) { viewModelScope.launchIO { - backdropService.submit(item) + try { + api.playlistsApi.removeItemFromPlaylist( + this@PlaylistViewModel.itemId.toServerString(), + listOf(itemId.toServerString()), + ) + (collectionState.value.items.successValue as? ApiRequestPager<*>) + ?.refreshPagesAfter(index) + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e( + ex, + "Error removing %s from playlist %s", + itemId, + this@PlaylistViewModel.itemId, + ) + showToast(context, "Error: ${ex.localizedMessage}") + } } } - fun setWatched( + fun onMoveItem( + index: Int, itemId: UUID, - played: Boolean, - ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { - favoriteWatchManager.setWatched(itemId, played) - } - - fun setFavorite( - itemId: UUID, - favorite: Boolean, - ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { - favoriteWatchManager.setFavorite(itemId, favorite) - } - - fun sendMediaReport(itemId: UUID) { - viewModelScope.launchDefault { mediaReportService.sendReportFor(itemId) } + direction: MoveDirection, + ) { + viewModelScope.launchIO { + val newIndex = index + if (direction == MoveDirection.UP) -1 else 1 + api.playlistsApi.moveItem( + playlistId = this@PlaylistViewModel.itemId.toServerString(), + itemId = itemId.toServerString(), + newIndex = newIndex, + ) + (collectionState.value.items.successValue as? ApiRequestPager<*>)?.refreshPagesAfter( + index - 1, + ) + } } } -@Immutable -data class FilterAndSort( - val filter: GetItemsFilter, - val sortAndDirection: SortAndDirection, -) - data class PlaylistDetailsState( - val playlist: BaseItem? = null, val mediaType: MediaType = MediaType.UNKNOWN, - val items: List<BaseItem?> = emptyList(), - val filterAndSort: FilterAndSort = - FilterAndSort( - filter = GetItemsFilter(), - sortAndDirection = - SortAndDirection( - ItemSortBy.DEFAULT, - SortOrder.ASCENDING, - ), - ), - val loading: LoadingState = LoadingState.Pending, + val canEdit: Boolean = false, ) @Composable @@ -361,20 +292,38 @@ fun PlaylistDetails( preferences: UserPreferences, destination: Destination.MediaItem, modifier: Modifier = Modifier, + collectionViewModel: CollectionFolderViewModel = + hiltViewModel<CollectionFolderViewModel, CollectionFolderViewModel.Factory>( + creationCallback = { + it.create( + itemId = destination.itemId.toServerString(), + initialSortAndDirection = + SortAndDirection( + ItemSortBy.DEFAULT, + SortOrder.ASCENDING, + ), + recursive = false, + collectionFilter = CollectionFolderFilter(), + useSeriesForPrimary = false, + defaultViewOptions = playlistDefaultViewOptions, + ) + }, + ), viewModel: PlaylistViewModel = hiltViewModel<PlaylistViewModel, PlaylistViewModel.Factory>( - creationCallback = { it.create(destination.itemId) }, + creationCallback = { it.create(destination.itemId, collectionViewModel.state) }, ), addToPlaylistViewModel: AddPlaylistViewModel = hiltViewModel(), ) { - val context = LocalContext.current - val state by viewModel.state.collectAsState() + val state by collectionViewModel.state.collectAsState() + val playlistState by viewModel.state.collectAsState() val musicState by viewModel.musicState.collectAsState() + val focusManager = LocalFocusManager.current var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) } var showConfirmTypeDialog by remember { mutableStateOf<Triple<Int, BaseItem, Boolean>?>(null) } var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) } - val addPlaylistState by addToPlaylistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) + val addPlaylistState by addToPlaylistViewModel.playlistState.collectAsState() fun play( index: Int, @@ -382,15 +331,15 @@ fun PlaylistDetails( shuffle: Boolean, mediaTypeOverride: MediaType? = null, ) { - when (mediaTypeOverride ?: state.mediaType) { + when (mediaTypeOverride ?: playlistState.mediaType) { MediaType.VIDEO -> { - viewModel.navigationManager.navigateTo( + collectionViewModel.navigateTo( Destination.PlaybackList( itemId = destination.itemId, startIndex = index, shuffle = shuffle, - filter = state.filterAndSort.filter, - sortAndDirection = state.filterAndSort.sortAndDirection, + filter = state.filter, + sortAndDirection = state.sortAndDirection, ), ) } @@ -404,84 +353,152 @@ fun PlaylistDetails( } } } - val musicContextActions = - MusicContextActions( - navigateTo = { viewModel.navigationManager.navigateTo(it) }, - onClickPlay = { index, item -> play(index, item, false, MediaType.AUDIO) }, - onClickPlayNext = { index, item -> viewModel.playNext(item) }, - onClickAddToQueue = { item -> viewModel.addToQueue(item, Int.MAX_VALUE) }, - onClickFavorite = { id, favorite -> viewModel.setFavorite(id, favorite) }, - onClickAddPlaylist = { itemId -> - addToPlaylistViewModel.loadPlaylists(MediaType.AUDIO) - showPlaylistDialog.makePresent(itemId) - }, - onClickRemoveFromQueue = { _, _ -> }, - onDeleteItem = viewModel::deleteItem, - ) - val contextActions = + + fun showContextMenu( + index: Int, + item: BaseItem, + fromLongClick: Boolean, + ) { + showContextMenu = + if (item.type == BaseItemKind.AUDIO) { + val musicContextActions = + MusicContextActions( + navigateTo = { collectionViewModel.navigateTo(it) }, + onClickPlay = { index, item -> + play( + index, + item, + false, + MediaType.AUDIO, + ) + }, + onClickPlayNext = { index, item -> viewModel.playNext(item) }, + onClickAddToQueue = { item -> + viewModel.addToQueue( + item, + Int.MAX_VALUE, + ) + }, + onClickFavorite = { id, favorite -> + collectionViewModel.setFavorite(index, id, favorite) + }, + onClickAddPlaylist = { itemId -> + addToPlaylistViewModel.loadPlaylists() + showPlaylistDialog.makePresent(itemId) + }, + onClickRemoveFromQueue = { _, _ -> }, + onDeleteItem = { collectionViewModel.deleteItem(index, item) }, + onRemoveFromPlaylist = viewModel::removeFromPlaylist, + ) + ContextMenu.ForMusic( + fromLongClick = fromLongClick, + item = item, + index = index, + canDelete = viewModel.canDelete(item, preferences.appPreferences), + canRemoveFromQueue = false, + actions = musicContextActions, + showRemoveFromPlaylist = playlistState.canEdit, + ) + } else { + val contextActions = + ContextMenuActions( + navigateTo = { collectionViewModel.navigateTo(it) }, + onClickWatch = { id, watched -> + collectionViewModel.setWatched( + index, + id, + watched, + ) + }, + onClickFavorite = { id, favorite -> + collectionViewModel.setFavorite(index, id, favorite) + }, + onClickAddPlaylist = { itemId -> + addToPlaylistViewModel.loadPlaylists() + showPlaylistDialog.makePresent(itemId) + }, + onSendMediaInfo = collectionViewModel::sendReportFor, + onDeleteItem = { collectionViewModel.deleteItem(index, item) }, + onClickAddToQueue = { viewModel.addToQueue(it, 0) }, + onShowOverview = {}, + onChooseVersion = { _, _ -> }, + onChooseTracks = {}, + onClearChosenStreams = {}, + onClickRemoveFromNextUp = {}, + onRemoveFromPlaylist = viewModel::removeFromPlaylist, + ) + ContextMenu.ForBaseItem( + fromLongClick = fromLongClick, + item = item, + index = index, + chosenStreams = null, + showGoTo = true, + showStreamChoices = false, + canDelete = viewModel.canDelete(item, preferences.appPreferences), + canRemoveContinueWatching = false, + canRemoveNextUp = false, + actions = contextActions, + showRemoveFromPlaylist = playlistState.canEdit, + ) + } + } + + val actions = remember { - ContextMenuActions( - navigateTo = { viewModel.navigationManager.navigateTo(it) }, - onClickWatch = { id, watched -> viewModel.setWatched(id, watched) }, - onClickFavorite = { id, favorite -> viewModel.setFavorite(id, favorite) }, - onClickAddPlaylist = { itemId -> - addToPlaylistViewModel.loadPlaylists(MediaType.VIDEO) - showPlaylistDialog.makePresent(itemId) + GridClickActions( + onClickItem = { index, item -> collectionViewModel.navigateTo(item.destination(index)) }, + onLongClickItem = { index, item -> showContextMenu(index, item, true) }, + onClickPlayAll = { shuffle -> + state.item.successValue?.let { playlist -> play(0, playlist, shuffle) } }, - onSendMediaInfo = viewModel::sendMediaReport, - onDeleteItem = viewModel::deleteItem, - onClickAddToQueue = { viewModel.addToQueue(it, 0) }, - onShowOverview = {}, - onChooseVersion = { _, _ -> }, - onChooseTracks = {}, - onClearChosenStreams = {}, - onClickRemoveFromNextUp = {}, + onClickPlayRemoteButton = { index, item -> play(index, item, false) }, ) } - - PlaylistDetailsContent( - loadingState = state.loading, - playlist = state.playlist, - items = state.items, - musicState = musicState, - onChangeBackdrop = viewModel::updateBackdrop, - onClickIndex = { index, item -> - play(index, item, false) - }, - onClickPlay = { shuffle -> - state.playlist?.let { - play(0, it, shuffle) - } - }, - onLongClickIndex = { index, item -> - showContextMenu = - if (item.type == BaseItemKind.AUDIO) { - ContextMenu.ForMusic( - fromLongClick = true, - item = item, - index = index, - canDelete = viewModel.canDelete(item, preferences.appPreferences), - canRemoveFromQueue = false, - actions = musicContextActions, - ) - } else { - ContextMenu.ForBaseItem( - fromLongClick = true, - item = item, - chosenStreams = null, - showGoTo = true, - showStreamChoices = false, - canDelete = viewModel.canDelete(item, preferences.appPreferences), - canRemoveContinueWatching = false, - canRemoveNextUp = false, - actions = contextActions, - ) - } - }, - filterAndSort = state.filterAndSort, - onFilterAndSortChange = viewModel::loadItems, - getPossibleFilterValues = viewModel::getFilterOptionValues, + CollectionFolderViewContent( + preferences = preferences, + state = state, + savedPosition = viewModel.position, + itemId = destination.itemId.toServerString(), + initialFilter = CollectionFolderFilter(filter = state.filter), + recursive = false, + actions = actions, + sortOptions = PlaylistSortOptions, + playEnabled = true, + defaultViewOptions = playlistDefaultViewOptions, + viewActions = collectionViewModel, + provider = collectionViewModel, + positionCallback = { _, position -> viewModel.position = position }, modifier = modifier, + listItemContent = { (item, index, onClick, onLongClick, _, mod) -> + PlaylistItem( + item = item, + index = index, + onClick = onClick, + onLongClick = onLongClick, + canMove = playlistState.canEdit, + moveUpAllowed = index > 0, + moveDownAllowed = + state.items.successValue + ?.lastIndex + ?.gt(index) ?: false, + onClickMove = { direction -> + item?.let { viewModel.onMoveItem(index, item.id, direction) } + when (direction) { + MoveDirection.UP -> focusManager.moveFocus(FocusDirection.Up) + MoveDirection.DOWN -> focusManager.moveFocus(FocusDirection.Down) + } + }, + onClickMore = { item?.let { showContextMenu(index, item, false) } }, + modifier = mod, +// interactionSource = TODO(), + isPlaying = + equalsNotNull( + musicState.currentItemId, + item?.id, + ), + isQueued = remember(musicState.queueVersion) { item?.id in musicState.queuedIds }, + ) + }, ) showContextMenu?.let { contextMenu -> ContextMenuDialog( @@ -511,351 +528,179 @@ fun PlaylistDetails( addToPlaylistViewModel.createPlaylistAndAddItem(it, itemId) showPlaylistDialog.makeAbsent() }, + onSearch = addToPlaylistViewModel::loadPlaylists, elevation = 3.dp, ) } } @Composable -fun PlaylistDetailsContent( - playlist: BaseItem?, - items: List<BaseItem?>, - musicState: MusicServiceState, - onClickIndex: (Int, BaseItem) -> Unit, - onLongClickIndex: (Int, BaseItem) -> Unit, - onClickPlay: (shuffle: Boolean) -> Unit, - onChangeBackdrop: (BaseItem) -> Unit, - filterAndSort: FilterAndSort, - onFilterAndSortChange: (GetItemsFilter, SortAndDirection) -> Unit, - getPossibleFilterValues: suspend (ItemFilterBy<*>) -> List<FilterValueOption>, - loadingState: LoadingState, - modifier: Modifier = Modifier, -) { - var savedIndex by rememberSaveable { mutableIntStateOf(0) } - var focusedIndex by remember { mutableIntStateOf(savedIndex) } - val focus = remember { FocusRequester() } - val focusedItem = items.getOrNull(focusedIndex) - LaunchedEffect(focusedItem) { - focusedItem?.let(onChangeBackdrop) - } - val focusRequester = remember { FocusRequester() } - LaunchedEffect(loadingState) { - if (loadingState is LoadingState.Success || loadingState is LoadingState.Error) { - focusRequester.tryRequestFocus() - } - } - - val playButtonFocusRequester = remember { FocusRequester() } - - Box( - modifier = modifier, - ) { - Column( - verticalArrangement = Arrangement.spacedBy(16.dp), - modifier = - Modifier - .padding(top = 16.dp) - .fillMaxSize(), - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(24.dp), - modifier = - Modifier - .fillMaxWidth(), - ) { - PlaylistDetailsHeader( - focusedItem = focusedItem, - onClickPlay = onClickPlay, - playButtonFocusRequester = playButtonFocusRequester, - focusRequester = if (items.isEmpty()) focusRequester else remember { FocusRequester() }, - filterAndSort = filterAndSort, - onFilterAndSortChange = onFilterAndSortChange, - getPossibleFilterValues = getPossibleFilterValues, - modifier = - Modifier - .padding(top = 80.dp) - .fillMaxWidth(.25f), - ) - when (loadingState) { - is LoadingState.Error -> { - ErrorMessage(loadingState, modifier) - } - - LoadingState.Pending, LoadingState.Loading -> { - LoadingPage(modifier) - } - - LoadingState.Success -> { - Column( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - ) { - Text( - text = playlist?.name ?: stringResource(R.string.playlist), - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.displayMedium, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth(), - ) - if (items.isNotEmpty()) { - LazyColumn( - contentPadding = PaddingValues(8.dp), - modifier = - Modifier - .padding(bottom = 32.dp) - .fillMaxHeight() -// .fillMaxWidth(.8f) - .weight(1f) - .background( - MaterialTheme.colorScheme - .surfaceColorAtElevation(1.dp) - .copy(alpha = .75f), - shape = RoundedCornerShape(16.dp), - ).focusProperties { - onExit = { - playButtonFocusRequester.tryRequestFocus() - } - }.focusRequester(focusRequester) - .focusGroup() - .focusRestorer(focus), - ) { - itemsIndexed(items) { index, item -> - PlaylistItem( - item = item, - index = index, - onClick = { - savedIndex = index - item?.let { - onClickIndex.invoke(index, item) - } - }, - onLongClick = { - savedIndex = index - item?.let { - onLongClickIndex.invoke(index, item) - } - }, - isPlaying = - equalsNotNull( - musicState.currentItemId, - item?.id, - ), - isQueued = item?.id in musicState.queuedIds, - modifier = - Modifier - .ifElse( - item?.type != BaseItemKind.AUDIO, - Modifier.height(80.dp), - ).ifElse( - index == savedIndex, - Modifier.focusRequester(focus), - ).onFocusChanged { - if (it.isFocused) { - focusedIndex = index - } - }.focusProperties { - left = playButtonFocusRequester - previous = playButtonFocusRequester - }, - ) - } - } - } else { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier.fillMaxWidth(), - ) { - Text( - text = stringResource(R.string.no_results), - style = MaterialTheme.typography.titleLarge, - textAlign = TextAlign.Center, - modifier = - Modifier - .focusProperties { - onExit = { - playButtonFocusRequester.tryRequestFocus() - } - }.focusRequester(focusRequester) - .focusable(), - ) - } - } - } - } - } - } - } - } -} - -@Composable -fun PlaylistDetailsHeader( - focusedItem: BaseItem?, - onClickPlay: (shuffle: Boolean) -> Unit, - playButtonFocusRequester: FocusRequester, - focusRequester: FocusRequester, - filterAndSort: FilterAndSort, - onFilterAndSortChange: (GetItemsFilter, SortAndDirection) -> Unit, - getPossibleFilterValues: suspend (ItemFilterBy<*>) -> List<FilterValueOption>, - modifier: Modifier = Modifier, -) { - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = modifier, - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier, - ) { - ExpandablePlayButton( - title = R.string.play, - resume = Duration.ZERO, - icon = Icons.Default.PlayArrow, - onClick = { onClickPlay.invoke(false) }, - modifier = Modifier.focusRequester(playButtonFocusRequester), - ) - ExpandableFaButton( - title = R.string.shuffle, - iconStringRes = R.string.fa_shuffle, - onClick = { onClickPlay.invoke(true) }, - ) - } - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier, - ) { - FilterByButton( - filterOptions = DefaultPlaylistItemsOptions, - current = filterAndSort.filter, - onFilterChange = { - onFilterAndSortChange.invoke( - it, - filterAndSort.sortAndDirection, - ) - }, - getPossibleValues = getPossibleFilterValues, - modifier = Modifier.focusRequester(focusRequester), - ) - SortByButton( - sortOptions = BoxSetSortOptions, - current = filterAndSort.sortAndDirection, - onSortChange = { onFilterAndSortChange.invoke(filterAndSort.filter, it) }, - ) - } - Text( - text = focusedItem?.title ?: "", - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.headlineSmall, - ) - Text( - text = focusedItem?.subtitle ?: "", - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.titleMedium, - ) - if (focusedItem?.type == BaseItemKind.EPISODE && focusedItem.data.premiereDate != null) { - Text( - text = formatDateTime(focusedItem.data.premiereDate!!), - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.titleSmall, - ) - } - OverviewText( - overview = focusedItem?.data?.overview ?: "", - maxLines = 10, - onClick = {}, - enabled = false, - ) - } -} - -@Composable -fun PlaylistItem( +fun CollectionFolderViewScope.PlaylistItem( item: BaseItem?, index: Int, onClick: () -> Unit, onLongClick: () -> Unit, + canMove: Boolean, + moveUpAllowed: Boolean, + moveDownAllowed: Boolean, + onClickMove: (MoveDirection) -> Unit, + onClickMore: () -> Unit, modifier: Modifier = Modifier, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, isPlaying: Boolean = false, isQueued: Boolean = false, ) { + val dense = viewOptions.type == ViewOptionsType.DENSE_LIST val focused by interactionSource.collectIsFocusedAsState() - val imageWidth = 160.dp - val density = LocalDensity.current - val imageWidthPx = remember(imageWidth) { with(density) { imageWidth.roundToPx() } } - ListItem( - selected = false, - onClick = onClick, - onLongClick = onLongClick, - interactionSource = interactionSource, - headlineContent = { - Text( - text = item?.title ?: "", - modifier = Modifier.enableMarquee(focused), - ) - }, - supportingContent = { - Text( - text = item?.subtitle ?: "", - modifier = Modifier.enableMarquee(focused), - ) - }, - trailingContent = { - item?.data?.runTimeTicks?.ticks?.roundMinutes?.let { duration -> - val now by LocalClock.current.now - val endTimeStr = - remember(item, now) { - val endTime = now.toLocalTime().plusSeconds(duration.inWholeSeconds) - getTimeFormatter().format(endTime) + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = + modifier + .fillMaxWidth(), +// .heightIn(min = 40.dp, max = 88.dp), + ) { + ListItemWrapper( + dense = dense, + selected = false, + onClick = onClick, + onLongClick = onLongClick, + interactionSource = interactionSource, + headlineContent = { + Text( + text = item?.title ?: "", + modifier = Modifier.enableMarquee(focused), + ) + }, + supportingContent = { + Text( + text = item?.subtitle ?: "", + modifier = Modifier.enableMarquee(focused), + ) + }, + trailingContent = { + val duration = + remember(item?.data?.runTimeTicks) { + when (item?.type) { + BaseItemKind.AUDIO -> { + item.data.runTimeTicks + ?.ticks + ?.roundSeconds + } + + else -> { + item + ?.data + ?.runTimeTicks + ?.ticks + ?.roundMinutes + } + } } - Column { - Text( - text = duration.toString(), - ) - if (item.type != BaseItemKind.AUDIO) { + duration?.let { duration -> + val now by LocalClock.current.now + val context = LocalContext.current + val endTimeStr = + remember(item, now, context) { + val endTime = now.toLocalTime().plusSeconds(duration.inWholeSeconds) + formatTime(context, endTime) + } + val resources = LocalResources.current + val durationText = + remember(resources, duration) { resources.formatDuration(duration) } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Column { + Text( + text = durationText, + ) + if (item?.type != BaseItemKind.AUDIO && !dense) { + val style = + LocalTextStyle.current.let { it.copy(fontSize = it.fontSize * .75) } + Text( + text = stringResource(R.string.ends_at, endTimeStr), + style = style, + ) + } + } + if (item?.played == true) { + WatchedIcon(padding = 0.dp) + } + } + } + }, + leadingContent = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (sortAndDirection.sort == ItemSortBy.DEFAULT) { +// val indexText = +// remember(index, collectionSize, sortAndDirection) { +// when (sortAndDirection.direction) { +// SortOrder.ASCENDING -> "${index + 1}." +// SortOrder.DESCENDING -> "${collectionSize - index}." +// } +// } + // TODO default descending doesn't work? + val indexText = "${index + 1}." Text( - text = stringResource(R.string.ends_at, endTimeStr), - style = MaterialTheme.typography.bodySmall, + text = indexText, ) } + if (item?.type == BaseItemKind.AUDIO) { + MusicQueueMarker( + isPlaying = isPlaying, + isQueued = isQueued, + ) + } + if (item?.favorite == true) FavoriteIndicator() + } + }, + modifier = Modifier.weight(1f), + ) + val contentHeight = 24.dp + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.wrapContentWidth(), + ) { + if (canMove) { + Button( + onClick = { onClickMove.invoke(MoveDirection.UP) }, + enabled = moveUpAllowed, + contentHeight = contentHeight, + ) { + Text( + text = stringResource(R.string.fa_caret_up), + fontFamily = FontAwesome, + ) + } + Button( + onClick = { onClickMove.invoke(MoveDirection.DOWN) }, + enabled = moveDownAllowed, + contentHeight = contentHeight, + ) { + Text( + text = stringResource(R.string.fa_caret_down), + fontFamily = FontAwesome, + ) } } - }, - leadingContent = { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp), + Button( + onClick = onClickMore, + enabled = true, + contentHeight = contentHeight, ) { Text( - text = "${index + 1}.", - style = MaterialTheme.typography.labelLarge, + text = stringResource(R.string.fa_ellipsis_vertical), + fontFamily = FontAwesome, ) - if (item?.type == BaseItemKind.AUDIO) { - MusicQueueMarker( - isPlaying = isPlaying, - isQueued = isQueued, - ) - } else { - ItemCardImage( - item = item, - name = item?.name, - showOverlay = true, - favorite = item?.data?.userData?.isFavorite ?: false, - watched = item?.data?.userData?.played ?: false, - unwatchedCount = item?.data?.userData?.unplayedItemCount ?: -1, - watchedPercent = 0.0, - numberOfVersions = item?.data?.mediaSourceCount ?: 0, - modifier = Modifier.width(imageWidth), - useFallbackText = false, - fillWidth = imageWidthPx, - ) - } } - }, - modifier = modifier, - ) + } + } } @Composable @@ -901,3 +746,10 @@ fun ConfirmMediaTypeDialog( } } } + +private val playlistDefaultViewOptions = + ViewOptions( + spacing = 0, + showDetails = true, + type = ViewOptionsType.LIST, + ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistList.kt index f94f7381..e38ff099 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistList.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistList.kt @@ -1,11 +1,16 @@ package com.github.damontecres.wholphin.ui.detail +import android.view.Gravity +import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape @@ -25,10 +30,13 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.graphics.Color +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction @@ -39,6 +47,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties +import androidx.compose.ui.window.DialogWindowProvider import androidx.tv.material3.Icon import androidx.tv.material3.ListItem import androidx.tv.material3.MaterialTheme @@ -50,12 +59,16 @@ import com.github.damontecres.wholphin.ui.components.BasicDialog import com.github.damontecres.wholphin.ui.components.Button import com.github.damontecres.wholphin.ui.components.EditTextBox import com.github.damontecres.wholphin.ui.components.ErrorMessage +import com.github.damontecres.wholphin.ui.components.SearchEditTextBox import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.tryRequestFocus +import kotlinx.coroutines.delay +import org.jellyfin.sdk.model.api.MediaType +import kotlin.time.Duration.Companion.milliseconds @Composable fun PlaylistList( - playlists: List<PlaylistInfo?>, + playlists: List<PlaylistInfo>, onClick: (PlaylistInfo) -> Unit, createEnabled: Boolean, onCreatePlaylist: (String) -> Unit, @@ -69,33 +82,37 @@ fun PlaylistList( items(playlists) { playlist -> ListItem( selected = false, - enabled = playlist != null, + enabled = true, headlineContent = { Text( - text = playlist?.name ?: stringResource(R.string.loading), + text = playlist.name, ) }, supportingContent = { - if (playlist != null) { - Text( - text = playlist.mediaType.serialName, - ) - } + val id = + when (playlist.mediaType) { + MediaType.UNKNOWN -> R.string.unknown + MediaType.VIDEO -> R.string.video + MediaType.AUDIO -> R.string.audio + MediaType.PHOTO -> R.string.photos + MediaType.BOOK -> R.string.unknown + } + Text( + text = stringResource(id), + ) }, trailingContent = { - if (playlist != null) { - Text( - text = - pluralStringResource( - R.plurals.items, - playlist.count, - playlist.count, - ), - ) - } + Text( + text = + pluralStringResource( + R.plurals.items, + playlist.count, + playlist.count, + ), + ) }, onClick = { - if (playlist != null) onClick.invoke(playlist) + onClick.invoke(playlist) }, modifier = Modifier, ) @@ -113,7 +130,6 @@ fun PlaylistList( leadingContent = { Icon( imageVector = Icons.Default.Add, - tint = Color.Green.copy(.7f), contentDescription = "Add", ) }, @@ -195,16 +211,38 @@ fun PlaylistDialog( state: PlaylistLoadingState, onDismissRequest: () -> Unit, onClick: (PlaylistInfo) -> Unit, + onSearch: (String) -> Unit, createEnabled: Boolean, onCreatePlaylist: (String) -> Unit, elevation: Dp = 3.dp, ) { val elevatedContainerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(elevation) + + val placeholderFocusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() } + val searchFocusRequester = remember { FocusRequester() } + var query by remember { mutableStateOf("") } + var searchClicked by remember { mutableStateOf(false) } + var searchHasFocus by remember { mutableStateOf(false) } + LaunchedEffect(query) { + val previousQuery = (state as? PlaylistLoadingState.Success)?.query ?: "" + if (previousQuery != query) { + delay(750.milliseconds) + if (searchClicked) { + searchClicked = false + return@LaunchedEffect + } + onSearch.invoke(query) + } + } + Dialog( onDismissRequest = onDismissRequest, ) { + val dialogWindowProvider = LocalView.current.parent as? DialogWindowProvider + dialogWindowProvider?.window?.setGravity(Gravity.TOP) + Column( verticalArrangement = Arrangement.spacedBy(8.dp), modifier = @@ -222,6 +260,74 @@ fun PlaylistDialog( textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth(), ) + SearchEditTextBox( + value = query, + onValueChange = { query = it }, + onSearchClick = { + if (query.isNotBlank()) { + searchClicked = true + onSearch.invoke(query) + } + }, + modifier = + Modifier + .fillMaxWidth() + .focusRequester(searchFocusRequester) + .onFocusChanged { searchHasFocus = it.hasFocus } + .focusProperties { + // Block focus to except for down/next to the results + // Don't want to focus on the placeholder + up = FocusRequester.Cancel + left = FocusRequester.Cancel + right = FocusRequester.Cancel + previous = FocusRequester.Cancel + start = FocusRequester.Cancel + end = FocusRequester.Cancel + down = focusRequester + next = focusRequester + }, + ) + LaunchedEffect(Unit) { + if (state !is PlaylistLoadingState.Success) { + placeholderFocusRequester.tryRequestFocus() + } + } + // Placeholder for initial focus + // After results load, focus will move to the first one + Box( + modifier = + Modifier + .align(Alignment.CenterHorizontally) + .width(0.dp) + .height(0.dp) + .focusRequester(placeholderFocusRequester) + .focusable(), + ) + val resultsModifier = + Modifier + .fillMaxWidth() + .focusRequester(focusRequester) + .focusProperties { + // Only focus up to search bar and ignore the placeholder + left = FocusRequester.Cancel + right = FocusRequester.Cancel + start = FocusRequester.Cancel + end = FocusRequester.Cancel + down = FocusRequester.Cancel + next = FocusRequester.Cancel + up = searchFocusRequester + previous = searchFocusRequester + onExit = { + if (requestedFocusDirection == FocusDirection.Next || + requestedFocusDirection == FocusDirection.Left || + requestedFocusDirection == FocusDirection.Right + ) { + cancelFocusChange() + } else { + searchFocusRequester.tryRequestFocus() + } + } + } when (val s = state) { PlaylistLoadingState.Pending, PlaylistLoadingState.Loading, @@ -236,20 +342,24 @@ fun PlaylistDialog( } is PlaylistLoadingState.Error -> { - ErrorMessage(s.message, s.exception) + ErrorMessage( + message = s.message, + exception = s.exception, + modifier = resultsModifier, + ) } is PlaylistLoadingState.Success -> { - LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + LaunchedEffect(Unit) { + // Once results load, focus unless the user is still focused on the search box + if (!searchHasFocus) focusRequester.tryRequestFocus() + } PlaylistList( playlists = s.items, onClick = onClick, createEnabled = createEnabled, onCreatePlaylist = onCreatePlaylist, - modifier = - Modifier - .fillMaxWidth() - .focusRequester(focusRequester), + modifier = resultsModifier, ) } } @@ -263,7 +373,8 @@ sealed interface PlaylistLoadingState { data object Loading : PlaylistLoadingState data class Success( - val items: List<PlaylistInfo?>, + val items: List<PlaylistInfo>, + val query: String, ) : PlaylistLoadingState data class Error( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionDetails.kt index 4da00ef7..291ef74a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionDetails.kt @@ -18,7 +18,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable @@ -52,13 +51,11 @@ import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo import com.github.damontecres.wholphin.ui.data.RowColumn import com.github.damontecres.wholphin.ui.data.SortAndDirection import com.github.damontecres.wholphin.ui.detail.PlaylistDialog -import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState import com.github.damontecres.wholphin.ui.main.HomePageHeader import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.HomeRowLoadingState import com.github.damontecres.wholphin.util.LoadingState -import org.jellyfin.sdk.model.api.MediaType import timber.log.Timber import java.util.UUID @@ -79,7 +76,7 @@ fun CollectionDetails( // Dialogs var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) } var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) } - val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) + val playlistState by playlistViewModel.playlistState.collectAsState() var showViewOptionsDialog by remember { mutableStateOf(false) } var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) } @@ -93,10 +90,10 @@ fun CollectionDetails( viewModel.setFavorite(itemId, favorite, position) }, onClickAddPlaylist = { itemId -> - playlistViewModel.loadPlaylists(MediaType.VIDEO) + playlistViewModel.loadPlaylists() showPlaylistDialog.makePresent(itemId) }, - onSendMediaInfo = viewModel.mediaReportService::sendReportFor, + onSendMediaInfo = viewModel.serverReportService::sendMediaReportFor, onDeleteItem = { viewModel.deleteItem(it, position) }, onShowOverview = { overviewDialog = ItemDetailsDialogInfo(it) }, onChooseVersion = { _, _ -> @@ -247,7 +244,7 @@ fun CollectionDetails( ItemDetailsDialog( info = info, showFilePath = - viewModel.serverRepository.currentUserDto.value + viewModel.serverRepository.currentUserDto ?.policy ?.isAdministrator == true, onDismissRequest = { overviewDialog = null }, @@ -275,6 +272,7 @@ fun CollectionDetails( playlistViewModel.createPlaylistAndAddItem(it, itemId) showPlaylistDialog.makeAbsent() }, + onSearch = playlistViewModel::loadPlaylists, elevation = 3.dp, ) } @@ -345,6 +343,7 @@ fun CollectionDetailsContent( modifier = Modifier .padding( + start = HeaderUtils.startPadding, top = HeaderUtils.topPadding, bottom = 8.dp, ).height(HeaderUtils.height), @@ -426,7 +425,7 @@ fun CollectionDetailsContent( onClickPlay = onClickPlay, modifier = Modifier.fillMaxSize(), onFocusPosition = { position -> - Timber.v("onFocusPosition=%s", position) +// Timber.v("onFocusPosition=%s", position) focusedItem = position.let { val key = diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionMixedGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionMixedGrid.kt index 757b4cf9..057c6db2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionMixedGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionMixedGrid.kt @@ -76,7 +76,7 @@ fun CollectionMixedGrid( columns = cardViewOptions.columns, spacing = cardViewOptions.spacing.dp, bringIntoViewSpec = - remember(cardViewOptions) { + remember(cardViewOptions, density) { val spacingPx = with(density) { cardViewOptions.spacing.dp.toPx() } if (cardViewOptions.showDetails) { ScrollToTopBringIntoViewSpec(spacingPx) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionRows.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionRows.kt index b33e751f..126a5765 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionRows.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionRows.kt @@ -51,18 +51,45 @@ fun CollectionRows( if (row is HomeRowLoadingState.Success) { // TODO not great to do this in the UI val viewOptions = - if (type == BaseItemKind.EPISODE) { - HomeRowViewOptions( - heightDp = Cards.HEIGHT_EPISODE, - episodeAspectRatio = AspectRatio.WIDE, - showTitles = cardViewOptions.showTitles, - useSeries = false, - ) - } else { - HomeRowViewOptions( - showTitles = cardViewOptions.showTitles, - ) + when (type) { + BaseItemKind.EPISODE -> { + HomeRowViewOptions( + heightDp = Cards.HEIGHT_EPISODE, + episodeAspectRatio = AspectRatio.WIDE, + showTitles = cardViewOptions.showTitles, + useSeries = false, + ) + } + + BaseItemKind.MUSIC_ARTIST, + BaseItemKind.MUSIC_ALBUM, + BaseItemKind.PLAYLIST, + BaseItemKind.AUDIO, + -> { + HomeRowViewOptions( + heightDp = Cards.HEIGHT_EPISODE, + aspectRatio = AspectRatio.SQUARE, + showTitles = cardViewOptions.showTitles, + ) + } + + BaseItemKind.VIDEO, + BaseItemKind.MUSIC_VIDEO, + -> { + HomeRowViewOptions( + heightDp = Cards.HEIGHT_EPISODE, + aspectRatio = AspectRatio.WIDE, + showTitles = cardViewOptions.showTitles, + ) + } + + else -> { + HomeRowViewOptions( + showTitles = cardViewOptions.showTitles, + ) + } } + row.copy(viewOptions = viewOptions) } else { row @@ -84,6 +111,7 @@ fun CollectionRows( headerComposable = {}, takeFocus = false, showLogo = preferences.appPreferences.interfacePreferences.showLogos, + showViewMore = false, modifier = Modifier, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionViewModel.kt index 915471d7..bdbb059a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionViewModel.kt @@ -3,7 +3,6 @@ package com.github.damontecres.wholphin.ui.detail.collection import android.content.Context import androidx.compose.runtime.Stable import androidx.lifecycle.ViewModel -import androidx.lifecycle.asFlow import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao import com.github.damontecres.wholphin.data.ServerRepository @@ -15,12 +14,13 @@ import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.FavoriteWatchManager +import com.github.damontecres.wholphin.services.FilterOptionCache import com.github.damontecres.wholphin.services.ImageUrlService import com.github.damontecres.wholphin.services.KeyValueService import com.github.damontecres.wholphin.services.MediaManagementService -import com.github.damontecres.wholphin.services.MediaReportService import com.github.damontecres.wholphin.services.MusicService import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.ServerReportService import com.github.damontecres.wholphin.services.ThemeSongPlayer import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.services.deleteItem @@ -34,18 +34,19 @@ import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.toServerString -import com.github.damontecres.wholphin.ui.util.FilterUtils +import com.github.damontecres.wholphin.ui.util.ResStringProvider import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.HomeRowLoadingState import com.github.damontecres.wholphin.util.LoadingState +import com.github.damontecres.wholphin.util.WholphinDispatchers import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow @@ -89,7 +90,8 @@ class CollectionViewModel private val libraryDisplayInfoDao: LibraryDisplayInfoDao, private val imageUrlService: ImageUrlService, private val musicService: MusicService, - val mediaReportService: MediaReportService, + val serverReportService: ServerReportService, + private val filterOptionCache: FilterOptionCache, @Assisted private val itemId: UUID, ) : ViewModel() { @AssistedFactory @@ -98,16 +100,14 @@ class CollectionViewModel } private val viewOptionsFlow = - serverRepository.currentUser - .asFlow() + serverRepository.currentUserFlow .filterNotNull() .flatMapLatest { keyValueService.get(it.id, VIEW_OPTIONS_KEY, CollectionViewOptions()) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), CollectionViewOptions()) private val libraryDisplayInfoFlow = - serverRepository.currentUser - .asFlow() + serverRepository.currentUserFlow .filterNotNull() .flatMapLatest { libraryDisplayInfoDao.getItemAsFlow(it.rowId, itemId.toServerString()) @@ -137,31 +137,33 @@ class CollectionViewModel } } viewModelScope.launchDefault { - val collection = - api.userLibraryApi - .getItem(itemId) - .content - .let { BaseItem(it, false) } - backdropService.submit(collection) - val logoImageUrl = - if (ImageType.LOGO in collection.data.imageTags.orEmpty()) { - imageUrlService.getItemImageUrl(collection, ImageType.LOGO) - } else { - null + try { + val collection = + api.userLibraryApi + .getItem(itemId) + .content + .let { BaseItem(it, false) } + backdropService.submit(collection) + val logoImageUrl = + if (ImageType.LOGO in collection.data.imageTags.orEmpty()) { + imageUrlService.getItemImageUrl(collection, ImageType.LOGO) + } else { + null + } + _state.update { + it.copy( + collection = collection, + logoImageUrl = logoImageUrl, + ) } - _state.update { - it.copy( - collection = collection, - logoImageUrl = logoImageUrl, - ) + listenForStateUpdates() + themeSongPlayer.playThemeFor(itemId) + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Error getting collection %s", itemId) + _state.update { it.copy(loadingState = LoadingState.Error(ex)) } } - listenForStateUpdates() - themeSongPlayer.playThemeFor( - itemId, - preferencesService - .getCurrent() - .appPreferences.interfacePreferences.playThemeSongs, - ) } } @@ -185,6 +187,8 @@ class CollectionViewModel .collectLatest { (sort, filter, separateTypes) -> try { updateData(sort, filter, separateTypes) + } catch (ex: CancellationException) { + throw ex } catch (ex: Exception) { Timber.e( ex, @@ -216,12 +220,16 @@ class CollectionViewModel supervisorScope { val jobs = typesInCollection.map { type -> - async(Dispatchers.IO) { - val title = context.getString(formatTypeName(type)) + async(WholphinDispatchers.IO) { + val title = ResStringProvider(formatTypeName(type)) val result = try { val pager = fetchItems(sort, filter, listOf(type)) - HomeRowLoadingState.Success(title, pager) + HomeRowLoadingState.Success( + title, + pager, + showViewMore = false, + ) } catch (ex: Exception) { Timber.e( ex, @@ -236,12 +244,15 @@ class CollectionViewModel } jobs.forEach { job -> val (type, row) = job.await() - _state.update { - val separateItems = - it.separateItems.toMutableMap().apply { - put(type, row) - } - it.copy(separateItems = separateItems) + // Only include Success if non-empty + if (row !is HomeRowLoadingState.Success || row.items.isNotEmpty()) { + _state.update { + val separateItems = + it.separateItems.toMutableMap().apply { + put(type, row) + } + it.copy(separateItems = separateItems) + } } } } @@ -286,7 +297,7 @@ class CollectionViewModel } val request = GetItemsRequest( - userId = serverRepository.currentUser.value?.id, + userId = serverRepository.currentUser?.id, parentId = itemId, includeItemTypes = includeItemTypes, excludeItemTypes = excludeItemTypes, @@ -302,7 +313,7 @@ class CollectionViewModel fun changeSort(sortAndDirection: SortAndDirection) { viewModelScope.launchIO { - val user = serverRepository.currentUser.value + val user = serverRepository.currentUser val state = _state.value if (user != null) { libraryDisplayInfoDao.saveItem( @@ -321,7 +332,7 @@ class CollectionViewModel fun changeFilter(filter: GetItemsFilter) { viewModelScope.launchIO { - val user = serverRepository.currentUser.value + val user = serverRepository.currentUser val state = _state.value if (user != null) { libraryDisplayInfoDao.saveItem( @@ -348,22 +359,20 @@ class CollectionViewModel backdropService.clearBackdrop() } } - serverRepository.currentUser.value?.id?.let { userId -> + serverRepository.currentUser?.id?.let { userId -> keyValueService.save(userId, VIEW_OPTIONS_KEY, viewOptions) } } } suspend fun getPossibleFilterValues(filterOption: ItemFilterBy<*>): List<FilterValueOption> = - FilterUtils.getFilterOptionValues( - api, - serverRepository.currentUser.value?.id, + filterOptionCache.getFilterOptionValues( itemId, filterOption, ) suspend fun letterPosition(letter: Char): Int = - withContext(Dispatchers.IO) { + withContext(WholphinDispatchers.IO) { val sort = state.value.sortAndDirection val filter = state.value.itemFilter val request = @@ -391,7 +400,7 @@ class CollectionViewModel itemId: UUID, played: Boolean, position: RowColumn?, - ) = viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) { + ) = viewModelScope.launch(WholphinDispatchers.IO + ExceptionHandler()) { favoriteWatchManager.setWatched(itemId, played) if (itemId == state.value.collection?.id) { refreshCollection() @@ -404,7 +413,7 @@ class CollectionViewModel itemId: UUID, favorite: Boolean, position: RowColumn?, - ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { + ) = viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { favoriteWatchManager.setFavorite(itemId, favorite) if (itemId == state.value.collection?.id) { refreshCollection() @@ -493,6 +502,9 @@ class CollectionViewModel BaseItemKind.EPISODE, BaseItemKind.VIDEO, BaseItemKind.BOX_SET, + BaseItemKind.MUSIC_ARTIST, + BaseItemKind.MUSIC_ALBUM, + BaseItemKind.MUSIC_VIDEO, ) const val VIEW_OPTIONS_KEY = "CollectionViewOptions" 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 85743e3d..d0017d84 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt @@ -17,7 +17,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -27,6 +26,7 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -38,13 +38,12 @@ import androidx.tv.material3.Text import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.api.seerr.model.MovieDetails import com.github.damontecres.wholphin.data.model.DiscoverItem -import com.github.damontecres.wholphin.data.model.DiscoverRating +import com.github.damontecres.wholphin.data.model.SourceRatings import com.github.damontecres.wholphin.data.model.LocalTrailer import com.github.damontecres.wholphin.data.model.RemoteTrailer import com.github.damontecres.wholphin.data.model.SeerrAvailability -import com.github.damontecres.wholphin.data.model.SeerrPermission import com.github.damontecres.wholphin.data.model.Trailer -import com.github.damontecres.wholphin.data.model.hasPermission +import com.github.damontecres.wholphin.data.model.canRequestMovies import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.SeerrUserConfig import com.github.damontecres.wholphin.services.TrailerService @@ -53,21 +52,18 @@ import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard import com.github.damontecres.wholphin.ui.cards.DiscoverPersonRow import com.github.damontecres.wholphin.ui.cards.ItemRow import com.github.damontecres.wholphin.ui.cards.SeasonCard -import com.github.damontecres.wholphin.ui.components.DialogItem -import com.github.damontecres.wholphin.ui.components.DialogParams -import com.github.damontecres.wholphin.ui.components.DialogPopup import com.github.damontecres.wholphin.ui.components.ErrorMessage +import com.github.damontecres.wholphin.ui.components.HeaderUtils import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.rememberInt import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.ExceptionHandler -import com.github.damontecres.wholphin.util.LoadingState import kotlinx.coroutines.launch import org.jellyfin.sdk.model.api.BaseItemKind -import org.jellyfin.sdk.model.serializer.toUUIDOrNull @Composable fun DiscoverMovieDetails( @@ -79,118 +75,89 @@ fun DiscoverMovieDetails( creationCallback = { it.create(destination.item) }, ), ) { + val resources = LocalResources.current val context = LocalContext.current LifecycleResumeEffect(Unit) { viewModel.init() onPauseOrDispose { } } - val item by viewModel.movie.observeAsState() - val rating by viewModel.rating.observeAsState(null) - val people by viewModel.people.observeAsState(listOf()) - val trailers by viewModel.trailers.observeAsState(listOf()) - val similar by viewModel.similar.observeAsState(listOf()) - val recommended by viewModel.recommended.observeAsState(listOf()) - val loading by viewModel.loading.observeAsState(LoadingState.Loading) + val state by viewModel.state.collectAsState() val userConfig by viewModel.userConfig.collectAsState(null) + val requestEnabled by viewModel.requestEnabled.collectAsState(false) val request4kEnabled by viewModel.request4kEnabled.collectAsState(false) - val canCancel by viewModel.canCancelRequest.collectAsState() var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) } - var moreDialog by remember { mutableStateOf<DialogParams?>(null) } + var showRequestDialog by remember { mutableStateOf(false) } - val requestStr = stringResource(R.string.request) - val request4kStr = stringResource(R.string.request_4k) - - when (val state = loading) { - is LoadingState.Error -> { - ErrorMessage(state, modifier) + when (val st = state.movie) { + is DataLoadingState.Error -> { + ErrorMessage(st, modifier) } - LoadingState.Loading, - LoadingState.Pending, + DataLoadingState.Loading, + DataLoadingState.Pending, -> { LoadingPage(modifier) } - LoadingState.Success -> { - item?.let { movie -> - DiscoverMovieDetailsContent( - preferences = preferences, + is DataLoadingState.Success<MovieDetails> -> { + val movie = st.data + DiscoverMovieDetailsContent( + preferences = preferences, + movie = movie, + userConfig = userConfig, + rating = state.rating, + canCancel = state.canCancelRequest, + people = state.people, + trailers = state.trailers, + similar = state.similar, + recommended = state.recommended, + requestOnClick = { + viewModel.requestOnClick() + showRequestDialog = true + }, + cancelOnClick = { + movie.id?.let { viewModel.cancelRequest(it) } + }, + onClickItem = { index, item -> + viewModel.navigateTo(Destination.DiscoveredItem(item)) + }, + onClickPerson = { item -> + viewModel.navigateTo(Destination.DiscoveredItem(item)) + }, + overviewOnClick = { + overviewDialog = + ItemDetailsDialogInfo( + title = movie.title ?: resources.getString(R.string.unknown), + overview = movie.overview, + genres = movie.genres?.mapNotNull { it.name }.orEmpty(), + files = listOf(), + ) + }, + goToOnClick = { + viewModel.goTo(movie.mediaInfo, BaseItemKind.MOVIE) + }, + moreOnClick = {}, + onLongClickPerson = { index, person -> }, + onLongClickSimilar = { index, similar -> + }, + trailerOnClick = { + TrailerService.onClick(context, it, viewModel::navigateTo) + }, + modifier = modifier, + ) + if (showRequestDialog) { + RequestMovieDialog( + loading = state.profileLoading, + data = state.requestData, + requestEnabled = requestEnabled, + request4kEnabled = request4kEnabled, movie = movie, - userConfig = userConfig, - rating = rating, - canCancel = canCancel, - people = people, - trailers = trailers, - similar = similar, - recommended = recommended, - requestOnClick = { - movie.id?.let { id -> - if (request4kEnabled) { - moreDialog = - DialogParams( - fromLongClick = false, - title = movie.title + " (${movie.releaseDate ?: ""})", - items = - listOf( - DialogItem( - text = requestStr, - onClick = { viewModel.request(id, false) }, - ), - DialogItem( - text = request4kStr, - onClick = { viewModel.request(id, true) }, - ), - ), - ) - } else { - viewModel.request(id, false) - } - } + onSubmit = { + viewModel.request(it) + showRequestDialog = false }, - cancelOnClick = { - movie.id?.let { viewModel.cancelRequest(it) } - }, - onClickItem = { index, item -> - viewModel.navigateTo(Destination.DiscoveredItem(item)) - }, - onClickPerson = { item -> - viewModel.navigateTo(Destination.DiscoveredItem(item)) - }, - overviewOnClick = { - overviewDialog = - ItemDetailsDialogInfo( - title = movie.title ?: context.getString(R.string.unknown), - overview = movie.overview, - genres = movie.genres?.mapNotNull { it.name }.orEmpty(), - files = listOf(), - ) - }, - goToOnClick = { - movie.mediaInfo?.jellyfinMediaId?.toUUIDOrNull()?.let { - viewModel.navigateTo( - Destination.MediaItem( - itemId = it, - type = BaseItemKind.MOVIE, - ), - ) - } - }, - moreOnClick = { - moreDialog = - DialogParams( - fromLongClick = false, - title = movie.title + " (${movie.releaseDate ?: ""})", - items = listOf(), - ) - }, - onLongClickPerson = { index, person -> }, - onLongClickSimilar = { index, similar -> - }, - trailerOnClick = { - TrailerService.onClick(context, it, viewModel::navigateTo) - }, - modifier = modifier, + onDismissRequest = { showRequestDialog = false }, ) } } @@ -202,16 +169,6 @@ fun DiscoverMovieDetails( onDismissRequest = { overviewDialog = null }, ) } - moreDialog?.let { params -> - DialogPopup( - showDialog = true, - title = params.title, - dialogItems = params.items, - onDismissRequest = { moreDialog = null }, - dismissOnClick = true, - waitToLoad = params.fromLongClick, - ) - } } private const val HEADER_ROW = 0 @@ -226,7 +183,7 @@ fun DiscoverMovieDetailsContent( preferences: UserPreferences, userConfig: SeerrUserConfig?, movie: MovieDetails, - rating: DiscoverRating?, + rating: SourceRatings?, canCancel: Boolean, people: List<DiscoverItem>, trailers: List<Trailer>, @@ -244,7 +201,6 @@ fun DiscoverMovieDetailsContent( onLongClickSimilar: (Int, DiscoverItem) -> Unit, modifier: Modifier = Modifier, ) { - val context = LocalContext.current val scope = rememberCoroutineScope() var position by rememberInt(0) val focusRequesters = remember { List(RECOMMENDED_ROW + 1) { FocusRequester() } } @@ -273,10 +229,11 @@ fun DiscoverMovieDetailsContent( rating = rating, bringIntoViewRequester = bringIntoViewRequester, overviewOnClick = overviewOnClick, + showLogo = preferences.appPreferences.interfacePreferences.showLogos, modifier = Modifier .fillMaxWidth() - .padding(top = 32.dp, bottom = 16.dp), + .padding(top = HeaderUtils.topPadding, bottom = 16.dp), ) ExpandableDiscoverButtons( availability = @@ -294,7 +251,7 @@ fun DiscoverMovieDetailsContent( } } }, - canRequest = userConfig.hasPermission(SeerrPermission.REQUEST), + canRequest = userConfig.canRequestMovies, canCancel = canCancel, trailers = trailers, trailerOnClick = trailerOnClick, @@ -398,7 +355,7 @@ fun TrailerRow( modifier = modifier, ) { Text( - text = stringResource(R.string.trailers), + text = stringResource(R.string.trailers_title), style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onBackground, ) 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 d8647132..34bf4657 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetailsHeader.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetailsHeader.kt @@ -11,26 +11,33 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontStyle -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp 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.api.seerr.model.MovieDetails -import com.github.damontecres.wholphin.data.model.DiscoverRating +import com.github.damontecres.wholphin.data.model.QuickDetailsData +import com.github.damontecres.wholphin.data.model.SourceRatings import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.services.jellyfinId +import com.github.damontecres.wholphin.ui.LocalImageUrlService import com.github.damontecres.wholphin.ui.components.GenreText +import com.github.damontecres.wholphin.ui.components.HeaderUtils import com.github.damontecres.wholphin.ui.components.OverviewText import com.github.damontecres.wholphin.ui.components.QuickDetails +import com.github.damontecres.wholphin.ui.components.TitleOrLogo +import com.github.damontecres.wholphin.ui.formatDuration import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.letNotEmpty import com.github.damontecres.wholphin.ui.listToDotString import com.github.damontecres.wholphin.ui.roundMinutes import com.github.damontecres.wholphin.util.ExceptionHandler import kotlinx.coroutines.launch +import org.jellyfin.sdk.model.api.ImageType import java.util.Locale import kotlin.time.Duration.Companion.minutes @@ -38,42 +45,49 @@ import kotlin.time.Duration.Companion.minutes fun DiscoverMovieDetailsHeader( preferences: UserPreferences, movie: MovieDetails, - rating: DiscoverRating?, + rating: SourceRatings?, bringIntoViewRequester: BringIntoViewRequester, overviewOnClick: () -> Unit, + showLogo: Boolean, modifier: Modifier = Modifier, ) { + val imageUrlService = LocalImageUrlService.current + val logoImageUrl = + remember(movie) { + movie.mediaInfo?.jellyfinId?.let { + imageUrlService.getItemImageUrl(it, ImageType.LOGO) + } + } val context = LocalContext.current val scope = rememberCoroutineScope() Column( - verticalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), modifier = modifier, ) { - // Title - Text( - text = movie.title ?: "", - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.displaySmall, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.fillMaxWidth(.75f), + TitleOrLogo( + title = movie.title, + logoImageUrl = logoImageUrl, + showLogo = showLogo, + modifier = + Modifier + .fillMaxWidth(.75f) + .padding(start = HeaderUtils.startPadding), ) Column( verticalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.fillMaxWidth(.60f), ) { - val padding = 4.dp + val resources = LocalResources.current val details = - remember(movie, rating) { + remember(movie, resources) { buildList { movie.releaseDate?.let(::add) movie.runtime ?.toDouble() ?.minutes ?.roundMinutes - ?.toString() - ?.let(::add) + ?.let { add(resources.formatDuration(it)) } val release = movie.releases ?.results @@ -92,17 +106,13 @@ fun DiscoverMovieDetailsHeader( ?.takeIf { it.isNotNullOrBlank() } ?.let(::add) }.let { - listToDotString( - it, - rating?.audienceRating, - rating?.criticRating?.toFloat(), - ) + QuickDetailsData(listToDotString(it)) } } - QuickDetails(details, null) + QuickDetails(details, null, Modifier.padding(start = HeaderUtils.startPadding), ratings = rating) movie.genres?.mapNotNull { it.name }?.letNotEmpty { - GenreText(it, Modifier.padding(bottom = padding)) + GenreText(it, Modifier.padding(start = HeaderUtils.startPadding)) } val tagline = remember { movie.tagline?.takeIf { it.isNotNullOrBlank() } } @@ -111,7 +121,7 @@ fun DiscoverMovieDetailsHeader( text = tagline, style = MaterialTheme.typography.bodyLarge, fontStyle = FontStyle.Italic, - modifier = Modifier, + modifier = Modifier.padding(start = HeaderUtils.startPadding), ) } @@ -148,6 +158,7 @@ fun DiscoverMovieDetailsHeader( text = stringResource(R.string.directed_by, it), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface, + modifier = 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 3edd7dd6..407c6935 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieViewModel.kt @@ -1,48 +1,56 @@ package com.github.damontecres.wholphin.ui.detail.discover import android.content.Context -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.api.seerr.model.MediaInfo import com.github.damontecres.wholphin.api.seerr.model.MediaRequest import com.github.damontecres.wholphin.api.seerr.model.MovieDetails import com.github.damontecres.wholphin.api.seerr.model.RelatedVideo import com.github.damontecres.wholphin.api.seerr.model.RequestPostRequest import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.DiscoverItem -import com.github.damontecres.wholphin.data.model.DiscoverRating +import com.github.damontecres.wholphin.data.model.SourceRatings import com.github.damontecres.wholphin.data.model.RemoteTrailer +import com.github.damontecres.wholphin.data.model.RequestStatus +import com.github.damontecres.wholphin.data.model.SeerrItemType import com.github.damontecres.wholphin.data.model.SeerrPermission 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 import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.Destination -import com.github.damontecres.wholphin.ui.setValueOnMain -import com.github.damontecres.wholphin.util.LoadingExceptionHandler +import com.github.damontecres.wholphin.ui.showToast +import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.LoadingState +import com.github.damontecres.wholphin.util.WholphinDispatchers +import com.github.damontecres.wholphin.util.successValue import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Deferred -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.model.api.BaseItemKind import timber.log.Timber +import kotlin.coroutines.cancellation.CancellationException @HiltViewModel(assistedFactory = DiscoverMovieViewModel.Factory::class) class DiscoverMovieViewModel @@ -54,6 +62,7 @@ class DiscoverMovieViewModel private val backdropService: BackdropService, val serverRepository: ServerRepository, val seerrService: SeerrService, + private val ratingsService: SeerrRatingsService, private val seerrServerRepository: SeerrServerRepository, @Assisted val item: DiscoverItem, ) : ViewModel() { @@ -62,17 +71,12 @@ class DiscoverMovieViewModel fun create(item: DiscoverItem): DiscoverMovieViewModel } - val loading = MutableLiveData<LoadingState>(LoadingState.Pending) - val movie = MutableLiveData<MovieDetails?>(null) - val rating = MutableLiveData<DiscoverRating?>(null) - - val trailers = MutableLiveData<List<Trailer>>(listOf()) - val people = MutableLiveData<List<DiscoverItem>>(listOf()) - val similar = MutableLiveData<List<DiscoverItem>>() - val recommended = MutableLiveData<List<DiscoverItem>>() - val canCancelRequest = MutableStateFlow(false) + private val _state = MutableStateFlow(DiscoverMovieState()) + val state: StateFlow<DiscoverMovieState> = _state val userConfig = seerrServerRepository.current.map { it?.config } + val requestEnabled = + seerrServerRepository.current.map { it?.requestMovieEnabled ?: false } val request4kEnabled = seerrServerRepository.current.map { it?.request4kMovieEnabled ?: false } @@ -80,104 +84,141 @@ class DiscoverMovieViewModel init() } - private fun fetchAndSetItem(): Deferred<MovieDetails> = - viewModelScope.async( - Dispatchers.IO + - LoadingExceptionHandler( - loading, - "Error fetching movie", - ), - ) { - val movie = seerrService.api.moviesApi.movieMovieIdGet(movieId = item.id) - this@DiscoverMovieViewModel.movie.setValueOnMain(movie) - movie + private fun fetchAndSetItem(): Deferred<MovieDetails?> = + viewModelScope.async(WholphinDispatchers.IO) { + try { + val movie = seerrService.api.moviesApi.movieMovieIdGet(movieId = item.id) + _state.update { it.copy(movie = DataLoadingState.Success(movie)) } + movie + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Error updating movie details") + null + } } fun init(): Job = - viewModelScope.launch( - Dispatchers.IO + - LoadingExceptionHandler( - loading, - "Error fetching movie", - ), - ) { + viewModelScope.launchIO { Timber.v("Init for movie %s", item.id) - val movie = fetchAndSetItem().await() - val discoveredItem = seerrService.createDiscoverItem(movie) - backdropService.submit(discoveredItem) + try { + val movie = seerrService.api.moviesApi.movieMovieIdGet(movieId = item.id) + _state.update { it.copy(movie = DataLoadingState.Success(movie)) } + val discoveredItem = seerrService.createDiscoverItem(movie) + backdropService.submit(discoveredItem) - updateCanCancel() + updateCanCancel() - withContext(Dispatchers.Main) { - loading.value = LoadingState.Success - } - viewModelScope.launchIO { - val result = seerrService.api.moviesApi.movieMovieIdRatingsGet(movieId = item.id) - rating.setValueOnMain(DiscoverRating(result)) - } - if (!similar.isInitialized) { viewModelScope.launchIO { - val result = - seerrService.api.moviesApi - .movieMovieIdSimilarGet(movieId = item.id, page = 1) - .results - ?.map { seerrService.createDiscoverItem(it) } - .orEmpty() - similar.setValueOnMain(result) + val rating = + ratingsService.get( + SeerrItemType.MOVIE, + item.id, + TmdbVote(movie.voteAverage?.toDouble(), movie.voteCount?.toLong()), + ) + _state.update { it.copy(rating = rating) } } - viewModelScope.launchIO { - val result = - seerrService.api.moviesApi - .movieMovieIdRecommendationsGet(movieId = item.id, page = 1) - .results - ?.map { seerrService.createDiscoverItem(it) } - .orEmpty() - recommended.setValueOnMain(result) + if (state.value.similar.isEmpty()) { + viewModelScope.launchIO { + val result = + seerrService.api.moviesApi + .movieMovieIdSimilarGet(movieId = item.id, page = 1) + .results + ?.map { seerrService.createDiscoverItem(it) } + .orEmpty() + _state.update { it.copy(similar = result) } + } + viewModelScope.launchIO { + val result = + seerrService.api.moviesApi + .movieMovieIdRecommendationsGet(movieId = item.id, page = 1) + .results + ?.map { seerrService.createDiscoverItem(it) } + .orEmpty() + _state.update { it.copy(recommended = result) } + } } - } - val people = - movie.credits - ?.cast - ?.map { seerrService.createDiscoverItem(it) } - .orEmpty() + + val people = movie.credits - ?.crew + ?.cast ?.map { seerrService.createDiscoverItem(it) } - .orEmpty() - this@DiscoverMovieViewModel.people.setValueOnMain(people) - val trailers = - movie.relatedVideos - ?.filter { it.type == RelatedVideo.Type.TRAILER } - ?.filter { it.name.isNotNullOrBlank() && it.url.isNotNullOrBlank() } - ?.map { - RemoteTrailer(it.name!!, it.url!!, it.site) - }.orEmpty() - this@DiscoverMovieViewModel.trailers.setValueOnMain(trailers) + .orEmpty() + + movie.credits + ?.crew + ?.map { seerrService.createDiscoverItem(it) } + .orEmpty() + _state.update { it.copy(people = people) } + val trailers = + movie.relatedVideos + ?.filter { it.type == RelatedVideo.Type.TRAILER } + ?.filter { it.name.isNotNullOrBlank() && it.url.isNotNullOrBlank() } + ?.map { + RemoteTrailer(it.name!!, it.url!!, it.site) + }.orEmpty() + _state.update { it.copy(trailers = trailers) } + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Error updating movie details") + _state.update { it.copy(movie = DataLoadingState.Error(ex)) } + } } private suspend fun updateCanCancel() { val user = userConfig.firstOrNull() - val canCancel = canUserCancelRequest(user, movie.value?.mediaInfo?.requests) - canCancelRequest.update { canCancel } + val canCancel = + canUserCancelRequest( + user, + state.value.movie.successValue + ?.mediaInfo + ?.requests, + ) + _state.update { it.copy(canCancelRequest = canCancel) } } fun navigateTo(destination: Destination) { navigationManager.navigateTo(destination) } - fun request( - id: Int, - is4k: Boolean, + fun goTo( + mediaInfo: MediaInfo?, + type: BaseItemKind, ) { + viewModelScope.launchDefault { + goToButtonDiscover(mediaInfo, type, context, navigationManager) + } + } + + fun request(request: MovieRequest) { viewModelScope.launchIO { - val request = - seerrService.api.requestApi.requestPost( + if (request.movieId == null) { + Timber.w("Null movie ID") + return@launchIO + } + try { + val postRequest = RequestPostRequest( - is4k = is4k, - mediaId = id, + is4k = request.is4k, + mediaId = request.movieId, mediaType = RequestPostRequest.MediaType.MOVIE, - ), - ) + serverId = + when { + request.profileId == null && request.folder == null -> null + request.is4k -> request.data.server4kId + else -> request.data.serverId + }, + profileId = request.profileId, + rootFolder = request.folder, + tags = emptyList(), + ) + Timber.v("postRequest=%s", postRequest) + seerrService.api.requestApi.requestPost(postRequest) + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Error requesting %s", request.movieId) + showToast(context, "An error occurred") + } fetchAndSetItem().await() updateCanCancel() } @@ -185,14 +226,43 @@ class DiscoverMovieViewModel fun cancelRequest(id: Int) { viewModelScope.launchIO { - movie.value?.mediaInfo?.requests?.firstOrNull()?.let { + state.value.movie.successValue?.mediaInfo?.requests?.firstOrNull()?.let { // TODO handle multiple requests? Or just delete self's request? - seerrService.api.requestApi.requestRequestIdDelete(it.id.toString()) + try { + seerrService.api.requestApi.requestRequestIdDelete(it.id.toString()) + } catch (ex: kotlinx.coroutines.CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Error requesting %s", id) + showToast(context, "An error occurred") + } fetchAndSetItem().await() updateCanCancel() } } } + + fun requestOnClick() { + viewModelScope.launchIO { + try { + val data = seerrService.getProfilesAndFolders(SeerrItemType.MOVIE) + _state.update { + it.copy( + profileLoading = LoadingState.Success, + requestData = data, + ) + } + } catch (ex: Exception) { + Timber.e(ex, "Error getting profiles & folders") + showToast(context, "Error getting profiles & folders: ${ex.localizedMessage}") + _state.update { + it.copy( + profileLoading = LoadingState.Success, + ) + } + } + } + } } fun canUserCancelRequest( @@ -202,5 +272,23 @@ fun canUserCancelRequest( ( // User requested this user.hasPermission(SeerrPermission.REQUEST) && - requests?.any { it.requestedBy?.id == user?.id } == true + requests + .orEmpty() + .any { + equalsNotNull(it.requestedBy?.id, user?.id) && + RequestStatus.from(it.status) < RequestStatus.APPROVED + } ) + +data class DiscoverMovieState( + val movie: DataLoadingState<MovieDetails> = DataLoadingState.Pending, + val rating: SourceRatings? = null, + val seasons: List<RequestSeason> = emptyList(), + val trailers: List<Trailer> = emptyList(), + val people: List<DiscoverItem> = emptyList(), + val similar: List<DiscoverItem> = emptyList(), + val recommended: List<DiscoverItem> = emptyList(), + val canCancelRequest: Boolean = false, + val profileLoading: LoadingState = LoadingState.Pending, + val requestData: SeerrRequestData = SeerrRequestData(), +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverPersonPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverPersonPage.kt index 5987b7eb..2ce4a3a9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverPersonPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverPersonPage.kt @@ -61,20 +61,7 @@ class DiscoverPersonViewModel viewModelScope.launchIO { backdropService.clearBackdrop() - val credits = - seerrService.api.personApi - .personPersonIdCombinedCreditsGet(personId = item.id) - .let { credits -> - val cast = - credits.cast - ?.map { seerrService.createDiscoverItem(it) } - .orEmpty() - val crew = - credits.crew - ?.map { seerrService.createDiscoverItem(it) } - .orEmpty() - cast + crew - } + val credits = seerrService.personCredits(item.id) this@DiscoverPersonViewModel.credits.update { DataLoadingState.Success(credits) } @@ -85,6 +72,7 @@ class DiscoverPersonViewModel @Composable fun DiscoverPersonPage( person: DiscoverItem, + startIndex: Int, modifier: Modifier = Modifier, viewModel: DiscoverPersonViewModel = hiltViewModel<DiscoverPersonViewModel, DiscoverPersonViewModel.Factory>( @@ -137,6 +125,7 @@ fun DiscoverPersonPage( }, onClickPlay = { _, item -> }, + initialPosition = startIndex, letterPosition = { c: Char -> 0 }, gridFocusRequester = focusRequester, showJumpButtons = false, 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 42d4ce05..860af84e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt @@ -1,6 +1,6 @@ package com.github.damontecres.wholphin.ui.detail.discover -import android.content.Context +import android.content.res.Resources import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -17,7 +17,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -25,27 +24,26 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Text import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.api.seerr.model.TvDetails import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.DiscoverItem -import com.github.damontecres.wholphin.data.model.DiscoverRating +import com.github.damontecres.wholphin.data.model.QuickDetailsData +import com.github.damontecres.wholphin.data.model.SourceRatings import com.github.damontecres.wholphin.data.model.SeerrAvailability -import com.github.damontecres.wholphin.data.model.SeerrPermission import com.github.damontecres.wholphin.data.model.Trailer -import com.github.damontecres.wholphin.data.model.hasPermission +import com.github.damontecres.wholphin.data.model.canRequestTv import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.SeerrUserConfig import com.github.damontecres.wholphin.services.TrailerService +import com.github.damontecres.wholphin.services.jellyfinId +import com.github.damontecres.wholphin.ui.LocalImageUrlService import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard import com.github.damontecres.wholphin.ui.cards.DiscoverPersonRow import com.github.damontecres.wholphin.ui.cards.ItemRow @@ -54,22 +52,26 @@ import com.github.damontecres.wholphin.ui.components.DialogParams import com.github.damontecres.wholphin.ui.components.DialogPopup import com.github.damontecres.wholphin.ui.components.ErrorMessage 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.TitleOrLogo import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo +import com.github.damontecres.wholphin.ui.formatDuration import com.github.damontecres.wholphin.ui.letNotEmpty import com.github.damontecres.wholphin.ui.listToDotString import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.rememberInt import com.github.damontecres.wholphin.ui.roundMinutes import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.ExceptionHandler -import com.github.damontecres.wholphin.util.LoadingState +import com.github.damontecres.wholphin.util.successValue import kotlinx.coroutines.launch import org.jellyfin.sdk.model.api.BaseItemKind -import org.jellyfin.sdk.model.serializer.toUUIDOrNull +import org.jellyfin.sdk.model.api.ImageType import kotlin.time.Duration.Companion.minutes @Composable @@ -83,94 +85,76 @@ fun DiscoverSeriesDetails( ), ) { val context = LocalContext.current - val loading by viewModel.loading.observeAsState(LoadingState.Loading) - - val item by viewModel.tvSeries.observeAsState() - val seasons by viewModel.seasons.observeAsState(listOf()) - val people by viewModel.people.observeAsState(listOf()) - val trailers by viewModel.trailers.observeAsState(listOf()) - val similar by viewModel.similar.observeAsState(listOf()) - val recommended by viewModel.recommended.observeAsState(listOf()) - val userConfig by viewModel.userConfig.collectAsState(null) - val request4kEnabled by viewModel.request4kEnabled.collectAsState(false) - val canCancel by viewModel.canCancelRequest.collectAsState() + val resources = LocalResources.current + val state by viewModel.state.collectAsState() + val requestEnabled by viewModel.requestEnabled.collectAsState() + val request4kEnabled by viewModel.request4kEnabled.collectAsState() var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) } var seasonDialog by remember { mutableStateOf<DialogParams?>(null) } var moreDialog by remember { mutableStateOf<DialogParams?>(null) } var showRequestSeasonDialog by remember { mutableStateOf(false) } - val requestStr = stringResource(R.string.request) - val request4kStr = stringResource(R.string.request_4k) - - when (val state = loading) { - is LoadingState.Error -> { - ErrorMessage(state, modifier) + when (val st = state.tvSeries) { + is DataLoadingState.Error -> { + ErrorMessage(st, modifier) } - LoadingState.Loading, - LoadingState.Pending, + DataLoadingState.Loading, + DataLoadingState.Pending, -> { LoadingPage(modifier) } - LoadingState.Success -> { - item?.let { item -> - val rating by viewModel.rating.observeAsState(null) - DiscoverSeriesDetailsContent( - preferences = preferences, - series = item, - userConfig = userConfig, - rating = rating, - canCancel = canCancel, - seasons = seasons, - people = people, - similar = similar, - recommended = recommended, - modifier = modifier, - onClickItem = { index, item -> - viewModel.navigateTo(Destination.DiscoveredItem(item)) - }, - onClickPerson = { - viewModel.navigateTo(Destination.DiscoveredItem(it)) - }, - goToOnClick = { - item.mediaInfo?.jellyfinMediaId?.toUUIDOrNull()?.let { - viewModel.navigateTo( - Destination.MediaItem( - itemId = it, - type = BaseItemKind.MOVIE, - ), - ) - } - }, - overviewOnClick = { - overviewDialog = - ItemDetailsDialogInfo( - title = item.name ?: context.getString(R.string.unknown), - overview = item.overview, - genres = item.genres?.mapNotNull { it.name }.orEmpty(), - files = listOf(), - ) - }, - trailerOnClick = { - TrailerService.onClick(context, it, viewModel::navigateTo) - }, - trailers = trailers, - requestOnClick = { - item.id?.let { id -> - showRequestSeasonDialog = true - } - }, - cancelOnClick = { - item.id?.let { viewModel.cancelRequest(it) } - }, - moreOnClick = { - }, - onLongClickPerson = { _, _ -> }, - onLongClickSimilar = { _, _ -> }, - ) - } + is DataLoadingState.Success<TvDetails> -> { + val item = st.data + val userConfig by viewModel.userConfig.collectAsState(null) + DiscoverSeriesDetailsContent( + preferences = preferences, + series = item, + availability = state.availability, + userConfig = userConfig, + rating = state.rating, + canCancel = state.canCancelRequest, + seasons = state.seasons, + people = state.people, + similar = state.similar, + recommended = state.recommended, + modifier = modifier, + onClickItem = { index, item -> + viewModel.navigateTo(Destination.DiscoveredItem(item)) + }, + onClickPerson = { + viewModel.navigateTo(Destination.DiscoveredItem(it)) + }, + goToOnClick = { + viewModel.goTo(item.mediaInfo, BaseItemKind.SERIES) + }, + overviewOnClick = { + overviewDialog = + ItemDetailsDialogInfo( + title = item.name ?: resources.getString(R.string.unknown), + overview = item.overview, + genres = item.genres?.mapNotNull { it.name }.orEmpty(), + files = listOf(), + ) + }, + trailerOnClick = { + TrailerService.onClick(context, it, viewModel::navigateTo) + }, + trailers = state.trailers, + requestOnClick = { + viewModel.requestOnClick() + showRequestSeasonDialog = true + }, + cancelOnClick = { + item.id?.let { viewModel.cancelRequest(it) } + }, + moreOnClick = { + }, + onLongClickPerson = { _, _ -> }, + onLongClickSimilar = { _, _ -> }, + ) } } overviewDialog?.let { info -> @@ -201,13 +185,18 @@ fun DiscoverSeriesDetails( } if (showRequestSeasonDialog) { RequestSeasonsDialog( - title = item?.name ?: "", - seasons = seasons, + id = state.tvSeries.successValue?.id ?: -1, + title = state.tvSeries.successValue?.name ?: "", + seasons = state.seasons, + seasons4k = state.seasons4k, + requestEnabled = requestEnabled, request4kEnabled = request4kEnabled, - onSubmit = { seasons, is4k -> - item?.id?.let { viewModel.request(it, seasons, is4k) } + onSubmit = { showRequestSeasonDialog = false + viewModel.request(it) }, + loading = state.profileLoading, + data = state.requestData, onDismissRequest = { showRequestSeasonDialog = false }, ) } @@ -225,7 +214,8 @@ fun DiscoverSeriesDetailsContent( preferences: UserPreferences, userConfig: SeerrUserConfig?, series: TvDetails, - rating: DiscoverRating?, + availability: SeerrAvailability, + rating: SourceRatings?, canCancel: Boolean, seasons: List<RequestSeason>, similar: List<DiscoverItem>, @@ -262,7 +252,6 @@ fun DiscoverSeriesDetailsContent( Column( modifier = Modifier - .padding(16.dp) .fillMaxSize(), ) { LazyColumn( @@ -282,15 +271,14 @@ fun DiscoverSeriesDetailsContent( series = series, rating = rating, overviewOnClick = overviewOnClick, + showLogo = preferences.appPreferences.interfacePreferences.showLogos, modifier = Modifier .fillMaxWidth() - .padding(top = 32.dp, bottom = 16.dp), + .padding(top = HeaderUtils.topPadding, bottom = 16.dp), ) ExpandableDiscoverButtons( - availability = - SeerrAvailability.from(series.mediaInfo?.status) - ?: SeerrAvailability.UNKNOWN, + availability = availability, requestOnClick = requestOnClick, pendingOnClick = requestOnClick, cancelOnClick = cancelOnClick, @@ -304,7 +292,7 @@ fun DiscoverSeriesDetailsContent( } } }, - canRequest = userConfig.hasPermission(SeerrPermission.REQUEST), + canRequest = userConfig.canRequestTv, canCancel = canCancel, trailers = trailers, trailerOnClick = trailerOnClick, @@ -436,28 +424,38 @@ fun DiscoverSeriesDetailsContent( @Composable fun DiscoverSeriesDetailsHeader( series: TvDetails, - rating: DiscoverRating?, + rating: SourceRatings?, overviewOnClick: () -> Unit, + showLogo: Boolean, modifier: Modifier = Modifier, ) { + val imageUrlService = LocalImageUrlService.current + val logoImageUrl = + remember(series) { + series.mediaInfo?.jellyfinId?.let { + imageUrlService.getItemImageUrl(it, ImageType.LOGO) + } + } Column( - verticalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), modifier = modifier, ) { - Text( - text = series.name ?: stringResource(R.string.unknown), - style = MaterialTheme.typography.displaySmall, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.fillMaxWidth(.75f), + TitleOrLogo( + title = series.name, + logoImageUrl = logoImageUrl, + showLogo = showLogo, + modifier = + Modifier + .fillMaxWidth(.75f) + .padding(start = HeaderUtils.startPadding), ) Column( verticalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.fillMaxWidth(.60f), ) { - val padding = 4.dp + val resources = LocalResources.current val details = - remember(series, rating) { + remember(series, resources) { buildList { series.firstAirDate?.let(::add) series.episodeRunTime @@ -465,21 +463,16 @@ fun DiscoverSeriesDetailsHeader( ?.takeIf { !it.isNaN() && it > 0 } ?.minutes ?.roundMinutes - ?.toString() - ?.let(::add) + ?.let { add(resources.formatDuration(it)) } // TODO }.let { - listToDotString( - it, - rating?.audienceRating, - rating?.criticRating?.toFloat(), - ) + QuickDetailsData(listToDotString(it)) } } - QuickDetails(details, null) + QuickDetails(details, null, Modifier.padding(start = HeaderUtils.startPadding), ratings = rating) series.genres?.mapNotNull { it.name }?.letNotEmpty { - GenreText(it, Modifier.padding(bottom = padding)) + GenreText(it, Modifier.padding(start = HeaderUtils.startPadding, bottom = 4.dp)) } series.overview?.let { overview -> OverviewText( @@ -494,7 +487,7 @@ fun DiscoverSeriesDetailsHeader( } fun buildDialogForSeason( - context: Context, + resources: Resources, s: BaseItem, onClickItem: (BaseItem) -> Unit, markPlayed: (Boolean) -> Unit, @@ -503,35 +496,34 @@ fun buildDialogForSeason( val items = buildList { add( - DialogItem(context.getString(R.string.go_to), Icons.Default.PlayArrow) { + DialogItem(resources.getString(R.string.go_to), Icons.Default.PlayArrow) { onClickItem.invoke(s) }, ) if (s.data.userData?.played == true) { add( - DialogItem(context.getString(R.string.mark_unwatched), R.string.fa_eye) { + DialogItem(resources.getString(R.string.mark_unwatched), R.string.fa_eye) { markPlayed.invoke(false) }, ) } else { add( - DialogItem(context.getString(R.string.mark_watched), R.string.fa_eye_slash) { + DialogItem(resources.getString(R.string.mark_watched), R.string.fa_eye_slash) { markPlayed.invoke(true) }, ) } add( DialogItem( - context.getString(R.string.play), + resources.getString(R.string.play), Icons.Default.PlayArrow, - iconColor = Color.Green.copy(alpha = .8f), ) { onClickPlay.invoke(false) }, ) add( DialogItem( - context.getString(R.string.shuffle), + resources.getString(R.string.shuffle), R.string.fa_shuffle, ) { onClickPlay.invoke(true) @@ -539,7 +531,7 @@ fun buildDialogForSeason( ) } return DialogParams( - title = s.name ?: context.getString(R.string.tv_season), + title = s.name ?: resources.getString(R.string.tv_season), fromLongClick = true, items = items, ) 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 54b3d98f..c20ab9c9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesViewModel.kt @@ -1,46 +1,55 @@ package com.github.damontecres.wholphin.ui.detail.discover import android.content.Context -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.api.seerr.model.MediaInfo import com.github.damontecres.wholphin.api.seerr.model.RelatedVideo import com.github.damontecres.wholphin.api.seerr.model.RequestPostRequest import com.github.damontecres.wholphin.api.seerr.model.RequestRequestIdPutRequest import com.github.damontecres.wholphin.api.seerr.model.TvDetails import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.DiscoverItem -import com.github.damontecres.wholphin.data.model.DiscoverRating +import com.github.damontecres.wholphin.data.model.SourceRatings import com.github.damontecres.wholphin.data.model.RemoteTrailer +import com.github.damontecres.wholphin.data.model.RequestStatus import com.github.damontecres.wholphin.data.model.SeerrAvailability +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 import com.github.damontecres.wholphin.ui.nav.Destination -import com.github.damontecres.wholphin.ui.setValueOnMain -import com.github.damontecres.wholphin.util.LoadingExceptionHandler +import com.github.damontecres.wholphin.ui.showToast +import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.LoadingState +import com.github.damontecres.wholphin.util.WholphinDispatchers +import com.github.damontecres.wholphin.util.successValue import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Deferred -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.model.api.BaseItemKind import timber.log.Timber @HiltViewModel(assistedFactory = DiscoverSeriesViewModel.Factory::class) @@ -53,6 +62,7 @@ class DiscoverSeriesViewModel private val backdropService: BackdropService, val serverRepository: ServerRepository, val seerrService: SeerrService, + private val ratingsService: SeerrRatingsService, private val seerrServerRepository: SeerrServerRepository, @Assisted val item: DiscoverItem, ) : ViewModel() { @@ -61,198 +71,398 @@ class DiscoverSeriesViewModel fun create(item: DiscoverItem): DiscoverSeriesViewModel } - val loading = MutableLiveData<LoadingState>(LoadingState.Pending) - val tvSeries = MutableLiveData<TvDetails?>(null) - val rating = MutableLiveData<DiscoverRating?>(null) - - val seasons = MutableLiveData<List<RequestSeason>>(listOf()) - val trailers = MutableLiveData<List<Trailer>>(listOf()) - val people = MutableLiveData<List<DiscoverItem>>(listOf()) - val similar = MutableLiveData<List<DiscoverItem>>() - val recommended = MutableLiveData<List<DiscoverItem>>() - val canCancelRequest = MutableStateFlow(false) + private val _state = MutableStateFlow(DiscoverSeriesState()) + val state: StateFlow<DiscoverSeriesState> = _state val userConfig = seerrServerRepository.current.map { it?.config } - val request4kEnabled = seerrServerRepository.current.map { it?.request4kTvEnabled ?: false } + val requestEnabled = + seerrServerRepository.current + .map { it?.requestTvEnabled ?: false } + .stateIn(viewModelScope, SharingStarted.Eagerly, false) + val request4kEnabled = + seerrServerRepository.current + .map { it?.request4kTvEnabled ?: false } + .stateIn(viewModelScope, SharingStarted.Eagerly, false) init { init() } - private fun fetchAndSetItem(): Deferred<TvDetails> = - viewModelScope.async( - Dispatchers.IO + - LoadingExceptionHandler( - loading, - "Error fetching movie", - ), - ) { - val tv = seerrService.api.tvApi.tvTvIdGet(tvId = item.id) - this@DiscoverSeriesViewModel.tvSeries.setValueOnMain(tv) - tv + private fun fetchAndSetItem(): Deferred<TvDetails?> = + viewModelScope.async(WholphinDispatchers.IO) { + try { + val tv = seerrService.api.tvApi.tvTvIdGet(tvId = item.id) + _state.update { it.copy(tvSeries = DataLoadingState.Success(tv)) } + tv + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Error updating tv details") + null + } } fun init(): Job = - viewModelScope.launch( - Dispatchers.IO + - LoadingExceptionHandler( - loading, - "Error fetching movie", - ), - ) { + viewModelScope.launchIO { Timber.v("Init for tv %s", item.id) - val tv = fetchAndSetItem().await() - val discoveredItem = seerrService.createDiscoverItem(tv) - backdropService.submit(discoveredItem) + try { + val tv = seerrService.api.tvApi.tvTvIdGet(tvId = item.id) + _state.update { it.copy(tvSeries = DataLoadingState.Success(tv)) } + val discoveredItem = seerrService.createDiscoverItem(tv) + backdropService.submit(discoveredItem) - updateSeasonStatus() - updateCanCancel() + updateSeasonStatus(tv) + updateAvailability() - withContext(Dispatchers.Main) { - loading.value = LoadingState.Success - } - viewModelScope.launchIO { - val result = seerrService.api.tvApi.tvTvIdRatingsGet(tvId = item.id) - rating.setValueOnMain(DiscoverRating(result)) - } - if (!similar.isInitialized) { viewModelScope.launchIO { - val result = - seerrService.api.tvApi - .tvTvIdSimilarGet(tvId = item.id, page = 1) - .results - ?.map { seerrService.createDiscoverItem(it) } - .orEmpty() - similar.setValueOnMain(result) + val rating = + ratingsService.get( + SeerrItemType.TV, + item.id, + TmdbVote(tv.voteAverage?.toDouble(), tv.voteCount?.toLong()), + ) + _state.update { it.copy(rating = rating) } } - viewModelScope.launchIO { - val result = - seerrService.api.tvApi - .tvTvIdRecommendationsGet(tvId = item.id, page = 1) - .results - ?.map { seerrService.createDiscoverItem(it) } - .orEmpty() - recommended.setValueOnMain(result) + if (state.value.similar.isEmpty()) { + viewModelScope.launchIO { + val result = + seerrService.api.tvApi + .tvTvIdSimilarGet(tvId = item.id, page = 1) + .results + ?.map { seerrService.createDiscoverItem(it) } + .orEmpty() + _state.update { it.copy(similar = result) } + } + viewModelScope.launchIO { + val result = + seerrService.api.tvApi + .tvTvIdRecommendationsGet(tvId = item.id, page = 1) + .results + ?.map { seerrService.createDiscoverItem(it) } + .orEmpty() + _state.update { it.copy(recommended = result) } + } } - } - val people = - tv.credits - ?.cast - ?.map { seerrService.createDiscoverItem(it) } - .orEmpty() + + val people = tv.credits - ?.crew + ?.cast ?.map { seerrService.createDiscoverItem(it) } - .orEmpty() - this@DiscoverSeriesViewModel.people.setValueOnMain(people) + .orEmpty() + + tv.credits + ?.crew + ?.map { seerrService.createDiscoverItem(it) } + .orEmpty() + _state.update { it.copy(people = people) } - val trailers = - tv.relatedVideos - ?.filter { it.type == RelatedVideo.Type.TRAILER } - ?.filter { it.name.isNotNullOrBlank() && it.url.isNotNullOrBlank() } - ?.map { - RemoteTrailer(it.name!!, it.url!!, it.site) - }.orEmpty() - this@DiscoverSeriesViewModel.trailers.setValueOnMain(trailers) + val trailers = + tv.relatedVideos + ?.filter { it.type == RelatedVideo.Type.TRAILER } + ?.filter { it.name.isNotNullOrBlank() && it.url.isNotNullOrBlank() } + ?.map { + RemoteTrailer(it.name!!, it.url!!, it.site) + }.orEmpty() + _state.update { it.copy(trailers = trailers) } + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Error getting tv details") + _state.update { it.copy(tvSeries = DataLoadingState.Error(ex)) } + } } fun navigateTo(destination: Destination) { navigationManager.navigateTo(destination) } - private suspend fun updateSeasonStatus() { - tvSeries.value?.let { tv -> - val seasonStatus = mutableMapOf<Int, SeerrAvailability>() - tv.seasons?.forEach { - it.seasonNumber?.let { - seasonStatus[it] = SeerrAvailability.UNKNOWN - } - } - val tvStatus = - SeerrAvailability.from(tv.mediaInfo?.status) ?: SeerrAvailability.UNKNOWN - tv.mediaInfo - ?.requests - ?.forEach { - it.seasons?.mapNotNull { season -> - season.seasonNumber?.let { - val current = seasonStatus[season.seasonNumber] - val new = - SeerrAvailability - .from(season.status) - ?.takeIf { it != SeerrAvailability.UNKNOWN } ?: tvStatus - if (current == null || new.status > current.status) { - seasonStatus[season.seasonNumber] = new - } - } - } - } - Timber.v("seasonStatus=%s", seasonStatus) - val requestSeasons = - seasonStatus.mapNotNull { (seasonNumber, availability) -> - tv.seasons?.firstOrNull { it.seasonNumber == seasonNumber }?.let { - RequestSeason(it, availability) - } - } - seasons.setValueOnMain(requestSeasons) + fun goTo( + mediaInfo: MediaInfo?, + type: BaseItemKind, + ) { + viewModelScope.launchDefault { + goToButtonDiscover(mediaInfo, type, context, navigationManager) } } - private suspend fun updateCanCancel() { - val user = userConfig.firstOrNull() - val canCancel = canUserCancelRequest(user, tvSeries.value?.mediaInfo?.requests) - canCancelRequest.update { canCancel } + private suspend fun updateSeasonStatus(tv: TvDetails) { + if (request4kEnabled.first()) { + updateSeasonStatus(tv, true) + } + updateSeasonStatus(tv, false) } - fun request( - id: Int, - seasons: Set<Int>, + private suspend fun updateSeasonStatus( + tv: TvDetails, is4k: Boolean, ) { - viewModelScope.launchIO { - tvSeries.value?.let { tv -> - val currentRequest = - tv.mediaInfo?.requests?.firstOrNull { - it.requestedBy?.id == - seerrServerRepository.currentUserId.first() + Timber.v("updateSeasonStatus: id=%s, is4k=%s", tv.id, is4k) + val currentUserId = seerrServerRepository.currentUserId.first() + val seasonStatus = mutableMapOf<Int, RequestStatus>() + val seasonAvailability = mutableMapOf<Int, SeerrAvailability>() + val editable = mutableMapOf<Int, Boolean>() + tv.seasons?.forEach { + it.seasonNumber?.let { seasonNumber -> + seasonStatus[seasonNumber] = RequestStatus.UNKNOWN + val status = if (is4k) it.status4k else it.status + val availability = + SeerrAvailability.from(status) ?: SeerrAvailability.UNKNOWN + seasonAvailability[seasonNumber] = availability + } + } + tv.mediaInfo?.seasons?.forEach { + it.seasonNumber?.let { seasonNumber -> + val status = if (is4k) it.status4k else it.status + val availability = + SeerrAvailability.from(status) ?: SeerrAvailability.UNKNOWN + val current = + seasonAvailability.getOrDefault(seasonNumber, SeerrAvailability.UNKNOWN) + if (availability > current) { + seasonAvailability[seasonNumber] = availability + } + } + } + + tv.mediaInfo + ?.requests + ?.filter { it.is4k == is4k } + ?.forEach { req -> + req.seasons?.mapNotNull { season -> + season.seasonNumber?.let { + val current = seasonStatus[season.seasonNumber] + // Not status4k because the request itself is marked as is4k or not + val status = season.status + val new = RequestStatus.from(status) + if (current == null || new.status > current.status) { + seasonStatus[season.seasonNumber] = new + } + editable[season.seasonNumber] = + currentUserId == req.requestedBy?.id && ( + req.status == RequestStatus.PENDING.status || + req.status == RequestStatus.DECLINED.status + ) } - if (currentRequest != null) { - Timber.v("User has pending request, will update") - seerrService.api.requestApi.requestRequestIdPut( - requestId = currentRequest.id.toString(), - requestRequestIdPutRequest = - RequestRequestIdPutRequest( - is4k = is4k, - mediaType = RequestRequestIdPutRequest.MediaType.TV, - seasons = seasons.toList(), - ), - ) - } else { - Timber.v("New request for %s seasons", seasons.size) - seerrService.api.requestApi.requestPost( - RequestPostRequest( - is4k = is4k, - mediaId = id, - mediaType = RequestPostRequest.MediaType.TV, - seasons = seasons.toList(), - ), + } + } + Timber.v("seasonAvailability=%s", seasonAvailability) + Timber.v("seasonStatus=%s", seasonStatus) + val requestSeasons = + seasonStatus.mapNotNull { (seasonNumber, status) -> + tv.seasons?.firstOrNull { it.seasonNumber == seasonNumber }?.let { + val availability = + when (status) { + RequestStatus.PENDING -> { + SeerrAvailability.PENDING + } + + RequestStatus.APPROVED -> { + SeerrAvailability.PROCESSING + } + + RequestStatus.DECLINED -> { + SeerrAvailability.UNKNOWN + } + + RequestStatus.FAILURE -> { + SeerrAvailability.UNKNOWN + } + + RequestStatus.UNKNOWN, + RequestStatus.COMPLETED, + -> { + seasonAvailability.getOrDefault( + seasonNumber, + SeerrAvailability.UNKNOWN, + ) + } + } + val defaultEditable = + availability != SeerrAvailability.AVAILABLE && + availability != SeerrAvailability.PARTIALLY_AVAILABLE && + availability != SeerrAvailability.PROCESSING && + availability != SeerrAvailability.BLOCKLISTED + RequestSeason( + season = it, + status = status, + availability = availability, + editable = editable.getOrDefault(seasonNumber, defaultEditable), ) } + } + Timber.v("Got %s seasons, is4k=%s", requestSeasons.size, is4k) +// requestSeasons.forEach { +// Timber.v( +// "is4k=%s, season %s: availability=%s, status=%s, editable=%s", +// is4k, +// it.season.seasonNumber, +// it.availability, +// it.status, +// it.editable, +// ) +// } + _state.update { + if (is4k) { + it.copy(seasons4k = requestSeasons) + } else { + it.copy(seasons = requestSeasons) + } + } + } - fetchAndSetItem().await() - updateSeasonStatus() - updateCanCancel() + private suspend fun updateAvailability() { + val user = userConfig.firstOrNull() + val canCancel = + canUserCancelRequest( + user, + state.value.tvSeries.successValue + ?.mediaInfo + ?.requests, + ) + val status = + state.value.tvSeries.successValue?.let { tv -> + val overallStatus = SeerrAvailability.from(tv.mediaInfo?.status) + if (overallStatus == SeerrAvailability.UNKNOWN) { + val userRequests = + tv.mediaInfo + ?.requests + ?.filter { it.requestedBy?.id == user?.id } + .orEmpty() + if (userRequests.any { RequestStatus.from(it.status) == RequestStatus.PENDING }) { + SeerrAvailability.PENDING + } else if (userRequests.any { RequestStatus.from(it.status) == RequestStatus.APPROVED }) { + SeerrAvailability.PROCESSING + } else { + SeerrAvailability.UNKNOWN + } + } else { + overallStatus + } + } + _state.update { + it.copy( + canCancelRequest = canCancel, + availability = status ?: SeerrAvailability.UNKNOWN, + ) + } + } + + fun requestOnClick() { + viewModelScope.launchIO { + try { + val data = seerrService.getProfilesAndFolders(SeerrItemType.TV) + _state.update { + it.copy( + profileLoading = LoadingState.Success, + requestData = data, + ) + } + } catch (ex: Exception) { + Timber.e(ex, "Error getting profiles & folders") + showToast(context, "Error getting profiles & folders: ${ex.localizedMessage}") + _state.update { + it.copy( + profileLoading = LoadingState.Success, + ) + } + } + } + } + + fun request(request: TvRequest) { + viewModelScope.launchIO { + state.value.tvSeries.successValue?.let { tv -> + val currentUserId = seerrServerRepository.currentUserId.first() + val currentRequest = + tv.mediaInfo?.requests?.firstOrNull { + it.status == RequestStatus.PENDING.status && + it.requestedBy?.id == currentUserId + } + try { + if (currentRequest != null) { + Timber.v("User has pending request, will update") + seerrService.api.requestApi.requestRequestIdPut( + requestId = currentRequest.id.toString(), + requestRequestIdPutRequest = + RequestRequestIdPutRequest( + is4k = request.is4k, + mediaType = RequestRequestIdPutRequest.MediaType.TV, + seasons = request.seasons, + serverId = + when { + request.profileId == null && request.folder == null -> null + request.is4k -> request.data.server4kId + else -> request.data.serverId + }, + profileId = request.profileId, + rootFolder = request.folder, + ), + ) + } else { + Timber.v("New request for %s seasons", request.seasons.size) + seerrService.api.requestApi.requestPost( + RequestPostRequest( + is4k = request.is4k, + mediaId = request.tvId, + mediaType = RequestPostRequest.MediaType.TV, + seasons = request.seasons, + serverId = + when { + request.profileId == null && request.folder == null -> null + request.is4k -> request.data.server4kId + else -> request.data.serverId + }, + profileId = request.profileId, + rootFolder = request.folder, + tags = emptyList(), + ), + ) + } + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Error requesting %s", request.tvId) + showToast(context, "An error occurred") + } + + fetchAndSetItem().await()?.let { + updateSeasonStatus(it) + updateAvailability() + } } } } fun cancelRequest(id: Int) { viewModelScope.launchIO { - tvSeries.value?.mediaInfo?.requests?.firstOrNull()?.let { + state.value.tvSeries.successValue?.mediaInfo?.requests?.firstOrNull()?.let { // TODO handle multiple requests? Or just delete self's request? - seerrService.api.requestApi.requestRequestIdDelete(it.id.toString()) - fetchAndSetItem().await() - updateCanCancel() + try { + seerrService.api.requestApi.requestRequestIdDelete(it.id.toString()) + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Error requesting %s", id) + showToast(context, "An error occurred") + } + + fetchAndSetItem().await()?.let { + updateSeasonStatus(it) + updateAvailability() + } } } } } + +data class DiscoverSeriesState( + val tvSeries: DataLoadingState<TvDetails> = DataLoadingState.Pending, + val rating: SourceRatings? = null, + val seasons: List<RequestSeason> = emptyList(), + val seasons4k: List<RequestSeason> = emptyList(), + val trailers: List<Trailer> = emptyList(), + val people: List<DiscoverItem> = emptyList(), + val similar: List<DiscoverItem> = emptyList(), + val recommended: List<DiscoverItem> = emptyList(), + val canCancelRequest: Boolean = false, + val profileLoading: LoadingState = LoadingState.Pending, + val requestData: SeerrRequestData = SeerrRequestData(), + val availability: SeerrAvailability = SeerrAvailability.UNKNOWN, +) 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 new file mode 100644 index 00000000..cc89159f --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverUtils.kt @@ -0,0 +1,49 @@ +package com.github.damontecres.wholphin.ui.detail.discover + +import android.content.Context +import com.github.damontecres.wholphin.api.seerr.model.MediaInfo +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 org.jellyfin.sdk.model.api.BaseItemKind +import timber.log.Timber + +/** + * Navigates to the Jellyfin media item if the [mediaInfo] has a valid [jellyfinId] + * + * If not, a error toast is shown + */ +suspend fun goToButtonDiscover( + mediaInfo: MediaInfo?, + type: BaseItemKind, + context: Context, + navigationManager: NavigationManager, +) { + val id = mediaInfo?.jellyfinId + if (id != null) { + navigationManager.navigateTo( + Destination.MediaItem( + itemId = id, + type = type, + ), + ) + } else { + val stringId = mediaInfo?.jellyfinIdAsString + val msg = + if (stringId.isNotNullOrBlank()) { + "Unknown Jellyfin ID: $stringId" + } else { + "No Jellyfin ID found" + } + Timber.w( + "Unknown jellyfinId for tmdb=%s: %s/%s", + mediaInfo?.tmdbId, + mediaInfo?.jellyfinMediaId, + mediaInfo?.jellyfinMediaId4k, + ) + showToast(context, msg) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/ExpandableDiscoverButtons.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/ExpandableDiscoverButtons.kt index 2c3fc036..c5921b3b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/ExpandableDiscoverButtons.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/ExpandableDiscoverButtons.kt @@ -60,7 +60,11 @@ fun ExpandableDiscoverButtons( SeerrAvailability.AVAILABLE, -> R.string.go_to - SeerrAvailability.DELETED -> R.string.delete // TODO + // TODO + SeerrAvailability.DELETED -> R.string.delete + + // TODO Blocklist + SeerrAvailability.BLOCKLISTED -> R.string.unavailable } val icon = when (availability) { @@ -74,13 +78,29 @@ fun ExpandableDiscoverButtons( SeerrAvailability.AVAILABLE, -> R.string.fa_play - SeerrAvailability.DELETED -> R.string.fa_video // TODO + SeerrAvailability.DELETED -> R.string.fa_video + + // TODO + SeerrAvailability.BLOCKLISTED -> R.string.fa_xmark } item("first") { ExpandableFaButton( title = text, iconStringRes = icon, - enabled = if (availability == SeerrAvailability.UNKNOWN) canRequest else true, + enabled = + when (availability) { + SeerrAvailability.UNKNOWN -> canRequest + + SeerrAvailability.PENDING, + SeerrAvailability.PROCESSING, + SeerrAvailability.PARTIALLY_AVAILABLE, + SeerrAvailability.AVAILABLE, + -> true + + SeerrAvailability.BLOCKLISTED, + SeerrAvailability.DELETED, + -> false + }, onClick = { when (availability) { SeerrAvailability.UNKNOWN -> { @@ -99,7 +119,9 @@ fun ExpandableDiscoverButtons( goToOnClick.invoke() } - SeerrAvailability.DELETED -> { + SeerrAvailability.DELETED, + SeerrAvailability.BLOCKLISTED, + -> { // TODO } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/RequestMovie.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/RequestMovie.kt new file mode 100644 index 00000000..ef6e54b7 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/RequestMovie.kt @@ -0,0 +1,404 @@ +package com.github.damontecres.wholphin.ui.detail.discover + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +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.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.tv.material3.Button +import androidx.tv.material3.ListItem +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.ui.PreviewTvSpec +import com.github.damontecres.wholphin.ui.components.BasicDialog +import com.github.damontecres.wholphin.ui.components.DialogItem +import com.github.damontecres.wholphin.ui.components.DialogParams +import com.github.damontecres.wholphin.ui.components.DialogPopup +import com.github.damontecres.wholphin.ui.components.DialogPopupContent +import com.github.damontecres.wholphin.ui.components.ErrorMessage +import com.github.damontecres.wholphin.ui.components.LoadingPage +import com.github.damontecres.wholphin.ui.components.SelectedLeadingContent +import com.github.damontecres.wholphin.ui.theme.WholphinTheme +import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.util.LoadingState + +@Composable +fun RequestMovieDialog( + loading: LoadingState, + data: SeerrRequestData, + requestEnabled: Boolean, + request4kEnabled: Boolean, + movie: MovieDetails, + onSubmit: (MovieRequest) -> Unit, + onDismissRequest: () -> Unit, +) { + BasicDialog( + onDismissRequest = onDismissRequest, + ) { + RequestMovie( + loading = loading, + data = data, + requestEnabled = requestEnabled, + request4kEnabled = request4kEnabled, + movie = movie, + onSubmit = onSubmit, + modifier = Modifier.padding(24.dp), + ) + } +} + +@Composable +fun RequestMovie( + loading: LoadingState, + data: SeerrRequestData, + requestEnabled: Boolean, + request4kEnabled: Boolean, + movie: MovieDetails, + onSubmit: (MovieRequest) -> Unit, + modifier: Modifier = Modifier, +) { + when (loading) { + is LoadingState.Error -> { + ErrorMessage(loading, modifier) + } + + LoadingState.Loading, + LoadingState.Pending, + -> { + LoadingPage(modifier) + } + + LoadingState.Success -> { + val focusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + val requestStr = stringResource(R.string.request) + val request4kStr = stringResource(R.string.request_4k) + if (data.profiles.isEmpty() && data.rootFolders.isEmpty() && + data.profiles4k.isEmpty() && data.rootFolders4k.isEmpty() + ) { + // Both enabled, show choice + if (requestEnabled && request4kEnabled) { + val items = + remember { + listOf( + DialogItem( + text = requestStr, + onClick = { + onSubmit.invoke(MovieRequest(data, movie.id, false, null, null)) + }, + ), + DialogItem( + text = request4kStr, + onClick = { + onSubmit.invoke(MovieRequest(data, movie.id, true, null, null)) + }, + ), + ) + } + DialogPopupContent( + title = movie.title + " (${movie.releaseDate ?: ""})", + dialogItems = items, + waiting = false, + onDismissRequest = {}, + dismissOnClick = false, + modifier = modifier.focusRequester(focusRequester), + ) + } else { + LaunchedEffect(Unit) { + onSubmit.invoke(MovieRequest(data, movie.id, request4kEnabled, null, null)) + } + } + } else { + RequestMovieWithOptions( + data = data, + requestEnabled = requestEnabled, + request4kEnabled = request4kEnabled, + movie = movie, + onSubmit = onSubmit, + modifier = modifier.focusRequester(focusRequester), + ) + } + } + } +} + +@Composable +private fun RequestMovieWithOptions( + data: SeerrRequestData, + requestEnabled: Boolean, + request4kEnabled: Boolean, + movie: MovieDetails, + onSubmit: (MovieRequest) -> Unit, + modifier: Modifier = Modifier, +) { + var is4k by remember { mutableStateOf(!requestEnabled) } + var profile by remember(is4k) { + mutableStateOf( + if (is4k) { + data.profiles4k.firstOrNull { it.default } ?: data.profiles4k.firstOrNull() + } else { + data.profiles.firstOrNull { it.default } ?: data.profiles.firstOrNull() + }, + ) + } + var folder by remember(is4k) { + mutableStateOf( + if (is4k) { + data.rootFolders4k.firstOrNull { it.default } ?: data.rootFolders4k.firstOrNull() + } else { + data.rootFolders.firstOrNull { it.default } ?: data.rootFolders.firstOrNull() + }, + ) + } + val profiles = remember(is4k, data) { if (is4k) data.profiles4k else data.profiles } + val folders = remember(is4k, data) { if (is4k) data.rootFolders4k else data.rootFolders } + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier, + ) { + Text( + text = movie.title + " (${movie.releaseDate ?: ""})", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + LazyColumn( + contentPadding = PaddingValues(vertical = 8.dp), + modifier = Modifier, + ) { + item { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = + Modifier + .fillMaxWidth() + .padding(bottom = 8.dp), + ) { + if (requestEnabled && request4kEnabled) { + ClickSwitch( + label = stringResource(R.string.request_4k), + checked = is4k, + onClick = { is4k = !is4k }, + ) + } + Button( + onClick = { + onSubmit.invoke(MovieRequest(data, movie.id, is4k, profile?.id, folder?.path)) + }, + ) { + Text( + text = stringResource(R.string.submit), + ) + } + } + } + + if (profiles.isNotEmpty()) { + profile?.let { + item { + ChooseProfile( + selectedProfile = it, + profiles = profiles, + onClickProfile = { profile = it }, + modifier = Modifier, + ) + } + } + } + if (folders.isNotEmpty()) { + folder?.let { + item { + ChooseFolder( + selectedFolder = it, + folders = folders, + onClickFolder = { folder = it }, + modifier = Modifier, + ) + } + } + } + } + } +} + +@Composable +fun ChooseProfile( + selectedProfile: SeerrProfile, + profiles: List<SeerrProfile>, + onClickProfile: (SeerrProfile) -> Unit, + modifier: Modifier = Modifier, +) { + val defaultStr = stringResource(R.string.is_default) + val qualityProfileStr = stringResource(R.string.quality_profile) + var showProfileDialog by remember { mutableStateOf(false) } + ListItem( + selected = false, + headlineContent = { + Text(qualityProfileStr) + }, + supportingContent = { + val text = + remember(selectedProfile) { + if (selectedProfile.default) "${selectedProfile.name} ($defaultStr)" else selectedProfile.name + } + Text(text) + }, + onClick = { showProfileDialog = true }, + modifier = modifier, + ) + if (showProfileDialog) { + val params = + remember { + val items = + profiles.map { + DialogItem( + headlineContent = { + Text(it.name) + }, + supportingContent = { + if (it.default) { + Text(defaultStr) + } + }, + leadingContent = { + SelectedLeadingContent(it == selectedProfile) + }, + onClick = { onClickProfile.invoke(it) }, + ) + } + DialogParams( + fromLongClick = false, + title = qualityProfileStr, + items = items, + ) + } + DialogPopup( + params = params, + onDismissRequest = { showProfileDialog = false }, + dismissOnClick = true, + elevation = 3.dp, + ) + } +} + +@Composable +fun ChooseFolder( + selectedFolder: SeerrRootFolder, + folders: List<SeerrRootFolder>, + onClickFolder: (SeerrRootFolder) -> Unit, + modifier: Modifier = Modifier, +) { + val defaultStr = stringResource(R.string.is_default) + val rootFolderStr = stringResource(R.string.root_folder) + var showFolderDialog by remember { mutableStateOf(false) } + ListItem( + selected = false, + headlineContent = { + Text(rootFolderStr) + }, + supportingContent = { + val text = + remember(selectedFolder) { + if (selectedFolder.default) "${selectedFolder.path} ($defaultStr)" else selectedFolder.path + } + Text(text) + }, + trailingContent = { + Text(selectedFolder.freeSpace) + }, + onClick = { showFolderDialog = true }, + modifier = modifier, + ) + if (showFolderDialog) { + val params = + remember { + val items = + folders.map { + DialogItem( + headlineContent = { + Text(it.path) + }, + supportingContent = { + if (it.default) { + Text(defaultStr) + } + }, + trailingContent = { + Text(it.freeSpace) + }, + leadingContent = { + SelectedLeadingContent(it == selectedFolder) + }, + onClick = { onClickFolder.invoke(it) }, + ) + } + DialogParams( + fromLongClick = false, + title = rootFolderStr, + items = items, + ) + } + DialogPopup( + params = params, + onDismissRequest = { showFolderDialog = false }, + dismissOnClick = true, + elevation = 3.dp, + ) + } +} + +@PreviewTvSpec +@Composable +fun MovieRequestPreview() { + WholphinTheme { + RequestMovie( + loading = LoadingState.Success, + data = + SeerrRequestData( + profiles = + listOf( + SeerrProfile(1, "HD", true), + SeerrProfile(2, "Ultra HD", false), + ), + rootFolders = + listOf( + SeerrRootFolder(1, "/movies", "400GB", true), + ), + profiles4k = + listOf( + SeerrProfile(1, "4K", true), + SeerrProfile(2, "Ultra HD", false), + ), + rootFolders4k = + listOf( + SeerrRootFolder(1, "/movies", "400GB", true), + ), + ), + requestEnabled = true, + request4kEnabled = true, + movie = MovieDetails(id = 1, title = "Movie Name", releaseDate = "2026"), + onSubmit = {}, + modifier = + Modifier + .width(320.dp) + .padding(16.dp), + ) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/RequestSeasons.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/RequestSeasons.kt index 4a56cb88..91a10279 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/RequestSeasons.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/RequestSeasons.kt @@ -12,7 +12,9 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateSetOf @@ -20,9 +22,10 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.tv.material3.Button @@ -35,73 +38,140 @@ import androidx.tv.material3.Text import androidx.tv.material3.contentColorFor import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.api.seerr.model.Season +import com.github.damontecres.wholphin.data.model.RequestStatus import com.github.damontecres.wholphin.data.model.SeerrAvailability import com.github.damontecres.wholphin.ui.cards.AvailableIndicator import com.github.damontecres.wholphin.ui.cards.PartiallyAvailableIndicator import com.github.damontecres.wholphin.ui.cards.PendingIndicator import com.github.damontecres.wholphin.ui.components.BasicDialog +import com.github.damontecres.wholphin.ui.components.ErrorMessage +import com.github.damontecres.wholphin.ui.components.LoadingPage +import com.github.damontecres.wholphin.ui.formatSeasonNumber import com.github.damontecres.wholphin.ui.theme.WholphinTheme +import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.util.LoadingState data class RequestSeason( val season: Season, + val status: RequestStatus, val availability: SeerrAvailability, + val editable: Boolean, ) @Composable fun RequestSeasons( + id: Int, title: String, seasons: List<RequestSeason>, - onSubmit: (Set<Int>, Boolean) -> Unit, + seasons4k: List<RequestSeason>, + data: SeerrRequestData, + requestEnabled: Boolean, request4kEnabled: Boolean, - modifier: Modifier, + onSubmit: (TvRequest) -> Unit, + modifier: Modifier = Modifier, ) { - val allSeasonNumbers = remember(seasons) { seasons.mapNotNull { it.season.seasonNumber }.toSet() } - val selected = - remember { - mutableStateSetOf<Int>( + var is4k by remember { mutableStateOf(!requestEnabled) } + val seasons = remember(is4k, seasons, seasons4k) { if (is4k) seasons4k else seasons } + + val allSeasonNumbers = + remember(seasons) { + seasons + .filter { it.editable } + .mapNotNull { it.season.seasonNumber } + .toSet() + } + val availableSeasons = + remember(seasons) { + mutableStateSetOf( *seasons - .mapNotNull { - if (it.availability > SeerrAvailability.UNKNOWN) { - it.season.seasonNumber - } else { - null - } - }.toTypedArray(), + .filter { season -> + !season.editable && ( + season.status == RequestStatus.PENDING || + season.status == RequestStatus.APPROVED || + season.status == RequestStatus.COMPLETED || + season.availability == SeerrAvailability.PARTIALLY_AVAILABLE || + season.availability == SeerrAvailability.AVAILABLE + ) + }.mapNotNull { season -> season.season.seasonNumber } + .toTypedArray(), ) } - var is4k by remember { mutableStateOf(false) } + val selectedSeasons = + remember(seasons) { + mutableStateSetOf<Int>( + *seasons + .filter { season -> season.status == RequestStatus.PENDING } + .mapNotNull { season -> season.season.seasonNumber } + .toTypedArray(), + ) + } + + var profile by remember(is4k) { + mutableStateOf( + if (is4k) { + data.profiles4k.firstOrNull { it.default } ?: data.profiles4k.firstOrNull() + } else { + data.profiles.firstOrNull { it.default } ?: data.profiles.firstOrNull() + }, + ) + } + var folder by remember(is4k) { + mutableStateOf( + if (is4k) { + data.rootFolders4k.firstOrNull { it.default } ?: data.rootFolders4k.firstOrNull() + } else { + data.rootFolders.firstOrNull { it.default } ?: data.rootFolders.firstOrNull() + }, + ) + } + val profiles = remember(is4k, data) { if (is4k) data.profiles4k else data.profiles } + val folders = remember(is4k, data) { if (is4k) data.rootFolders4k else data.rootFolders } + + fun submit() { + onSubmit.invoke( + TvRequest( + data = data, + tvId = id, + seasons = selectedSeasons.toList(), + is4k = is4k, + profileId = profile?.id, + folder = folder?.path, + ), + ) + } + Column( - verticalArrangement = Arrangement.spacedBy(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), modifier = modifier, ) { Text( text = title, style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier, ) - LazyColumn { + LazyColumn( + modifier = Modifier, + ) { item { - val isSelected = selected.containsAll(allSeasonNumbers) Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier.fillMaxWidth(), + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), ) { - ClickSwitch( - label = stringResource(R.string.select_all), - checked = isSelected, - onClick = { - if (isSelected) { - selected.removeAll(allSeasonNumbers) - } else { - selected.addAll(allSeasonNumbers) - } - }, - ) + if (requestEnabled && request4kEnabled) { + ClickSwitch( + label = stringResource(R.string.request_4k), + checked = is4k, + onClick = { is4k = !is4k }, + ) + } Button( - onClick = { onSubmit.invoke(selected, is4k) }, + onClick = ::submit, + enabled = selectedSeasons.isNotEmpty(), ) { Text( text = stringResource(R.string.submit), @@ -109,39 +179,80 @@ fun RequestSeasons( } } } - if (request4kEnabled) { - item { - ClickSwitch( - label = stringResource(R.string.request_4k), - checked = is4k, - onClick = { is4k = !is4k }, - ) + if (profiles.isNotEmpty()) { + profile?.let { + item { + ChooseProfile( + selectedProfile = it, + profiles = profiles, + onClickProfile = { profile = it }, + modifier = Modifier, + ) + } } } + if (folders.isNotEmpty()) { + folder?.let { + item { + ChooseFolder( + selectedFolder = it, + folders = folders, + onClickFolder = { folder = it }, + modifier = Modifier, + ) + } + } + } + item { + HorizontalDivider() + } + item { + Text( + text = stringResource(R.string.tv_seasons), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + val isSelected = selectedSeasons.containsAll(allSeasonNumbers) + ClickSwitch( + label = stringResource(R.string.select_all), + checked = isSelected, + onClick = { + if (isSelected) { + selectedSeasons.removeAll(allSeasonNumbers) + } else { + selectedSeasons.addAll(allSeasonNumbers) + } + }, + ) + } itemsIndexed(seasons) { index, season -> val seasonNumber = season.season.seasonNumber - val isSelected = seasonNumber in selected + val checked = seasonNumber in selectedSeasons || seasonNumber in availableSeasons SeasonListItem( season = season, - selected = isSelected, + checked = checked, onClick = { - if (isSelected) { - selected.remove(seasonNumber) + if (checked) { + selectedSeasons.remove(seasonNumber) } else { - seasonNumber?.let { selected.add(it) } + seasonNumber?.let { selectedSeasons.add(it) } } }, modifier = Modifier, ) } - if (seasons.size > 7) { + if (seasons.size > 3) { item { Box( contentAlignment = Alignment.Center, - modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), ) { Button( - onClick = { onSubmit.invoke(selected, is4k) }, + onClick = ::submit, + enabled = selectedSeasons.isNotEmpty(), ) { Text( text = stringResource(R.string.submit), @@ -157,17 +268,19 @@ fun RequestSeasons( @Composable fun SeasonListItem( season: RequestSeason, - selected: Boolean, + checked: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier, ) { ListItem( + enabled = season.editable, selected = false, headlineContent = { Text( text = season.season.name - ?: (stringResource(R.string.tv_season) + " ${season.season.seasonNumber}"), + ?: season.season.seasonNumber?.let { formatSeasonNumber(it) } + ?: "", ) }, supportingContent = { @@ -180,9 +293,10 @@ fun SeasonListItem( }, leadingContent = { when (season.availability) { - SeerrAvailability.UNKNOWN -> {} - - SeerrAvailability.DELETED -> {} + SeerrAvailability.UNKNOWN, + SeerrAvailability.DELETED, + -> { + } SeerrAvailability.PENDING, SeerrAvailability.PROCESSING, @@ -197,12 +311,17 @@ fun SeasonListItem( SeerrAvailability.AVAILABLE -> { AvailableIndicator() } + + SeerrAvailability.BLOCKLISTED -> { + // TODO handle block listed + // BlocklistedIndicator() + } } }, trailingContent = { Row { Switch( - checked = selected, + checked = checked, onCheckedChange = { onClick.invoke() }, @@ -237,7 +356,7 @@ private fun ClickSurface( } @Composable -private fun ClickSwitch( +fun ClickSwitch( label: String, checked: Boolean, onClick: () -> Unit, @@ -267,22 +386,50 @@ private fun ClickSwitch( @Composable fun RequestSeasonsDialog( + id: Int, title: String, + loading: LoadingState, + data: SeerrRequestData, seasons: List<RequestSeason>, + seasons4k: List<RequestSeason>, + requestEnabled: Boolean, request4kEnabled: Boolean, - onSubmit: (Set<Int>, Boolean) -> Unit, + onSubmit: (TvRequest) -> Unit, onDismissRequest: () -> Unit, ) { BasicDialog( onDismissRequest = onDismissRequest, ) { - RequestSeasons( - title = title, - seasons = seasons, - request4kEnabled = request4kEnabled, - onSubmit = onSubmit, - modifier = Modifier.padding(16.dp), - ) + when (loading) { + is LoadingState.Error -> { + ErrorMessage(loading, Modifier) + } + + LoadingState.Loading, + LoadingState.Pending, + -> { + LoadingPage(Modifier) + } + + LoadingState.Success -> { + val focusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + RequestSeasons( + id = id, + title = title, + data = data, + seasons = seasons, + seasons4k = seasons4k, + requestEnabled = requestEnabled, + request4kEnabled = request4kEnabled, + onSubmit = onSubmit, + modifier = + Modifier + .padding(16.dp) + .focusRequester(focusRequester), + ) + } + } } } @@ -302,21 +449,38 @@ fun RequestSeasonsPreview() { seasonNumber = it + 1, episodeCount = 10 + it, ), + status = RequestStatus.UNKNOWN, availability = if (it < 3) { SeerrAvailability.AVAILABLE } else { SeerrAvailability.UNKNOWN }, + editable = it >= 3, ) } WholphinTheme { RequestSeasons( + id = 1, title = "Series title", seasons = seasons, + seasons4k = emptyList(), + data = + SeerrRequestData( + profiles4k = + listOf( + SeerrProfile(1, "HD", true), + SeerrProfile(2, "Ultra HD", false), + ), + rootFolders4k = + listOf( + SeerrRootFolder(1, "/tv", "400GB", true), + ), + ), + requestEnabled = true, request4kEnabled = true, - onSubmit = { _, _ -> }, + onSubmit = { }, modifier = Modifier.width(400.dp), ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/SeerrRequestData.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/SeerrRequestData.kt new file mode 100644 index 00000000..355049dc --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/SeerrRequestData.kt @@ -0,0 +1,40 @@ +package com.github.damontecres.wholphin.ui.detail.discover + +data class SeerrRequestData( + val serverId: Int = -1, + val server4kId: Int = -1, + val profiles: List<SeerrProfile> = emptyList(), + val rootFolders: List<SeerrRootFolder> = emptyList(), + val profiles4k: List<SeerrProfile> = emptyList(), + val rootFolders4k: List<SeerrRootFolder> = emptyList(), +) + +data class SeerrProfile( + val id: Int, + val name: String, + val default: Boolean, +) + +data class SeerrRootFolder( + val id: Int, + val path: String, + val freeSpace: String, + val default: Boolean, +) + +data class MovieRequest( + val data: SeerrRequestData, + val movieId: Int?, + val is4k: Boolean, + val profileId: Int?, + val folder: String?, +) + +data class TvRequest( + val data: SeerrRequestData, + val tvId: Int, + val seasons: List<Int>, + val is4k: Boolean, + val profileId: Int?, + val folder: String?, +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt index fa39f919..b9956fb4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt @@ -13,7 +13,6 @@ import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -40,16 +39,16 @@ import com.github.damontecres.wholphin.ui.components.HeaderUtils import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.Optional import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel +import com.github.damontecres.wholphin.ui.data.ChooseVersionParams import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo import com.github.damontecres.wholphin.ui.detail.PlaylistDialog -import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.rememberInt +import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.ExceptionHandler -import com.github.damontecres.wholphin.util.LoadingState import kotlinx.coroutines.launch -import org.jellyfin.sdk.model.api.MediaType +import org.jellyfin.sdk.model.api.MediaSourceInfo import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.serializer.toUUID import java.util.UUID @@ -66,27 +65,20 @@ fun EpisodeDetails( ), playlistViewModel: AddPlaylistViewModel = hiltViewModel(), ) { - val context = LocalContext.current LifecycleResumeEffect(Unit) { viewModel.init() onPauseOrDispose { } } - val item by viewModel.item.observeAsState() - val loading by viewModel.loading.observeAsState(LoadingState.Loading) - val chosenStreams by viewModel.chosenStreams.observeAsState(null) + val state by viewModel.state.collectAsState() var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) } var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) } var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) } - val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) + val playlistState by playlistViewModel.playlistState.collectAsState() val canDelete by viewModel.canDelete.collectAsState() - val preferredSubtitleLanguage = - viewModel.serverRepository.currentUserDto - .observeAsState() - .value - ?.configuration - ?.subtitleLanguagePreference + val userDto by viewModel.serverRepository.currentUserDtoFlow.collectAsState(null) + val preferredSubtitleLanguage = userDto?.configuration?.subtitleLanguagePreference val contextActions = remember { @@ -95,10 +87,10 @@ fun EpisodeDetails( onClickWatch = viewModel::setWatched, onClickFavorite = viewModel::setFavorite, onClickAddPlaylist = { itemId -> - playlistViewModel.loadPlaylists(MediaType.VIDEO) + playlistViewModel.loadPlaylists() showPlaylistDialog.makePresent(itemId) }, - onSendMediaInfo = viewModel.mediaReportService::sendReportFor, + onSendMediaInfo = viewModel.serverReportService::sendMediaReportFor, onDeleteItem = viewModel::deleteItem, onShowOverview = { overviewDialog = ItemDetailsDialogInfo(it) }, onChooseVersion = { item, source -> @@ -119,76 +111,75 @@ fun EpisodeDetails( ) } - when (val state = loading) { - is LoadingState.Error -> { - ErrorMessage(state, modifier) + when (val st = state.episode) { + is DataLoadingState.Error -> { + ErrorMessage(st, modifier) } - LoadingState.Loading, - LoadingState.Pending, + DataLoadingState.Loading, + DataLoadingState.Pending, -> { LoadingPage(modifier) } - LoadingState.Success -> { - item?.let { ep -> - LifecycleResumeEffect(destination.itemId) { - viewModel.maybePlayThemeSong( - destination.itemId, - preferences.appPreferences.interfacePreferences.playThemeSongs, - ) - onPauseOrDispose { - viewModel.release() - } + is DataLoadingState.Success<BaseItem> -> { + val ep = st.data + LifecycleResumeEffect(ep) { + ep.data.seriesId?.let { seriesId -> + viewModel.maybePlayThemeSong(seriesId) + } + onPauseOrDispose { + viewModel.release() } - EpisodeDetailsContent( - preferences = preferences, - ep = ep, - chosenStreams = chosenStreams, - playOnClick = { - viewModel.navigateTo( - Destination.Playback( - ep.id, - it.inWholeMilliseconds, - ), - ) - }, - overviewOnClick = { - overviewDialog = - ItemDetailsDialogInfo(ep) - }, - moreOnClick = { - showContextMenu = - ContextMenu.ForBaseItem( - fromLongClick = false, - item = ep, - chosenStreams = chosenStreams, - showGoTo = false, - showStreamChoices = true, - canDelete = canDelete, - canRemoveContinueWatching = false, - canRemoveNextUp = false, - actions = contextActions, - ) - }, - watchOnClick = { - viewModel.setWatched(ep.id, !ep.played) - }, - favoriteOnClick = { - viewModel.setFavorite(ep.id, !ep.favorite) - }, - canDelete = canDelete, - onConfirmDelete = { viewModel.deleteItem(ep) }, - modifier = modifier, - ) } + EpisodeDetailsContent( + preferences = preferences, + ep = ep, + chosenStreams = state.chosenStreams, + playOnClick = { + viewModel.navigateTo( + Destination.Playback( + ep.id, + it.inWholeMilliseconds, + ), + ) + }, + overviewOnClick = { + overviewDialog = + ItemDetailsDialogInfo(ep) + }, + moreOnClick = { + showContextMenu = + ContextMenu.ForBaseItem( + fromLongClick = false, + item = ep, + chosenStreams = state.chosenStreams, + showGoTo = false, + showStreamChoices = true, + canDelete = canDelete, + canRemoveContinueWatching = false, + canRemoveNextUp = false, + actions = contextActions, + ) + }, + watchOnClick = { + viewModel.setWatched(ep.id, !ep.played) + }, + favoriteOnClick = { + viewModel.setFavorite(ep.id, !ep.favorite) + }, + canDelete = canDelete, + onConfirmDelete = { viewModel.deleteItem(ep) }, + onChooseVersion = { contextActions.onChooseVersion.invoke(ep, it) }, + modifier = modifier, + ) } } overviewDialog?.let { info -> ItemDetailsDialog( info = info, showFilePath = - viewModel.serverRepository.currentUserDto.value + userDto ?.policy ?.isAdministrator == true, onDismissRequest = { overviewDialog = null }, @@ -216,6 +207,7 @@ fun EpisodeDetails( playlistViewModel.createPlaylistAndAddItem(it, itemId) showPlaylistDialog.makeAbsent() }, + onSearch = playlistViewModel::loadPlaylists, elevation = 3.dp, ) } @@ -235,6 +227,7 @@ fun EpisodeDetailsContent( moreOnClick: () -> Unit, canDelete: Boolean, onConfirmDelete: () -> Unit, + onChooseVersion: (MediaSourceInfo) -> Unit, modifier: Modifier = Modifier, ) { val context = LocalContext.current @@ -295,6 +288,14 @@ fun EpisodeDetailsContent( trailerOnClick = {}, canDelete = canDelete, onConfirmDelete = onConfirmDelete, + chooseVersionParams = + remember(chosenStreams, ep, onChooseVersion) { + ChooseVersionParams( + chosenStreams = chosenStreams, + mediaSources = ep.data.mediaSources.orEmpty(), + onChooseVersion = onChooseVersion, + ) + }, modifier = Modifier .fillMaxWidth() diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetailsHeader.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetailsHeader.kt index e8872180..3c341ff2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetailsHeader.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetailsHeader.kt @@ -55,7 +55,7 @@ fun EpisodeDetailsHeader( modifier = Modifier.fillMaxWidth(.60f), ) { val padding = 8.dp - QuickDetails(ep.ui.quickDetails, ep.timeRemainingOrRuntime) + QuickDetails(ep, ep.timeRemainingOrRuntime) VideoStreamDetails( chosenStreams = chosenStreams, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt index 2110a3a5..97aaaf86 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt @@ -1,21 +1,18 @@ package com.github.damontecres.wholphin.ui.detail.episode import android.content.Context -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel -import androidx.lifecycle.asFlow import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.data.ChosenStreams import com.github.damontecres.wholphin.data.ItemPlaybackRepository import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.ItemPlayback -import com.github.damontecres.wholphin.preferences.ThemeSongVolume import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.MediaManagementService -import com.github.damontecres.wholphin.services.MediaReportService import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.ServerReportService import com.github.damontecres.wholphin.services.StreamChoiceService import com.github.damontecres.wholphin.services.ThemeSongPlayer import com.github.damontecres.wholphin.services.UserPreferencesService @@ -23,26 +20,26 @@ import com.github.damontecres.wholphin.services.deleteItem import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.Destination -import com.github.damontecres.wholphin.ui.setValueOnMain +import com.github.damontecres.wholphin.ui.showToast +import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.ExceptionHandler -import com.github.damontecres.wholphin.util.LoadingExceptionHandler -import com.github.damontecres.wholphin.util.LoadingState +import com.github.damontecres.wholphin.util.WholphinDispatchers import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.Deferred -import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Job -import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.model.api.MediaStreamType +import timber.log.Timber import java.util.UUID @HiltViewModel(assistedFactory = EpisodeViewModel.Factory::class) @@ -55,7 +52,7 @@ class EpisodeViewModel val serverRepository: ServerRepository, val itemPlaybackRepository: ItemPlaybackRepository, val streamChoiceService: StreamChoiceService, - val mediaReportService: MediaReportService, + val serverReportService: ServerReportService, private val themeSongPlayer: ThemeSongPlayer, private val favoriteWatchManager: FavoriteWatchManager, private val userPreferencesService: UserPreferencesService, @@ -68,60 +65,68 @@ class EpisodeViewModel fun create(itemId: UUID): EpisodeViewModel } - val loading = MutableLiveData<LoadingState>(LoadingState.Pending) - val item = MutableLiveData<BaseItem?>(null) - val chosenStreams = MutableLiveData<ChosenStreams?>(null) + private val _state = MutableStateFlow(EpisodeState()) + val state: StateFlow<EpisodeState> = _state val canDelete = MutableStateFlow(false) init { init() viewModelScope.launchDefault { - mediaManagementService.collectCanDelete(item.asFlow()) { canDelete -> + mediaManagementService.collectCanDelete( + state.map { (it.episode as? DataLoadingState.Success<BaseItem?>)?.data }, + ) { canDelete -> this@EpisodeViewModel.canDelete.update { canDelete } } } } - private fun fetchAndSetItem(): Deferred<BaseItem> = - viewModelScope.async( - Dispatchers.IO + - LoadingExceptionHandler( - loading, - "Error fetching movie", - ), - ) { - val item = - api.userLibraryApi.getItem(itemId).content.let { - BaseItem.from(it, api) - } - this@EpisodeViewModel.item.setValueOnMain(item) - item + private fun fetchAndSetItem() { + viewModelScope.launchIO { + try { + val item = + api.userLibraryApi.getItem(itemId).content.let { + BaseItem.from(it, api) + } + _state.update { it.copy(episode = DataLoadingState.Success(item)) } + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Error getting episode %s", itemId) + showToast(context, "Error updating episode") + } } + } fun init(): Job = - viewModelScope.launch( - Dispatchers.IO + - LoadingExceptionHandler( - loading, - "Error fetching movie", - ), - ) { - val prefs = userPreferencesService.getCurrent() - val item = fetchAndSetItem().await() - val result = itemPlaybackRepository.getSelectedTracks(item.id, item, prefs) - withContext(Dispatchers.Main) { - this@EpisodeViewModel.item.value = item - chosenStreams.value = result - loading.value = LoadingState.Success + viewModelScope.launchIO { + try { + val prefs = userPreferencesService.getCurrent() + val item = + api.userLibraryApi.getItem(itemId).content.let { + BaseItem(it) + } + val chosenStreams = + itemPlaybackRepository.getSelectedTracks(item.id, item, prefs) + _state.update { + it.copy( + episode = DataLoadingState.Success(item), + chosenStreams = chosenStreams, + ) + } backdropService.submit(item) + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Error getting episode %s", itemId) + _state.update { it.copy(episode = DataLoadingState.Error(ex)) } } } fun setWatched( itemId: UUID, played: Boolean, - ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { + ) = viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { favoriteWatchManager.setWatched(itemId, played) fetchAndSetItem() } @@ -129,7 +134,7 @@ class EpisodeViewModel fun setFavorite( itemId: UUID, favorite: Boolean, - ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { + ) = viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { favoriteWatchManager.setFavorite(itemId, favorite) fetchAndSetItem() } @@ -140,15 +145,12 @@ class EpisodeViewModel ) { viewModelScope.launchIO { val prefs = userPreferencesService.getCurrent() - val plc = streamChoiceService.getPlaybackLanguageChoice(item.data) val result = itemPlaybackRepository.savePlayVersion(item.id, sourceId) val chosen = result?.let { - itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs) + itemPlaybackRepository.getChosenItemFromPlayback(item, result, prefs) } - withContext(Dispatchers.Main) { - chosenStreams.value = chosen - } + _state.update { it.copy(chosenStreams = chosen) } } } @@ -160,7 +162,6 @@ class EpisodeViewModel ) { viewModelScope.launchIO { val prefs = userPreferencesService.getCurrent() - val plc = streamChoiceService.getPlaybackLanguageChoice(item.data) val result = itemPlaybackRepository.saveTrackSelection( item = item, @@ -168,25 +169,14 @@ class EpisodeViewModel trackIndex = trackIndex, type = type, ) - val chosen = - result?.let { - itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs) - } - withContext(Dispatchers.Main) { - chosenStreams.value = chosen - } + val chosen = itemPlaybackRepository.getChosenItemFromPlayback(item, result, prefs) + _state.update { it.copy(chosenStreams = chosen) } } } - fun maybePlayThemeSong( - seriesId: UUID, - playThemeSongs: ThemeSongVolume, - ) { + fun maybePlayThemeSong(seriesId: UUID) { viewModelScope.launchIO { - themeSongPlayer.playThemeFor(seriesId, playThemeSongs) - addCloseable { - themeSongPlayer.stop() - } + themeSongPlayer.playThemeFor(seriesId) } } @@ -202,14 +192,16 @@ class EpisodeViewModel fun clearChosenStreams(chosenStreams: ChosenStreams?) { viewModelScope.launchIO { itemPlaybackRepository.deleteChosenStreams(chosenStreams) - item.value?.let { item -> - val result = - itemPlaybackRepository.getSelectedTracks( - itemId, - item, - userPreferencesService.getCurrent(), - ) - this@EpisodeViewModel.chosenStreams.setValueOnMain(result) + state.value.episode.let { item -> + if (item is DataLoadingState.Success<BaseItem>) { + val result = + itemPlaybackRepository.getSelectedTracks( + itemId, + item.data, + userPreferencesService.getCurrent(), + ) + _state.update { it.copy(chosenStreams = result) } + } } } } @@ -220,3 +212,8 @@ class EpisodeViewModel } } } + +data class EpisodeState( + val episode: DataLoadingState<BaseItem> = DataLoadingState.Pending, + val chosenStreams: ChosenStreams? = null, +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/Components.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/Components.kt index c27cde4e..e5bf4e8f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/Components.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/Components.kt @@ -5,45 +5,42 @@ 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.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +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.res.colorResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.tv.material3.ClickableSurfaceDefaults +import androidx.tv.material3.LocalContentColor import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Surface import androidx.tv.material3.Text -import androidx.tv.material3.contentColorFor import androidx.tv.material3.surfaceColorAtElevation import coil3.compose.AsyncImage import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.ui.FontAwesome +import com.github.damontecres.wholphin.ui.formatSeasonEpisode import java.time.LocalDateTime @Composable fun Program( guideStart: LocalDateTime, program: TvProgram, - focused: Boolean, colorCode: Boolean, + onClick: () -> Unit, + onLongClick: (() -> Unit)?, modifier: Modifier = Modifier, ) { - val background = - if (focused) { - MaterialTheme.colorScheme.inverseSurface - } else if (colorCode) { - program.category?.color - ?: MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp) - } else { - MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp) - } - val textColor = MaterialTheme.colorScheme.contentColorFor(background) val startedBeforeGuide = program.start.isBefore(guideStart) val shape = remember(startedBeforeGuide) { @@ -60,15 +57,28 @@ fun Program( } } val title = program.name ?: program.id.toString() - Box( + Surface( + onClick = onClick, + onLongClick = onLongClick, + shape = ClickableSurfaceDefaults.shape(shape), + scale = ClickableSurfaceDefaults.scale(1f, 1f, .95f), + colors = + ClickableSurfaceDefaults.colors( + containerColor = + if (colorCode) { + program.category?.color + ?: MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp) + } else { + MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp) + }, + contentColor = MaterialTheme.colorScheme.onSurface, + focusedContainerColor = MaterialTheme.colorScheme.inverseSurface, + focusedContentColor = MaterialTheme.colorScheme.inverseOnSurface, + ), modifier = modifier .padding(2.dp) - .fillMaxSize() - .background( - color = background, - shape = shape, - ), + .fillMaxSize(), ) { Row( modifier = Modifier.fillMaxSize(), @@ -77,7 +87,7 @@ fun Program( Text( text = stringResource(R.string.fa_caret_left), fontFamily = FontAwesome, - color = textColor, + color = LocalContentColor.current, fontSize = 16.sp, modifier = Modifier @@ -94,26 +104,33 @@ fun Program( ) { Text( text = title, - color = textColor, + color = LocalContentColor.current, fontSize = 16.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier, ) - listOfNotNull( - program.seasonEpisode?.let { "S${it.season} E${it.episode}" }, - program.subtitle, - ).joinToString(" - ") - .ifBlank { null } - ?.let { - Text( - text = it, - color = textColor, - fontSize = 14.sp, - overflow = TextOverflow.Ellipsis, - modifier = Modifier, - ) + val seasonEpisodeLabel = + formatSeasonEpisode( + program.seasonEpisode?.season, + program.seasonEpisode?.episode, + ) + val subtitle = + remember(program, seasonEpisodeLabel) { + listOfNotNull( + seasonEpisodeLabel, + program.subtitle, + ).joinToString(" - ").ifBlank { null } } + subtitle?.let { + Text( + text = it, + color = LocalContentColor.current, + fontSize = 14.sp, + overflow = TextOverflow.Ellipsis, + modifier = Modifier, + ) + } } } RecordingMarker( @@ -128,43 +145,84 @@ fun Program( fun Channel( channel: TvChannel, channelIndex: Int, - focused: Boolean, + onClick: () -> Unit, + onLongClick: () -> Unit, modifier: Modifier = Modifier, ) { - val background = - if (focused) { - MaterialTheme.colorScheme.inverseSurface - } else { - MaterialTheme.colorScheme.surface - } - val textColor = MaterialTheme.colorScheme.contentColorFor(background) - Box( + Surface( + onClick = onClick, + onLongClick = onLongClick, + shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(8.dp)), + scale = ClickableSurfaceDefaults.scale(1f, 1f, .95f), + colors = + ClickableSurfaceDefaults.colors( + containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp), + focusedContainerColor = MaterialTheme.colorScheme.inverseSurface, + ), modifier = modifier - .fillMaxSize() - .background( - background, - shape = RoundedCornerShape(4.dp), - ), + .background(MaterialTheme.colorScheme.background) + .padding(2.dp) + .fillMaxSize(), ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), + Box( modifier = Modifier .padding(4.dp) .fillMaxSize(), ) { - Text( - text = channel.number ?: channel.name ?: channelIndex.toString(), - color = textColor, - modifier = Modifier, - ) - AsyncImage( - model = channel.imageUrl, - contentDescription = null, - modifier = Modifier.fillMaxHeight(.66f), - ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxSize(), + ) { + Text( + text = channel.number ?: "", + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier, + ) + Column( + verticalArrangement = Arrangement.spacedBy(0.dp), + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier, + ) { + var showImage by remember { mutableStateOf(true) } + if (showImage) { + AsyncImage( + model = channel.imageUrl, + contentDescription = channel.name, + modifier = Modifier.weight(1f), + onError = { + showImage = false + }, + ) + } + channel.name?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + fontSize = if (showImage) 10.sp else 16.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier, + ) + } + } + } + if (channel.favorite) { + Text( + color = colorResource(android.R.color.holo_red_light), + text = stringResource(R.string.fa_heart), + fontSize = 16.sp, + fontFamily = FontAwesome, + modifier = + Modifier + .padding(2.dp) + .align(Alignment.TopEnd), + ) + } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/DvrSchedule.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/DvrSchedule.kt index f9758d5f..5de558c2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/DvrSchedule.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/DvrSchedule.kt @@ -1,6 +1,7 @@ package com.github.damontecres.wholphin.ui.detail.livetv import android.text.format.DateUtils +import android.widget.Toast import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -10,8 +11,8 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -24,7 +25,6 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.tv.material3.ListItem @@ -42,14 +42,16 @@ import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.seasonEpisode import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.ExceptionHandler -import com.github.damontecres.wholphin.util.LoadingExceptionHandler import com.github.damontecres.wholphin.util.LoadingState import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.liveTvApi +import timber.log.Timber import java.time.LocalDate import java.time.OffsetDateTime import java.time.ZoneId @@ -62,36 +64,51 @@ class DvrScheduleViewModel private val api: ApiClient, val navigationManager: NavigationManager, ) : ViewModel() { - val loading = MutableLiveData<LoadingState>(LoadingState.Loading) - val active = MutableLiveData<List<BaseItem>>() - val scheduled = MutableLiveData<Map<LocalDate, List<BaseItem>>>() + private val _state = MutableStateFlow(DvrScheduleState()) + val state: StateFlow<DvrScheduleState> = _state + + init { + init() + } fun init() { -// loading.value = LoadingState.Loading - viewModelScope.launchIO(LoadingExceptionHandler(loading, "Error fetching DVR Schedule")) { - val active = - api.liveTvApi - .getRecordings( - isInProgress = true, - fields = SlimItemFields, - limit = 100, - ).content.items - .map { BaseItem.from(it, api, true) } - val scheduled = - api.liveTvApi - .getTimers( - isActive = false, - isScheduled = true, - ).content.items - .map { BaseItem.from(it.programInfo!!, api, true) } // TODO this probably breaks for time based recordings - .groupBy { - it.data.startDate!!.toLocalDate() - } + viewModelScope.launchIO { + try { + val active = + api.liveTvApi + .getRecordings( + isInProgress = true, + fields = SlimItemFields, + limit = 100, + ).content.items + .map { BaseItem.from(it, api, true) } + val scheduled = + api.liveTvApi + .getTimers( + isActive = false, + isScheduled = true, + ).content.items + .map { + BaseItem.from( + it.programInfo!!, + api, + true, + ) + } // TODO this probably breaks for time based recordings + .groupBy { + it.data.startDate!!.toLocalDate() + } - withContext(Dispatchers.Main) { - this@DvrScheduleViewModel.active.value = active - this@DvrScheduleViewModel.scheduled.value = scheduled - loading.value = LoadingState.Success + _state.update { + it.copy( + loading = LoadingState.Success, + active = active, + scheduled = scheduled, + ) + } + } catch (ex: Exception) { + Timber.e(ex, "Error fetching DVR schedule") + _state.update { it.copy(loading = LoadingState.Error(ex)) } } } } @@ -111,6 +128,12 @@ class DvrScheduleViewModel } } +data class DvrScheduleState( + val loading: LoadingState = LoadingState.Loading, + val active: List<BaseItem> = emptyList(), + val scheduled: Map<LocalDate, List<BaseItem>> = emptyMap(), +) + @Composable fun DvrSchedule( requestFocusAfterLoading: Boolean, @@ -121,12 +144,10 @@ fun DvrSchedule( LaunchedEffect(Unit) { viewModel.init() } - val loading by viewModel.loading.observeAsState(LoadingState.Pending) - val active by viewModel.active.observeAsState(listOf()) - val recordings by viewModel.scheduled.observeAsState(mapOf()) - when (val state = loading) { + val state by viewModel.state.collectAsState() + when (val st = state.loading) { is LoadingState.Error -> { - ErrorMessage(state, modifier) + ErrorMessage(st, modifier) } LoadingState.Loading, @@ -140,7 +161,7 @@ fun DvrSchedule( val focusRequester = remember { FocusRequester() } if (requestFocusAfterLoading) { LaunchedEffect(Unit) { - if (active.isNotEmpty() || recordings.isNotEmpty()) { + if (state.active.isNotEmpty() || state.scheduled.isNotEmpty()) { focusRequester.tryRequestFocus() } else { focusRequesterOnEmpty.tryRequestFocus() @@ -148,8 +169,8 @@ fun DvrSchedule( } } DvrScheduleContent( - activeRecordings = active, - scheduledRecordings = recordings, + activeRecordings = state.active, + scheduledRecordings = state.scheduled, onClickItem = { showDialog = it }, @@ -158,29 +179,35 @@ fun DvrSchedule( .focusRequester(focusRequester), ) showDialog?.let { item -> + val context = LocalContext.current ProgramDialog( - item = item, + state = DataLoadingState.Success(item), canRecord = true, - loading = LoadingState.Success, onDismissRequest = { showDialog = null }, - onWatch = { - item.data.channelId?.let { + onWatch = { program -> + val channelId = program.data.channelId + if (channelId != null) { viewModel.navigationManager.navigateTo( Destination.Playback( - itemId = it, + itemId = channelId, positionMs = 0L, ), ) + } else { + Toast + .makeText(context, "Program has no channel ID", Toast.LENGTH_LONG) + .show() } }, - onRecord = { + onRecord = { _, _ -> // no-op }, - onCancelRecord = { series -> + onCancelRecord = { program, series -> showDialog = null - val timerId = if (series) item.data.seriesTimerId else item.data.timerId + val timerId = + if (series) program.data.seriesTimerId else program.data.timerId if (timerId != null) { viewModel.cancelRecording(timerId, series) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt index ce3fbd5b..bcba145c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt @@ -6,7 +6,6 @@ import androidx.compose.runtime.Stable import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.buildAnnotatedString import androidx.datastore.core.DataStore -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.R @@ -14,35 +13,39 @@ import com.github.damontecres.wholphin.WholphinApplication import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.services.FavoriteWatchManager +import com.github.damontecres.wholphin.services.ImageUrlService +import com.github.damontecres.wholphin.services.LiveTvService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.ui.AppColors import com.github.damontecres.wholphin.ui.data.RowColumn import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisode import com.github.damontecres.wholphin.ui.dot +import com.github.damontecres.wholphin.ui.formatDuration +import com.github.damontecres.wholphin.ui.formatSeasonEpisode import com.github.damontecres.wholphin.ui.isNotNullOrBlank +import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.roundMinutes -import com.github.damontecres.wholphin.ui.setValueOnMain -import com.github.damontecres.wholphin.ui.toServerString +import com.github.damontecres.wholphin.ui.showToast +import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.ExceptionHandler -import com.github.damontecres.wholphin.util.LoadingExceptionHandler import com.github.damontecres.wholphin.util.LoadingState import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.map -import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.update import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.api.client.extensions.imageApi import org.jellyfin.sdk.api.client.extensions.liveTvApi import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.GetProgramsDto @@ -50,7 +53,6 @@ import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.api.ItemFields import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.SortOrder -import org.jellyfin.sdk.model.api.TimerInfoDto import org.jellyfin.sdk.model.api.request.GetLiveTvChannelsRequest import org.jellyfin.sdk.model.extensions.ticks import timber.log.Timber @@ -78,24 +80,23 @@ class LiveTvViewModel val navigationManager: NavigationManager, val preferences: DataStore<AppPreferences>, private val serverRepository: ServerRepository, + private val imageUrlService: ImageUrlService, + private val favoriteWatchManager: FavoriteWatchManager, + private val liveTvService: LiveTvService, ) : ViewModel() { - val loading = MutableLiveData<LoadingState>(LoadingState.Pending) - private lateinit var channelsIdToIndex: Map<UUID, Int> private val mutex = Mutex() - val guideTimes = MutableLiveData<List<LocalDateTime>>(buildGuideTimes()) - val channels = MutableLiveData<List<TvChannel>>() - val channelProgramCount = mutableMapOf<UUID, Int>() - val programs = MutableLiveData<FetchedPrograms>() + private val _state = MutableStateFlow(LiveTvState()) + val state: StateFlow<LiveTvState> = _state - val fetchingItem = MutableLiveData<LoadingState>(LoadingState.Pending) - val fetchedItem = MutableLiveData<BaseItem?>(null) + private val _programDialogState = MutableStateFlow(ProgramDialogState()) + val programDialogState: StateFlow<ProgramDialogState> = _programDialogState private val range = 100 init { - viewModelScope.launchIO { + viewModelScope.launchDefault { preferences.data .map { it.interfacePreferences.liveTvPreferences.let { @@ -105,69 +106,78 @@ class LiveTvViewModel .debounce { 500.milliseconds } .collectLatest { Timber.v("Init due to pref change") - loading.setValueOnMain(LoadingState.Pending) - init() + _state.update { LiveTvState() } + init(it.first, it.second) } } } - fun init() { - val guideStart = guideTimes.value!!.first() - viewModelScope.launch( - Dispatchers.IO + - LoadingExceptionHandler( - loading, - "Could not fetch channels", - ), - ) { - val prefs = - (preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance()) - .interfacePreferences.liveTvPreferences - val channelData by api.liveTvApi.getLiveTvChannels( - GetLiveTvChannelsRequest( - startIndex = 0, - userId = serverRepository.currentUser.value?.id, - enableFavoriteSorting = prefs.favoriteChannelsAtBeginning, - sortBy = - if (prefs.sortByRecentlyWatched) { - listOf(ItemSortBy.DATE_PLAYED) - } else { - null - }, - sortOrder = - if (prefs.sortByRecentlyWatched) { - SortOrder.DESCENDING - } else { - null - }, - addCurrentProgram = false, - ), - ) - val channels = - channelData.items - .map { - TvChannel( - it.id, - it.channelNumber, - it.channelName, - api.imageApi.getItemImageUrl(it.id, ImageType.PRIMARY), - ) - } - Timber.d("Got ${channels.size} channels") - channelsIdToIndex = - channels.withIndex().associateBy({ it.value.id }, { it.index }) - // Initially, quickly load the first 10 channels (only some are visible immediately), then below will load more - // This makes the guide appear faster, and load more usable data in the background - val initial = 10 - fetchPrograms(guideStart, channels, 0..<initial.coerceAtMost(channels.size)) + private fun init( + sortByRecentlyWatched: Boolean, + favoriteChannelsAtBeginning: Boolean, + ) { + viewModelScope.launchDefault { + try { + val guideTimes = buildGuideTimes() + _state.update { it.copy(guideTimes = guideTimes) } + val guideStart = guideTimes.first() + val channelData by api.liveTvApi.getLiveTvChannels( + GetLiveTvChannelsRequest( + startIndex = 0, + userId = serverRepository.currentUser?.id, + enableFavoriteSorting = favoriteChannelsAtBeginning, + sortBy = + if (sortByRecentlyWatched) { + listOf(ItemSortBy.DATE_PLAYED) + } else { + null + }, + sortOrder = + if (sortByRecentlyWatched) { + SortOrder.DESCENDING + } else { + null + }, + addCurrentProgram = false, + ), + ) + val channels = + channelData.items + .map { + TvChannel( + id = it.id, + number = it.channelNumber, + name = it.channelName ?: it.name, + imageUrl = + imageUrlService.getItemImageUrl( + itemId = it.id, + imageType = ImageType.PRIMARY, + tag = it.imageTags?.get(ImageType.PRIMARY), + ), + favorite = it.userData?.isFavorite == true, + ) + } + Timber.d("Got ${channels.size} channels") + channelsIdToIndex = + channels.withIndex().associateBy({ it.value.id }, { it.index }) + // Initially, quickly load the first 10 channels (only some are visible immediately), then below will load more + // This makes the guide appear faster, and load more usable data in the background + val initial = 10 + fetchPrograms(guideStart, channels, 0..<initial.coerceAtMost(channels.size)) - withContext(Dispatchers.Main) { - this@LiveTvViewModel.channels.value = channels - loading.value = LoadingState.Success - } - // Now load the full range - if (channels.size > initial) { - fetchPrograms(guideStart, channels, 0..<range.coerceAtMost(channels.size)) + _state.update { + it.copy( + channels = channels, + loading = LoadingState.Success, + ) + } + // Now load the full range + if (channels.size > initial) { + fetchPrograms(guideStart, channels, 0..<range.coerceAtMost(channels.size)) + } + } catch (ex: Exception) { + Timber.e(ex, "Erroring during init") + _state.update { it.copy(loading = LoadingState.Error(ex)) } } } } @@ -187,14 +197,21 @@ class LiveTvViewModel } } - private suspend fun fetchProgramsWithLoading( + private suspend fun refreshPrograms( channels: List<TvChannel>, range: IntRange, ) { - loading.setValueOnMain(LoadingState.Loading) - val guideStart = guideTimes.value!!.first() - fetchPrograms(guideStart, channels, range) - loading.setValueOnMain(LoadingState.Success) + _state.update { it.copy(refreshing = true) } + val guideStart = _state.value.guideTimes.first() + try { + fetchPrograms(guideStart, channels, range) + _state.update { it.copy(refreshing = false) } + } catch (_: CancellationException) { + // no-op + } catch (ex: Exception) { + Timber.e(ex, "Error fetching programs") + _state.update { it.copy(loading = LoadingState.Error(ex)) } + } } /** @@ -219,7 +236,7 @@ class LiveTvViewModel minEndDate = minEndDate, channelIds = channelsToFetch.map { it.id }, sortBy = listOf(ItemSortBy.START_DATE), - userId = serverRepository.currentUser.value?.id, + userId = serverRepository.currentUser?.id, fields = listOf(ItemFields.OVERVIEW), ) val fetchedPrograms = @@ -282,6 +299,12 @@ class LiveTvViewModel isSeriesRecording = dto.seriesTimerId.isNotNullOrBlank(), isRepeat = dto.isRepeat ?: false, category = category, + imageUrl = + imageUrlService.getItemImageUrl( + dto.id, + ImageType.PRIMARY, + tag = dto.imageTags?.get(ImageType.PRIMARY), + ), ) if (index == 0) { if (p.startHours > 0) { @@ -382,13 +405,12 @@ class LiveTvViewModel category = ProgramCategory.FAKE, ) } - programsByChannel.put(channel.id, fakePrograms) + programsByChannel[channel.id] = fakePrograms fake.addAll(fakePrograms) } - programsByChannel.forEach { (channelId, programs) -> - channelProgramCount[channelId] = programs.size - } + val channelProgramCount = programsByChannel.map { it.key to it.value.size }.toMap() + val finalProgramList = (programsByChannel.values.flatten()) .sortedWith( @@ -398,30 +420,30 @@ class LiveTvViewModel ), ) Timber.d("Got ${fetchedPrograms.size} programs & ${finalProgramList.size} total programs") - withContext(Dispatchers.Main) { - this@LiveTvViewModel.programs.value = - FetchedPrograms(channelIndices, finalProgramList, programsByChannel) + _state.update { + it.copy( + channelProgramCount = channelProgramCount, + programs = + FetchedPrograms( + channelIndices, + finalProgramList, + programsByChannel, + ), + ) } } - fun getItem(programId: UUID) { - fetchingItem.value = LoadingState.Loading - viewModelScope.launchIO(LoadingExceptionHandler(fetchingItem, "Error")) { - val result = - api.liveTvApi - .getProgram(programId.toServerString()) - .content - .let { BaseItem.from(it, api) } - withContext(Dispatchers.Main) { - fetchedItem.value = result - fetchingItem.value = LoadingState.Success - } - if (result.data.seriesTimerId != null) { - val items = - api.liveTvApi - .getPrograms(GetProgramsDto(seriesTimerId = result.data.seriesTimerId)) - .content.items - Timber.v("items=$items") + fun fetchProgramForDialog(programId: UUID) { + _programDialogState.update { it.copy(loading = DataLoadingState.Loading) } + viewModelScope.launchDefault { + try { + val result = liveTvService.fetchProgramForDialog(programId) + _programDialogState.update { it.copy(loading = DataLoadingState.Success(result)) } + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Error fetching program $programId") + _programDialogState.update { it.copy(loading = DataLoadingState.Error(ex)) } } } } @@ -430,14 +452,19 @@ class LiveTvViewModel series: Boolean, timerId: String?, ) { - if (timerId != null) { - viewModelScope.launchIO(ExceptionHandler(autoToast = true)) { - if (series) { - api.liveTvApi.cancelSeriesTimer(timerId) - } else { - api.liveTvApi.cancelTimer(timerId) + viewModelScope.launchIO(ExceptionHandler(autoToast = true)) { + try { + val result = liveTvService.cancelRecording(series, timerId) + if (result) { + state.value.let { + refreshPrograms(it.channels, it.programs.range) + } } - fetchProgramsWithLoading(channels.value.orEmpty(), programs.value!!.range) + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Error canceling timer %s, series=%s", timerId, series) + showToast(context, "Error: ${ex.localizedMessage}") } } } @@ -447,36 +474,17 @@ class LiveTvViewModel series: Boolean, ) { viewModelScope.launchIO { - val d by api.liveTvApi.getDefaultTimer(programId.toServerString()) - if (series) { - api.liveTvApi.createSeriesTimer(d) - } else { - val payload = - TimerInfoDto( - id = d.id, - type = d.type, - serverId = d.serverId, - externalId = d.externalId, - channelId = d.channelId, - externalChannelId = d.externalChannelId, - channelName = d.channelName, - programId = d.programId, - externalProgramId = d.externalProgramId, - name = d.name, - overview = d.overview, - startDate = d.startDate, - endDate = d.endDate, - serviceName = d.serviceName, - priority = d.priority, - prePaddingSeconds = d.prePaddingSeconds, - postPaddingSeconds = d.postPaddingSeconds, - isPrePaddingRequired = d.isPrePaddingRequired, - isPostPaddingRequired = d.isPostPaddingRequired, - keepUntil = d.keepUntil, - ) - api.liveTvApi.createTimer(payload) + try { + liveTvService.record(programId, series) + state.value.let { + refreshPrograms(it.channels, it.programs.range) + } + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Error recording %s, series=%s", programId, series) + showToast(context, "Error: ${ex.localizedMessage}") } - fetchProgramsWithLoading(channels.value.orEmpty(), programs.value!!.range) } } @@ -488,43 +496,60 @@ class LiveTvViewModel * This determines if more programs/channels should be fetched based on the current position */ fun onFocusChannel(position: RowColumn) { - channels.value?.let { channels -> - val fetchedRange = programs.value!!.range - val quarter = range / 4 - var rangeStart = fetchedRange.start + quarter - var rangeEnd = fetchedRange.last - quarter + val state = state.value + val channels = state.channels + val fetchedRange = state.programs.range + val quarter = range / 4 + var rangeStart = fetchedRange.start + quarter + var rangeEnd = fetchedRange.last - quarter - if (rangeEnd - rangeStart < range) { - if (position.row < range / 2) { - // Close to beginning - rangeStart = 0 - } else if (position.row > (channels.size - range / 2)) { - // Close to the end - rangeEnd = channels.size - } + if (rangeEnd - rangeStart < range) { + if (position.row < range / 2) { + // Close to beginning + rangeStart = 0 + } else if (position.row > (channels.size - range / 2)) { + // Close to the end + rangeEnd = channels.size } - val testRange = rangeStart..<rangeEnd + } + val testRange = rangeStart..<rangeEnd - Timber.v( - "onFocusChannel: position=%s, fetchedRange=%s, testRange=%s", - position, - fetchedRange, - testRange, - ) + Timber.v( + "onFocusChannel: position=%s, fetchedRange=%s, testRange=%s", + position, + fetchedRange, + testRange, + ) - val fetchStart = (position.row - range).coerceAtLeast(0) - val fetchEnd = (position.row + range).coerceAtMost(channels.size) - val newFetchRange = fetchStart..<fetchEnd - // If current channel is not within +/- range - // And the potential new fetch range is not wholly within the current (eg not near the top or bottom) - // Fetch new data - if (position.row !in testRange && !newFetchRange.within(fetchedRange)) { - Timber.v("Loading more programs for channels $newFetchRange") - focusLoadingJob?.cancel() - focusLoadingJob = - viewModelScope.launchIO { - fetchProgramsWithLoading(channels, newFetchRange) - } + val fetchStart = (position.row - range).coerceAtLeast(0) + val fetchEnd = (position.row + range).coerceAtMost(channels.size) + val newFetchRange = fetchStart..<fetchEnd + // If current channel is not within +/- range + // And the potential new fetch range is not wholly within the current (eg not near the top or bottom) + // Fetch new data + if (position.row !in testRange && !newFetchRange.within(fetchedRange)) { + Timber.v("Loading more programs for channels $newFetchRange") + focusLoadingJob?.cancel() + focusLoadingJob = + viewModelScope.launchIO { + refreshPrograms(channels, newFetchRange) + } + } + } + + fun toggleFavorite( + index: Int, + channel: TvChannel, + ) { + viewModelScope.launchIO { + favoriteWatchManager.setFavorite(channel.id, !channel.favorite) + _state.update { + it.copy( + channels = + it.channels.toMutableList().apply { + set(index, channel.copy(favorite = !channel.favorite)) + }, + ) } } } @@ -543,11 +568,25 @@ fun hoursBetween( .between(start, target) .seconds / (60f * 60f) +data class LiveTvState( + val loading: LoadingState = LoadingState.Pending, + val refreshing: Boolean = false, + val guideTimes: List<LocalDateTime> = emptyList(), + val channels: List<TvChannel> = emptyList(), + val channelProgramCount: Map<UUID, Int> = emptyMap(), + val programs: FetchedPrograms = FetchedPrograms(), +) + +data class ProgramDialogState( + val loading: DataLoadingState<BaseItem> = DataLoadingState.Pending, +) + data class TvChannel( val id: UUID, val number: String?, val name: String?, val imageUrl: String?, + val favorite: Boolean, ) @Stable @@ -568,6 +607,7 @@ data class TvProgram( val isRepeat: Boolean, val category: ProgramCategory?, val officialRating: String? = null, + val imageUrl: String? = null, ) { val isFake = category == ProgramCategory.FAKE @@ -589,27 +629,27 @@ data class TvProgram( DateUtils.FORMAT_SHOW_TIME or if (differentDay) DateUtils.FORMAT_SHOW_WEEKDAY else 0, ) append(time) - dot() if (!isFake) { - duration - .roundMinutes - .toString() - .let(::append) + val resources = WholphinApplication.instance.resources dot() + append(resources.formatDuration(duration.roundMinutes)) if (now.isAfter(start) && now.isBefore(end)) { + dot() java.time.Duration .between(now, end) .toKotlinDuration() .roundMinutes - .let { append("$it left") } - dot() + .let { append(resources.getString(R.string.time_left, resources.formatDuration(it))) } } - seasonEpisode?.let { "S${it.season} E${it.episode}" }?.let { + formatSeasonEpisode(seasonEpisode?.season, seasonEpisode?.episode)?.let { + dot() + append(it) + } + officialRating?.let { + dot() append(it) - dot() } - officialRating?.let(::append) } } } @@ -653,9 +693,9 @@ enum class ProgramCategory( } data class FetchedPrograms( - val range: IntRange, - val programs: List<TvProgram>, - val programsByChannel: Map<UUID, List<TvProgram>>, + val range: IntRange = 0..0, + val programs: List<TvProgram> = emptyList(), + val programsByChannel: Map<UUID, List<TvProgram>> = emptyMap(), ) fun LocalDateTime.roundDownToHalfHour(): LocalDateTime { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewOptionsDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewOptionsDialog.kt index a0971f8e..320ef649 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewOptionsDialog.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewOptionsDialog.kt @@ -4,8 +4,11 @@ import android.view.Gravity import androidx.compose.foundation.background import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items @@ -19,6 +22,7 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties @@ -54,40 +58,47 @@ fun LiveTvViewOptionsDialog( window.setGravity(Gravity.END) window.setDimAmount(0f) } - LazyColumn( - state = columnState, - contentPadding = PaddingValues(16.dp), + Column( verticalArrangement = Arrangement.spacedBy(0.dp), modifier = Modifier .width(256.dp) .heightIn(max = 380.dp) - .focusRequester(focusRequester) .background( MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp), shape = RoundedCornerShape(8.dp), ), ) { - stickyHeader { - Text( - text = stringResource(R.string.view_options), - style = MaterialTheme.typography.titleMedium, - ) - } - items(liveTvPreferences) { pref -> - pref as AppPreference<AppPreferences, Any> - val interactionSource = remember { MutableInteractionSource() } - val value = pref.getter.invoke(preferences) - ComposablePreference( - preference = pref, - value = value, - onNavigate = {}, - onValueChange = { newValue -> - onViewOptionsChange.invoke(pref.setter(preferences, newValue)) - }, - interactionSource = interactionSource, - modifier = Modifier, - ) + Text( + text = stringResource(R.string.view_options), + style = MaterialTheme.typography.titleMedium, + textAlign = TextAlign.Center, + modifier = + Modifier + .padding(vertical = 8.dp) + .fillMaxWidth(), + ) + LazyColumn( + state = columnState, + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(0.dp), + modifier = Modifier.focusRequester(focusRequester), + ) { + items(liveTvPreferences) { pref -> + pref as AppPreference<AppPreferences, Any> + val interactionSource = remember { MutableInteractionSource() } + val value = pref.getter.invoke(preferences) + ComposablePreference( + preference = pref, + value = value, + onNavigate = {}, + onValueChange = { newValue -> + onViewOptionsChange.invoke(pref.setter(preferences, newValue)) + }, + interactionSource = interactionSource, + modifier = Modifier, + ) + } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/ProgramDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/ProgramDialog.kt index 8b42e9e1..04daa260 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/ProgramDialog.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/ProgramDialog.kt @@ -34,7 +34,6 @@ import androidx.tv.material3.Text import androidx.tv.material3.surfaceColorAtElevation import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.BaseItem -import com.github.damontecres.wholphin.ui.components.Button import com.github.damontecres.wholphin.ui.components.CircularProgress import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.TextButton @@ -42,19 +41,18 @@ import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.seasonEpisode import com.github.damontecres.wholphin.ui.tryRequestFocus -import com.github.damontecres.wholphin.util.LoadingState +import com.github.damontecres.wholphin.util.DataLoadingState import java.time.LocalDateTime import java.time.ZoneId @Composable fun ProgramDialog( - item: BaseItem?, + state: DataLoadingState<BaseItem>, canRecord: Boolean, - loading: LoadingState, onDismissRequest: () -> Unit, - onWatch: () -> Unit, - onRecord: (series: Boolean) -> Unit, - onCancelRecord: (series: Boolean) -> Unit, + onWatch: (BaseItem) -> Unit, + onRecord: (BaseItem, series: Boolean) -> Unit, + onCancelRecord: (BaseItem, series: Boolean) -> Unit, ) { val context = LocalContext.current Dialog( @@ -65,7 +63,7 @@ fun ProgramDialog( modifier = Modifier .background( - MaterialTheme.colorScheme.surfaceColorAtElevation(10.dp), + MaterialTheme.colorScheme.surfaceColorAtElevation(5.dp), shape = RoundedCornerShape(16.dp), ).focusRequester(focusRequester), ) { @@ -75,13 +73,13 @@ fun ProgramDialog( Modifier .padding(16.dp), ) { - when (val st = loading) { - is LoadingState.Error -> { + when (val st = state) { + is DataLoadingState.Error -> { ErrorMessage(st) } - LoadingState.Loading, - LoadingState.Pending, + DataLoadingState.Loading, + DataLoadingState.Pending, -> { CircularProgress( Modifier @@ -90,185 +88,183 @@ fun ProgramDialog( ) } - LoadingState.Success -> { - item?.let { item -> - val now = LocalDateTime.now() - val dto = item.data - val isRecording = dto.timerId.isNotNullOrBlank() - val isSeriesRecording = dto.seriesTimerId.isNotNullOrBlank() - LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } - Text( - text = item.name ?: "", - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.titleLarge, + is DataLoadingState.Success<BaseItem> -> { + val item = st.data + val now = LocalDateTime.now() + val dto = item.data + val isRecording = dto.timerId.isNotNullOrBlank() + val isSeriesRecording = dto.seriesTimerId.isNotNullOrBlank() + LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + Text( + text = item.name ?: "", + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.titleLarge, + ) + if (dto.isSeries ?: false) { + listOfNotNull(dto.seasonEpisode, dto.episodeTitle) + .joinToString(" - ") + .ifBlank { null } + ?.let { + Text( + text = it, + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.titleMedium, + ) + } + } + val time = + DateUtils.formatDateRange( + context, + dto.startDate!! + .atZone(ZoneId.systemDefault()) + .toInstant() + .epochSecond * 1000, + dto.endDate!! + .atZone(ZoneId.systemDefault()) + .toInstant() + .epochSecond * 1000, + DateUtils.FORMAT_SHOW_TIME, ) - if (dto.isSeries ?: false) { - listOfNotNull(dto.seasonEpisode, dto.episodeTitle) - .joinToString(" - ") - .ifBlank { null } - ?.let { + Text( + text = time, + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.titleSmall, + ) + dto.overview?.let { overview -> + Text( + text = overview, + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.bodyMedium, + overflow = TextOverflow.Ellipsis, + maxLines = 3, + ) + } + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + modifier = + Modifier + .padding(top = 8.dp) + .fillMaxWidth(), + ) { + if (now.isAfter(dto.startDate!!) && now.isBefore(dto.endDate!!)) { + TextButton( + onClick = { onWatch.invoke(item) }, + modifier = Modifier, + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Default.PlayArrow, + contentDescription = stringResource(R.string.delete), + ) Text( - text = it, - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.titleMedium, + text = stringResource(R.string.watch_live), ) } - } - val time = - DateUtils.formatDateRange( - context, - dto.startDate!! - .atZone(ZoneId.systemDefault()) - .toInstant() - .epochSecond * 1000, - dto.endDate!! - .atZone(ZoneId.systemDefault()) - .toInstant() - .epochSecond * 1000, - DateUtils.FORMAT_SHOW_TIME, - ) - Text( - text = time, - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.titleSmall, - ) - dto.overview?.let { overview -> - Text( - text = overview, - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.bodyMedium, - overflow = TextOverflow.Ellipsis, - maxLines = 3, - ) - } - Column( - verticalArrangement = Arrangement.spacedBy(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - modifier = - Modifier - .padding(top = 8.dp) - .fillMaxWidth(), - ) { - if (now.isAfter(dto.startDate!!) && now.isBefore(dto.endDate!!)) { - TextButton( - onClick = onWatch, - modifier = Modifier, - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = Icons.Default.PlayArrow, - contentDescription = stringResource(R.string.delete), - tint = Color.Green.copy(alpha = .75f), - ) - Text( - text = stringResource(R.string.watch_live), - ) - } - } } - if (canRecord) { - val recordFocusRequester = remember { FocusRequester() } - LazyRow( - horizontalArrangement = - Arrangement.spacedBy( - 16.dp, - Alignment.CenterHorizontally, - ), - modifier = - Modifier - .fillMaxWidth() - .focusRestorer(recordFocusRequester), - ) { - if (dto.isSeries ?: false) { - item { - TextButton( - onClick = { - if (isSeriesRecording) { - onCancelRecord.invoke(true) - } else { - onRecord.invoke(true) - } - }, - modifier = - Modifier.focusRequester(recordFocusRequester), + } + if (canRecord) { + val recordFocusRequester = remember { FocusRequester() } + LazyRow( + horizontalArrangement = + Arrangement.spacedBy( + 16.dp, + Alignment.CenterHorizontally, + ), + modifier = + Modifier + .fillMaxWidth() + .focusRestorer(recordFocusRequester), + ) { + if (dto.isSeries ?: false) { + item { + TextButton( + onClick = { + if (isSeriesRecording) { + onCancelRecord.invoke(item, true) + } else { + onRecord.invoke(item, true) + } + }, + modifier = + Modifier.focusRequester(recordFocusRequester), + ) { + Row( + horizontalArrangement = + Arrangement.spacedBy( + 4.dp, + ), + verticalAlignment = Alignment.CenterVertically, ) { - Row( - horizontalArrangement = - Arrangement.spacedBy( - 4.dp, - ), - verticalAlignment = Alignment.CenterVertically, - ) { - if (isSeriesRecording) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = null, - tint = Color.Red, - ) - } - Text( - text = - if (isSeriesRecording) { - stringResource( - R.string.cancel_series_recording, - ) - } else { - stringResource(R.string.record_series) - }, + if (isSeriesRecording) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = null, + tint = Color.Red, ) } + Text( + text = + if (isSeriesRecording) { + stringResource( + R.string.cancel_series_recording, + ) + } else { + stringResource(R.string.record_series) + }, + ) } } } - if (dto.endDate?.isAfter(LocalDateTime.now()) ?: true) { - // Only show program specific recording button if it hasn't finished yet - item { - TextButton( - onClick = { - if (isRecording) { - onCancelRecord.invoke(false) - } else { - onRecord.invoke(false) - } - }, - modifier = - Modifier.ifElse( - !(dto.isSeries ?: false), - Modifier.focusRequester( - recordFocusRequester, - ), + } + if (dto.endDate?.isAfter(LocalDateTime.now()) ?: true) { + // Only show program specific recording button if it hasn't finished yet + item { + TextButton( + onClick = { + if (isRecording) { + onCancelRecord.invoke(item, false) + } else { + onRecord.invoke(item, false) + } + }, + modifier = + Modifier.ifElse( + !(dto.isSeries ?: false), + Modifier.focusRequester( + recordFocusRequester, ), + ), + ) { + Row( + horizontalArrangement = + Arrangement.spacedBy( + 4.dp, + ), + verticalAlignment = Alignment.CenterVertically, ) { - Row( - horizontalArrangement = - Arrangement.spacedBy( - 4.dp, - ), - verticalAlignment = Alignment.CenterVertically, - ) { - if (isRecording) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = null, - tint = Color.Red, - ) - } - Text( - text = - if (isRecording) { - stringResource( - R.string.cancel_recording, - ) - } else { - stringResource( - R.string.record_program, - ) - }, + if (isRecording) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = null, + tint = Color.Red, ) } + Text( + text = + if (isRecording) { + stringResource( + R.string.cancel_recording, + ) + } else { + stringResource( + R.string.record_program, + ) + }, + ) } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideGrid.kt index 30309304..aafaac87 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideGrid.kt @@ -2,11 +2,11 @@ package com.github.damontecres.wholphin.ui.detail.livetv import android.text.format.DateUtils import android.widget.Toast +import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandVertically import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.background -import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -16,31 +16,24 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusDirection 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.input.key.Key -import androidx.compose.ui.input.key.KeyEventType -import androidx.compose.ui.input.key.key -import androidx.compose.ui.input.key.onPreviewKeyEvent -import androidx.compose.ui.input.key.type +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.tv.material3.MaterialTheme @@ -48,23 +41,31 @@ import androidx.tv.material3.Surface import androidx.tv.material3.SurfaceDefaults import androidx.tv.material3.Text import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.LiveTvPreferences +import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.components.CircularProgress +import com.github.damontecres.wholphin.ui.components.DialogItem +import com.github.damontecres.wholphin.ui.components.DialogParams +import com.github.damontecres.wholphin.ui.components.DialogPopup import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.ExpandableFaButton +import com.github.damontecres.wholphin.ui.components.HeaderUtils import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.data.RowColumn +import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.rememberInt import com.github.damontecres.wholphin.ui.rememberPosition import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.LoadingState +import com.github.damontecres.wholphin.util.WholphinDispatchers import eu.wewox.programguide.ProgramGuide import eu.wewox.programguide.ProgramGuideDimensions import eu.wewox.programguide.ProgramGuideItem import eu.wewox.programguide.rememberSaveableProgramGuideState import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import timber.log.Timber import java.time.LocalDateTime import java.time.OffsetDateTime @@ -73,29 +74,21 @@ import kotlin.math.abs @Composable fun TvGuideGrid( - requestFocusAfterLoading: Boolean, + preferences: UserPreferences, onRowPosition: (Int) -> Unit, modifier: Modifier = Modifier, viewModel: LiveTvViewModel = hiltViewModel(), ) { val scope = rememberCoroutineScope() -// var firstLoad by rememberSaveable { mutableStateOf(true) } -// LaunchedEffect(Unit) { -// viewModel.init(firstLoad) -// firstLoad = false -// } - val loading by viewModel.loading.observeAsState(LoadingState.Pending) - val channels by viewModel.channels.observeAsState(listOf()) - val programs by viewModel.programs.observeAsState(FetchedPrograms(0..0, listOf(), mapOf())) -// val programsByChannel by viewModel.programsByChannel.observeAsState(mapOf()) -// val fetchedRange by viewModel.fetchedRange.observeAsState(0..0) - val preferences by viewModel.preferences.data - .collectAsState(AppPreferences.getDefaultInstance()) + val state by viewModel.state.collectAsState() + val preferences by viewModel.preferences.data.collectAsState(preferences.appPreferences) val tvPrefs = preferences.interfacePreferences.liveTvPreferences var showViewOptions by remember { mutableStateOf(false) } - when (val state = loading) { + var showChannelDialog by remember { mutableStateOf<Pair<Int, TvChannel>?>(null) } + + when (val st = state.loading) { is LoadingState.Error -> { - ErrorMessage(state, modifier) + ErrorMessage(st, modifier) } LoadingState.Pending, @@ -107,39 +100,46 @@ fun TvGuideGrid( LoadingState.Success, -> { val context = LocalContext.current - val guideTimes by viewModel.guideTimes.observeAsState(listOf()) - val fetchedItem by viewModel.fetchedItem.observeAsState(null) - val loadingItem by viewModel.fetchingItem.observeAsState(LoadingState.Pending) + val programDialogState = + viewModel.programDialogState + .collectAsState() + .value.loading + var showItemDialog by remember { mutableStateOf<Int?>(null) } val focusRequester = remember { FocusRequester() } val buttonFocusRequester = remember { FocusRequester() } - if (requestFocusAfterLoading) { - LaunchedEffect(Unit) { - focusRequester.tryRequestFocus() - } + LaunchedEffect(Unit) { + focusRequester.tryRequestFocus() } var focusedPosition by rememberPosition(0, 0) val focusedProgram = remember(focusedPosition) { focusedPosition.let { - val channelId = channels.getOrNull(it.row)?.id - programs.programsByChannel[channelId]?.getOrNull(it.column) + val channelId = state.channels.getOrNull(it.row)?.id + state.programs.programsByChannel[channelId]?.getOrNull(it.column) } } Column( verticalArrangement = Arrangement.spacedBy(4.dp), modifier = modifier, ) { - if (channels.isEmpty()) { + if (state.channels.isEmpty()) { ErrorMessage("Live TV is enabled, but no channels were found.", null) } else { - AnimatedVisibility(tvPrefs.showHeader) { + AnimatedVisibility( + visible = tvPrefs.showHeader, + enter = expandVertically(), + exit = shrinkVertically(), + ) { TvGuideHeader( program = focusedProgram, modifier = Modifier - .padding(top = 24.dp, bottom = 16.dp, start = 32.dp) - .fillMaxHeight(.30f), + .padding( + top = HeaderUtils.topPadding, + bottom = 0.dp, + start = HeaderUtils.startPadding, + ).fillMaxHeight(.30f), ) } AnimatedVisibility( @@ -159,18 +159,13 @@ fun TvGuideGrid( } TvGuideGridContent( preferences = tvPrefs, - loading = state is LoadingState.Loading, - channels = channels, - programs = programs, - channelProgramCount = viewModel.channelProgramCount, - guideTimes = guideTimes, + refreshing = state.refreshing, + channels = state.channels, + programs = state.programs, + channelProgramCount = state.channelProgramCount, + guideTimes = state.guideTimes, onClickChannel = { index, channel -> - viewModel.navigationManager.navigateTo( - Destination.Playback( - itemId = channel.id, - positionMs = 0L, - ), - ) + showChannelDialog = index to channel }, onFocus = { focusedPosition = it @@ -196,53 +191,52 @@ fun TvGuideGrid( ).show() } } else { - viewModel.getItem(program.id) + viewModel.fetchProgramForDialog(program.id) showItemDialog = index } }, - buttonFocusRequester = buttonFocusRequester, + focusRequester = focusRequester, modifier = Modifier .fillMaxSize() - .background(MaterialTheme.colorScheme.surface) - .focusRequester(focusRequester), + .background(MaterialTheme.colorScheme.background), ) } } if (showItemDialog != null) { val onDismissRequest = { showItemDialog = null } ProgramDialog( - item = fetchedItem, + state = programDialogState, canRecord = true, - loading = loadingItem, onDismissRequest = onDismissRequest, onWatch = { onDismissRequest.invoke() - fetchedItem?.data?.channelId?.let { + val channelId = it.data.channelId + if (channelId != null) { viewModel.navigationManager.navigateTo( Destination.Playback( - itemId = it, + itemId = channelId, positionMs = 0L, ), ) + } else { + Toast + .makeText(context, "Program has no channel ID", Toast.LENGTH_LONG) + .show() } }, - onRecord = { series -> - fetchedItem?.let { - viewModel.record( - programId = it.id, - series = series, - ) - } + onRecord = { program, series -> + viewModel.record( + programId = program.id, + series = series, + ) onDismissRequest.invoke() }, - onCancelRecord = { series -> - fetchedItem?.data?.let { - viewModel.cancelRecording( - series = series, - timerId = if (series) it.seriesTimerId else it.timerId, - ) - } + onCancelRecord = { program, series -> + viewModel.cancelRecording( + series = series, + timerId = if (series) program.data.seriesTimerId else program.data.timerId, + ) onDismissRequest.invoke() }, ) @@ -262,6 +256,39 @@ fun TvGuideGrid( }, ) } + showChannelDialog?.let { (index, channel) -> + val watchLiveStr = stringResource(R.string.watch_live) + val dialogItems = + remember { + listOf( + DialogItem( + watchLiveStr, + Icons.Default.PlayArrow, + dismissOnClick = true, + ) { + viewModel.navigationManager.navigateTo( + Destination.Playback( + itemId = channel.id, + positionMs = 0L, + ), + ) + }, + DialogItem( + text = if (channel.favorite) R.string.remove_favorite else R.string.add_favorite, + iconStringRes = R.string.fa_heart, + iconColor = if (channel.favorite) Color.Red else Color.Unspecified, + dismissOnClick = true, + ) { + viewModel.toggleFavorite(index, channel) + }, + ) + } + DialogPopup( + onDismissRequest = { showChannelDialog = null }, + params = DialogParams(false, channel.name ?: "", dialogItems), + elevation = 3.dp, + ) + } } val tvGuideDimensions = @@ -276,7 +303,7 @@ val tvGuideDimensions = @Composable fun TvGuideGridContent( preferences: LiveTvPreferences, - loading: Boolean, + refreshing: Boolean, channels: List<TvChannel>, programs: FetchedPrograms, channelProgramCount: Map<UUID, Int>, @@ -284,231 +311,66 @@ fun TvGuideGridContent( onClickChannel: (Int, TvChannel) -> Unit, onClickProgram: (Int, TvProgram) -> Unit, onFocus: (RowColumn) -> Unit, - buttonFocusRequester: FocusRequester, + focusRequester: FocusRequester, modifier: Modifier = Modifier, ) { val context = LocalContext.current - val focusManager = LocalFocusManager.current val state = rememberSaveableProgramGuideState() val scope = rememberCoroutineScope() - val guideStart = guideTimes.first() + val guideStart = remember(guideTimes) { guideTimes.first() } + var focusedChannelIndex by rememberInt(-1) var focusedItem by rememberPosition(RowColumn(0, 0)) - val focusedChannelIndex = focusedItem.row - var focusedProgramIndex by remember { mutableIntStateOf(0) } + var focusedProgramIndex by rememberInt(0) - var gridHasFocus by rememberSaveable { mutableStateOf(false) } - var channelColumnFocused by rememberSaveable { mutableStateOf(false) } - Box(modifier = modifier) { + LaunchedEffect(onFocus, focusedProgramIndex) { + withContext(WholphinDispatchers.Default) { + val program = programs.programs.getOrNull(focusedProgramIndex) + if (program != null) { + val channelIndex = channels.indexOfFirst { it.id == program.channelId } + val programsBefore = + (0..<channelIndex) + .mapNotNull { + val channel = channels[it] + channelProgramCount[channel.id] + }.sum() + val programIndex = focusedProgramIndex - programsBefore + focusedItem = RowColumn(channelIndex, programIndex) + onFocus.invoke(focusedItem) + } + } + } + val programFocusRequester = remember { FocusRequester() } + + BackHandler(focusedItem.row > 0) { + scope.launch { + focusedProgramIndex = 0 + state.animateToProgram(0, Alignment.Center) + programFocusRequester.tryRequestFocus() + } + } + + BackHandler(focusedItem.column > 0) { + scope.launch { + val programIndex = + (0..<focusedItem.row) + .mapNotNull { + val channel = channels[it] + channelProgramCount[channel.id] + }.sum() + focusedProgramIndex = programIndex + state.animateToProgram(programIndex, Alignment.Center) + programFocusRequester.tryRequestFocus() + } + } + + Box( + modifier = modifier, + ) { ProgramGuide( state = state, dimensions = tvGuideDimensions, - modifier = - Modifier - .fillMaxSize() - .onFocusChanged { - gridHasFocus = it.hasFocus - }.focusProperties { - up = buttonFocusRequester - }.focusable() - .onPreviewKeyEvent { - if (it.type == KeyEventType.KeyUp) { - return@onPreviewKeyEvent false - } - val item = focusedItem - val newFocusedItem = - when (it.key) { - Key.Back -> { - if (item.column > 0) { - // Not at beginning of row, so move to beginning - item.copy(column = 0) - } else if (item.row > 0) { - item.copy(row = 0) - } else { - // At beginning, so allow normal back button behavior - return@onPreviewKeyEvent false - } - } - - Key.DirectionRight -> { - if (channelColumnFocused) { - channelColumnFocused = false - item.copy(column = 0) - } else { - item.copy(column = item.column + 1) - } - } - - Key.DirectionLeft -> { - if (channelColumnFocused) { - focusManager.moveFocus(FocusDirection.Left) - null - } else if (item.column == 0) { - channelColumnFocused = true - item - } else { - item.copy(column = item.column - 1) - } - } - - Key.DirectionUp -> { - if (item.row <= 0) { -// focusManager.moveFocus(FocusDirection.Up) - null - } else { - val newChannelIndex = item.row - 1 - if (channelColumnFocused) { - RowColumn(newChannelIndex, 0) - } else { - val currentChannel = channels[item.row].id - val currentProgram = - programs.programsByChannel[currentChannel]?.get(item.column) - val newChannelId = channels[newChannelIndex].id - val newChannelPrograms = - programs.programsByChannel[newChannelId] - if (newChannelPrograms == null) { - return@onPreviewKeyEvent true - } - if (currentProgram == null) { - item - } else { - val start = currentProgram.startHours - val pIndex = - newChannelPrograms.indexOfFirst { start in (it.startHours..<it.endHours) } - if (pIndex >= 0) { - RowColumn(newChannelIndex, pIndex) - } else { - val pIndex = - newChannelPrograms.indexOfFirst { it.startHours >= start } - if (pIndex >= 0) { - RowColumn(newChannelIndex, pIndex) - } else { - RowColumn(newChannelIndex, 0) - } - } - } - } - } - } - - Key.DirectionDown -> { - // Move channel focus down - val newChannelIndex = item.row + 1 - if (newChannelIndex >= channels.size) { - // If trying to move below the final channel, then move focus out of the grid - focusManager.moveFocus(FocusDirection.Down) - null - } else { - // Otherwise, moving to a new row - // Get the new row/channel's programs - val newChannelId = channels[newChannelIndex].id - val newChannelPrograms = - programs.programsByChannel[newChannelId] - if (newChannelPrograms == null) { - // This means there is no data for the new channel and it is loading in the background - return@onPreviewKeyEvent true - } - if (channelColumnFocused) { - // If focused on the channel column, move down a channel - RowColumn(newChannelIndex, 0) - } else { - // Get current program & its start time - val currentChannel = channels[item.row].id - val currentProgram = - programs.programsByChannel[currentChannel]?.get(item.column) - if (currentProgram == null) { - // Data is loading in the background - item - } else { - val start = currentProgram.startHours - // Find the first program where the start time (of the previously focused program) is in the middle of a program - val pIndex = - newChannelPrograms.indexOfFirst { start in (it.startHours..<it.endHours) } - if (pIndex >= 0) { - // Found one, so focus on it - RowColumn(newChannelIndex, pIndex) - } else { - // Didn't find one, probably due to missing data - // So now first the first one that starts after the previously focused program - val pIndex = - newChannelPrograms.indexOfFirst { it.startHours >= start } - if (pIndex >= 0) { - // Found one, so focus on it - RowColumn(newChannelIndex, pIndex) - } else { - // Did not find one, so focus on the final program in the list - RowColumn( - newChannelIndex, - newChannelPrograms.size - 1, - ) - } - } - } - } - } - } - - Key.DirectionCenter, Key.Enter, Key.NumPadEnter -> { - if (channelColumnFocused) { - val channel = channels[focusedChannelIndex] - Timber.v("Clicked on %s", channel) - onClickChannel.invoke(focusedChannelIndex, channel) - } else { - val currentChannel = channels[item.row].id - val currentProgram = - programs.programsByChannel[currentChannel]?.get(item.column) - if (currentProgram == null) { - // Data is loading in the background - return@onPreviewKeyEvent true - } - Timber.v("Clicked on %s", currentProgram) - onClickProgram.invoke( - focusedProgramIndex, - currentProgram, - ) - } - null - } - - else -> { - null - } - } - - if (newFocusedItem != null) { - val channel = channels[newFocusedItem.row] - val channelPrograms = programs.programsByChannel[channel.id].orEmpty() - // Ensure it isn't going out of range - val toFocus = - newFocusedItem - .copy( - row = newFocusedItem.row.coerceIn(0, channels.size - 1), - column = - newFocusedItem.column.coerceIn( - 0, - (channelPrograms.size - 1).coerceAtLeast(0), - ), - ) - focusedItem = toFocus - focusedProgramIndex = - toFocus.let { focus -> - (programs.range.first..<focus.row).sumOf { - val channelId = channels[it].id - channelProgramCount[channelId] ?: 0 - } + focus.column - } - scope.launch { - try { - state.animateToProgram(focusedProgramIndex, Alignment.Center) - } catch (ex: Exception) { - Timber.e(ex, "Couldn't scroll to $focusedProgramIndex") - } - } - onFocus(toFocus) - return@onPreviewKeyEvent true - } - return@onPreviewKeyEvent false - }, + modifier = Modifier.fillMaxSize(), ) { guideStartHour = 0f currentTime( @@ -593,9 +455,39 @@ fun TvGuideGridContent( }, ) { programIndex -> val program = programs.programs[programIndex] - val focused = - gridHasFocus && !channelColumnFocused && programIndex == focusedProgramIndex - Program(guideStart, program, focused, preferences.colorCodePrograms, Modifier) + Program( + guideStart = guideStart, + program = program, + colorCode = preferences.colorCodePrograms, + onClick = { + onClickProgram.invoke(programIndex, program) + }, + onLongClick = {}, + modifier = + Modifier + .ifElse( + programIndex == focusedProgramIndex, + Modifier.focusRequester(programFocusRequester), + ).ifElse( + focusedChannelIndex < 0 && programIndex == focusedProgramIndex, + Modifier.focusRequester(focusRequester), + ).onFocusChanged { + if (it.isFocused) { + focusedChannelIndex = -1 + focusedProgramIndex = programIndex + scope.launch { + try { + state.animateToProgram( + programIndex, + Alignment.Center, + ) + } catch (ex: Exception) { + Timber.e(ex, "Couldn't scroll to $programIndex") + } + } + } + }, + ) } channels( @@ -605,13 +497,33 @@ fun TvGuideGridContent( }, ) { channelIndex -> val channel = channels[channelIndex] - val focused = - gridHasFocus && channelColumnFocused && focusedChannelIndex == channelIndex Channel( channel = channel, channelIndex = channelIndex, - focused = focused, - modifier = Modifier, + onClick = { onClickChannel.invoke(channelIndex, channel) }, + onLongClick = {}, + modifier = + Modifier + .ifElse( + focusedChannelIndex == channelIndex, + Modifier.focusRequester(focusRequester), + ).onFocusChanged { + if (it.isFocused) { + focusedChannelIndex = channelIndex + scope.launch { + try { + state.animateToChannel( + channelIndex, + Alignment.CenterVertically, + ) + } catch (ex: Exception) { + Timber.e(ex, "Couldn't scroll to $channelIndex") + } + } + focusedItem = RowColumn(channelIndex, 0) + onFocus.invoke(focusedItem) + } + }, ) } @@ -623,7 +535,7 @@ fun TvGuideGridContent( ) } } - if (loading) { + if (refreshing) { CircularProgress( Modifier .background( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideHeader.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideHeader.kt index 1c39ec71..d0673c28 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideHeader.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideHeader.kt @@ -4,66 +4,94 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Text +import coil3.annotation.ExperimentalCoilApi +import coil3.compose.AsyncImage +import coil3.compose.useExistingImageAsPlaceholder +import coil3.request.ImageRequest +import coil3.request.transitionFactory import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.ui.CrossFadeFactory +import com.github.damontecres.wholphin.ui.components.EpisodeName +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.StreamLabel +import com.github.damontecres.wholphin.ui.components.TitleOrLogo +import kotlin.time.Duration.Companion.milliseconds +@OptIn(ExperimentalCoilApi::class) @Composable fun TvGuideHeader( program: TvProgram?, modifier: Modifier = Modifier, ) { - Column( - verticalArrangement = Arrangement.spacedBy(4.dp), - horizontalAlignment = Alignment.Start, + Row( + horizontalArrangement = Arrangement.SpaceBetween, modifier = modifier, ) { - Text( - text = program?.name ?: program?.id.toString(), - style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold), - color = MaterialTheme.colorScheme.onBackground, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.fillMaxWidth(.75f), - ) Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.fillMaxWidth(.6f), + verticalArrangement = Arrangement.spacedBy(4.dp), + horizontalAlignment = Alignment.Start, ) { - program?.subtitle?.let { - Text( - text = program.subtitle, - style = MaterialTheme.typography.headlineSmall, - color = MaterialTheme.colorScheme.onBackground, - maxLines = 1, - overflow = TextOverflow.Ellipsis, + TitleOrLogo( + title = program?.name ?: program?.id.toString(), + logoImageUrl = null, + showLogo = false, + modifier = + Modifier + .padding(start = HeaderUtils.startPadding) + .fillMaxWidth(.75f), + ) + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth(.6f), + ) { + program?.subtitle?.let { + EpisodeName( + episodeName = program.subtitle, + modifier = Modifier.padding(start = HeaderUtils.startPadding), + ) + } + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(start = HeaderUtils.startPadding), + ) { + program?.quickDetails?.let { + QuickDetailsText( + it, + modifier = Modifier.padding(vertical = 4.dp), + ) + } + if (program?.isRepeat == true) { + StreamLabel(stringResource(R.string.live_tv_repeat)) + } + } + OverviewText( + overview = program?.overview ?: "", + maxLines = 3, + onClick = {}, + enabled = false, ) } - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - program?.quickDetails?.let { QuickDetails(it, null) } - if (program?.isRepeat == true) { - StreamLabel(stringResource(R.string.live_tv_repeat)) - } - } - OverviewText( - overview = program?.overview ?: "", - maxLines = 3, - onClick = {}, - enabled = false, - ) } + + AsyncImage( + model = + ImageRequest + .Builder(LocalContext.current) + .data(program?.imageUrl) + .transitionFactory(CrossFadeFactory(300.milliseconds)) + .useExistingImageAsPlaceholder(true) + .build(), + contentDescription = null, + ) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt index e198689d..ef0d186e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt @@ -13,7 +13,6 @@ import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -34,10 +33,8 @@ import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.data.model.Person import com.github.damontecres.wholphin.data.model.Trailer -import com.github.damontecres.wholphin.data.model.aspectRatioFloat import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.TrailerService -import com.github.damontecres.wholphin.ui.AspectRatios import com.github.damontecres.wholphin.ui.Cards import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus import com.github.damontecres.wholphin.ui.cards.ChapterRow @@ -55,21 +52,22 @@ import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.Optional import com.github.damontecres.wholphin.ui.components.PersonContextActions import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel +import com.github.damontecres.wholphin.ui.data.ChooseVersionParams import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo import com.github.damontecres.wholphin.ui.detail.PlaylistDialog -import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState import com.github.damontecres.wholphin.ui.discover.DiscoverRow import com.github.damontecres.wholphin.ui.discover.DiscoverRowData import com.github.damontecres.wholphin.ui.letNotEmpty import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.rememberInt +import com.github.damontecres.wholphin.ui.util.ResStringProvider import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.DiscoverRequestType import com.github.damontecres.wholphin.util.ExceptionHandler import kotlinx.coroutines.launch import org.jellyfin.sdk.model.api.BaseItemKind -import org.jellyfin.sdk.model.api.MediaType +import org.jellyfin.sdk.model.api.MediaSourceInfo import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.serializer.toUUID import java.util.UUID @@ -96,14 +94,10 @@ fun MovieDetails( var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) } var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) } var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) } - val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) + val playlistState by playlistViewModel.playlistState.collectAsState() - val preferredSubtitleLanguage = - viewModel.serverRepository.currentUserDto - .observeAsState() - .value - ?.configuration - ?.subtitleLanguagePreference + val userDto by viewModel.serverRepository.currentUserDtoFlow.collectAsState(null) + val preferredSubtitleLanguage = userDto?.configuration?.subtitleLanguagePreference val contextActions = remember { @@ -112,10 +106,10 @@ fun MovieDetails( onClickWatch = viewModel::setWatched, onClickFavorite = viewModel::setFavorite, onClickAddPlaylist = { itemId -> - playlistViewModel.loadPlaylists(MediaType.VIDEO) + playlistViewModel.loadPlaylists() showPlaylistDialog.makePresent(itemId) }, - onSendMediaInfo = viewModel.mediaReportService::sendReportFor, + onSendMediaInfo = viewModel.serverReportService::sendMediaReportFor, onDeleteItem = viewModel::deleteItem, onChooseVersion = { item, source -> viewModel.savePlayVersion( @@ -152,10 +146,7 @@ fun MovieDetails( val movie by rememberUpdatedState(s.data) val chosenStreams by rememberUpdatedState(state.chosenStreams) LifecycleResumeEffect(destination.itemId) { - viewModel.maybePlayThemeSong( - destination.itemId, - preferences.appPreferences.interfacePreferences.playThemeSongs, - ) + viewModel.maybePlayThemeSong(destination.itemId) onPauseOrDispose { viewModel.release() } @@ -244,6 +235,7 @@ fun MovieDetails( }, canDelete = state.canDelete, onConfirmDelete = { state.movie?.let { viewModel.deleteItem(it) } }, + onChooseVersion = { contextActions.onChooseVersion.invoke(movie, it) }, modifier = modifier, ) } @@ -260,7 +252,7 @@ fun MovieDetails( ItemDetailsDialog( info = info, showFilePath = - viewModel.serverRepository.currentUserDto.value + userDto ?.policy ?.isAdministrator == true, onDismissRequest = { overviewDialog = null }, @@ -280,6 +272,7 @@ fun MovieDetails( playlistViewModel.createPlaylistAndAddItem(it, itemId) showPlaylistDialog.makeAbsent() }, + onSearch = playlistViewModel::loadPlaylists, elevation = 3.dp, ) } @@ -312,6 +305,7 @@ fun MovieDetailsContent( onClickDiscover: (Int, DiscoverItem) -> Unit, canDelete: Boolean, onConfirmDelete: () -> Unit, + onChooseVersion: (MediaSourceInfo) -> Unit, modifier: Modifier = Modifier, ) { val context = LocalContext.current @@ -377,6 +371,14 @@ fun MovieDetailsContent( }, canDelete = canDelete, onConfirmDelete = onConfirmDelete, + chooseVersionParams = + remember(state.chosenStreams, movie, onChooseVersion) { + ChooseVersionParams( + chosenStreams = state.chosenStreams, + mediaSources = movie.data.mediaSources.orEmpty(), + onChooseVersion = onChooseVersion, + ) + }, modifier = Modifier .fillMaxWidth() @@ -408,7 +410,6 @@ fun MovieDetailsContent( item { ChapterRow( chapters = chapters, - aspectRatio = movie.data.aspectRatioFloat ?: AspectRatios.WIDE, onClick = { position = CHAPTER_ROW playOnClick.invoke(it.position) @@ -481,7 +482,7 @@ fun MovieDetailsContent( DiscoverRow( row = DiscoverRowData( - stringResource(R.string.discover), + ResStringProvider(R.string.discover), DataLoadingState.Success(discovered), type = DiscoverRequestType.UNKNOWN, ), diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt index ee8f74f0..6dbbf965 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt @@ -64,7 +64,7 @@ fun MovieDetailsHeader( modifier = Modifier.fillMaxWidth(.60f), ) { QuickDetails( - movie.ui.quickDetails, + movie, movie.timeRemainingOrRuntime, Modifier.padding(start = HeaderUtils.startPadding), ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt index bd2cc373..9a8dd033 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt @@ -13,21 +13,20 @@ import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.data.model.Person import com.github.damontecres.wholphin.data.model.Trailer -import com.github.damontecres.wholphin.preferences.ThemeSongVolume import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.ExtrasService import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.MediaManagementService -import com.github.damontecres.wholphin.services.MediaReportService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.PeopleFavorites import com.github.damontecres.wholphin.services.SeerrService +import com.github.damontecres.wholphin.services.ServerReportService import com.github.damontecres.wholphin.services.StreamChoiceService import com.github.damontecres.wholphin.services.ThemeSongPlayer import com.github.damontecres.wholphin.services.TrailerService import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.services.deleteItem -import com.github.damontecres.wholphin.ui.SlimItemFields +import com.github.damontecres.wholphin.ui.ItemRowFields import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.letNotEmpty @@ -63,7 +62,7 @@ class MovieViewModel val serverRepository: ServerRepository, val itemPlaybackRepository: ItemPlaybackRepository, val streamChoiceService: StreamChoiceService, - val mediaReportService: MediaReportService, + val serverReportService: ServerReportService, private val themeSongPlayer: ThemeSongPlayer, private val favoriteWatchManager: FavoriteWatchManager, private val peopleFavorites: PeopleFavorites, @@ -118,7 +117,7 @@ class MovieViewModel userPreferencesService.getCurrent(), ) val remoteTrailers = trailerService.getRemoteTrailers(movie) - val chapters = Chapter.fromDto(movie.data, api) + val chapters = Chapter.fromDto(movie.data) _state.update { it.copy( loading = DataLoadingState.Success(movie), @@ -167,9 +166,9 @@ class MovieViewModel api.libraryApi .getSimilarItems( GetSimilarItemsRequest( - userId = serverRepository.currentUser.value?.id, + userId = serverRepository.currentUser?.id, itemId = itemId, - fields = SlimItemFields, + fields = ItemRowFields, limit = 25, ), ).content.items @@ -224,11 +223,10 @@ class MovieViewModel ) { viewModelScope.launchIO { val prefs = userPreferencesService.getCurrent() - val plc = streamChoiceService.getPlaybackLanguageChoice(item.data) val result = itemPlaybackRepository.savePlayVersion(item.id, sourceId) val chosen = result?.let { - itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs) + itemPlaybackRepository.getChosenItemFromPlayback(item, result, prefs) } _state.update { it.copy(chosenStreams = chosen) } } @@ -242,7 +240,6 @@ class MovieViewModel ) { viewModelScope.launchIO { val prefs = userPreferencesService.getCurrent() - val plc = streamChoiceService.getPlaybackLanguageChoice(item.data) val result = itemPlaybackRepository.saveTrackSelection( item = item, @@ -250,20 +247,14 @@ class MovieViewModel trackIndex = trackIndex, type = type, ) - val chosen = - result?.let { - itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs) - } + val chosen = itemPlaybackRepository.getChosenItemFromPlayback(item, result, prefs) _state.update { it.copy(chosenStreams = chosen) } } } - fun maybePlayThemeSong( - seriesId: UUID, - playThemeSongs: ThemeSongVolume, - ) { + fun maybePlayThemeSong(itemId: UUID) { viewModelScope.launchIO { - themeSongPlayer.playThemeFor(seriesId, playThemeSongs) + themeSongPlayer.playThemeFor(itemId) addCloseable { themeSongPlayer.stop() } @@ -309,6 +300,7 @@ data class MovieState( val extras: List<ExtrasItem> = emptyList(), val similar: List<BaseItem> = emptyList(), val discovered: List<DiscoverItem> = 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/music/AlbumDetailsPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/AlbumDetailsPage.kt index 8d374a0c..8f873bcd 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/AlbumDetailsPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/AlbumDetailsPage.kt @@ -20,7 +20,6 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -45,21 +44,24 @@ import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import coil3.compose.AsyncImage import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.ExtrasItem import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.BackdropService +import com.github.damontecres.wholphin.services.ExtrasService import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.ImageUrlService import com.github.damontecres.wholphin.services.MediaManagementService -import com.github.damontecres.wholphin.services.MediaReportService import com.github.damontecres.wholphin.services.MusicService import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.ServerReportService import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.ui.AspectRatios -import com.github.damontecres.wholphin.ui.DefaultItemFields +import com.github.damontecres.wholphin.ui.ItemRowFields import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.cards.BannerCardWithTitle +import com.github.damontecres.wholphin.ui.cards.ExtrasRow import com.github.damontecres.wholphin.ui.cards.ItemRow import com.github.damontecres.wholphin.ui.components.ContextMenu import com.github.damontecres.wholphin.ui.components.ContextMenuDialog @@ -73,7 +75,6 @@ import com.github.damontecres.wholphin.ui.components.QuickDetails import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel import com.github.damontecres.wholphin.ui.data.RowColumn import com.github.damontecres.wholphin.ui.detail.PlaylistDialog -import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO @@ -85,12 +86,12 @@ import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.LoadingState +import com.github.damontecres.wholphin.util.WholphinDispatchers import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -103,7 +104,6 @@ import org.jellyfin.sdk.api.client.extensions.libraryApi import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.ImageType -import org.jellyfin.sdk.model.api.MediaType import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.jellyfin.sdk.model.api.request.GetSimilarItemsRequest import java.util.UUID @@ -118,16 +118,21 @@ class AlbumViewModel navigationManager: NavigationManager, mediaManagementService: MediaManagementService, val serverRepository: ServerRepository, - val mediaReportService: MediaReportService, + val serverReportService: ServerReportService, private val favoriteWatchManager: FavoriteWatchManager, private val userPreferencesService: UserPreferencesService, private val backdropService: BackdropService, private val imageUrlService: ImageUrlService, - @Assisted itemId: UUID, + private val extrasService: ExtrasService, + @Assisted("itemId") itemId: UUID, + @Assisted("initialSongId") private val initialSongId: UUID?, ) : MusicViewModel(itemId, context, api, musicService, navigationManager, mediaManagementService) { @AssistedFactory interface Factory { - fun create(itemId: UUID): AlbumViewModel + fun create( + @Assisted("itemId") itemId: UUID, + @Assisted("initialSongId") initialSongId: UUID?, + ): AlbumViewModel } private val _state = MutableStateFlow(AlbumState.EMPTY) @@ -157,6 +162,10 @@ class AlbumViewModel val songsDeferred = async { getPagerForAlbum(api, itemId) } val album = itemDeferred.await() val songs = songsDeferred.await() + val initialSongIndex = + initialSongId?.let { id -> + songs.indexOfBlocking { it?.id == id }.takeIf { it >= 0 } + } val imageUrl = imageUrlService.getItemImageUrl(album, ImageType.PRIMARY) val allArtists = album.data.artists.orEmpty() + @@ -174,6 +183,7 @@ class AlbumViewModel isVariousArtists = isVariousArtists, imageUrl = imageUrl, songs = songs, + initialSongIndex = initialSongIndex, loading = LoadingState.Success, ) } @@ -184,7 +194,7 @@ class AlbumViewModel api.libraryApi .getSimilarItems( GetSimilarItemsRequest( - userId = serverRepository.currentUser.value?.id, + userId = serverRepository.currentUser?.id, itemId = itemId, excludeArtistIds = album.data.albumArtists?.map { it.id }, fields = SlimItemFields, @@ -198,10 +208,10 @@ class AlbumViewModel viewModelScope.launchIO { val request = GetItemsRequest( - userId = serverRepository.currentUser.value?.id, + userId = serverRepository.currentUser?.id, albumIds = listOf(itemId), parentId = null, - fields = DefaultItemFields, + fields = ItemRowFields, recursive = true, includeItemTypes = listOf(BaseItemKind.MUSIC_VIDEO), ) @@ -213,6 +223,14 @@ class AlbumViewModel } } } + viewModelScope.launchIO { + val extras = extrasService.getExtras(itemId) + if (extras.isNotEmpty()) { + _state.update { + it.copy(extras = extras) + } + } + } } catch (ex: Exception) { _state.update { it.copy(loading = LoadingState.Error(ex)) } } @@ -232,7 +250,7 @@ class AlbumViewModel fun setFavorite( itemId: UUID, favorite: Boolean, - ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { + ) = viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { favoriteWatchManager.setFavorite(itemId, favorite) val album = api.userLibraryApi @@ -280,7 +298,9 @@ data class AlbumState( val songs: List<BaseItem?>, val similar: List<BaseItem>, val loading: LoadingState, + val initialSongIndex: Int? = null, val musicVideos: List<BaseItem?> = emptyList(), + val extras: List<ExtrasItem> = emptyList(), val canDelete: Boolean = false, ) { companion object { @@ -291,16 +311,18 @@ data class AlbumState( private const val HEADER_ROW = 0 private const val SONG_ROW = HEADER_ROW + 1 private const val MUSIC_VIDEO_ROW = SONG_ROW + 1 -private const val SIMILAR_ROW = MUSIC_VIDEO_ROW + 1 +private const val EXTRAS_ROW = SONG_ROW + 1 +private const val SIMILAR_ROW = EXTRAS_ROW + 1 @Composable fun AlbumDetailsPage( itemId: UUID, preferences: UserPreferences, modifier: Modifier = Modifier, + initialSongId: UUID? = null, viewModel: AlbumViewModel = hiltViewModel<AlbumViewModel, AlbumViewModel.Factory>( - creationCallback = { it.create(itemId) }, + creationCallback = { it.create(itemId, initialSongId) }, ), playlistViewModel: AddPlaylistViewModel = hiltViewModel(), ) { @@ -309,12 +331,11 @@ fun AlbumDetailsPage( val state by viewModel.state.collectAsState() val currentMusic by viewModel.currentMusic.collectAsState() - var position by rememberPosition(0, 0) val focusRequesters = remember { List(SIMILAR_ROW + 1) { FocusRequester() } } val focusManager = LocalFocusManager.current var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) } - val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) + val playlistState by playlistViewModel.playlistState.collectAsState() var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) } val moreDialogActions = remember { @@ -325,7 +346,7 @@ fun AlbumDetailsPage( onClickAddToQueue = { item -> viewModel.addToQueue(item, -1) }, onClickFavorite = { itemId, favorite -> viewModel.setFavorite(itemId, favorite) }, onClickAddPlaylist = { itemId -> - playlistViewModel.loadPlaylists(MediaType.AUDIO) + playlistViewModel.loadPlaylists() showPlaylistDialog.makePresent(itemId) }, onClickRemoveFromQueue = { _, _ -> }, @@ -345,12 +366,20 @@ fun AlbumDetailsPage( LoadingState.Success -> { val album = state.album!! + val itemsBefore = 2 + val initialSongIndex = state.initialSongIndex + var position by rememberPosition( + row = if (initialSongIndex != null) SONG_ROW else 0, + column = initialSongIndex ?: 0, + ) val firstFocusRequester = remember { FocusRequester() } val firstBringIntoViewRequester = remember { BringIntoViewRequester() } val bringIntoViewRequester = remember { BringIntoViewRequester() } - val listState = rememberLazyListState() - val itemsBefore = 2 + val listState = + rememberLazyListState( + initialFirstVisibleItemIndex = initialSongIndex?.let { itemsBefore + it } ?: 0, + ) val songFocusRequester = remember { FocusRequester() } LaunchedEffect(Unit) { @@ -534,7 +563,7 @@ fun AlbumDetailsPage( item = item, onClick = onClick, onLongClick = onLongClick, - aspectRatio = AspectRatios.WIDE, + aspectRatio = item?.aspectRatio ?: AspectRatios.WIDE, played = item?.played ?: false, playPercent = item?.data?.userData?.playedPercentage ?: 0.0, favorite = item?.favorite ?: false, @@ -545,6 +574,19 @@ fun AlbumDetailsPage( ) } } + if (state.extras.isNotEmpty()) { + item { + ExtrasRow( + extras = state.extras, + onClickItem = { index, extra -> + position = RowColumn(EXTRAS_ROW, index) + viewModel.navigationManager.navigateTo(extra.destination) + }, + onLongClickItem = { _, _ -> }, + modifier = Modifier.focusRequester(focusRequesters[EXTRAS_ROW]), + ) + } + } if (state.similar.isNotEmpty()) { item { ItemRow( @@ -567,6 +609,7 @@ fun AlbumDetailsPage( ), canRemoveFromQueue = false, actions = moreDialogActions, + showGoTo = true, ) }, cardContent = { index: Int, item: BaseItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit -> @@ -610,6 +653,7 @@ fun AlbumDetailsPage( playlistViewModel.createPlaylistAndAddItem(it, itemId) showPlaylistDialog.makeAbsent() }, + onSearch = playlistViewModel::loadPlaylists, elevation = 3.dp, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/ArtistDetailsPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/ArtistDetailsPage.kt index 1f693322..20a39e0f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/ArtistDetailsPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/ArtistDetailsPage.kt @@ -18,7 +18,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -50,16 +49,17 @@ import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.ImageUrlService import com.github.damontecres.wholphin.services.MediaManagementService -import com.github.damontecres.wholphin.services.MediaReportService import com.github.damontecres.wholphin.services.MusicService import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.ServerReportService import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.ui.AspectRatios -import com.github.damontecres.wholphin.ui.DefaultItemFields +import com.github.damontecres.wholphin.ui.ItemRowFields import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.cards.BannerCardWithTitle import com.github.damontecres.wholphin.ui.cards.ItemRow import com.github.damontecres.wholphin.ui.components.ContextMenu +import com.github.damontecres.wholphin.ui.components.ContextMenuActions import com.github.damontecres.wholphin.ui.components.ContextMenuDialog import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.GenreText @@ -71,7 +71,6 @@ import com.github.damontecres.wholphin.ui.components.QuickDetails import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel import com.github.damontecres.wholphin.ui.data.RowColumn import com.github.damontecres.wholphin.ui.detail.PlaylistDialog -import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO @@ -83,12 +82,12 @@ import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.LoadingState +import com.github.damontecres.wholphin.util.WholphinDispatchers import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -101,7 +100,6 @@ import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.api.ItemSortBy -import org.jellyfin.sdk.model.api.MediaType import org.jellyfin.sdk.model.api.SortOrder import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.jellyfin.sdk.model.api.request.GetSimilarItemsRequest @@ -117,7 +115,7 @@ class ArtistViewModel navigationManager: NavigationManager, mediaManagementService: MediaManagementService, val serverRepository: ServerRepository, - val mediaReportService: MediaReportService, + val serverReportService: ServerReportService, private val favoriteWatchManager: FavoriteWatchManager, private val userPreferencesService: UserPreferencesService, private val backdropService: BackdropService, @@ -157,8 +155,9 @@ class ArtistViewModel async { val request = GetItemsRequest( - parentId = itemId, - fields = DefaultItemFields, + albumArtistIds = listOf(itemId), + recursive = true, + fields = SlimItemFields, includeItemTypes = listOf(BaseItemKind.MUSIC_ALBUM), sortBy = listOf( @@ -169,14 +168,38 @@ class ArtistViewModel ) ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope).init() } + val appearsOnDeferred = + async { + val request = + GetItemsRequest( + contributingArtistIds = listOf(itemId), + recursive = true, + fields = SlimItemFields, + includeItemTypes = listOf(BaseItemKind.MUSIC_ALBUM), + sortBy = + listOf( + ItemSortBy.PREMIERE_DATE, + ItemSortBy.SORT_NAME, + ), + sortOrder = listOf(SortOrder.DESCENDING, SortOrder.ASCENDING), + ) + ApiRequestPager( + api, + request, + GetItemsRequestHandler, + viewModelScope, + ).init() + } val artist = itemDeferred.await() val albums = albumsDeferred.await() + val appearsOn = appearsOnDeferred.await() val imageUrl = imageUrlService.getItemImageUrl(artist, ImageType.PRIMARY) _state.update { it.copy( artist = artist, imageUrl = imageUrl, albums = albums, + appearsOnAlbums = appearsOn, loading = LoadingState.Success, ) } @@ -185,7 +208,7 @@ class ArtistViewModel val request = GetItemsRequest( artistIds = listOf(itemId), - fields = DefaultItemFields, + fields = SlimItemFields, recursive = true, includeItemTypes = listOf(BaseItemKind.AUDIO), minCommunityRating = 1.0, @@ -210,7 +233,7 @@ class ArtistViewModel api.libraryApi .getSimilarItems( GetSimilarItemsRequest( - userId = serverRepository.currentUser.value?.id, + userId = serverRepository.currentUser?.id, itemId = itemId, excludeArtistIds = listOf(itemId), fields = SlimItemFields, @@ -221,24 +244,7 @@ class ArtistViewModel _state.update { it.copy(similar = similar) } } } - viewModelScope.launchIO { - val request = - GetItemsRequest( - userId = serverRepository.currentUser.value?.id, - artistIds = listOf(itemId), - parentId = null, - fields = DefaultItemFields, - recursive = true, - includeItemTypes = listOf(BaseItemKind.MUSIC_VIDEO), - ) - val musicVideos = - GetItemsRequestHandler.execute(api, request).toBaseItems(api, false) - if (musicVideos.isNotEmpty()) { - _state.update { - it.copy(musicVideos = musicVideos) - } - } - } + updateMusicVideos() } catch (ex: Exception) { _state.update { it.copy(loading = LoadingState.Error(ex)) } } @@ -253,17 +259,52 @@ class ArtistViewModel } } + private fun updateMusicVideos() { + viewModelScope.launchIO { + val request = + GetItemsRequest( + userId = serverRepository.currentUser?.id, + artistIds = listOf(itemId), + parentId = null, + fields = ItemRowFields, + recursive = true, + includeItemTypes = listOf(BaseItemKind.MUSIC_VIDEO), + ) + val musicVideos = + GetItemsRequestHandler.execute(api, request).toBaseItems(api, false) + if (musicVideos.isNotEmpty()) { + _state.update { + it.copy(musicVideos = musicVideos) + } + } + } + } + fun setFavorite( itemId: UUID, favorite: Boolean, - ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { + ) = viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { favoriteWatchManager.setFavorite(itemId, favorite) - val artist = - api.userLibraryApi - .getItem(itemId = itemId) - .content - .let { BaseItem(it, false) } - _state.update { it.copy(artist = artist) } + // Refresh if artist, otherwise only could be a music video + if (itemId == this@ArtistViewModel.itemId) { + val artist = + api.userLibraryApi + .getItem(itemId = itemId) + .content + .let { BaseItem(it, false) } + _state.update { it.copy(artist = artist) } + } else { + updateMusicVideos() + } + } + + fun setWatched( + itemId: UUID, + played: Boolean, + ) = viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { + favoriteWatchManager.setWatched(itemId, played) + // Only applies to music videos + updateMusicVideos() } } @@ -272,6 +313,7 @@ data class ArtistState( val imageUrl: String?, val topSongs: List<BaseItem?>, val albums: List<BaseItem?>, + val appearsOnAlbums: List<BaseItem?>, val similar: List<BaseItem>, val loading: LoadingState, val musicVideos: List<BaseItem?>, @@ -285,6 +327,7 @@ data class ArtistState( emptyList(), emptyList(), emptyList(), + emptyList(), LoadingState.Pending, emptyList(), false, @@ -295,7 +338,8 @@ data class ArtistState( private const val HEADER_ROW = 0 private const val SONG_ROW = HEADER_ROW + 1 private const val ALBUM_ROW = SONG_ROW + 1 -private const val MUSIC_VIDEO_ROW = ALBUM_ROW + 1 +private const val APPEARS_ON_ROW = SONG_ROW + 1 +private const val MUSIC_VIDEO_ROW = APPEARS_ON_ROW + 1 private const val SIMILAR_ROW = MUSIC_VIDEO_ROW + 1 @Composable @@ -319,7 +363,7 @@ fun ArtistDetailsPage( remember { List(SIMILAR_ROW + 1) { FocusRequester() } } var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) } - val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) + val playlistState by playlistViewModel.playlistState.collectAsState() var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) } val moreDialogActions = remember { @@ -330,7 +374,7 @@ fun ArtistDetailsPage( onClickAddToQueue = { item -> viewModel.addToQueue(item, -1) }, onClickFavorite = { itemId, favorite -> viewModel.setFavorite(itemId, favorite) }, onClickAddPlaylist = { itemId -> - playlistViewModel.loadPlaylists(MediaType.AUDIO) + playlistViewModel.loadPlaylists() showPlaylistDialog.makePresent(itemId) }, onClickRemoveFromQueue = { _, _ -> }, @@ -502,41 +546,83 @@ fun ArtistDetailsPage( } } } - item { - ItemRow( - title = stringResource(R.string.albums), - items = state.albums, - onClickItem = { index, album -> - position = RowColumn(ALBUM_ROW, index) - viewModel.navigationManager.navigateTo(album.destination()) - }, - onLongClickItem = { index, album -> - showContextMenu = - ContextMenu.ForMusic( - fromLongClick = true, + if (state.albums.isNotEmpty()) { + item { + ItemRow( + title = stringResource(R.string.albums), + items = state.albums, + onClickItem = { index, album -> + position = RowColumn(ALBUM_ROW, index) + viewModel.navigationManager.navigateTo(album.destination()) + }, + onLongClickItem = { index, album -> + showContextMenu = + ContextMenu.ForMusic( + fromLongClick = true, + item = album, + index = index, + canDelete = + viewModel.canDelete( + album, + preferences.appPreferences, + ), + canRemoveFromQueue = false, + actions = moreDialogActions, + showGoTo = true, + ) + }, + cardContent = { index: Int, album: BaseItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit -> + BannerCardWithTitle( + title = album?.name, + subtitle = album?.data?.productionYear?.toString(), item = album, - index = index, - canDelete = - viewModel.canDelete( - album, - preferences.appPreferences, - ), - canRemoveFromQueue = false, - actions = moreDialogActions, + onClick = onClick, + onLongClick = onLongClick, + aspectRatio = AspectRatios.SQUARE, ) - }, - cardContent = { index: Int, album: BaseItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit -> - BannerCardWithTitle( - title = album?.name, - subtitle = album?.data?.productionYear?.toString(), - item = album, - onClick = onClick, - onLongClick = onLongClick, - aspectRatio = AspectRatios.SQUARE, - ) - }, - modifier = Modifier.focusRequester(focusRequesters[ALBUM_ROW]), - ) + }, + modifier = Modifier.focusRequester(focusRequesters[ALBUM_ROW]), + ) + } + } + if (state.appearsOnAlbums.isNotEmpty()) { + item { + ItemRow( + title = stringResource(R.string.appears_on), + items = state.appearsOnAlbums, + onClickItem = { index, album -> + position = RowColumn(APPEARS_ON_ROW, index) + viewModel.navigationManager.navigateTo(album.destination()) + }, + onLongClickItem = { index, album -> + showContextMenu = + ContextMenu.ForMusic( + fromLongClick = true, + item = album, + index = index, + canDelete = + viewModel.canDelete( + album, + preferences.appPreferences, + ), + canRemoveFromQueue = false, + actions = moreDialogActions, + showGoTo = true, + ) + }, + cardContent = { index: Int, album: BaseItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit -> + BannerCardWithTitle( + title = album?.name, + subtitle = album?.data?.productionYear?.toString(), + item = album, + onClick = onClick, + onLongClick = onLongClick, + aspectRatio = AspectRatios.SQUARE, + ) + }, + modifier = Modifier.focusRequester(focusRequesters[ALBUM_ROW]), + ) + } } if (state.musicVideos.isNotEmpty()) { item { @@ -548,7 +634,37 @@ fun ArtistDetailsPage( viewModel.navigationManager.navigateTo(item.destination()) }, onLongClickItem = { index, item -> - // TODO + showContextMenu = + ContextMenu.ForBaseItem( + fromLongClick = true, + item = item, + chosenStreams = null, + showGoTo = true, + showStreamChoices = false, + canDelete = + viewModel.canDelete( + item, + preferences.appPreferences, + ), + canRemoveContinueWatching = false, + canRemoveNextUp = false, + actions = + ContextMenuActions( + navigateTo = viewModel.navigationManager::navigateTo, + onShowOverview = {}, + onClickWatch = viewModel::setWatched, + onClickFavorite = viewModel::setFavorite, + onClickAddPlaylist = { itemId -> + playlistViewModel.loadPlaylists() + showPlaylistDialog.makePresent(itemId) + }, + onSendMediaInfo = viewModel.serverReportService::sendMediaReportFor, + onDeleteItem = viewModel::deleteItem, + onChooseVersion = { _, _ -> }, + onChooseTracks = { }, + onClearChosenStreams = {}, + ), + ) }, cardContent = { index: Int, item: BaseItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit -> BannerCardWithTitle( @@ -557,7 +673,7 @@ fun ArtistDetailsPage( item = item, onClick = onClick, onLongClick = onLongClick, - aspectRatio = AspectRatios.WIDE, + aspectRatio = item?.aspectRatio ?: AspectRatios.WIDE, played = item?.played ?: false, playPercent = item?.data?.userData?.playedPercentage ?: 0.0, favorite = item?.favorite ?: false, @@ -590,6 +706,7 @@ fun ArtistDetailsPage( ), canRemoveFromQueue = false, actions = moreDialogActions, + showGoTo = true, ) }, cardContent = { index: Int, item: BaseItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit -> @@ -633,6 +750,7 @@ fun ArtistDetailsPage( playlistViewModel.createPlaylistAndAddItem(it, itemId) showPlaylistDialog.makeAbsent() }, + onSearch = playlistViewModel::loadPlaylists, elevation = 3.dp, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/LyricsContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/LyricsContent.kt index 6ccea46e..e60e2a08 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/LyricsContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/LyricsContent.kt @@ -58,7 +58,7 @@ fun LyricsContent( LaunchedEffect(currentLyricPosition) { if (currentLyricPosition != null) { listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index?.let { - if (currentLyricPosition !in listState.firstVisibleItemIndex..it) { + if (currentLyricPosition >= 0 && currentLyricPosition !in listState.firstVisibleItemIndex..it) { listState.animateScrollToItem(currentLyricPosition) } } @@ -121,7 +121,10 @@ fun LyricsContent( ), modifier = Modifier - .focusRequester(focusRequesters[index]), + .focusRequester(focusRequesters[index]) + .onFocusChanged { + if (it.isFocused) onFocusLyrics.invoke(true) + }, ) { val text = remember(lyric.text) { lyric.text.ifBlank { " " } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/MusicExpandableButtons.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/MusicExpandableButtons.kt index b93483c9..96e82032 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/MusicExpandableButtons.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/MusicExpandableButtons.kt @@ -31,6 +31,7 @@ fun MusicExpandableButtons( canDelete: Boolean, buttonOnFocusChanged: (FocusState) -> Unit, modifier: Modifier = Modifier, + showShuffle: Boolean = true, ) { val firstFocus = remember { FocusRequester() } LazyRow( @@ -53,13 +54,15 @@ fun MusicExpandableButtons( .onFocusChanged(buttonOnFocusChanged), ) } - item("shuffle") { - ExpandableFaButton( - title = R.string.shuffle, - iconStringRes = R.string.fa_shuffle, - onClick = { actions.onClickPlay.invoke(true) }, - modifier = Modifier.onFocusChanged(buttonOnFocusChanged), - ) + if (showShuffle) { + item("shuffle") { + ExpandableFaButton( + title = R.string.shuffle, + iconStringRes = R.string.fa_shuffle, + onClick = { actions.onClickPlay.invoke(true) }, + modifier = Modifier.onFocusChanged(buttonOnFocusChanged), + ) + } } item("instant_mix") { ExpandableFaButton( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/MusicViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/MusicViewModel.kt index 812927f1..87062cf2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/MusicViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/MusicViewModel.kt @@ -147,3 +147,11 @@ fun ViewModel.addToQueue( } } } + +suspend fun addToQueue( + api: ApiClient, + musicService: MusicService, + item: BaseItem, + index: Int, +) { +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingButtons.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingButtons.kt index 1d976ede..f8dc0ccd 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingButtons.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingButtons.kt @@ -74,7 +74,7 @@ fun NowPlayingButtons( modifier = Modifier.align(Alignment.CenterStart), ) { PlaybackButton( - iconRes = R.drawable.baseline_more_vert_96, + iconRes = R.drawable.vector_settings, onClick = { onControllerInteraction.invoke() onClickMore.invoke() diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingOverlay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingOverlay.kt index a340f66c..8d12bb33 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingOverlay.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingOverlay.kt @@ -22,6 +22,8 @@ import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.MoreVert @@ -48,6 +50,7 @@ import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.AudioItem +import com.github.damontecres.wholphin.services.rememberQueue import com.github.damontecres.wholphin.ui.components.Button import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.main.settings.MoveDirection @@ -65,7 +68,6 @@ fun NowPlayingOverlay( state: NowPlayingState, player: Player, current: AudioItem?, - queue: List<AudioItem>, controllerViewState: ControllerViewState, onClickSong: (Int, AudioItem) -> Unit, onLongClickSong: (Int, AudioItem) -> Unit, @@ -78,6 +80,7 @@ fun NowPlayingOverlay( ) { val scope = rememberCoroutineScope() val focusRequester = remember { FocusRequester() } + val currentItemFocusRequester = remember { FocusRequester() } LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } var queueHasFocus by remember { mutableStateOf(false) } @@ -89,13 +92,16 @@ fun NowPlayingOverlay( }, animationSpec = tween(durationMillis = 500), ) - val listState = rememberLazyListState() + val listState = + rememberLazyListState( + initialFirstVisibleItemIndex = state.musicServiceState.currentIndex, + ) var showButtons by remember { mutableStateOf(true) } val firstFocusRequester = remember { FocusRequester() } BackHandler(!showButtons) { scope.launch { - listState.animateScrollToItem(0) + listState.scrollToItem(0) firstFocusRequester.tryRequestFocus() } } @@ -141,6 +147,12 @@ fun NowPlayingOverlay( .align(Alignment.CenterHorizontally), ) } + val queue = + rememberQueue( + player, + state.musicServiceState.queueVersion, + state.musicServiceState.queueSize, + ) if (queue.isEmpty()) { Text("No items") } else { @@ -158,12 +170,16 @@ fun NowPlayingOverlay( .onFocusChanged { queueHasFocus = it.hasFocus }.focusProperties { + onEnter = { + currentItemFocusRequester.tryRequestFocus() + } onExit = { if (requestedFocusDirection == FocusDirection.Up) focusRequester.requestFocus() } }, ) { itemsIndexed(queue, key = { _, song -> song.key }) { index, song -> + val bringIntoViewRequester = remember { BringIntoViewRequester() } Row( verticalAlignment = Alignment.CenterVertically, modifier = @@ -176,7 +192,8 @@ fun NowPlayingOverlay( .onFocusChanged { if (it.hasFocus) showButtons = index < 3 controllerViewState.pulseControls() - }.animateItem(), + }.animateItem() + .bringIntoViewRequester(bringIntoViewRequester), ) { SongListItem( title = song.title, @@ -193,6 +210,9 @@ fun NowPlayingOverlay( .ifElse( index == 0, Modifier.focusRequester(firstFocusRequester), + ).ifElse( + index == state.musicServiceState.currentIndex, + Modifier.focusRequester(currentItemFocusRequester), ), ) Row( @@ -203,12 +223,22 @@ fun NowPlayingOverlay( MoveButton( icon = R.string.fa_caret_up, enabled = index > 0, - onClick = { onMoveQueue.invoke(index, MoveDirection.UP) }, + onClick = { + onMoveQueue.invoke(index, MoveDirection.UP) + scope.launch { + bringIntoViewRequester.bringIntoView() + } + }, ) MoveButton( icon = R.string.fa_caret_down, enabled = index < queue.lastIndex, - onClick = { onMoveQueue.invoke(index, MoveDirection.DOWN) }, + onClick = { + onMoveQueue.invoke(index, MoveDirection.DOWN) + scope.launch { + bringIntoViewRequester.bringIntoView() + } + }, ) Button( onClick = { onClickMoreItem.invoke(index, song) }, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingPage.kt index a9d43a27..5b22e6d2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingPage.kt @@ -48,7 +48,9 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi +import androidx.media3.ui.compose.state.rememberCurrentMediaItemState import androidx.tv.material3.Button import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text @@ -58,7 +60,6 @@ import com.github.damontecres.wholphin.data.model.AudioItem import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.BackdropStyle import com.github.damontecres.wholphin.preferences.UserPreferences -import com.github.damontecres.wholphin.services.rememberQueue import com.github.damontecres.wholphin.ui.components.BasicDialog import com.github.damontecres.wholphin.ui.components.ContextMenu import com.github.damontecres.wholphin.ui.components.ContextMenuDialog @@ -72,7 +73,9 @@ import com.github.damontecres.wholphin.ui.playback.overlay.BottomDialog import com.github.damontecres.wholphin.ui.playback.overlay.BottomDialogItem import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.LoadingState +import kotlinx.coroutines.delay import org.jellyfin.sdk.model.extensions.ticks +import java.util.Date import kotlin.time.Duration.Companion.seconds @OptIn(UnstableApi::class) @@ -87,13 +90,8 @@ fun NowPlayingPage( val context = LocalContext.current val state by viewModel.state.collectAsState() val player = viewModel.player - val queue = - rememberQueue( - player, - state.musicServiceState.queueVersion, - state.musicServiceState.queueSize, - ) - val current = queue.getOrNull(state.musicServiceState.currentIndex) + val currentMediaItem = rememberCurrentMediaItemState(player) + val current = (currentMediaItem.mediaItem?.localConfiguration?.tag as? AudioItem) val viz by viewModel.viz.collectAsState() val controllerViewState = viewModel.controllerViewState @@ -102,6 +100,7 @@ fun NowPlayingPage( .collectAsState( UserPreferences( AppPreferences.getDefaultInstance(), + null, ), ).value.appPreferences val musicPrefs = preferences.musicPreferences @@ -118,6 +117,7 @@ fun NowPlayingPage( // seekBack = preferences.playbackPreferences.skipBackMs.milliseconds, controllerViewState = controllerViewState, updateSkipIndicator = {}, + clearSkipIndicator = {}, skipBackOnResume = null, // skipBackOnResume = preferences.playbackPreferences.skipBackOnResume, onInteraction = viewModel::reportInteraction, @@ -127,25 +127,37 @@ fun NowPlayingPage( }, onPlaybackDialogTypeClick = { }, getDurationMs = { player.duration }, + dpadSeekMode = preferences.playbackPreferences.dpadSeekMode, + onSeekToNext = { + if (player.isCommandAvailable(Player.COMMAND_SEEK_TO_NEXT)) player.seekToNext() + }, + onPSeekToPrevious = { + if (player.isCommandAvailable(Player.COMMAND_SEEK_TO_PREVIOUS)) player.seekToPrevious() + }, ) } var showViewOptionsDialog by remember { mutableStateOf(false) } var showContextMenu by remember { mutableStateOf<ContextMenu.ForQueue?>(null) } - var lyricsHaveFocus by remember { mutableStateOf(false) } + var lyricsFocused by remember { mutableStateOf<Date?>(null) } val focusRequester = remember { FocusRequester() } LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } val lyricsFocusRequester = remember { FocusRequester() } val hasLyrics = musicPrefs.showLyrics && state.hasLyrics - LaunchedEffect(lyricsHaveFocus) { - if (lyricsHaveFocus) { + LaunchedEffect(lyricsFocused) { + if (lyricsFocused != null) { controllerViewState.hideControls() + delay(5.seconds) + if (!controllerViewState.controlsVisible) { + focusRequester.tryRequestFocus() + } + lyricsFocused = null } } - BackHandler(lyricsHaveFocus) { + BackHandler(lyricsFocused != null) { focusRequester.tryRequestFocus() } @@ -283,8 +295,10 @@ fun NowPlayingPage( LyricsContent( lyrics = state.lyrics, currentLyricPosition = state.currentLyricIndex, - lyricsHaveFocus = lyricsHaveFocus, - onFocusLyrics = { lyricsHaveFocus = it }, + lyricsHaveFocus = lyricsFocused != null, + onFocusLyrics = { + lyricsFocused = Date() + }, onClick = { it.start ?.ticks @@ -337,7 +351,6 @@ fun NowPlayingPage( state = state, player = player, current = current, - queue = queue, controllerViewState = controllerViewState, onMoveQueue = { index, direction -> viewModel.moveQueue(index, direction) }, onClickMore = { showViewOptionsDialog = true }, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingState.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingState.kt index f6fe40e4..cfaa1cfc 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingState.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingState.kt @@ -1,6 +1,7 @@ package com.github.damontecres.wholphin.ui.detail.music import androidx.compose.runtime.Stable +import com.github.damontecres.wholphin.data.model.AudioItem import com.github.damontecres.wholphin.services.BackdropResult import com.github.damontecres.wholphin.services.MusicServiceState import org.jellyfin.sdk.model.api.LyricDto @@ -8,6 +9,7 @@ import org.jellyfin.sdk.model.api.LyricDto @Stable data class NowPlayingState( val musicServiceState: MusicServiceState, + val currentMediaItem: AudioItem? = null, val lyrics: LyricDto? = null, val currentLyricIndex: Int? = null, val visualizerPermissions: Boolean = false, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingViewModel.kt index f1cc9225..af14ff70 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingViewModel.kt @@ -12,51 +12,54 @@ import androidx.media3.common.MediaItem import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi import com.github.damontecres.wholphin.data.model.AudioItem -import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.updateMusicPreferences import com.github.damontecres.wholphin.services.BackdropResult import com.github.damontecres.wholphin.services.BackdropService +import com.github.damontecres.wholphin.services.ExtractedColors import com.github.damontecres.wholphin.services.ImageUrlService import com.github.damontecres.wholphin.services.MusicService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NowPlayingStatus import com.github.damontecres.wholphin.services.UserPreferencesService +import com.github.damontecres.wholphin.ui.combinePair import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.main.settings.MoveDirection import com.github.damontecres.wholphin.ui.onMain import com.github.damontecres.wholphin.ui.playback.ControllerViewState +import com.github.damontecres.wholphin.util.GetItemsRequestHandler +import com.github.damontecres.wholphin.util.WholphinDispatchers import com.mayakapps.kache.InMemoryKache import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.api.client.extensions.itemsApi import org.jellyfin.sdk.api.client.extensions.lyricsApi import org.jellyfin.sdk.api.client.extensions.userLibraryApi -import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.ImageType -import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.LyricDto +import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.jellyfin.sdk.model.extensions.ticks import timber.log.Timber import java.util.UUID import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds -import kotlin.time.Duration.Companion.seconds +import kotlin.time.Duration.Companion.minutes @UnstableApi @HiltViewModel(assistedFactory = NowPlayingViewModel.Factory::class) @@ -95,10 +98,13 @@ class NowPlayingViewModel private val lyricCache = InMemoryKache<UUID, LyricDto>(20) { - creationScope = CoroutineScope(Dispatchers.IO) + creationScope = CoroutineScope(WholphinDispatchers.IO) } init { + state.update { + it.copy(currentMediaItem = player.currentMediaItem?.localConfiguration?.tag as? AudioItem) + } player.addListener(this) addCloseable { player.removeListener(this) @@ -126,62 +132,39 @@ class NowPlayingViewModel }.join() controllerViewState.pulseControls() } - viewModelScope.launchDefault { - backdropService.clearBackdrop() - updateBackdrop(getCurrent()) - } - playbackLoop() + viewModelScope.launchDefault { backdropService.clearBackdrop() } + listenForBackdrop() + listenForLyrics() } fun reportInteraction() { controllerViewState.pulseControls() } - private suspend fun getCurrent(): AudioItem? { - val mediaItem = - onMain { - player.currentMediaItemIndex - .takeIf { it in 0..<player.mediaItemCount } - ?.let { player.getMediaItemAt(it) } - } - return mediaItem?.localConfiguration?.tag as? AudioItem - } - - private fun playbackLoop() { + private fun listenForLyrics() { viewModelScope.launchDefault { - while (isActive) { - val position = onMain { player.currentPosition }.milliseconds -// Timber.v("playbackLoop: %s", position) - getCurrent()?.let { audio -> -// Timber.v("Got current %s", audio.id) - if (audio.hasLyrics) { - val lyrics = - lyricCache.getOrPut(audio.id) { - // TODO remote lyrics? - api.lyricsApi.getLyrics(audio.id).content - } + state.map { it.currentMediaItem }.distinctUntilChanged().collectLatest { audio -> + if (audio != null && audio.hasLyrics) { + val lyrics = fetchAndUpdateLyrics(audio) + Timber.d("Got lyrics for %s: %s", audio.id, lyrics != null) + while (lyrics != null && isActive) { + val position = onMain { player.currentPosition }.milliseconds + val offset = lyrics.metadata.offset?.ticks ?: Duration.ZERO + val lyricPosition = offset + position val lyricIndex = - if (lyrics != null) { - val offset = lyrics.metadata.offset?.ticks ?: Duration.ZERO - val lyricPosition = offset + position - lyrics.lyrics - .indexOfLast { - it.start?.ticks?.let { lyricPosition >= it } == true - }.takeIf { it >= 0 } - } else { - null - } -// Timber.v("lyricIndex=$lyricIndex") + lyrics.lyrics + .indexOfLast { + it.start?.ticks?.let { lyricPosition >= it } == true + }.takeIf { it >= 0 } state.update { it.copy( lyrics = lyrics, currentLyricIndex = lyricIndex, ) } + delay(150.milliseconds) } } - - delay(150) } } } @@ -192,100 +175,114 @@ class NowPlayingViewModel ) { val audio = mediaItem?.localConfiguration?.tag as? AudioItem Timber.v("onMediaItemTransition to %s", audio?.id) - updateBackdrop(audio) - viewModelScope.launchDefault { - state.update { - it.copy( - lyrics = null, - currentLyricIndex = null, - ) - } - audio?.let { audio -> - if (audio.hasLyrics) { - val lyrics = - lyricCache.getOrPut(audio.id) { - // TODO remote lyrics? - api.lyricsApi.getLyrics(audio.id).content - } - Timber.d("Got lyrics for %s: %s", audio.id, lyrics != null) - state.update { - it.copy( - lyrics = lyrics, - ) - } - } - } + state.update { + it.copy( + currentMediaItem = audio, + lyrics = null, + currentLyricIndex = null, + ) } } - private var backDropJob: Job? = null - - private fun updateBackdrop(audio: AudioItem?) { - backDropJob?.cancel() - backDropJob = - viewModelScope.launchDefault { - val showBackdrop = - userPreferencesService - .getCurrent() - .appPreferences.musicPreferences.showBackdrop - if (showBackdrop) { - var backdropItem: BaseItem? = null + private suspend fun fetchAndUpdateLyrics(audio: AudioItem?): LyricDto? = + audio?.let { audio -> + if (audio.hasLyrics) { + lyricCache.getOrPut(audio.id) { + // TODO remote lyrics? try { - if (audio?.artistId != null) { - api.userLibraryApi.getItem(audio.artistId).content.let { - if (it.backdropImageTags?.isNotEmpty() == true) { - backdropItem = BaseItem(it, false) - } - } - } - if (backdropItem == null && audio?.albumId != null) { - api.userLibraryApi.getItem(audio.albumId).content.let { - backdropItem = - getBackdropItemForAlbum(api, BaseItem(it, false)) - } - } - if (backdropItem != null) { - doUpdateBackdrop(backdropItem) - } else { - doUpdateBackdropRandom() - } + api.lyricsApi.getLyrics(audio.id).content + } catch (ex: CancellationException) { + throw ex } catch (ex: Exception) { - Timber.e(ex, "Error fetching backdrop") - doUpdateBackdropRandom() + Timber.e(ex, "Error fetching lyrics for %s", audio.id) + null } - delay(60.seconds) - doUpdateBackdropRandom() } + } else { + null } - } + } - private suspend fun doUpdateBackdropRandom() { - val randomArtist = - api.itemsApi - .getItems( - recursive = true, - imageTypes = listOf(ImageType.BACKDROP), - includeItemTypes = listOf(BaseItemKind.MUSIC_ARTIST), - sortBy = listOf(ItemSortBy.RANDOM), - limit = 1, - ).content.items - .firstOrNull() - if (randomArtist != null) { - doUpdateBackdrop(BaseItem(randomArtist)) - } else { - clearBackdrop() + private fun listenForBackdrop() { + viewModelScope.launchDefault { + userPreferencesService.flow + .map { it.appPreferences.musicPreferences.showBackdrop } + .combinePair(state.map { it.currentMediaItem }) + .distinctUntilChanged() + .collectLatest { (showBackdrop, audio) -> + if (showBackdrop && audio != null) { + try { + val song = + api.userLibraryApi + .getItem(audio.id) + .content + val songBackdrops = song.backdropImageUrls() + + val artistIds = song.artistItems.orEmpty().map { it.id } + val artists = + GetItemsRequestHandler + .execute( + api, + GetItemsRequest( + ids = artistIds, + imageTypes = listOf(ImageType.BACKDROP), + limit = 50, + enableImages = true, + enableImageTypes = listOf(ImageType.BACKDROP), + ), + ).content.items + val artistBackdrops = + artists.flatMap { artist -> artist.backdropImageUrls() } + + val list = songBackdrops.shuffled() + artistBackdrops.shuffled() + Timber.v("Got %s backdrops for %s", list.size, song.id) + if (list.isNotEmpty()) { + val urls = generateSequence { list }.flatten() + urls.forEach { + val (position, duration) = onMain { player.currentPosition to player.duration } + if (duration - position >= 15_000 || state.value.backdropResult == BackdropResult.NONE) { + // Only change if there's backdrop currently, or at 15 seconds left to prevent quick changes + doUpdateBackdrop(audio.id, it) + delay(2.minutes) + } + } + } else { + clearBackdrop() + } + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Error fetching backdrops for %s", audio.id) + clearBackdrop() + } + } else { + clearBackdrop() + } + } } } - private suspend fun doUpdateBackdrop(item: BaseItem) { - val imageUrl = imageUrlService.getItemImageUrl(item, ImageType.BACKDROP) + private fun BaseItemDto.backdropImageUrls() = + backdropImageTags?.sorted().orEmpty().mapIndexedNotNull { index, tag -> + imageUrlService.getItemImageUrl( + itemId = id, + imageType = ImageType.BACKDROP, + tag = tag, + imageIndex = index, + ) + } + + private suspend fun doUpdateBackdrop( + itemId: UUID, + imageUrl: String, + ) { val (primaryColor, secondaryColor, tertiaryColor) = backdropService.extractColorsFromBackdrop( imageUrl, - ) + ) ?: ExtractedColors.DEFAULT val backdropResult = BackdropResult( - itemId = item.id.toString(), + itemId = itemId.toString(), imageUrl = imageUrl, primaryColor = primaryColor, secondaryColor = secondaryColor, @@ -343,19 +340,9 @@ class NowPlayingViewModel fun updatePreferences(prefs: AppPreferences) { viewModelScope.launchDefault { - var backdropChanged = false preferencesDataStore.updateData { - backdropChanged = - it.musicPreferences.showBackdrop != prefs.musicPreferences.showBackdrop prefs } - if (backdropChanged) { - if (prefs.musicPreferences.showBackdrop) { - updateBackdrop(getCurrent()) - } else { - clearBackdrop() - } - } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/SongDetailsPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/SongDetailsPage.kt new file mode 100644 index 00000000..acc802a8 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/SongDetailsPage.kt @@ -0,0 +1,270 @@ +package com.github.damontecres.wholphin.ui.detail.music + +import android.content.Context +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.foundation.relocation.bringIntoViewRequester +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.services.FavoriteWatchManager +import com.github.damontecres.wholphin.services.ImageUrlService +import com.github.damontecres.wholphin.services.MediaManagementService +import com.github.damontecres.wholphin.services.MusicService +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus +import com.github.damontecres.wholphin.ui.components.ContextMenu +import com.github.damontecres.wholphin.ui.components.ContextMenuDialog +import com.github.damontecres.wholphin.ui.components.ErrorMessage +import com.github.damontecres.wholphin.ui.components.LoadingPage +import com.github.damontecres.wholphin.ui.components.MusicContextActions +import com.github.damontecres.wholphin.ui.components.Optional +import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel +import com.github.damontecres.wholphin.ui.detail.PlaylistDialog +import com.github.damontecres.wholphin.ui.launchDefault +import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.util.ExceptionHandler +import com.github.damontecres.wholphin.util.LoadingState +import com.github.damontecres.wholphin.util.WholphinDispatchers +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.userLibraryApi +import org.jellyfin.sdk.model.api.ImageType +import java.util.UUID + +@HiltViewModel(assistedFactory = SongViewModel.Factory::class) +class SongViewModel + @AssistedInject + constructor( + @ApplicationContext context: Context, + api: ApiClient, + musicService: MusicService, + navigationManager: NavigationManager, + mediaManagementService: MediaManagementService, + private val favoriteWatchManager: FavoriteWatchManager, + private val imageUrlService: ImageUrlService, + @Assisted itemId: UUID, + ) : MusicViewModel(itemId, context, api, musicService, navigationManager, mediaManagementService) { + @AssistedFactory + interface Factory { + fun create(itemId: UUID): SongViewModel + } + + private val _state = MutableStateFlow(SongState.EMPTY) + val state: StateFlow<SongState> = _state + + init { + init() + viewModelScope.launchDefault { + mediaManagementService.collectCanDelete(state.map { it.song }) { canDelete -> + _state.update { it.copy(canDelete = canDelete) } + } + } + } + + override fun init() { + viewModelScope.launchIO { + try { + val song = + api.userLibraryApi + .getItem(itemId = itemId) + .content + .let { BaseItem(it, false) } + val imageUrl = imageUrlService.getItemImageUrl(song, ImageType.PRIMARY) + _state.update { + it.copy( + song = song, + imageUrl = imageUrl, + loading = LoadingState.Success, + ) + } + } catch (ex: Exception) { + _state.update { it.copy(loading = LoadingState.Error(ex)) } + } + } + } + + fun setFavorite( + itemId: UUID, + favorite: Boolean, + ) = viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { + // The toggle returns the updated user data, so no refetch of the song is needed + val userData = favoriteWatchManager.setFavorite(itemId, favorite) + _state.update { state -> + state.copy( + song = state.song?.let { it.copy(data = it.data.copy(userData = userData)) }, + ) + } + } + } + +data class SongState( + val song: BaseItem?, + val imageUrl: String?, + val loading: LoadingState, + val canDelete: Boolean = false, +) { + companion object { + val EMPTY = SongState(null, null, LoadingState.Pending) + } +} + +/** + * Details page for a song that has no album, and therefore no album page to open. + */ +@Composable +fun SongDetailsPage( + itemId: UUID, + preferences: UserPreferences, + modifier: Modifier = Modifier, + viewModel: SongViewModel = + hiltViewModel<SongViewModel, SongViewModel.Factory>( + creationCallback = { it.create(itemId) }, + ), + playlistViewModel: AddPlaylistViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsState() + var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) } + val playlistState by playlistViewModel.playlistState.collectAsState() + var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) } + val moreDialogActions = + remember { + MusicContextActions( + navigateTo = { viewModel.navigationManager.navigateTo(it) }, + onClickPlay = { _, song -> viewModel.play(song) }, + onClickPlayNext = { _, song -> viewModel.playNext(song) }, + onClickAddToQueue = { item -> viewModel.addToQueue(item, -1) }, + onClickFavorite = { id, favorite -> viewModel.setFavorite(id, favorite) }, + onClickAddPlaylist = { id -> + playlistViewModel.loadPlaylists() + showPlaylistDialog.makePresent(id) + }, + onClickRemoveFromQueue = { _, _ -> }, + onDeleteItem = viewModel::deleteItem, + ) + } + when (val loading = state.loading) { + is LoadingState.Error -> { + ErrorMessage(loading, modifier) + } + + LoadingState.Loading, + LoadingState.Pending, + -> { + LoadingPage(modifier) + } + + LoadingState.Success -> { + val song = state.song!! + val bringIntoViewRequester = remember { BringIntoViewRequester() } + val buttonsFocusRequester = remember { FocusRequester() } + RequestOrRestoreFocus(buttonsFocusRequester) + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = + modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .bringIntoViewRequester(bringIntoViewRequester) + .padding(bottom = 32.dp), + ) { + AlbumHeader( + album = song, + imageUrl = state.imageUrl, + overviewOnClick = {}, + bringIntoViewRequester = bringIntoViewRequester, + modifier = Modifier.fillMaxWidth(), + ) + MusicExpandableButtons( + title = song.title ?: "", + actions = + remember(song) { + MusicButtonActions( + onClickPlay = { shuffled -> + viewModel.play(song, shuffled = shuffled) + }, + onClickInstantMix = { viewModel.startInstantMix(song.id) }, + onClickFavorite = { + viewModel.setFavorite(song.id, !song.favorite) + }, + onClickMore = { + showContextMenu = + ContextMenu.ForMusic( + fromLongClick = false, + item = song, + index = 0, + canDelete = + viewModel.canDelete( + song, + preferences.appPreferences, + ), + canRemoveFromQueue = false, + actions = moreDialogActions, + ) + }, + onConfirmDelete = { viewModel.deleteItem(song) }, + ) + }, + favorite = song.favorite, + canDelete = state.canDelete, + showShuffle = false, + buttonOnFocusChanged = {}, + modifier = Modifier.focusRequester(buttonsFocusRequester), + ) + } + } + } + showContextMenu?.let { contextMenu -> + ContextMenuDialog( + onDismissRequest = { showContextMenu = null }, + getMediaSource = null, + contextMenu = contextMenu, + preferredSubtitleLanguage = null, + ) + } + showPlaylistDialog.compose { songId -> + PlaylistDialog( + title = stringResource(R.string.add_to_playlist), + state = playlistState, + onDismissRequest = { showPlaylistDialog.makeAbsent() }, + onClick = { + playlistViewModel.addToPlaylist(it.id, songId) + showPlaylistDialog.makeAbsent() + }, + createEnabled = true, + onCreatePlaylist = { + playlistViewModel.createPlaylistAndAddItem(it, songId) + showPlaylistDialog.makeAbsent() + }, + onSearch = playlistViewModel::loadPlaylists, + elevation = 3.dp, + ) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/SongListDisplay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/SongListDisplay.kt index 82846282..e4664ac9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/SongListDisplay.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/SongListDisplay.kt @@ -3,6 +3,7 @@ package com.github.damontecres.wholphin.ui.detail.music import androidx.compose.foundation.background import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column @@ -19,10 +20,11 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.tv.material3.DenseListItem import androidx.tv.material3.Icon -import androidx.tv.material3.ListItem import androidx.tv.material3.ListItemDefaults import androidx.tv.material3.LocalContentColor import androidx.tv.material3.Text @@ -31,6 +33,7 @@ import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.ui.PreviewTvSpec import com.github.damontecres.wholphin.ui.components.Button import com.github.damontecres.wholphin.ui.enableMarquee +import com.github.damontecres.wholphin.ui.formatDuration import com.github.damontecres.wholphin.ui.letNotEmpty import com.github.damontecres.wholphin.ui.roundSeconds import com.github.damontecres.wholphin.ui.theme.WholphinTheme @@ -51,7 +54,12 @@ fun SongListItem( isQueued: Boolean = false, ) = SongListItem( title = song?.title, - artist = if (showArtist) song?.data?.albumArtist else null, + artist = + if (showArtist) { + remember(song) { song?.data?.artists?.joinToString(", ") } + } else { + null + }, indexNumber = song?.data?.indexNumber, runtime = remember(song) { @@ -114,42 +122,31 @@ fun SongListItem( } val trailingContent = @Composable { Text( - text = runtime.toString(), + text = runtime?.let { LocalResources.current.formatDuration(it) } ?: "", ) } - if (showArtist) { - // TODO use dense? - ListItem( - selected = isPlaying, - onClick = onClick, - onLongClick = onLongClick, - interactionSource = interactionSource, - leadingContent = leadingContent, - headlineContent = headlineContent, - supportingContent = { - Text( - text = artist ?: "", - ) + DenseListItem( + selected = isPlaying, + onClick = onClick, + onLongClick = onLongClick, + interactionSource = interactionSource, + leadingContent = leadingContent, + headlineContent = headlineContent, + supportingContent = + if (showArtist && artist != null) { + { + Text( + text = artist, + ) + } + } else { + null }, - trailingContent = trailingContent, - scale = ListItemDefaults.scale(1f, 1f, .95f), - modifier = Modifier.weight(1f), - ) - } else { - ListItem( - selected = isPlaying, - onClick = onClick, - onLongClick = onLongClick, - interactionSource = interactionSource, - leadingContent = leadingContent, - headlineContent = headlineContent, - supportingContent = null, - trailingContent = trailingContent, - scale = ListItemDefaults.scale(1f, 1f, .95f), - modifier = Modifier.weight(1f), - ) - } + trailingContent = trailingContent, + scale = ListItemDefaults.scale(1f, 1f, .95f), + modifier = Modifier.weight(1f), + ) if (showMoreButton) { Button( onClick = onClickMore, @@ -198,7 +195,7 @@ fun MusicQueueMarker( @Composable fun SongListItemPreview() { WholphinTheme { - Column { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { SongListItem( title = "Song title", artist = "Artists", @@ -232,6 +229,17 @@ fun SongListItemPreview() { showArtist = true, isPlaying = true, ) + SongListItem( + title = "Song title", + artist = null, + indexNumber = 2, + runtime = 2.minutes + 30.seconds, + onClick = {}, + onLongClick = { }, + modifier = Modifier, + showArtist = true, + isPlaying = true, + ) } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/Utils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/Utils.kt index 8e170706..26346e7c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/Utils.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/Utils.kt @@ -2,7 +2,7 @@ package com.github.damontecres.wholphin.ui.detail.music import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.github.damontecres.wholphin.ui.DefaultItemFields +import com.github.damontecres.wholphin.ui.DetailItemFields import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.GetItemsRequestHandler import org.jellyfin.sdk.api.client.ApiClient @@ -19,7 +19,7 @@ suspend fun ViewModel.getPagerForAlbum( GetItemsRequest( parentId = albumId, includeItemTypes = listOf(BaseItemKind.AUDIO), - fields = DefaultItemFields, + fields = DetailItemFields, sortBy = listOf( ItemSortBy.PARENT_INDEX_NUMBER, @@ -39,7 +39,7 @@ suspend fun ViewModel.getPagerForArtist( artistIds = listOf(artistId), recursive = true, includeItemTypes = listOf(BaseItemKind.AUDIO), - fields = DefaultItemFields, + fields = DetailItemFields, // TODO better sort sortBy = listOf( @@ -60,7 +60,7 @@ suspend fun ViewModel.getPagerForPlaylist( parentId = playlistId, recursive = true, includeItemTypes = listOf(BaseItemKind.AUDIO), - fields = DefaultItemFields, + fields = DetailItemFields, sortBy = listOf( ItemSortBy.DEFAULT, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/search/SearchForViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/search/SearchForViewModel.kt deleted file mode 100644 index baa98ef1..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/search/SearchForViewModel.kt +++ /dev/null @@ -1,71 +0,0 @@ -package com.github.damontecres.wholphin.ui.detail.search - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.github.damontecres.wholphin.data.ServerRepository -import com.github.damontecres.wholphin.services.NavigationManager -import com.github.damontecres.wholphin.ui.SlimItemFields -import com.github.damontecres.wholphin.ui.components.VoiceInputManager -import com.github.damontecres.wholphin.ui.launchIO -import com.github.damontecres.wholphin.ui.main.SearchResult -import com.github.damontecres.wholphin.util.ApiRequestPager -import com.github.damontecres.wholphin.util.GetItemsRequestHandler -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.update -import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.model.api.BaseItemKind -import org.jellyfin.sdk.model.api.request.GetItemsRequest -import timber.log.Timber -import javax.inject.Inject - -@HiltViewModel -class SearchForViewModel - @Inject - constructor( - private val api: ApiClient, - private val serverRepository: ServerRepository, - val navigationManager: NavigationManager, - val voiceInputManager: VoiceInputManager, - ) : ViewModel() { - val state = MutableStateFlow(SearchForState()) - - init { - state.value = SearchForState() - } - - fun search( - searchType: BaseItemKind, - query: String, - ) { - viewModelScope.launchIO { - if (state.value.query != query) { - if (query.isBlank()) { - state.update { SearchForState(query, SearchResult.NoQuery) } - return@launchIO - } - state.update { SearchForState(query, SearchResult.Searching) } - try { - val request = - GetItemsRequest( - userId = serverRepository.currentUser.value?.id, - searchTerm = query, - includeItemTypes = listOf(searchType), - recursive = true, - fields = SlimItemFields, - ) - val pager = ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope).init() - state.update { SearchForState(query, SearchResult.Success(pager)) } - } catch (ex: Exception) { - Timber.e(ex) - state.update { SearchForState(query, SearchResult.Error(ex)) } - } - } - } - } - } - -data class SearchForState( - val query: String = "", - val results: SearchResult = SearchResult.NoQuery, -) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeFooter.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeFooter.kt index 83987524..600dee28 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeFooter.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeFooter.kt @@ -13,6 +13,8 @@ import com.github.damontecres.wholphin.data.ChosenStreams import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.components.ExpandablePlayButtons +import com.github.damontecres.wholphin.ui.data.ChooseVersionParams +import org.jellyfin.sdk.model.api.MediaSourceInfo import org.jellyfin.sdk.model.extensions.ticks import kotlin.time.Duration @@ -27,6 +29,7 @@ fun FocusedEpisodeFooter( favoriteOnClick: () -> Unit, canDelete: Boolean, onConfirmDelete: () -> Unit, + onChooseVersion: (MediaSourceInfo) -> Unit, modifier: Modifier = Modifier, buttonOnFocusChanged: (FocusState) -> Unit = {}, ) { @@ -52,6 +55,14 @@ fun FocusedEpisodeFooter( trailerOnClick = {}, canDelete = canDelete, onConfirmDelete = onConfirmDelete, + chooseVersionParams = + remember(chosenStreams, ep, onChooseVersion) { + ChooseVersionParams( + chosenStreams = chosenStreams, + mediaSources = ep.data.mediaSources.orEmpty(), + onChooseVersion = onChooseVersion, + ) + }, modifier = Modifier.fillMaxWidth(), ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeHeader.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeHeader.kt index b63f1cbc..be7795f4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeHeader.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeHeader.kt @@ -35,10 +35,10 @@ fun FocusedEpisodeHeader( ) { EpisodeName(dto, modifier = Modifier.padding(start = HeaderUtils.startPadding)) - ep?.ui?.quickDetails?.let { + ep?.let { QuickDetails( it, - ep.timeRemainingOrRuntime, + it.timeRemainingOrRuntime, Modifier.padding(start = HeaderUtils.startPadding), ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt index 18a539c4..0308468e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt @@ -1,6 +1,6 @@ package com.github.damontecres.wholphin.ui.detail.series -import android.content.Context +import android.content.res.Resources import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -22,7 +22,6 @@ import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -34,7 +33,6 @@ import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -79,19 +77,17 @@ import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo import com.github.damontecres.wholphin.ui.detail.PlaylistDialog -import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState import com.github.damontecres.wholphin.ui.discover.DiscoverRow import com.github.damontecres.wholphin.ui.discover.DiscoverRowData import com.github.damontecres.wholphin.ui.letNotEmpty import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.rememberInt +import com.github.damontecres.wholphin.ui.util.ResStringProvider import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.DiscoverRequestType import com.github.damontecres.wholphin.util.ExceptionHandler -import com.github.damontecres.wholphin.util.LoadingState import kotlinx.coroutines.launch import org.jellyfin.sdk.model.api.BaseItemKind -import org.jellyfin.sdk.model.api.MediaType import org.jellyfin.sdk.model.serializer.toUUID import java.util.UUID import kotlin.time.Duration @@ -110,20 +106,9 @@ fun SeriesDetails( playlistViewModel: AddPlaylistViewModel = hiltViewModel(), ) { val context = LocalContext.current - val scope = rememberCoroutineScope() - val focusManager = LocalFocusManager.current - val loading by viewModel.loading.observeAsState(LoadingState.Loading) - val item by viewModel.item.observeAsState() - val canDelete by viewModel.canDeleteSeries.collectAsState() - val seasons by viewModel.seasons.observeAsState(listOf()) - val trailers by viewModel.trailers.observeAsState(listOf()) - val extras by viewModel.extras.observeAsState(listOf()) - val people by viewModel.people.observeAsState(listOf()) - val similar by viewModel.similar.observeAsState(listOf()) - val discovered by viewModel.discovered.collectAsState() - val discoverSeries by viewModel.discoverSeries.collectAsState() - val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) + val state by viewModel.state.collectAsState() + val playlistState by playlistViewModel.playlistState.collectAsState() var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) } var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) } @@ -147,10 +132,10 @@ fun SeriesDetails( viewModel.setFavorite(itemId, favorite, null) }, onClickAddPlaylist = { itemId -> - playlistViewModel.loadPlaylists(MediaType.VIDEO) + playlistViewModel.loadPlaylists() showPlaylistDialog = Optional.present(itemId) }, - onSendMediaInfo = viewModel.mediaReportService::sendReportFor, + onSendMediaInfo = viewModel.serverReportService::sendMediaReportFor, onDeleteItem = viewModel::deleteItem, onChooseVersion = { item, source -> viewModel.savePlayVersion( @@ -179,118 +164,116 @@ fun SeriesDetails( } } - when (val state = loading) { - is LoadingState.Error -> { - ErrorMessage(state, modifier) + when (val st = state.series) { + is DataLoadingState.Error -> { + ErrorMessage(st, modifier) } - LoadingState.Loading, - LoadingState.Pending, + DataLoadingState.Loading, + DataLoadingState.Pending, -> { LoadingPage(modifier) } - LoadingState.Success -> { - item?.let { item -> - LifecycleResumeEffect(destination.itemId) { - viewModel.onResumePage() + is DataLoadingState.Success<BaseItem> -> { + LifecycleResumeEffect(destination.itemId) { + viewModel.onResumePage() - onPauseOrDispose {} - } - - val played = item.data.userData?.played ?: false - SeriesDetailsContent( - preferences = preferences, - series = item, - seasons = seasons, - trailers = trailers, - extras = extras, - people = people, - similar = similar, - played = played, - favorite = item.data.userData?.isFavorite ?: false, - canDelete = canDelete, - modifier = modifier, - onClickItem = { index, item -> - viewModel.navigateTo(item.destination()) - }, - onClickPerson = { + onPauseOrDispose {} + } + val series = st.data + val played = series.data.userData?.played ?: false + SeriesDetailsContent( + preferences = preferences, + series = series, + seasons = state.seasons, + trailers = state.trailers, + extras = state.extras, + people = state.people, + similar = state.similar, + played = played, + favorite = series.data.userData?.isFavorite ?: false, + canDelete = state.canDeleteSeries, + modifier = modifier, + onClickItem = { index, item -> + viewModel.navigateTo(item.destination()) + }, + onClickPerson = { + viewModel.navigateTo( + Destination.MediaItem( + it.id, + BaseItemKind.PERSON, + ), + ) + }, + onLongClickItem = { index, season -> + showContextMenu = + ContextMenu.ForBaseItem( + fromLongClick = true, + item = season, + chosenStreams = null, + showGoTo = true, + showStreamChoices = false, + canDelete = viewModel.canDelete(season, preferences.appPreferences), + canRemoveContinueWatching = false, + canRemoveNextUp = false, + actions = contextActions, + ) + }, + overviewOnClick = { + overviewDialog = ItemDetailsDialogInfo(series) + }, + playOnClick = { shuffle -> + if (shuffle) { viewModel.navigateTo( - Destination.MediaItem( - it.id, - BaseItemKind.PERSON, + Destination.PlaybackList( + itemId = series.id, + shuffle = true, ), ) + } else { + viewModel.playNextUp() + } + }, + watchOnClick = { showWatchConfirmation = true }, + favoriteOnClick = { + val favorite = series.data.userData?.isFavorite ?: false + viewModel.setFavorite(series.id, !favorite, null) + }, + trailerOnClick = { + TrailerService.onClick(context, it, viewModel::navigateTo) + }, + onClickExtra = { _, extra -> + viewModel.navigateTo(extra.destination) + }, + discoverSeries = state.discoverSeries, + onClickDiscoverSeries = { + state.discoverSeries?.let { + viewModel.navigateTo(Destination.DiscoveredItem(it)) + } + }, + discovered = state.discovered, + onClickDiscover = { index, item -> + viewModel.navigateTo(item.destination) + }, + onShowContextMenu = { + showContextMenu = it + }, + actions = contextActions, + ) + if (showWatchConfirmation) { + ConfirmDialog( + title = series.name ?: "", + body = + stringResource(if (played) R.string.mark_entire_series_as_unplayed else R.string.mark_entire_series_as_played), + onCancel = { + showWatchConfirmation = false }, - onLongClickItem = { index, season -> - showContextMenu = - ContextMenu.ForBaseItem( - fromLongClick = true, - item = season, - chosenStreams = null, - showGoTo = true, - showStreamChoices = false, - canDelete = viewModel.canDelete(season, preferences.appPreferences), - canRemoveContinueWatching = false, - canRemoveNextUp = false, - actions = contextActions, - ) + onConfirm = { + viewModel.setWatchedSeries(!played) + showWatchConfirmation = false }, - overviewOnClick = { - overviewDialog = ItemDetailsDialogInfo(item) - }, - playOnClick = { shuffle -> - if (shuffle) { - viewModel.navigateTo( - Destination.PlaybackList( - itemId = item.id, - shuffle = true, - ), - ) - } else { - viewModel.playNextUp() - } - }, - watchOnClick = { showWatchConfirmation = true }, - favoriteOnClick = { - val favorite = item.data.userData?.isFavorite ?: false - viewModel.setFavorite(item.id, !favorite, null) - }, - trailerOnClick = { - TrailerService.onClick(context, it, viewModel::navigateTo) - }, - onClickExtra = { _, extra -> - viewModel.navigateTo(extra.destination) - }, - discoverSeries = discoverSeries, - onClickDiscoverSeries = { - discoverSeries?.let { - viewModel.navigateTo(Destination.DiscoveredItem(it)) - } - }, - discovered = discovered, - onClickDiscover = { index, item -> - viewModel.navigateTo(item.destination) - }, - onShowContextMenu = { - showContextMenu = it - }, - actions = contextActions, ) - if (showWatchConfirmation) { - ConfirmDialog( - title = item.name ?: "", - body = - stringResource(if (played) R.string.mark_entire_series_as_unplayed else R.string.mark_entire_series_as_played), - onCancel = { - showWatchConfirmation = false - }, - onConfirm = { - viewModel.setWatchedSeries(!played) - showWatchConfirmation = false - }, - ) - } } } } @@ -323,6 +306,7 @@ fun SeriesDetails( playlistViewModel.createPlaylistAndAddItem(it, itemId) showPlaylistDialog.makeAbsent() }, + onSearch = playlistViewModel::loadPlaylists, elevation = 3.dp, ) } @@ -644,7 +628,7 @@ fun SeriesDetailsContent( DiscoverRow( row = DiscoverRowData( - stringResource(R.string.discover), + ResStringProvider(R.string.discover), DataLoadingState.Success(discovered), type = DiscoverRequestType.UNKNOWN, ), @@ -690,7 +674,7 @@ fun SeriesDetailsHeader( modifier = Modifier.fillMaxWidth(.60f), ) { QuickDetails( - series.ui.quickDetails, + series, null, Modifier.padding(start = HeaderUtils.startPadding), ) @@ -726,7 +710,7 @@ fun SeriesDetailsHeader( } fun buildDialogForSeason( - context: Context, + resources: Resources, s: BaseItem, canDelete: Boolean, onClickItem: (BaseItem) -> Unit, @@ -737,35 +721,34 @@ fun buildDialogForSeason( val items = buildList { add( - DialogItem(context.getString(R.string.go_to), Icons.Default.PlayArrow) { + DialogItem(resources.getString(R.string.go_to), Icons.Default.PlayArrow) { onClickItem.invoke(s) }, ) if (s.data.userData?.played == true) { add( - DialogItem(context.getString(R.string.mark_unwatched), R.string.fa_eye) { + DialogItem(resources.getString(R.string.mark_unwatched), R.string.fa_eye) { markPlayed.invoke(false) }, ) } else { add( - DialogItem(context.getString(R.string.mark_watched), R.string.fa_eye_slash) { + DialogItem(resources.getString(R.string.mark_watched), R.string.fa_eye_slash) { markPlayed.invoke(true) }, ) } add( DialogItem( - context.getString(R.string.play), + resources.getString(R.string.play), Icons.Default.PlayArrow, - iconColor = Color.Green.copy(alpha = .8f), ) { onClickPlay.invoke(false) }, ) add( DialogItem( - context.getString(R.string.shuffle), + resources.getString(R.string.shuffle), R.string.fa_shuffle, ) { onClickPlay.invoke(true) @@ -774,7 +757,7 @@ fun buildDialogForSeason( if (canDelete) { add( DialogItem( - context.getString(R.string.delete), + resources.getString(R.string.delete), Icons.Default.Delete, iconColor = Color.Red.copy(alpha = .8f), ) { @@ -784,7 +767,7 @@ fun buildDialogForSeason( } } return DialogParams( - title = s.name ?: context.getString(R.string.tv_season), + title = s.name ?: resources.getString(R.string.tv_season), fromLongClick = true, items = items, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt index 6d99e156..ccc8f493 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt @@ -6,21 +6,18 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.LifecycleResumeEffect -import androidx.lifecycle.map import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus import com.github.damontecres.wholphin.ui.components.ContextMenu @@ -32,15 +29,13 @@ import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo import com.github.damontecres.wholphin.ui.detail.PlaylistDialog -import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.rememberInt -import com.github.damontecres.wholphin.util.LoadingState +import com.github.damontecres.wholphin.util.DataLoadingState import kotlinx.coroutines.flow.update import kotlinx.serialization.Serializable import kotlinx.serialization.UseSerializers import org.jellyfin.sdk.model.api.BaseItemKind -import org.jellyfin.sdk.model.api.MediaType import org.jellyfin.sdk.model.api.PersonKind import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.serializer.UUIDSerializer @@ -82,29 +77,22 @@ fun SeriesOverview( ), playlistViewModel: AddPlaylistViewModel = hiltViewModel(), ) { - val context = LocalContext.current - val scope = rememberCoroutineScope() val firstItemFocusRequester = remember { FocusRequester() } val episodeRowFocusRequester = remember { FocusRequester() } val castCrewRowFocusRequester = remember { FocusRequester() } val guestStarRowFocusRequester = remember { FocusRequester() } val extrasRowFocusRequester = remember { FocusRequester() } - val loading by viewModel.loading.observeAsState(LoadingState.Loading) - - val series by viewModel.item.observeAsState(null) - val seasons by viewModel.seasons.observeAsState(emptyList()) - val episodes by viewModel.episodes.observeAsState(EpisodeList.Loading) - val seasonExtras by viewModel.extras.observeAsState(emptyList()) - val peopleInEpisode by viewModel.peopleInEpisode.map { it.people }.observeAsState(emptyList()) - val episodeList = (episodes as? EpisodeList.Success)?.episodes + val state by viewModel.state.collectAsState() + val episodeList = + remember(state.episodes) { (state.episodes as? EpisodeList.Success)?.episodes } val position by viewModel.position.collectAsState(SeriesOverviewPosition(0, 0)) val currentPosition by rememberUpdatedState(position) LaunchedEffect(Unit) { - if (seasons.isNotEmpty()) { - seasons.getOrNull(position.seasonTabIndex)?.let { - viewModel.loadEpisodes(it.id) + if (state.seasons.isNotEmpty()) { + state.seasons.getOrNull(position.seasonTabIndex)?.let { + viewModel.loadEpisodes(it.id, it.indexNumber) } } } @@ -112,7 +100,7 @@ fun SeriesOverview( var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) } var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) } var showPlaylistDialog by remember { mutableStateOf<UUID?>(null) } - val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) + val playlistState by playlistViewModel.playlistState.collectAsState() var rowFocused by rememberInt() @@ -127,10 +115,10 @@ fun SeriesOverview( viewModel.setFavorite(itemId, favorite, currentPosition.episodeRowIndex) }, onClickAddPlaylist = { itemId -> - playlistViewModel.loadPlaylists(MediaType.VIDEO) + playlistViewModel.loadPlaylists() showPlaylistDialog = itemId }, - onSendMediaInfo = viewModel.mediaReportService::sendReportFor, + onSendMediaInfo = viewModel.serverReportService::sendMediaReportFor, onDeleteItem = viewModel::deleteItem, onChooseVersion = { item, source -> viewModel.savePlayVersion( @@ -149,7 +137,7 @@ fun SeriesOverview( onShowOverview = { overviewDialog = ItemDetailsDialogInfo(it) }, onClearChosenStreams = { val focusedEpisode = - (episodes as? EpisodeList.Success) + (state.episodes as? EpisodeList.Success) ?.episodes ?.getOrNull(currentPosition.episodeRowIndex) if (focusedEpisode != null) { @@ -159,9 +147,9 @@ fun SeriesOverview( ) } - LaunchedEffect(position, episodes) { + LaunchedEffect(position, state.episodes) { val focusedEpisode = - (episodes as? EpisodeList.Success) + (state.episodes as? EpisodeList.Success) ?.episodes ?.getOrNull(position.episodeRowIndex) @@ -170,93 +158,126 @@ fun SeriesOverview( viewModel.lookupPeopleInEpisode(it) } } - val chosenStreams by viewModel.chosenStreams.observeAsState(null) + val chosenStreams = state.chosenStreams - val preferredSubtitleLanguage = - viewModel.serverRepository.currentUserDto - .observeAsState() - .value - ?.configuration - ?.subtitleLanguagePreference + val userDto by viewModel.serverRepository.currentUserDtoFlow.collectAsState(null) + val preferredSubtitleLanguage = userDto?.configuration?.subtitleLanguagePreference - when (val state = loading) { - is LoadingState.Error -> { - ErrorMessage(state, modifier) + when (val st = state.series) { + is DataLoadingState.Error -> { + ErrorMessage(st, modifier) } - LoadingState.Loading, - LoadingState.Pending, + DataLoadingState.Loading, + DataLoadingState.Pending, -> { LoadingPage(modifier) } - LoadingState.Success -> { - series?.let { series -> + is DataLoadingState.Success<BaseItem> -> { + RequestOrRestoreFocus( + when (rowFocused) { + EPISODE_ROW -> episodeRowFocusRequester + CAST_AND_CREW_ROW -> castCrewRowFocusRequester + GUEST_STAR_ROW -> guestStarRowFocusRequester + EXTRAS_ROW -> extrasRowFocusRequester + else -> episodeRowFocusRequester + }, + "series_overview", + ) + LifecycleResumeEffect(destination.itemId) { + viewModel.onResumePage() - RequestOrRestoreFocus( - when (rowFocused) { - EPISODE_ROW -> episodeRowFocusRequester - CAST_AND_CREW_ROW -> castCrewRowFocusRequester - GUEST_STAR_ROW -> guestStarRowFocusRequester - EXTRAS_ROW -> extrasRowFocusRequester - else -> episodeRowFocusRequester - }, - "series_overview", - ) - LifecycleResumeEffect(destination.itemId) { - viewModel.onResumePage() - - onPauseOrDispose { - viewModel.release() - } + onPauseOrDispose { + viewModel.release() } + } - SeriesOverviewContent( - preferences = preferences, - series = series, - seasons = seasons, - episodes = episodes, - chosenStreams = chosenStreams, - peopleInEpisode = peopleInEpisode, - seasonExtras = seasonExtras, - position = position, - firstItemFocusRequester = firstItemFocusRequester, - episodeRowFocusRequester = episodeRowFocusRequester, - castCrewRowFocusRequester = castCrewRowFocusRequester, - guestStarRowFocusRequester = guestStarRowFocusRequester, - extrasRowFocusRequester = extrasRowFocusRequester, - onChangeSeason = { index -> - if (index != position.seasonTabIndex) { - seasons.getOrNull(index)?.let { season -> - viewModel.loadEpisodes(season.id) - viewModel.position.update { - SeriesOverviewPosition(index, 0) - } + SeriesOverviewContent( + preferences = preferences, + series = st.data, + seasons = state.seasons, + episodes = state.episodes, + chosenStreams = chosenStreams, + peopleInEpisode = state.peopleInEpisode.people, + seasonExtras = state.extras, + position = position, + firstItemFocusRequester = firstItemFocusRequester, + episodeRowFocusRequester = episodeRowFocusRequester, + castCrewRowFocusRequester = castCrewRowFocusRequester, + guestStarRowFocusRequester = guestStarRowFocusRequester, + extrasRowFocusRequester = extrasRowFocusRequester, + onChangeSeason = { index -> + if (index != position.seasonTabIndex) { + state.seasons.getOrNull(index)?.let { season -> + viewModel.loadEpisodes(season.id, season.indexNumber) + viewModel.position.update { + SeriesOverviewPosition(index, 0) } } - }, - onFocusEpisode = { episodeIndex -> - viewModel.position.update { - it.copy(episodeRowIndex = episodeIndex) - } - }, - onClick = { - rowFocused = EPISODE_ROW - val resumePosition = - it.data.userData - ?.playbackPositionTicks - ?.ticks ?: Duration.ZERO + } + }, + onFocusEpisode = { episodeIndex -> + viewModel.position.update { + it.copy(episodeRowIndex = episodeIndex) + } + }, + onClick = { + rowFocused = EPISODE_ROW + val resumePosition = + it.data.userData + ?.playbackPositionTicks + ?.ticks ?: Duration.ZERO + viewModel.navigateTo( + Destination.Playback( + it.id, + resumePosition.inWholeMilliseconds, + ), + ) + }, + onLongClick = { ep -> + showContextMenu = + ContextMenu.ForBaseItem( + fromLongClick = true, + item = ep, + chosenStreams = chosenStreams, + showGoTo = false, + showStreamChoices = true, + canDelete = viewModel.canDelete(ep, preferences.appPreferences), + canRemoveContinueWatching = false, + canRemoveNextUp = false, + actions = contextActions, + ) + }, + playOnClick = { resume -> + rowFocused = EPISODE_ROW + episodeList?.getOrNull(position.episodeRowIndex)?.let { + viewModel.release() viewModel.navigateTo( Destination.Playback( it.id, - resumePosition.inWholeMilliseconds, + resume.inWholeMilliseconds, ), ) - }, - onLongClick = { ep -> + } + }, + watchOnClick = { + episodeList?.getOrNull(position.episodeRowIndex)?.let { + val played = it.data.userData?.played ?: false + viewModel.setWatched(it.id, !played, position.episodeRowIndex) + } + }, + favoriteOnClick = { + episodeList?.getOrNull(position.episodeRowIndex)?.let { + val favorite = it.data.userData?.isFavorite ?: false + viewModel.setFavorite(it.id, !favorite, position.episodeRowIndex) + } + }, + moreOnClick = { + episodeList?.getOrNull(position.episodeRowIndex)?.let { ep -> showContextMenu = ContextMenu.ForBaseItem( - fromLongClick = true, + fromLongClick = false, item = ep, chosenStreams = chosenStreams, showGoTo = false, @@ -266,71 +287,32 @@ fun SeriesOverview( canRemoveNextUp = false, actions = contextActions, ) - }, - playOnClick = { resume -> - rowFocused = EPISODE_ROW - episodeList?.getOrNull(position.episodeRowIndex)?.let { - viewModel.release() - viewModel.navigateTo( - Destination.Playback( - it.id, - resume.inWholeMilliseconds, - ), - ) - } - }, - watchOnClick = { - episodeList?.getOrNull(position.episodeRowIndex)?.let { - val played = it.data.userData?.played ?: false - viewModel.setWatched(it.id, !played, position.episodeRowIndex) - } - }, - favoriteOnClick = { - episodeList?.getOrNull(position.episodeRowIndex)?.let { - val favorite = it.data.userData?.isFavorite ?: false - viewModel.setFavorite(it.id, !favorite, position.episodeRowIndex) - } - }, - moreOnClick = { - episodeList?.getOrNull(position.episodeRowIndex)?.let { ep -> - showContextMenu = - ContextMenu.ForBaseItem( - fromLongClick = false, - item = ep, - chosenStreams = chosenStreams, - showGoTo = false, - showStreamChoices = true, - canDelete = viewModel.canDelete(ep, preferences.appPreferences), - canRemoveContinueWatching = false, - canRemoveNextUp = false, - actions = contextActions, - ) - } - }, - overviewOnClick = { - episodeList?.getOrNull(position.episodeRowIndex)?.let { - overviewDialog = ItemDetailsDialogInfo(it) - } - }, - personOnClick = { - rowFocused = - if (it.type == PersonKind.GUEST_STAR) GUEST_STAR_ROW else CAST_AND_CREW_ROW - viewModel.navigateTo( - Destination.MediaItem( - it.id, - BaseItemKind.PERSON, - ), - ) - }, - onClickExtra = { _, extra -> - rowFocused = EXTRAS_ROW - viewModel.navigateTo(extra.destination) - }, - canDelete = { viewModel.canDelete(it, preferences.appPreferences) }, - onConfirmDelete = viewModel::deleteItem, - modifier = modifier, - ) - } + } + }, + overviewOnClick = { + episodeList?.getOrNull(position.episodeRowIndex)?.let { + overviewDialog = ItemDetailsDialogInfo(it) + } + }, + personOnClick = { + rowFocused = + if (it.type == PersonKind.GUEST_STAR) GUEST_STAR_ROW else CAST_AND_CREW_ROW + viewModel.navigateTo( + Destination.MediaItem( + it.id, + BaseItemKind.PERSON, + ), + ) + }, + onClickExtra = { _, extra -> + rowFocused = EXTRAS_ROW + viewModel.navigateTo(extra.destination) + }, + canDelete = { viewModel.canDelete(it, preferences.appPreferences) }, + onConfirmDelete = viewModel::deleteItem, + onChooseVersion = contextActions.onChooseVersion, + modifier = modifier, + ) } } showContextMenu?.let { contextMenu -> @@ -345,7 +327,7 @@ fun SeriesOverview( ItemDetailsDialog( info = info, showFilePath = - viewModel.serverRepository.currentUserDto.value + userDto ?.policy ?.isAdministrator == true, onDismissRequest = { overviewDialog = null }, @@ -365,6 +347,7 @@ fun SeriesOverview( playlistViewModel.createPlaylistAndAddItem(it, itemId) showPlaylistDialog = null }, + onSearch = playlistViewModel::loadPlaylists, elevation = 3.dp, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt index 999ed7c4..0759572c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt @@ -42,7 +42,6 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.ChosenStreams @@ -57,15 +56,19 @@ import com.github.damontecres.wholphin.ui.cards.PersonRow import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.HeaderUtils import com.github.damontecres.wholphin.ui.components.LoadingPage +import com.github.damontecres.wholphin.ui.components.TabDetails import com.github.damontecres.wholphin.ui.components.TabRow import com.github.damontecres.wholphin.ui.components.TitleOrLogo +import com.github.damontecres.wholphin.ui.formatSeasonNumber import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.logTab import com.github.damontecres.wholphin.ui.playback.isPlayKeyUp import com.github.damontecres.wholphin.ui.rememberInt import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.ui.util.StringStringProvider import com.github.damontecres.wholphin.ui.util.rememberDelayedNestedScroll import kotlinx.coroutines.launch +import org.jellyfin.sdk.model.api.MediaSourceInfo import org.jellyfin.sdk.model.api.PersonKind import kotlin.time.Duration @@ -97,6 +100,7 @@ fun SeriesOverviewContent( canDelete: (BaseItem) -> Boolean, onConfirmDelete: (BaseItem) -> Unit, onClickExtra: (Int, ExtrasItem) -> Unit, + onChooseVersion: (BaseItem, MediaSourceInfo) -> Unit, modifier: Modifier = Modifier, ) { val scope = rememberCoroutineScope() @@ -117,14 +121,21 @@ fun SeriesOverviewContent( val scrollConnection = rememberDelayedNestedScroll() var requestFocusAfterSeason by remember { mutableStateOf(false) } - val seasonStr = stringResource(R.string.tv_season) + val tabFocusRequesters = remember(seasons) { List(seasons.size) { FocusRequester() } } + val contentFocusRequesters = remember(seasons) { List(seasons.size) { FocusRequester() } } val tabs = - seasons.map { season -> - season?.name - ?: season?.data?.indexNumber?.let { "$seasonStr $it" } - ?: "" + seasons.mapIndexed { index, season -> + TabDetails( + title = + StringStringProvider( + season?.name + ?: season?.data?.indexNumber?.let { formatSeasonNumber(it) } + ?: "", + ), + tabFocusRequester = tabFocusRequesters[index], + contentFocusRequester = contentFocusRequesters[index], + ) } - val focusRequesters = remember(seasons) { List(seasons.size) { FocusRequester() } } val currentOnChangeSeason by rememberUpdatedState(onChangeSeason) @@ -170,7 +181,6 @@ fun SeriesOverviewContent( requestFocusAfterSeason = true } }, - focusRequesters = focusRequesters, modifier = Modifier .focusRequester(tabRowFocusRequester) @@ -224,7 +234,7 @@ fun SeriesOverviewContent( contentPadding = PaddingValues(horizontal = 16.dp), modifier = Modifier - .focusRestorer(firstItemFocusRequester) + .focusRestorer(episodeRowFocusRequester) // .focusRequester(episodeRowFocusRequester) .onFocusChanged { cardRowHasFocus = it.hasFocus @@ -323,6 +333,7 @@ fun SeriesOverviewContent( }, canDelete = canDelete.invoke(ep), onConfirmDelete = { onConfirmDelete.invoke(ep) }, + onChooseVersion = { onChooseVersion.invoke(ep, it) }, modifier = Modifier .padding(top = 4.dp) 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 4d0ce9e2..3bbc3a6b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt @@ -2,7 +2,7 @@ package com.github.damontecres.wholphin.ui.detail.series import android.content.Context import android.widget.Toast -import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.data.ChosenStreams import com.github.damontecres.wholphin.data.ExtrasItem @@ -18,17 +18,16 @@ import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.ExtrasService import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.MediaManagementService -import com.github.damontecres.wholphin.services.MediaReportService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.PeopleFavorites import com.github.damontecres.wholphin.services.SeerrService +import com.github.damontecres.wholphin.services.ServerReportService import com.github.damontecres.wholphin.services.StreamChoiceService import com.github.damontecres.wholphin.services.ThemeSongPlayer import com.github.damontecres.wholphin.services.TrailerService import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.services.deleteItem -import com.github.damontecres.wholphin.ui.SlimItemFields -import com.github.damontecres.wholphin.ui.detail.ItemViewModel +import com.github.damontecres.wholphin.ui.ItemRowFields import com.github.damontecres.wholphin.ui.equalsNotNull import com.github.damontecres.wholphin.ui.gt import com.github.damontecres.wholphin.ui.launchDefault @@ -36,27 +35,29 @@ import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.letNotEmpty import com.github.damontecres.wholphin.ui.lt import com.github.damontecres.wholphin.ui.nav.Destination -import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.util.ApiRequestPager +import com.github.damontecres.wholphin.util.BlockingList +import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.GetEpisodesRequestHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler -import com.github.damontecres.wholphin.util.LoadingExceptionHandler -import com.github.damontecres.wholphin.util.LoadingState +import com.github.damontecres.wholphin.util.WholphinDispatchers +import com.github.damontecres.wholphin.util.successValue import com.google.common.cache.CacheBuilder import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Deferred -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.flowOf @@ -84,7 +85,7 @@ import java.util.UUID class SeriesViewModel @AssistedInject constructor( - api: ApiClient, + private val api: ApiClient, @param:ApplicationContext val context: Context, val serverRepository: ServerRepository, private val navigationManager: NavigationManager, @@ -95,7 +96,7 @@ class SeriesViewModel private val trailerService: TrailerService, private val extrasService: ExtrasService, val streamChoiceService: StreamChoiceService, - val mediaReportService: MediaReportService, + val serverReportService: ServerReportService, private val userPreferencesService: UserPreferencesService, private val backdropService: BackdropService, private val seerrService: SeerrService, @@ -103,7 +104,7 @@ class SeriesViewModel @Assisted val seriesId: UUID, @Assisted val seasonEpisodeIds: SeasonEpisodeIds?, @Assisted val seriesPageType: SeriesPageType, - ) : ItemViewModel(api) { + ) : ViewModel() { @AssistedFactory interface Factory { fun create( @@ -113,47 +114,44 @@ class SeriesViewModel ): SeriesViewModel } - val loading = MutableLiveData<LoadingState>(LoadingState.Loading) - val seasons = MutableLiveData<List<BaseItem?>>(listOf()) - val episodes = MutableLiveData<EpisodeList>(EpisodeList.Loading) - - val trailers = MutableLiveData<List<Trailer>>(listOf()) - val extras = MutableLiveData<List<ExtrasItem>>(listOf()) - val people = MutableLiveData<List<Person>>(listOf()) - val similar = MutableLiveData<List<BaseItem>>() - val canDeleteSeries = MutableStateFlow(false) - - val peopleInEpisode = MutableLiveData<PeopleInItem>(PeopleInItem()) - val discovered = MutableStateFlow<List<DiscoverItem>>(listOf()) - val discoverSeries = MutableStateFlow<DiscoverItem?>(null) + private val _state = MutableStateFlow(SeriesState()) + val state: StateFlow<SeriesState> = _state val position = MutableStateFlow(SeriesOverviewPosition(0, 0)) init { - viewModelScope.launch( - LoadingExceptionHandler( - loading, - "Error loading series $seriesId", - ) + Dispatchers.IO, - ) { + viewModelScope.launchIO { Timber.v("Start") addCloseable { themeSongPlayer.stop() } - val item = fetchItem(seriesId) + val series = + try { + api.userLibraryApi + .getItem(seriesId) + .content + .let { BaseItem(it) } + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Error fetching series %s", seriesId) + _state.update { it.copy(series = DataLoadingState.Error(ex)) } + return@launchIO + } viewModelScope.launchDefault { - mediaManagementService.collectCanDelete(flowOf(item)) { canDelete -> - canDeleteSeries.update { canDelete } + mediaManagementService.collectCanDelete(flowOf(series)) { canDelete -> + _state.update { it.copy(canDeleteSeries = canDelete) } } } - backdropService.submit(item) + backdropService.submit(series) - val seasonsDeferred = getSeasons(item, seasonEpisodeIds?.seasonNumber) + val seasonsDeferred = getSeasons(series, seasonEpisodeIds?.seasonNumber) val episodeListDeferred = if (seriesPageType == SeriesPageType.OVERVIEW) { - viewModelScope.async(Dispatchers.IO) { + viewModelScope.async(WholphinDispatchers.IO) { if (seasonEpisodeIds != null) { loadEpisodesInternal( seasonEpisodeIds.seasonId, + seasonEpisodeIds.seasonNumber, seasonEpisodeIds.episodeId, seasonEpisodeIds.episodeNumber, ) @@ -161,6 +159,7 @@ class SeriesViewModel seasonsDeferred.await().firstOrNull()?.let { loadEpisodesInternal( it.id, + it.indexNumber, null, null, ) @@ -170,18 +169,30 @@ class SeriesViewModel } else { CompletableDeferred(value = EpisodeList.Loading) } - val seasons = seasonsDeferred.await() - val episodes = episodeListDeferred.await() + val (seasons, episodes) = + try { + val seasons = seasonsDeferred.await() + val episodes = episodeListDeferred.await() + seasons to episodes + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Exception fetching seasons/episodes for series %s", seriesId) + _state.update { it.copy(series = DataLoadingState.Error(ex)) } + return@launchIO + } Timber.v("Done") if (seriesPageType == SeriesPageType.OVERVIEW && seasonEpisodeIds != null) { viewModelScope.launchIO { val index = (seasons as? ApiRequestPager<*>)?.let { - findIndexOf( - seasonEpisodeIds.seasonNumber, - seasonEpisodeIds.seasonId, - it, + findIndexByNumberOrIdFast( + targetNum = seasonEpisodeIds.seasonNumber, + targetId = seasonEpisodeIds.seasonId, + list = it, + parentId = null, + parentIndex = null, ) } ?: 0 Timber.v("Got initial season index: $index") @@ -191,57 +202,58 @@ class SeriesViewModel } viewModelScope.launchIO { val extras = extrasService.getExtras(seasonEpisodeIds.seasonId) - this@SeriesViewModel.extras.setValueOnMain(extras) + _state.update { it.copy(extras = extras) } } } - val remoteTrailers = trailerService.getRemoteTrailers(item) - withContext(Dispatchers.Main) { - this@SeriesViewModel.trailers.value = remoteTrailers - this@SeriesViewModel.position.update { - it.copy( - episodeRowIndex = - (episodes as? EpisodeList.Success)?.initialEpisodeIndex ?: 0, - ) - } - this@SeriesViewModel.seasons.value = seasons - this@SeriesViewModel.episodes.value = episodes - loading.value = LoadingState.Success + val remoteTrailers = trailerService.getRemoteTrailers(series) + this@SeriesViewModel.position.update { + it.copy( + episodeRowIndex = + (episodes as? EpisodeList.Success)?.initialEpisodeIndex ?: 0, + ) } + _state.update { + it.copy( + series = DataLoadingState.Success(series), + seasons = seasons, + episodes = episodes, + trailers = remoteTrailers, + ) + } + if (seriesPageType == SeriesPageType.DETAILS) { viewModelScope.launchIO { - trailerService.getLocalTrailers(item).letNotEmpty { localTrailers -> - withContext(Dispatchers.Main) { - this@SeriesViewModel.trailers.value = localTrailers + remoteTrailers - } + trailerService.getLocalTrailers(series).letNotEmpty { localTrailers -> + _state.update { it.copy(trailers = localTrailers + remoteTrailers) } } } viewModelScope.launchIO { - val people = peopleFavorites.getPeopleFor(item) - this@SeriesViewModel.people.setValueOnMain(people) + val people = peopleFavorites.getPeopleFor(series) + _state.update { it.copy(people = people) } } viewModelScope.launchIO { - val extras = extrasService.getExtras(item.id) - this@SeriesViewModel.extras.setValueOnMain(extras) + val extras = extrasService.getExtras(series.id) + _state.update { it.copy(extras = extras) } } - if (!similar.isInitialized) { + if (state.value.similar.isEmpty()) { viewModelScope.launchIO { val similar = api.libraryApi .getSimilarItems( GetSimilarItemsRequest( - userId = serverRepository.currentUser.value?.id, + userId = serverRepository.currentUser?.id, itemId = seriesId, - fields = SlimItemFields, + fields = ItemRowFields, limit = 25, ), ).content.items - .map { BaseItem.from(it, api, true) } - this@SeriesViewModel.similar.setValueOnMain(similar) + .map { BaseItem(it, true) } + _state.update { it.copy(similar = similar) } } } viewModelScope.launchIO { - val results = seerrService.similar(item).orEmpty() - discovered.update { results } + val results = seerrService.similar(series).orEmpty() + _state.update { it.copy(discovered = results) } } viewModelScope.launchIO { seerrService.active.collectLatest { active -> @@ -249,7 +261,7 @@ class SeriesViewModel if (active) { try { seerrService - .getTvSeries(item) + .getTvSeries(series) ?.let { seerrService.createDiscoverItem(it) } } catch (ex: Exception) { Timber.e(ex) @@ -258,7 +270,7 @@ class SeriesViewModel } else { null } - discoverSeries.update { tv } + _state.update { it.copy(discoverSeries = tv) } } } } @@ -270,8 +282,8 @@ class SeriesViewModel deletedItem.item.id, seriesId, ) - val seasons = getSeasons(item, seasonEpisodeIds?.seasonNumber).await() - this@SeriesViewModel.seasons.setValueOnMain(seasons) + val seasons = getSeasons(series, seasonEpisodeIds?.seasonNumber).await() + _state.update { it.copy(seasons = seasons) } } }.catch { ex -> Timber.e(ex, "Error refreshing after deleted item") @@ -280,24 +292,18 @@ class SeriesViewModel } fun onResumePage() { - item.value?.let { item -> + state.value.series.successValue?.let { item -> viewModelScope.launchDefault { backdropService.submit(item) } - viewModelScope.launchIO { - val playThemeSongs = - userPreferencesService - .getCurrent() - .appPreferences.interfacePreferences.playThemeSongs - themeSongPlayer.playThemeFor(seriesId, playThemeSongs) + viewModelScope.launchDefault { + themeSongPlayer.playThemeFor(seriesId) } } } fun refresh() { - item.value?.let { item -> - if (loading.value == LoadingState.Success) { - viewModelScope.launchIO { - (seasons.value as? ApiRequestPager<*>)?.refresh() - } + state.value.series.successValue?.let { item -> + viewModelScope.launchIO { + (state.value.seasons as? ApiRequestPager<*>)?.refresh() } } } @@ -310,7 +316,7 @@ class SeriesViewModel series: BaseItem, seasonNum: Int?, ): Deferred<List<BaseItem?>> = - viewModelScope.async(Dispatchers.IO) { + viewModelScope.async(WholphinDispatchers.IO) { Timber.v("getSeasons for %s", series.id) val request = GetItemsRequest( @@ -319,6 +325,7 @@ class SeriesViewModel includeItemTypes = listOf(BaseItemKind.SEASON), sortBy = listOf(ItemSortBy.INDEX_NUMBER), sortOrder = listOf(SortOrder.ASCENDING), + enableUserData = seriesPageType == SeriesPageType.DETAILS, fields = if (seriesPageType == SeriesPageType.DETAILS) { listOf( @@ -337,21 +344,14 @@ class SeriesViewModel request, GetItemsRequestHandler, viewModelScope, - pageSize = 10, + pageSize = 20, ).init(seasonNum ?: 0) -// val seasons = -// GetItemsRequestHandler.execute(api, request).content.items.map { -// BaseItem.from( -// it, -// api, -// ) -// } -// Timber.v("Loaded ${seasons.size} seasons for series ${series.id}") pager } private suspend fun loadEpisodesInternal( seasonId: UUID, + seasonNum: Int?, episodeId: UUID?, episodeNumber: Int?, ): EpisodeList { @@ -368,6 +368,7 @@ class SeriesViewModel ItemFields.CUSTOM_RATING, ItemFields.PRIMARY_IMAGE_ASPECT_RATIO, ItemFields.CAN_DELETE, + ItemFields.PARENT_ID, ), ) Timber.v( @@ -379,10 +380,15 @@ class SeriesViewModel pager.init(episodeNumber ?: 0) val initialIndex = if (episodeId != null || episodeNumber != null) { - findIndexOf(episodeNumber, episodeId, pager) - .coerceAtLeast(0) + findIndexByNumberOrIdFast( + targetNum = episodeNumber, + targetId = episodeId, + list = pager, + parentId = seasonId, + parentIndex = seasonNum, + ).coerceAtLeast(0) } else { - // Force the first page to to be fetched + // Force the first page to be fetched if (pager.isNotEmpty()) { pager.getBlocking(0) } @@ -392,28 +398,33 @@ class SeriesViewModel return EpisodeList.Success(seasonId, pager, initialIndex) } - fun loadEpisodes(seasonId: UUID) { - val currentEpisodes = (this@SeriesViewModel.episodes.value as? EpisodeList.Success) + fun loadEpisodes( + seasonId: UUID, + seasonNum: Int?, + ) { + val currentEpisodes = (state.value.episodes as? EpisodeList.Success) if (currentEpisodes == null || currentEpisodes.seasonId != seasonId) { - this@SeriesViewModel.peopleInEpisode.value = PeopleInItem() - this@SeriesViewModel.episodes.value = EpisodeList.Loading - this@SeriesViewModel.extras.value = emptyList() + _state.update { + it.copy( + peopleInEpisode = PeopleInItem(), + episodes = EpisodeList.Loading, + extras = emptyList(), + ) + } } viewModelScope.launchIO(ExceptionHandler(true)) { val episodes = try { - loadEpisodesInternal(seasonId, null, null) + loadEpisodesInternal(seasonId, seasonNum, null, null) } catch (e: Exception) { Timber.e(e, "Error loading episodes for $seriesId for season $seasonId") EpisodeList.Error(e) } - withContext(Dispatchers.Main) { - this@SeriesViewModel.episodes.value = episodes - } + _state.update { it.copy(episodes = episodes) } } viewModelScope.launchIO { val extras = extrasService.getExtras(seasonId) - this@SeriesViewModel.extras.setValueOnMain(extras) + _state.update { it.copy(extras = extras) } } } @@ -421,68 +432,83 @@ class SeriesViewModel itemId: UUID, played: Boolean, listIndex: Int?, - ) = viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) { + ) = viewModelScope.launch(WholphinDispatchers.IO + ExceptionHandler()) { favoriteWatchManager.setWatched(itemId, played) listIndex?.let { refreshEpisode(itemId, listIndex) } } + private fun updateSeries() { + viewModelScope.launchIO { + try { + val series = + api.userLibraryApi + .getItem(seriesId) + .content + .let(::BaseItem) + _state.update { it.copy(series = DataLoadingState.Success(series)) } + viewModelScope.launchIO { + val people = peopleFavorites.getPeopleFor(series) + _state.update { it.copy(people = people) } + } + viewModelScope.launchIO { + val seasons = getSeasons(series, null).await() + _state.update { it.copy(seasons = seasons) } + } + } catch (ex: Exception) { + Timber.e(ex, "Error updating series") + showToast(context, "Error updating series") + } + } + } + fun setFavorite( itemId: UUID, favorite: Boolean, listIndex: Int?, - ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { + ) = viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { favoriteWatchManager.setFavorite(itemId, favorite) if (listIndex != null) { refreshEpisode(itemId, listIndex) } else { - val item = fetchItem(seriesId) - viewModelScope.launchIO { - val people = peopleFavorites.getPeopleFor(item) - this@SeriesViewModel.people.setValueOnMain(people) - } + updateSeries() } } fun setSeasonWatched( seasonId: UUID, played: Boolean, - ) = viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) { + ) = viewModelScope.launch(WholphinDispatchers.IO + ExceptionHandler()) { setWatched(seasonId, played, null) - val series = fetchItem(seriesId) - val seasons = getSeasons(series, null).await() - this@SeriesViewModel.seasons.setValueOnMain(seasons) + updateSeries() } fun setWatchedSeries(played: Boolean) = - viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { + viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { favoriteWatchManager.setWatched(seriesId, played) - val series = fetchItem(seriesId) - val seasons = getSeasons(series, null).await() - this@SeriesViewModel.seasons.setValueOnMain(seasons) + updateSeries() } fun refreshEpisode( itemId: UUID, listIndex: Int, - ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { - val eps = episodes.value + ) = viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { + val eps = state.value.episodes if (eps is EpisodeList.Success) { eps.episodes.refreshItem(listIndex, itemId) - withContext(Dispatchers.Main) { - episodes.value = eps - } + _state.update { it.copy(episodes = eps) } } // Kind of hack to ensure the backdrop is reloaded if needed - item.value?.let { backdropService.submit(it) } + state.value.series.successValue + ?.let { backdropService.submit(it) } } /** * Play whichever episode is next up for series or else the first episode */ fun playNextUp() { - viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { + viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { val result by api.tvShowsApi.getNextUp(seriesId = seriesId) val nextUp = result.items.firstOrNull() ?: api.tvShowsApi @@ -492,8 +518,8 @@ class SeriesViewModel ).content.items .firstOrNull() if (nextUp != null) { - withContext(Dispatchers.Main) { - navigateTo(Destination.Playback(nextUp.id, 0L)) + withContext(WholphinDispatchers.Main) { + navigateTo(Destination.Playback(BaseItem(nextUp))) } } else { showToast( @@ -510,7 +536,6 @@ class SeriesViewModel navigationManager.navigateTo(destination) } - val chosenStreams = MutableLiveData<ChosenStreams?>(null) private var chosenStreamsJob: Job? = null fun lookUpChosenTracks( @@ -526,9 +551,7 @@ class SeriesViewModel item, userPreferencesService.getCurrent(), ) - withContext(Dispatchers.Main) { - chosenStreams.value = result - } + _state.update { it.copy(chosenStreams = result) } } } @@ -538,15 +561,12 @@ class SeriesViewModel ) { viewModelScope.launchIO { val prefs = userPreferencesService.getCurrent() - val plc = streamChoiceService.getPlaybackLanguageChoice(item.data) val result = itemPlaybackRepository.savePlayVersion(item.id, sourceId) val chosen = result?.let { - itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs) + itemPlaybackRepository.getChosenItemFromPlayback(item, result, prefs) } - withContext(Dispatchers.Main) { - chosenStreams.value = chosen - } + _state.update { it.copy(chosenStreams = chosen) } } } @@ -558,7 +578,6 @@ class SeriesViewModel ) { viewModelScope.launchIO { val prefs = userPreferencesService.getCurrent() - val plc = streamChoiceService.getPlaybackLanguageChoice(item.data) val result = itemPlaybackRepository.saveTrackSelection( item = item, @@ -566,13 +585,8 @@ class SeriesViewModel trackIndex = trackIndex, type = type, ) - val chosen = - result?.let { - itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs) - } - withContext(Dispatchers.Main) { - chosenStreams.value = chosen - } + val chosen = itemPlaybackRepository.getChosenItemFromPlayback(item, result, prefs) + _state.update { it.copy(chosenStreams = chosen) } } } @@ -585,12 +599,12 @@ class SeriesViewModel suspend fun lookupPeopleInEpisode(item: BaseItem) { peopleInEpisodeJob?.cancel() - if (peopleInEpisode.value?.itemId != item.id) { - peopleInEpisode.setValueOnMain(PeopleInItem()) + if (state.value.peopleInEpisode.itemId != item.id) { + _state.update { it.copy(peopleInEpisode = PeopleInItem()) } val result = peopleInEpisodeCache .get(item.id) { - viewModelScope.async(Dispatchers.IO) { + viewModelScope.async(WholphinDispatchers.IO) { val list = api.userLibraryApi .getItem(item.id) @@ -604,7 +618,8 @@ class SeriesViewModel peopleInEpisodeJob = viewModelScope.launch(ExceptionHandler()) { delay(250) - peopleInEpisode.setValueOnMain(result.await()) + val peopleInEpisode = result.await() + _state.update { it.copy(peopleInEpisode = peopleInEpisode) } } } } @@ -625,17 +640,17 @@ class SeriesViewModel if (item.type == BaseItemKind.SERIES) { navigationManager.goBack() } else if (seriesPageType == SeriesPageType.DETAILS) { - this@SeriesViewModel.item.value?.let { series -> + state.value.series.successValue?.let { series -> val seasons = getSeasons(series, null).await() if (seasons.isEmpty()) { navigationManager.goBack() } else { - this@SeriesViewModel.seasons.setValueOnMain(seasons) + _state.update { it.copy(seasons = seasons) } } } } else { position.value.let { (_, episodeIndex) -> - val eps = episodes.value as? EpisodeList.Success + val eps = state.value.episodes as? EpisodeList.Success if (eps != null) { val pager = eps.episodes val lastIndex = pager.lastIndex @@ -645,22 +660,28 @@ class SeriesViewModel } else { if (episodeIndex == lastIndex) { // Deleted last episode, so need to move left - episodes.setValueOnMain( - EpisodeList.Success( - eps.seasonId, - pager, - episodeIndex - 1, - ), - ) + _state.update { + it.copy( + episodes = + EpisodeList.Success( + eps.seasonId, + pager, + episodeIndex - 1, + ), + ) + } position.update { it.copy(episodeRowIndex = episodeIndex - 1) } } else { - episodes.setValueOnMain( - EpisodeList.Success( - eps.seasonId, - pager, - episodeIndex, - ), - ) + _state.update { + it.copy( + episodes = + EpisodeList.Success( + eps.seasonId, + pager, + episodeIndex, + ), + ) + } } } } @@ -705,46 +726,170 @@ enum class SeriesPageType { OVERVIEW, } -private suspend fun findIndexOf( +private fun checkNumberOrId( targetNum: Int?, targetId: UUID?, - pager: ApiRequestPager<*>, + indexNumber: Int?, + id: UUID?, +): Boolean = equalsNotNull(targetId, id) || equalsNotNull(indexNumber, targetNum) + +private fun checkParent( + parentId: UUID?, + parentIndex: Int?, + item: BaseItem?, +) = if (parentId != null || parentIndex != null) { + equalsNotNull(parentId, item?.data?.parentId) || + equalsNotNull(parentIndex, item?.data?.parentIndexNumber) +} else { + true +} + +/** + * Find the index in the [list] where the item's `indexNumber`==[targetNum] or `id`==[targetId] + * + * If the list is smaller than its page size, then the entire dataset is cached and + * [BlockingList.indexOfBlocking] is used, otherwise this function calls [findIndexByNumberOrId] + */ +suspend fun findIndexByNumberOrIdFast( + targetNum: Int?, + targetId: UUID?, + list: ApiRequestPager<*>, + parentId: UUID?, + parentIndex: Int?, +): Int = + if (list.size <= list.pageSize) { + Timber.v("Using findIndexByNumberOrIdFast indexOfBlocking method") + list.indexOfBlocking { + checkNumberOrId(targetNum, targetId, it?.indexNumber, it?.id) && + checkParent(parentId, parentIndex, it) + } + } else { + findIndexByNumberOrId( + targetNum, + targetId, + list as BlockingList<BaseItem?>, + parentId, + parentIndex, + ) + } + +/** + * Find the index in the [list] where the item's `indexNumber`==[targetNum] or `id`==[targetId] + * + * This is necessary in cases where items are missing. E.g. if looking for episode 4 but + * episodes 2 & 3 is missing, then the index in the list for episode 4 will be `1`. + * + * @param targetNum the 1-index season or episode number + * @param targetId the season or episode ID + * + * @return the index within [list] that matches, or zero if no match is found + */ +suspend fun findIndexByNumberOrId( + targetNum: Int?, + targetId: UUID?, + list: BlockingList<BaseItem?>, + parentId: UUID? = null, + parentIndex: Int? = null, ): Int { + Timber.v("Using findIndexByNumberOrId") + // Adjust for 1-based numbers + val listIndex = targetNum?.minus(1)?.coerceAtLeast(0) val index = - if (targetId != null && (targetNum == null || targetNum !in pager.indices)) { + if (targetId != null && (targetNum == null || listIndex !in list.indices)) { // No hint info, so have to check everything - pager.indexOfBlocking { - equalsNotNull(it?.indexNumber, targetNum) || - equalsNotNull(it?.id, targetId) - } - } else if (targetNum != null && targetNum in pager.indices) { - // Start searching from the season number and choose direction from there - val num = pager.getBlocking(targetNum)?.indexNumber - if (num.lt(targetNum)) { - for (i in targetNum + 1 until pager.lastIndex) { - val season = pager.getBlocking(i) - if (equalsNotNull(season?.indexNumber, targetNum) || - equalsNotNull(season?.id, targetId) - ) { - return i - } - } - return 0 - } else if (num.gt(targetNum)) { - for (i in targetNum - 1 downTo 0) { - val season = pager.getBlocking(i) - if (equalsNotNull(season?.indexNumber, targetNum) || - equalsNotNull(season?.id, targetId) - ) { - return i - } - } - return 0 - } else { - targetNum - } + list + .indexOfBlocking { + checkNumberOrId(targetNum, targetId, it?.indexNumber, it?.id) + }.coerceAtLeast(0) + } else if (listIndex != null && listIndex in list.indices) { + searchList(listIndex, targetNum, targetId, list, parentId, parentIndex) } else { 0 } return index } + +private suspend fun searchList( + listIndex: Int, + targetNum: Int, + targetId: UUID?, + list: BlockingList<BaseItem?>, + parentId: UUID?, + parentIndex: Int?, +): Int { + var index = listIndex + var item = list.getBlocking(index) + if ((parentId != null || parentIndex != null) && !checkParent(parentId, parentIndex, item)) { + var leftIndex = listIndex - 1 + var rightIndex = listIndex + 1 + // Search before & after to find an episode with the right parent + while (leftIndex in list.indices || rightIndex in list.indices) { + if (leftIndex in list.indices) { + val left = list.getBlocking(leftIndex) + if (checkParent(parentId, parentIndex, left)) { + index = leftIndex + break + } + } + if (rightIndex in list.indices) { + val right = list.getBlocking(rightIndex) + if (checkParent(parentId, parentIndex, right)) { + index = rightIndex + break + } + } + leftIndex-- + rightIndex++ + } + // Search for the right episode starting from this new one + item = list.getBlocking(index) + } + val num = item?.indexNumber + if (num.lt(targetNum)) { + for (i in listIndex + 1 until list.size) { + val item = list.getBlocking(i) + if (checkNumberOrId(targetNum, targetId, item?.indexNumber, item?.id) && + checkParent(parentId, parentIndex, item) + ) { + return i + } + } + return 0 + } else if (num.gt(targetNum)) { + for (i in listIndex - 1 downTo 0) { + val item = list.getBlocking(i) + if (checkNumberOrId(targetNum, targetId, item?.indexNumber, item?.id) && + checkParent(parentId, parentIndex, item) + ) { + return i + } + } + return 0 + } else if (checkNumberOrId(targetNum, targetId, item?.indexNumber, item?.id) && + checkParent(parentId, parentIndex, item) + ) { + return index + } else { + return list + .indexOfBlocking { + checkNumberOrId(targetNum, targetId, it?.indexNumber, it?.id) && + checkParent(parentId, parentIndex, item) + }.coerceAtLeast(0) + } +} + +data class SeriesState( + val series: DataLoadingState<BaseItem> = DataLoadingState.Pending, + val seasons: List<BaseItem?> = emptyList(), + val episodes: EpisodeList = EpisodeList.Loading, + val trailers: List<Trailer> = emptyList(), + val extras: List<ExtrasItem> = emptyList(), + val people: List<Person> = emptyList(), + val similar: List<BaseItem> = emptyList(), + val canDeleteSeries: Boolean = false, + val peopleInEpisode: PeopleInItem = PeopleInItem(), + val discovered: List<DiscoverItem> = 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/DiscoverFocusedItem.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverFocusedItem.kt new file mode 100644 index 00000000..b0d49985 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverFocusedItem.kt @@ -0,0 +1,58 @@ +package com.github.damontecres.wholphin.ui.discover + +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.model.DiscoverItem +import com.github.damontecres.wholphin.data.model.SeerrItemType +import com.github.damontecres.wholphin.ui.util.ResArgStringProvider +import com.github.damontecres.wholphin.ui.util.StringProvider +import com.github.damontecres.wholphin.ui.util.StringStringProvider + +sealed interface DiscoverFocusedItem { + val id: Int + val title: StringProvider? + val subtitle: String? + val overview: String? + val backDropUrl: String? + val logoUrl: String? + + data class Item( + val item: DiscoverItem, + ) : DiscoverFocusedItem { + override val id: Int + get() = item.id + override val title: StringProvider? = item.title?.let { StringStringProvider(it) } + override val subtitle: String? + get() = item.subtitle + override val overview: String? + get() = item.overview + override val backDropUrl: String? + get() = item.backDropUrl + override val logoUrl: String? + get() = item.logoUrl + } + + data class Genre( + override val id: Int, + val name: String, + val type: SeerrItemType, + override val backDropUrl: String?, + ) : DiscoverFocusedItem { + override val title: StringProvider = discoverGenreTitle(name, type) + override val subtitle: String? + get() = null + override val overview: String? + get() = null + override val logoUrl: String? + get() = null + } +} + +fun discoverGenreTitle( + name: String, + type: SeerrItemType, +): ResArgStringProvider = + when (type) { + SeerrItemType.MOVIE -> ResArgStringProvider(R.string.genre_movies, name) + SeerrItemType.TV -> ResArgStringProvider(R.string.genre_tv_shows, name) + else -> throw UnsupportedOperationException("$type not supported") + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverGenreRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverGenreRow.kt new file mode 100644 index 00000000..c61c0799 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverGenreRow.kt @@ -0,0 +1,82 @@ +package com.github.damontecres.wholphin.ui.discover + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged +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.Cards +import com.github.damontecres.wholphin.ui.cards.GenreCard +import com.github.damontecres.wholphin.ui.cards.ItemRow +import com.github.damontecres.wholphin.ui.components.ErrorMessage +import com.github.damontecres.wholphin.ui.components.Genre +import com.github.damontecres.wholphin.ui.util.StringProvider +import com.github.damontecres.wholphin.util.DataLoadingState + +@Composable +fun DiscoverGenreRow( + title: StringProvider, + items: DataLoadingState<List<Genre>>, + onClickItem: (Int, Genre) -> Unit, + onCardFocus: (Int) -> Unit, + modifier: Modifier = Modifier, + onLongClickItem: (Int, Genre) -> Unit = { _, _ -> }, +) { + when (items) { + is DataLoadingState.Error -> { + ErrorMessage(items.message, items.exception, modifier) + } + + DataLoadingState.Loading, + DataLoadingState.Pending, + -> { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier, + ) { + Text( + text = title.getString(), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onBackground, + ) + Text( + text = stringResource(R.string.loading), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onBackground, + ) + } + } + + is DataLoadingState.Success<List<Genre>> -> { + ItemRow( + title = title.getString(), + items = items.data, + onClickItem = onClickItem, + onLongClickItem = onLongClickItem, + cardContent = { index, item, mod, onClick, onLongClick -> + GenreCard( + genre = item, + onClick = onClick, + onLongClick = onLongClick, + modifier = + mod + .height(Cards.heightEpisode) + .onFocusChanged { + if (it.isFocused) onCardFocus.invoke(index) + }, + ) + }, + modifier = modifier, + horizontalPadding = 16.dp, + showViewMore = false, + viewMoreCardContent = {}, + ) + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverGridHeader.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverGridHeader.kt new file mode 100644 index 00000000..2064faa1 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverGridHeader.kt @@ -0,0 +1,104 @@ +package com.github.damontecres.wholphin.ui.discover + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusRestorer +import androidx.compose.ui.unit.dp +import com.github.damontecres.wholphin.data.filter.DiscoverFilter +import com.github.damontecres.wholphin.data.filter.DiscoverFilterBy +import com.github.damontecres.wholphin.data.filter.DiscoverSort +import com.github.damontecres.wholphin.data.filter.DiscoverSortAndDirection +import com.github.damontecres.wholphin.data.filter.FilterValueOption +import com.github.damontecres.wholphin.ui.components.DiscoverFilterByButton +import com.github.damontecres.wholphin.ui.components.DiscoverSortByButton +import com.github.damontecres.wholphin.ui.components.GridTitle + +@Composable +fun DiscoverGridHeader( + showHeader: Boolean, + showTitle: Boolean, + title: String, + sortAndDirection: DiscoverSortAndDirection, + onSortChange: (DiscoverSortAndDirection) -> Unit, + sortOptions: List<DiscoverSort>, + getPossibleFilterValues: suspend (DiscoverFilterBy<*>) -> List<FilterValueOption>, + onClickShowViewOptions: () -> Unit, + modifier: Modifier = Modifier, + currentFilter: DiscoverFilter = DiscoverFilter(), + filterOptions: List<DiscoverFilterBy<*>> = listOf(), + onFilterChange: (DiscoverFilter) -> Unit = {}, + onShowFilterDropdown: ((Boolean) -> Unit)? = null, + filterButtonFocusRequester: FocusRequester = remember { FocusRequester() }, +) { + AnimatedVisibility( + showHeader, + enter = expandVertically(), + exit = shrinkVertically(), + modifier = modifier, + ) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth(), + ) { + if (showTitle) { + GridTitle(title) + } + val endPadding = + 16.dp // + if (sortAndDirection.sort == DiscoverSort.ALPHABETICAL) 24.dp else 0.dp + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .padding(start = 16.dp, end = endPadding) + .focusRestorer() + .fillMaxWidth(), + ) { + if (sortOptions.isNotEmpty() || filterOptions.isNotEmpty()) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.focusRestorer(), + ) { + if (sortOptions.isNotEmpty()) { + DiscoverSortByButton( + sortOptions = sortOptions, + current = sortAndDirection, + onSortChange = onSortChange, + modifier = Modifier, + ) + } + if (filterOptions.isNotEmpty()) { + DiscoverFilterByButton( + filterOptions = filterOptions, + current = currentFilter, + onFilterChange = onFilterChange, + getPossibleValues = getPossibleFilterValues, + modifier = Modifier.focusRequester(filterButtonFocusRequester), + onShow = onShowFilterDropdown, + ) + } +// ExpandableFaButton( +// title = R.string.view_options, +// iconStringRes = R.string.fa_sliders, +// onClick = onClickShowViewOptions, +// modifier = Modifier, +// ) + } + } + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverPage.kt index 5c18a0ea..6eff501e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverPage.kt @@ -1,99 +1,121 @@ package com.github.damontecres.wholphin.ui.discover -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.expandVertically -import androidx.compose.animation.shrinkVertically -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.compose.ui.focus.focusRequester import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.preferences.UserPreferences -import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect import com.github.damontecres.wholphin.ui.components.ErrorMessage -import com.github.damontecres.wholphin.ui.components.TabRow -import com.github.damontecres.wholphin.ui.logTab +import com.github.damontecres.wholphin.ui.components.TabDetails +import com.github.damontecres.wholphin.ui.components.TabbedPage +import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.NavDrawerItem -import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel +import com.github.damontecres.wholphin.util.DiscoverRequestType @Composable fun DiscoverPage( preferences: UserPreferences, modifier: Modifier = Modifier, - preferencesViewModel: PreferencesViewModel = hiltViewModel(), ) { - val rememberedTabIndex = - remember { preferencesViewModel.getRememberedTab(preferences, NavDrawerItem.Discover.id, 0) } - val tabs = - listOf( - stringResource(R.string.discover), - stringResource(R.string.request), - ) - var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) } - val tabFocusRequesters = remember(tabs) { List(tabs.size) { FocusRequester() } } - - LaunchedEffect(selectedTabIndex) { - logTab("discover", selectedTabIndex) - preferencesViewModel.saveRememberedTab(preferences, NavDrawerItem.Discover.id, selectedTabIndex) - } - OneTimeLaunchedEffect { preferencesViewModel.backdropService.clearBackdrop() } - - var showHeader by rememberSaveable { mutableStateOf(true) } - - Column( - modifier = modifier, - ) { - AnimatedVisibility( - showHeader, - enter = expandVertically(), - exit = shrinkVertically(), - ) { - TabRow( - selectedTabIndex = selectedTabIndex, - modifier = - Modifier - .padding(start = 32.dp, top = 16.dp, bottom = 16.dp), - tabs = tabs, - onClick = { selectedTabIndex = it }, - focusRequesters = tabFocusRequesters, + remember { + listOf( + TabDetails(R.string.discover), + TabDetails(R.string.request), + TabDetails(R.string.search), + TabDetails(R.string.movies_title), + TabDetails(R.string.tv_shows_title), ) } - when (selectedTabIndex) { + var showHeader by rememberSaveable { mutableStateOf(true) } + + TabbedPage( + itemId = NavDrawerItem.Discover.id, + tabs = tabs, + modifier = modifier, + showTabs = showHeader, + ) { tabIndex, tabDetails -> + when (tabIndex) { // Discover 0 -> { SeerrDiscoverPage( preferences = preferences, + positionCallback = { showHeader = it.row < 1 }, modifier = Modifier - .fillMaxSize(), + .fillMaxSize() + .focusRequester(tabDetails.contentFocusRequester), ) } // Requests 1 -> { SeerrRequestsPage( - focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex), + focusRequesterOnEmpty = tabDetails.tabFocusRequester, + positionCallback = { columns, index -> showHeader = index < columns }, modifier = Modifier - .fillMaxSize(), + .fillMaxSize() + .focusRequester(tabDetails.contentFocusRequester), + ) + } + + // Search + 2 -> { + DiscoverSearchPage( + preferences = preferences, + positionCallback = { columns, index -> showHeader = index < columns }, + modifier = + Modifier + .fillMaxSize() + .focusRequester(tabDetails.contentFocusRequester), + ) + } + + // Movies + 3 -> { + DiscoverRequestGrid( + viewModelKey = "movies", + showTitle = false, + destination = + Destination.DiscoverMoreResult( + DiscoverRequestType.DISCOVER_MOVIES, + 0, + ), + positionCallback = { columns, index -> showHeader = index < columns }, + modifier = + Modifier + .fillMaxSize() + .focusRequester(tabDetails.contentFocusRequester), + ) + } + + // TV + 4 -> { + DiscoverRequestGrid( + viewModelKey = "tv", + showTitle = false, + destination = + Destination.DiscoverMoreResult( + DiscoverRequestType.DISCOVER_TV, + 0, + ), + positionCallback = { columns, index -> showHeader = index < columns }, + modifier = + Modifier + .fillMaxSize() + .focusRequester(tabDetails.contentFocusRequester), ) } else -> { - ErrorMessage("Invalid tab index $selectedTabIndex", null) + ErrorMessage("Invalid tab index $tabIndex", null) } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverRequestGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverRequestGrid.kt index aa463ad6..fe6dd475 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverRequestGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverRequestGrid.kt @@ -1,26 +1,40 @@ package com.github.damontecres.wholphin.ui.discover import android.content.Context +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.data.filter.DiscoverFilter +import com.github.damontecres.wholphin.data.filter.DiscoverFilterBy +import com.github.damontecres.wholphin.data.filter.DiscoverSort +import com.github.damontecres.wholphin.data.filter.DiscoverSortAndDirection +import com.github.damontecres.wholphin.data.filter.FilterValueOption +import com.github.damontecres.wholphin.data.filter.discoverMovieFilters +import com.github.damontecres.wholphin.data.filter.discoverTvFilters import com.github.damontecres.wholphin.data.model.DiscoverItem +import com.github.damontecres.wholphin.services.FilterOptionCache import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.SeerrApi import com.github.damontecres.wholphin.services.SeerrService import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard import com.github.damontecres.wholphin.ui.components.ErrorMessage -import com.github.damontecres.wholphin.ui.components.GridTitle import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.detail.CardGrid import com.github.damontecres.wholphin.ui.launchDefault @@ -39,6 +53,8 @@ import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update @@ -52,14 +68,17 @@ class DiscoverRequestViewModel private val seerrService: SeerrService, val navigationManager: NavigationManager, private val api: SeerrApi, + private val filterOptionCache: FilterOptionCache, @Assisted val type: DiscoverRequestType, @Assisted startIndex: Int, + @Assisted initialFilter: DiscoverFilter, ) : ViewModel() { @AssistedFactory interface Factory { fun create( type: DiscoverRequestType, startIndex: Int, + initialFilter: DiscoverFilter, ): DiscoverRequestViewModel } @@ -68,113 +87,219 @@ class DiscoverRequestViewModel init { viewModelScope.launchDefault { - try { - val pager = - when (type) { - DiscoverRequestType.DISCOVER_TV -> { - DiscoverRequestPager( - api, - DiscoverTvRequestHandler, - seerrService::createDiscoverItem, - viewModelScope, - ) - } - - DiscoverRequestType.DISCOVER_MOVIES -> { - DiscoverRequestPager( - api, - DiscoverMovieRequestHandler, - seerrService::createDiscoverItem, - viewModelScope, - ) - } - - DiscoverRequestType.TRENDING -> { - DiscoverRequestPager( - api, - TrendingRequestHandler, - seerrService::createDiscoverItem, - viewModelScope, - ) - } - - DiscoverRequestType.UPCOMING_TV -> { - DiscoverRequestPager( - api, - UpcomingTvRequestHandler, - seerrService::createDiscoverItem, - viewModelScope, - ) - } - - DiscoverRequestType.UPCOMING_MOVIES -> { - DiscoverRequestPager( - api, - UpcomingMovieRequestHandler, - seerrService::createDiscoverItem, - viewModelScope, - ) - } - - DiscoverRequestType.UNKNOWN -> { - throw IllegalArgumentException("Cannot display grid for DiscoverRequestType.UNKNOWN") - } - }.init(startIndex) - _state.update { - it.copy( - loading = DataLoadingState.Success(pager), - ) + val (sortOptions, potentialFilterOptions) = + when (type) { + DiscoverRequestType.DISCOVER_TV -> DiscoverSort.entries.toList() to discoverTvFilters + DiscoverRequestType.DISCOVER_MOVIES -> DiscoverSort.entries.toList() to discoverMovieFilters + DiscoverRequestType.TRENDING -> emptyList<DiscoverSort>() to emptyList() + DiscoverRequestType.UPCOMING_TV -> emptyList<DiscoverSort>() to emptyList() + DiscoverRequestType.UPCOMING_MOVIES -> emptyList<DiscoverSort>() to emptyList() + DiscoverRequestType.UNKNOWN -> emptyList<DiscoverSort>() to emptyList() } - } catch (ex: Exception) { - Timber.e(ex, "Error initializing %s", type) - _state.update { - it.copy( - loading = DataLoadingState.Error(ex), - ) + val sortAndDirection = + when (type) { + DiscoverRequestType.DISCOVER_TV -> DiscoverSortAndDirection() + DiscoverRequestType.DISCOVER_MOVIES -> DiscoverSortAndDirection() + DiscoverRequestType.TRENDING -> DiscoverSortAndDirection() + DiscoverRequestType.UPCOMING_TV -> DiscoverSortAndDirection() + DiscoverRequestType.UPCOMING_MOVIES -> DiscoverSortAndDirection() + DiscoverRequestType.UNKNOWN -> DiscoverSortAndDirection() } + val filterOptions = + if (potentialFilterOptions.isNotEmpty()) { + // If the initial filter includes filtering (eg genres), then don't allow for changing it + potentialFilterOptions.filter { it.get(initialFilter) == null } + } else { + potentialFilterOptions + } + _state.update { + it.copy( + startIndex = startIndex, + sortOptions = sortOptions, + filterOptions = filterOptions, + sortAndDirection = sortAndDirection, + ) + } + fetchData(sortAndDirection, initialFilter, startIndex) + } + } + + private var fetchJob: Job? = null + + private suspend fun fetchData( + sortAndDirection: DiscoverSortAndDirection, + filter: DiscoverFilter, + startIndex: Int = 0, + ) { + fetchJob?.cancel() + try { + _state.update { + it.copy( + filter = filter, + sortAndDirection = sortAndDirection, + loading = DataLoadingState.Loading, + ) + } + val filter = filter.copy(sortBy = sortAndDirection) + fetchJob = + viewModelScope.launchDefault { + val pager = + when (type) { + DiscoverRequestType.DISCOVER_TV -> { + DiscoverRequestPager( + api, + DiscoverTvRequestHandler(filter), + seerrService::createDiscoverItem, + viewModelScope, + ) + } + + DiscoverRequestType.DISCOVER_MOVIES -> { + DiscoverRequestPager( + api, + DiscoverMovieRequestHandler(filter), + seerrService::createDiscoverItem, + viewModelScope, + ) + } + + DiscoverRequestType.TRENDING -> { + DiscoverRequestPager( + api, + TrendingRequestHandler, + seerrService::createDiscoverItem, + viewModelScope, + ) + } + + DiscoverRequestType.UPCOMING_TV -> { + DiscoverRequestPager( + api, + UpcomingTvRequestHandler, + seerrService::createDiscoverItem, + viewModelScope, + ) + } + + DiscoverRequestType.UPCOMING_MOVIES -> { + DiscoverRequestPager( + api, + UpcomingMovieRequestHandler, + seerrService::createDiscoverItem, + viewModelScope, + ) + } + + DiscoverRequestType.UNKNOWN -> { + throw IllegalArgumentException("Cannot display grid for DiscoverRequestType.UNKNOWN") + } + }.init(startIndex) + _state.update { + it.copy( + loading = DataLoadingState.Success(pager), + ) + } + } + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Error initializing %s", type) + _state.update { + it.copy( + loading = DataLoadingState.Error(ex), + ) } } } + + fun onSortChange(newSort: DiscoverSortAndDirection) { + viewModelScope.launchDefault { + _state.update { it.copy(startIndex = 0) } + fetchData(newSort, state.value.filter) + } + } + + fun onFilterChange(newFilter: DiscoverFilter) { + viewModelScope.launchDefault { + _state.update { it.copy(startIndex = 0) } + fetchData(state.value.sortAndDirection, newFilter) + } + } + + suspend fun getPossibleFilterValues(filter: DiscoverFilterBy<*>): List<FilterValueOption> = + filterOptionCache.getFilterOptionValues(null, filter) } data class DiscoverRequestState( val loading: DataLoadingState<List<DiscoverItem?>> = DataLoadingState.Pending, + val startIndex: Int = 0, + val sortOptions: List<DiscoverSort> = emptyList(), + val filterOptions: List<DiscoverFilterBy<*>> = emptyList(), + val filter: DiscoverFilter = DiscoverFilter(), + val sortAndDirection: DiscoverSortAndDirection = DiscoverSortAndDirection(), ) @Composable fun DiscoverRequestGrid( destination: Destination.DiscoverMoreResult, + showTitle: Boolean, + positionCallback: (columns: Int, position: Int) -> Unit, modifier: Modifier = Modifier, + viewModelKey: String? = null, viewModel: DiscoverRequestViewModel = hiltViewModel<DiscoverRequestViewModel, DiscoverRequestViewModel.Factory>( - creationCallback = { it.create(destination.type, destination.startIndex) }, + key = viewModelKey, + creationCallback = { + it.create( + destination.type, + destination.startIndex, + destination.initialFilter, + ) + }, ), ) { val state by viewModel.state.collectAsState() - when (val s = state.loading) { - DataLoadingState.Pending, - DataLoadingState.Loading, - -> { - LoadingPage(modifier) - } - is DataLoadingState.Error -> { - ErrorMessage(s, modifier) - } + var showHeader by rememberSaveable { mutableStateOf(state.startIndex < 6) } + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier, + ) { + DiscoverGridHeader( + showHeader = showHeader, + showTitle = showTitle, + title = + destination.titleOverride?.getString() + ?: stringResource(destination.type.stringRes), + sortAndDirection = state.sortAndDirection, + onSortChange = viewModel::onSortChange, + sortOptions = state.sortOptions, + getPossibleFilterValues = viewModel::getPossibleFilterValues, + onClickShowViewOptions = {}, + currentFilter = state.filter, + filterOptions = state.filterOptions, + onFilterChange = viewModel::onFilterChange, + modifier = Modifier.fillMaxWidth(), + ) + when (val s = state.loading) { + DataLoadingState.Pending, + DataLoadingState.Loading, + -> { + LoadingPage(Modifier) + } - is DataLoadingState.Success<List<DiscoverItem?>> -> { - val gridFocusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { gridFocusRequester.tryRequestFocus() } - Column( - modifier = modifier, - ) { - GridTitle(stringResource(destination.type.stringRes)) + is DataLoadingState.Error -> { + ErrorMessage(s, Modifier) + } + is DataLoadingState.Success<List<DiscoverItem?>> -> { + val gridFocusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { gridFocusRequester.tryRequestFocus() } CardGrid( - initialPosition = destination.startIndex, + initialPosition = state.startIndex, pager = s.data, onClickItem = { index, item -> - viewModel.navigationManager.navigateTo(Destination.DiscoveredItem(item)) + viewModel.navigationManager.navigateTo(item.destination) }, onLongClickItem = { index, item -> }, onClickPlay = { _, _ -> }, @@ -191,6 +316,12 @@ fun DiscoverRequestGrid( width = Dp.Unspecified, ) }, + columns = 6, + positionCallback = { columns, index -> + showHeader = index < columns + positionCallback.invoke(columns, index) + }, + modifier = Modifier.fillMaxSize(), ) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverRow.kt index 806a816a..bbf1dc18 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverRow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverRow.kt @@ -27,8 +27,8 @@ import androidx.tv.material3.Text import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard -import com.github.damontecres.wholphin.ui.cards.DiscoverViewMoreCard import com.github.damontecres.wholphin.ui.cards.ItemRowTitle +import com.github.damontecres.wholphin.ui.cards.ViewMoreCard import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.rememberInt @@ -59,7 +59,7 @@ fun DiscoverRow( modifier = modifier, ) { Text( - text = row.title, + text = row.title.getString(), style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onBackground, ) @@ -73,7 +73,7 @@ fun DiscoverRow( is DataLoadingState.Success<List<DiscoverItem>> -> { DiscoverItemRow( - title = row.title, + title = row.title.getString(), items = state.data, onClickItem = onClickItem, onLongClickItem = onLongClickItem, @@ -169,7 +169,7 @@ fun DiscoverItemRow( } if (enableViewMore) { item { - DiscoverViewMoreCard( + ViewMoreCard( onClick = remember { { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverSearchPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverSearchPage.kt new file mode 100644 index 00000000..fccf32ac --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverSearchPage.kt @@ -0,0 +1,261 @@ +package com.github.damontecres.wholphin.ui.discover + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandHorizontally +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkHorizontally +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusRestorer +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.data.model.DiscoverItem +import com.github.damontecres.wholphin.data.model.SeerrItemType +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.SeerrService +import com.github.damontecres.wholphin.services.UserPreferencesService +import com.github.damontecres.wholphin.ui.components.SearchEditTextBox +import com.github.damontecres.wholphin.ui.components.VoiceInputManager +import com.github.damontecres.wholphin.ui.components.VoiceSearchButton +import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.rememberInt +import com.github.damontecres.wholphin.ui.search.SearchCombinedResults +import com.github.damontecres.wholphin.ui.search.SearchResult +import com.github.damontecres.wholphin.ui.tryRequestFocus +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import timber.log.Timber +import javax.inject.Inject + +@HiltViewModel +class DiscoverSearchViewModel + @Inject + constructor( + val navigationManager: NavigationManager, + private val seerrService: SeerrService, + val voiceInputManager: VoiceInputManager, + val userPreferencesService: UserPreferencesService, + ) : ViewModel() { + val seerrResults = MutableStateFlow<SearchResult>(SearchResult.NoQuery) + + private var searchJob: Job? = null + var currentQuery: String = "" + + fun search(query: String) { + if (query == currentQuery) { + return + } + currentQuery = query + viewModelScope.launchIO { + if (query.isBlank()) { + seerrResults.value = SearchResult.NoQuery + return@launchIO + } + Timber.v("Starting seerr search") + seerrResults.value = SearchResult.Searching + try { + val results = + seerrService + .search(query) + .map { seerrService.createDiscoverItem(it) } + .filter { it.type == SeerrItemType.MOVIE || it.type == SeerrItemType.TV } + Timber.v("Seerr search complete: %s results", results.size) + seerrResults.value = SearchResult.SuccessSeerr(results) + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Error during seerr search") + seerrResults.value = SearchResult.Error(ex) + } + } + } + } + +@Composable +fun DiscoverSearchPage( + preferences: UserPreferences, + positionCallback: (columns: Int, position: Int) -> Unit, + modifier: Modifier = Modifier, + viewModel: DiscoverSearchViewModel = hiltViewModel(), +) { + val focusManager = LocalFocusManager.current + val keyboardController = LocalSoftwareKeyboardController.current + val seerrResults by viewModel.seerrResults.collectAsState() + var immediateSearchQuery by rememberSaveable { mutableStateOf<String?>(null) } + var query by rememberSaveable { mutableStateOf(viewModel.currentQuery) } + var position by rememberInt(-1) + + // Start with current preferences, but collect updates when view options change + val prefs = + viewModel.userPreferencesService.flow + .collectAsState(preferences) + .value.appPreferences.interfacePreferences.searchPreferences + + val onClickDiscover = { _: Int, item: DiscoverItem -> + val dest = + if (item.jellyfinItemId != null && item.type.baseItemKind != null) { + Destination.MediaItem( + itemId = item.jellyfinItemId, + type = item.type.baseItemKind, + ) + } else { + Destination.DiscoveredItem(item) + } + viewModel.navigationManager.navigateTo(dest) + } + + fun triggerImmediateSearch(searchQuery: String) { + immediateSearchQuery = searchQuery + viewModel.search(searchQuery) + } + + LaunchedEffect(query) { + when { + immediateSearchQuery == query -> { + immediateSearchQuery = null + } + + else -> { + delay(750L) + viewModel.search(query) + } + } + } + val gridFocusRequester = remember { FocusRequester() } + val textFieldFocusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { + if (position >= 0) { + gridFocusRequester.tryRequestFocus("grid") + } else { + textFieldFocusRequester.tryRequestFocus("textField") + } + } + + Column( + modifier = modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(0.dp), + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxWidth(), + ) { + var isSearchActive by remember { mutableStateOf(false) } + var isTextFieldFocused by remember { mutableStateOf(false) } + + BackHandler(isTextFieldFocused) { + when { + isSearchActive -> { + isSearchActive = false + keyboardController?.hide() + } + + else -> { + focusManager.moveFocus(FocusDirection.Next) + } + } + } + + Row( + horizontalArrangement = Arrangement.spacedBy(0.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .padding(start = 16.dp, end = 16.dp) + .focusGroup() + .focusRestorer() + .focusRequester(textFieldFocusRequester), + ) { + AnimatedVisibility( + visible = prefs.showVoiceSearchButton, + enter = fadeIn() + expandHorizontally(expandFrom = Alignment.End), + exit = fadeOut() + shrinkHorizontally(shrinkTowards = Alignment.End), + ) { + VoiceSearchButton( + onSpeechResult = { spokenText -> + query = spokenText + triggerImmediateSearch(spokenText) + }, + voiceInputManager = viewModel.voiceInputManager, + modifier = Modifier.padding(end = 12.dp), + ) + } + + SearchEditTextBox( + value = query, + onValueChange = { + isSearchActive = true + query = it + }, + onSearchClick = { triggerImmediateSearch(query) }, + readOnly = !isSearchActive, + modifier = + Modifier + .onFocusChanged { state -> + isTextFieldFocused = state.isFocused + if (!state.isFocused) isSearchActive = false + }.onPreviewKeyEvent { event -> + val isActivationKey = + event.key in listOf(Key.DirectionCenter, Key.Enter) + if (event.type == KeyEventType.KeyUp && isActivationKey && !isSearchActive) { + isSearchActive = true + keyboardController?.show() + true + } else { + false + } + }, + ) + } + } + SearchCombinedResults( + result = seerrResults, + focusRequester = gridFocusRequester, + onClickItem = { _, _ -> }, + onLongClickItem = { _, _ -> }, + onPlayItem = { _, _ -> }, + onClickPosition = { position = it.column }, + onClickDiscover = onClickDiscover, + positionCallback = { columns, index -> + position = index + positionCallback.invoke(columns, index) + }, + modifier = Modifier.fillMaxSize(), + ) + } +} 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 6c5b299a..62075882 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt @@ -6,12 +6,11 @@ import androidx.compose.foundation.gestures.LocalBringIntoViewSpec import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect @@ -23,6 +22,7 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp @@ -30,14 +30,18 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.api.seerr.infrastructure.ClientException +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.launchIO import com.github.damontecres.wholphin.ui.listToDotString @@ -45,14 +49,21 @@ import com.github.damontecres.wholphin.ui.main.HomePageHeader import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.rememberPosition import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.ui.util.EmptyStringProvider +import com.github.damontecres.wholphin.ui.util.ResProviderStringProvider +import com.github.damontecres.wholphin.ui.util.ResStringProvider import com.github.damontecres.wholphin.ui.util.ScrollToTopBringIntoViewSpec +import com.github.damontecres.wholphin.ui.util.StringProvider import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.DiscoverRequestType -import com.google.common.cache.CacheBuilder +import com.github.damontecres.wholphin.util.successValue 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 +import org.jellyfin.sdk.model.api.ImageType import timber.log.Timber import javax.inject.Inject @@ -62,11 +73,12 @@ class SeerrDiscoverViewModel constructor( @param:ApplicationContext private val context: Context, private val seerrService: SeerrService, + private val ratingsService: SeerrRatingsService, val navigationManager: NavigationManager, private val backdropService: BackdropService, ) : ViewModel() { val state = MutableStateFlow<DiscoverState>(DiscoverState()) - val rating = MutableStateFlow<Map<Int, DiscoverRating>>(mapOf()) + val rating = MutableStateFlow<Map<Pair<SeerrItemType, Int>, SourceRatings>>(mapOf()) init { viewModelScope.launchIO { @@ -76,7 +88,7 @@ class SeerrDiscoverViewModel this.copy( movies = DiscoverRowData( - context.getString(R.string.movies), + ResStringProvider(R.string.movies_title), it, DiscoverRequestType.DISCOVER_MOVIES, ), @@ -86,7 +98,7 @@ class SeerrDiscoverViewModel this.copy( tv = DiscoverRowData( - context.getString(R.string.tv_shows), + ResStringProvider(R.string.tv_shows_title), it, DiscoverRequestType.DISCOVER_TV, ), @@ -96,7 +108,7 @@ class SeerrDiscoverViewModel this.copy( trending = DiscoverRowData( - context.getString(R.string.trending), + ResStringProvider(R.string.trending), it, DiscoverRequestType.TRENDING, ), @@ -106,7 +118,7 @@ class SeerrDiscoverViewModel this.copy( upcomingMovies = DiscoverRowData( - context.getString(R.string.upcoming_movies), + ResStringProvider(R.string.upcoming_movies), it, DiscoverRequestType.UPCOMING_MOVIES, ), @@ -116,12 +128,89 @@ class SeerrDiscoverViewModel this.copy( upcomingTv = DiscoverRowData( - context.getString(R.string.upcoming_tv), + ResStringProvider(R.string.upcoming_tv), it, DiscoverRequestType.UPCOMING_TV, ), ) } + viewModelScope.launchIO { + val movieGenres = + try { + val result = seerrService.api.searchApi.discoverGenresliderMovieGet() + val genres = + result + .filter { it.id != null && it.name != null } + .map { + val id = it.id!!.toUUID() + val imageUrl = + it.backdrops?.randomOrNull()?.let { path -> + seerrService.createImageUrl( + imageType = ImageType.BACKDROP, + path = path, + mediaInfo = null, + // Don't need high resolution + backdropWidth = 1280, + backdropHeight = 720, + ) + } + Genre( + id = id, + name = it.name!!, + imageUrl = imageUrl, + ) + } + Timber.v("Got %s movie genres", genres.size) + DataLoadingState.Success(genres) + } catch (ex: Exception) { + Timber.e(ex, "Error getting movie genres") + DataLoadingState.Error(ex) + } + state.update { + it.copy( + movieGenres = movieGenres, + ) + } + } + + viewModelScope.launchIO { + val tvGenres = + try { + val result = seerrService.api.searchApi.discoverGenresliderTvGet() + val genres = + result + .filter { it.id != null && it.name != null } + .map { + val id = it.id!!.toUUID() + val imageUrl = + it.backdrops?.randomOrNull()?.let { path -> + seerrService.createImageUrl( + imageType = ImageType.BACKDROP, + path = path, + mediaInfo = null, + // Don't need high resolution + backdropWidth = 1280, + backdropHeight = 720, + ) + } + Genre( + id = id, + name = it.name!!, + imageUrl = imageUrl, + ) + } + Timber.v("Got %s tv genres", genres.size) + DataLoadingState.Success(genres) + } catch (ex: Exception) { + Timber.e(ex, "Error getting tv genres") + DataLoadingState.Error(ex) + } + state.update { + it.copy( + tvGenres = tvGenres, + ) + } + } } private fun fetchAndUpdateState( @@ -145,80 +234,55 @@ class SeerrDiscoverViewModel } } - fun updateBackdrop(item: DiscoverItem?) { + fun updateBackdrop(item: DiscoverFocusedItem?) { viewModelScope.launchIO { if (item != null) { backdropService.submit("discover_${item.id}", item.backDropUrl) - fetchRating(item) + if (item is DiscoverFocusedItem.Item) { + fetchRating(item.id, item.item.type) + } } else { backdropService.clearBackdrop() } } } - private val ratingCache = - CacheBuilder - .newBuilder() - .maximumSize(100) - .build<Int, DiscoverRating>() + private var ratingJob: Job? = null - // TODO this is not very efficient - fun fetchRating(item: DiscoverItem) { - viewModelScope.launchIO { - val cachedResult = ratingCache.getIfPresent(item.id) - if (cachedResult != null) { - return@launchIO + /** + * Fetch the ratings for the focused item, cancelling any previous request that is still running + */ + 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) } } - val result = - try { - when (item.type) { - SeerrItemType.MOVIE -> { - DiscoverRating( - seerrService.api.moviesApi.movieMovieIdRatingsGet( - movieId = item.id, - ), - ) - } - - SeerrItemType.TV -> { - DiscoverRating(seerrService.api.tvApi.tvTvIdRatingsGet(tvId = item.id)) - } - - SeerrItemType.PERSON -> { - DiscoverRating(null, null) - } - - SeerrItemType.UNKNOWN -> { - DiscoverRating(null, null) - } - } - } catch (ex: ClientException) { - if (ex.statusCode == 404) { - Timber.w("No rating found for %s", item.id) - DiscoverRating(null, null) - } else { - Timber.e(ex, "Error getting rating for %s", item.id) - return@launchIO - } - } catch (ex: Exception) { - Timber.e(ex, "Error getting rating for %s", item.id) - return@launchIO - } - ratingCache.put(item.id, result) - rating.update { - ratingCache.asMap().toMap() - } - } } } +fun Int.toUUID() = UUID(0L, toLong()) + +fun UUID.toInt() = leastSignificantBits.toInt() + data class DiscoverRowData( - val title: String, + val title: StringProvider, val items: DataLoadingState<List<DiscoverItem>>, val type: DiscoverRequestType, ) { companion object { - val EMPTY = DiscoverRowData("", DataLoadingState.Pending, DiscoverRequestType.UNKNOWN) + val EMPTY = + DiscoverRowData( + EmptyStringProvider, + DataLoadingState.Pending, + DiscoverRequestType.UNKNOWN, + ) } } @@ -228,26 +292,85 @@ data class DiscoverState( val trending: DiscoverRowData = DiscoverRowData.EMPTY, val upcomingMovies: DiscoverRowData = DiscoverRowData.EMPTY, val upcomingTv: DiscoverRowData = DiscoverRowData.EMPTY, + val movieGenres: DataLoadingState<List<Genre>> = DataLoadingState.Pending, + val tvGenres: DataLoadingState<List<Genre>> = DataLoadingState.Pending, ) +private const val ROW_TRENDING = 0 +private const val ROW_MOVIES = ROW_TRENDING + 1 +private const val ROW_GENRES_MOVIE = ROW_MOVIES + 1 +private const val ROW_UPCOMING_MOVIES = ROW_GENRES_MOVIE + 1 +private const val ROW_TV = ROW_UPCOMING_MOVIES + 1 +private const val ROW_GENRES_TV = ROW_TV + 1 +private const val ROW_UPCOMING_TV = ROW_GENRES_TV + 1 + +private const val LAST_ROW = ROW_UPCOMING_TV + @OptIn(ExperimentalFoundationApi::class) @Composable fun SeerrDiscoverPage( preferences: UserPreferences, + positionCallback: (RowColumn) -> Unit, modifier: Modifier = Modifier, viewModel: SeerrDiscoverViewModel = hiltViewModel(), ) { val state by viewModel.state.collectAsState() - val rows = - listOf(state.trending, state.movies, state.tv, state.upcomingMovies, state.upcomingTv) val ratingMap by viewModel.rating.collectAsState() - val focusRequesters = remember(rows) { List(rows.size) { FocusRequester() } } + val focusRequesters = remember { List(LAST_ROW + 1) { FocusRequester() } } var position by rememberPosition(0, -1) - val focusedItem = + val focusedItem: DiscoverFocusedItem? = remember(position) { position.let { - (rows.getOrNull(it.row)?.items as? DataLoadingState.Success)?.data?.getOrNull(it.column) + val discoverRow = + when (position.row) { + ROW_TRENDING -> state.trending + ROW_MOVIES -> state.movies + ROW_UPCOMING_MOVIES -> state.upcomingMovies + ROW_TV -> state.tv + ROW_UPCOMING_TV -> state.upcomingTv + else -> null + } + if (discoverRow != null) { + (discoverRow.items as? DataLoadingState.Success) + ?.data + ?.getOrNull(it.column) + ?.let { + DiscoverFocusedItem.Item(it) + } + } else { + when (position.row) { + ROW_GENRES_MOVIE -> { + state.movieGenres.successValue + ?.getOrNull(position.column) + ?.let { + DiscoverFocusedItem.Genre( + id = it.id.toInt(), + name = it.name, + type = SeerrItemType.MOVIE, + backDropUrl = it.imageUrl, + ) + } + } + + ROW_GENRES_TV -> { + state.movieGenres.successValue + ?.getOrNull(position.column) + ?.let { + DiscoverFocusedItem.Genre( + id = it.id.toInt(), + name = it.name, + type = SeerrItemType.TV, + backDropUrl = it.imageUrl, + ) + } + } + + else -> { + null + } + } + } } } LaunchedEffect(focusedItem) { @@ -266,36 +389,41 @@ fun SeerrDiscoverPage( modifier = modifier, ) { val details = - remember(focusedItem, ratingMap) { - buildList { - focusedItem - ?.releaseDate - ?.year - ?.toString() - ?.let(::add) - }.let { - val rating = focusedItem?.id?.let { ratingMap[it] } - listToDotString( - it, - rating?.audienceRating, - rating?.criticRating?.toFloat(), - ) + remember(focusedItem) { + (focusedItem as? DiscoverFocusedItem.Item)?.let { focusedItem -> + buildList { + focusedItem.item + .releaseDate + ?.year + ?.toString() + ?.let(::add) + }.let { + QuickDetailsData(listToDotString(it)) + } } } + val focusedRatings = (focusedItem as? DiscoverFocusedItem.Item)?.let { ratingMap[it.item.type to it.id] } HomePageHeader( - title = focusedItem?.title, + title = focusedItem?.title?.getString(), subtitle = focusedItem?.subtitle, overview = focusedItem?.overview, - overviewTwoLines = true, + overviewTwoLines = false, quickDetails = details, + ratings = focusedRatings, timeRemaining = null, + endsAt = null, showLogo = preferences.appPreferences.interfacePreferences.showLogos, - logoImageUrl = null, // TODO + logoImageUrl = focusedItem?.logoUrl, modifier = Modifier - .padding(top = 24.dp, bottom = 16.dp, start = 32.dp) - .fillMaxHeight(.25f), + .padding( + top = HeaderUtils.topPadding, + // Intentionally no bottom padding because of tabs + bottom = 0.dp, + start = HeaderUtils.startPadding, + ).height(HeaderUtils.height), ) + val density = LocalDensity.current val spaceAbovePx = with(density) { @@ -303,6 +431,43 @@ fun SeerrDiscoverPage( 50.dp.toPx() } val defaultBringIntoViewSpec = LocalBringIntoViewSpec.current + + @Composable + fun discoverItemRow( + rowIndex: Int, + row: DiscoverRowData, + ) { + CompositionLocalProvider(LocalBringIntoViewSpec provides defaultBringIntoViewSpec) { + DiscoverRow( + row = row, + onClickItem = { index, item -> + position = RowColumn(rowIndex, index) + viewModel.navigationManager.navigateTo(item.destination) + }, + onLongClickItem = { index, item -> }, + onCardFocus = { index -> + position = RowColumn(rowIndex, index) + positionCallback.invoke(position) + }, + focusRequester = focusRequesters[rowIndex], + enableViewMore = row.type != DiscoverRequestType.UNKNOWN, + onClickViewMore = { + (row.items as? DataLoadingState.Success<List<DiscoverItem>>)?.data?.size?.let { + position = RowColumn(rowIndex, it) + positionCallback.invoke(position) + } + viewModel.navigationManager.navigateTo( + Destination.DiscoverMoreResult( + type = row.type, + ), + ) + }, + modifier = + Modifier + .fillMaxWidth(), + ) + } + } CompositionLocalProvider( LocalBringIntoViewSpec provides ScrollToTopBringIntoViewSpec(spaceAbovePx), ) { @@ -314,32 +479,89 @@ fun SeerrDiscoverPage( .focusRestorer() .fillMaxSize(), ) { - itemsIndexed(rows) { rowIndex, row -> + item { discoverItemRow(ROW_TRENDING, state.trending) } + + // Movies + item { discoverItemRow(ROW_MOVIES, state.movies) } + item { CompositionLocalProvider(LocalBringIntoViewSpec provides defaultBringIntoViewSpec) { - DiscoverRow( - row = row, - onClickItem = { index, item -> - position = RowColumn(rowIndex, index) - viewModel.navigationManager.navigateTo(item.destination) - }, - onLongClickItem = { index, item -> }, - onCardFocus = { index -> position = RowColumn(rowIndex, index) }, - focusRequester = focusRequesters[rowIndex], - enableViewMore = row.type != DiscoverRequestType.UNKNOWN, - onClickViewMore = { - (row.items as? DataLoadingState.Success<List<DiscoverItem>>)?.data?.size?.let { - position = RowColumn(rowIndex, it) - } + DiscoverGenreRow( + title = + remember { + ResProviderStringProvider( + R.string.genres_in, + ResStringProvider(R.string.movies_title), + ) + }, + items = state.movieGenres, + onClickItem = { index, genre -> + position = RowColumn(ROW_GENRES_MOVIE, index) viewModel.navigationManager.navigateTo( - Destination.DiscoverMoreResult(row.type), + Destination.DiscoverMoreResult( + type = DiscoverRequestType.DISCOVER_MOVIES, + initialFilter = + DiscoverFilter( + genreIds = listOf(genre.id.toInt()), + ), + titleOverride = + discoverGenreTitle( + genre.name, + SeerrItemType.MOVIE, + ), + startIndex = 0, + ), ) }, - modifier = - Modifier - .fillMaxWidth(), + onCardFocus = { index -> + position = RowColumn(ROW_GENRES_MOVIE, index) + positionCallback.invoke(position) + }, + modifier = Modifier.focusRequester(focusRequesters[ROW_GENRES_MOVIE]), ) } } + item { discoverItemRow(ROW_UPCOMING_MOVIES, state.upcomingMovies) } + + // TV + item { discoverItemRow(ROW_TV, state.tv) } + item { + CompositionLocalProvider(LocalBringIntoViewSpec provides defaultBringIntoViewSpec) { + DiscoverGenreRow( + title = + remember { + ResProviderStringProvider( + R.string.genres_in, + ResStringProvider(R.string.tv_shows_title), + ) + }, + items = state.tvGenres, + onClickItem = { index, genre -> + position = RowColumn(ROW_GENRES_TV, index) + viewModel.navigationManager.navigateTo( + Destination.DiscoverMoreResult( + type = DiscoverRequestType.DISCOVER_TV, + initialFilter = + DiscoverFilter( + genreIds = listOf(genre.id.toInt()), + ), + titleOverride = + discoverGenreTitle( + genre.name, + SeerrItemType.TV, + ), + startIndex = 0, + ), + ) + }, + onCardFocus = { index -> + position = RowColumn(ROW_GENRES_TV, index) + positionCallback.invoke(position) + }, + modifier = Modifier.focusRequester(focusRequesters[ROW_GENRES_TV]), + ) + } + } + item { discoverItemRow(ROW_UPCOMING_TV, state.upcomingTv) } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrRequestsPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrRequestsPage.kt index d83f840b..52815683 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrRequestsPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrRequestsPage.kt @@ -35,8 +35,8 @@ import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.DataLoadingState +import com.github.damontecres.wholphin.util.WholphinDispatchers import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.flow.MutableStateFlow @@ -77,7 +77,7 @@ class SeerrRequestsViewModel val requests = mediaRequests.mapNotNull { request -> if (request.media?.tmdbId != null) { - viewModelScope.async(Dispatchers.IO) { + viewModelScope.async(WholphinDispatchers.IO) { semaphore.withPermit { val type = SeerrItemType.fromString(request.type) when (type) { @@ -148,10 +148,12 @@ data class RequestGridItem( @Composable fun SeerrRequestsPage( focusRequesterOnEmpty: FocusRequester?, + positionCallback: (columns: Int, position: Int) -> Unit, modifier: Modifier = Modifier, viewModel: SeerrRequestsViewModel = hiltViewModel(), ) { val state by viewModel.state.collectAsState(SeerrRequestsState.EMPTY) + val columns = 6 when (val state = state.requests) { is DataLoadingState.Error -> { @@ -213,7 +215,8 @@ fun SeerrRequestsPage( modifier = mod, ) }, - columns = 6, + columns = columns, + positionCallback = positionCallback, modifier = Modifier.fillMaxSize(), ) } 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 a3b32382..26cc96d5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt @@ -23,7 +23,6 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState @@ -40,16 +39,20 @@ import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.LifecycleStartEffect 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.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 @@ -57,6 +60,7 @@ import com.github.damontecres.wholphin.ui.cards.BannerCardWithTitle import com.github.damontecres.wholphin.ui.cards.GenreCard import com.github.damontecres.wholphin.ui.cards.ItemRow import com.github.damontecres.wholphin.ui.cards.StudioCard +import com.github.damontecres.wholphin.ui.cards.ViewMoreCard import com.github.damontecres.wholphin.ui.components.CircularProgress import com.github.damontecres.wholphin.ui.components.ContextMenu import com.github.damontecres.wholphin.ui.components.ContextMenuActions @@ -69,12 +73,12 @@ 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 import com.github.damontecres.wholphin.ui.data.RowColumn import com.github.damontecres.wholphin.ui.detail.PlaylistDialog -import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState import com.github.damontecres.wholphin.ui.indexOfFirstOrNull import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.nav.Destination @@ -87,8 +91,8 @@ import com.github.damontecres.wholphin.ui.util.ScrollToTopBringIntoViewSpec import com.github.damontecres.wholphin.util.HomeRowLoadingState import com.github.damontecres.wholphin.util.LoadingState import kotlinx.coroutines.delay +import org.jellyfin.sdk.model.DateTime import org.jellyfin.sdk.model.api.BaseItemKind -import org.jellyfin.sdk.model.api.MediaType import timber.log.Timber import java.util.UUID import kotlin.time.Duration @@ -101,8 +105,9 @@ fun HomePage( playlistViewModel: AddPlaylistViewModel = hiltViewModel(), ) { val context = LocalContext.current - LaunchedEffect(Unit) { + LifecycleStartEffect(Unit) { viewModel.init() + onStopOrDispose { } } val state by viewModel.state.collectAsState() val loading = state.loadingState @@ -125,15 +130,22 @@ fun HomePage( var showPlaylistDialog by remember { mutableStateOf<UUID?>(null) } var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) } - val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) + val playlistState by playlistViewModel.playlistState.collectAsState() var position by rememberPosition() val onFocusPosition = remember { { it: RowColumn -> position = it } } + val currentHomePrefs by rememberUpdatedState(preferences.appPreferences.homePagePreferences) val onClickItem = remember { { clickedPosition: RowColumn, item: BaseItem -> position = clickedPosition - viewModel.navigationManager.navigateTo(item.destination()) + if (currentHomePrefs.clickToPlay && + homeRows.getOrNull(clickedPosition.row)?.isContinueWatchingNextUp == true + ) { + viewModel.navigationManager.navigateTo(Destination.Playback(item)) + } else { + viewModel.navigationManager.navigateTo(item.destination()) + } } } val onLongClickItem = @@ -166,10 +178,10 @@ fun HomePage( onClickWatch = viewModel::setWatched, onClickFavorite = viewModel::setFavorite, onClickAddPlaylist = { itemId -> - playlistViewModel.loadPlaylists(MediaType.VIDEO) + playlistViewModel.loadPlaylists() showPlaylistDialog = itemId }, - onSendMediaInfo = viewModel.mediaReportService::sendReportFor, + onSendMediaInfo = viewModel.serverReportService::sendMediaReportFor, onDeleteItem = { viewModel.deleteItem(position, it) }, @@ -194,6 +206,14 @@ fun HomePage( viewModel.navigationManager.navigateTo(Destination.Playback(item)) } } + val onClickViewMore = + remember { + { _: RowColumn, row: HomeRowLoadingState.Success -> + viewModel.navigationManager.navigateTo( + Destination.MoreHomeRow(row.title, row.rowType!!, row.items.size), + ) + } + } HomePageContent( homeRows = homeRows, @@ -206,6 +226,8 @@ fun HomePage( showClock = preferences.appPreferences.interfacePreferences.showClock, onUpdateBackdrop = viewModel::updateBackdrop, showLogo = preferences.appPreferences.interfacePreferences.showLogos, + showViewMore = true, + onClickViewMore = onClickViewMore, modifier = modifier, ) overviewDialog?.let { info -> @@ -237,6 +259,7 @@ fun HomePage( playlistViewModel.createPlaylistAndAddItem(it, itemId) showPlaylistDialog = null }, + onSearch = playlistViewModel::loadPlaylists, elevation = 3.dp, ) } @@ -244,6 +267,14 @@ fun HomePage( } } +val HomeRowLoadingState?.isContinueWatchingNextUp: Boolean + get() = + (this as? HomeRowLoadingState.Success).let { row -> + row?.rowType is HomeRowConfig.ContinueWatching || + row?.rowType is HomeRowConfig.NextUp || + row?.rowType is HomeRowConfig.ContinueWatchingCombined + } + @OptIn(ExperimentalFoundationApi::class) @Composable fun HomePageContent( @@ -256,6 +287,7 @@ fun HomePageContent( showClock: Boolean, onUpdateBackdrop: (BaseItem) -> Unit, showLogo: Boolean, + showViewMore: Boolean, modifier: Modifier = Modifier, loadingState: LoadingState? = null, listState: LazyListState = rememberLazyListState(), @@ -268,6 +300,7 @@ fun HomePageContent( modifier = HeaderUtils.modifier, ) }, + onClickViewMore: (RowColumn, HomeRowLoadingState.Success) -> Unit = { _, _ -> }, ) { val focusedItem = remember(homeRows, position) { @@ -333,7 +366,7 @@ fun HomePageContent( ) { LazyColumn( state = listState, - verticalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(0.dp), contentPadding = PaddingValues( bottom = Cards.height2x3, @@ -343,6 +376,10 @@ fun HomePageContent( .focusRestorer(), ) { itemsIndexed(homeRows) { rowIndex, row -> + val rowModifier = + Modifier + .animateItem(placementSpec = null) + .padding(bottom = 8.dp) CompositionLocalProvider( LocalBringIntoViewSpec provides defaultBringIntoViewSpec, ) { @@ -351,18 +388,18 @@ fun HomePageContent( is HomeRowLoadingState.Pending, -> { FocusableItemRow( - title = r.title, + title = r.title.getString(), subtitle = stringResource(R.string.loading), - modifier = Modifier.animateItem(), + modifier = rowModifier, ) } is HomeRowLoadingState.Error -> { FocusableItemRow( - title = r.title, + title = r.title.getString(), subtitle = r.localizedMessage, isError = true, - modifier = Modifier.animateItem(), + modifier = rowModifier, ) } @@ -370,7 +407,7 @@ fun HomePageContent( if (row.items.isNotEmpty()) { val viewOptions = row.viewOptions ItemRow( - title = row.title, + title = row.title.getString(), items = row.items, onClickItem = remember(rowIndex, onClickItem) { @@ -394,11 +431,10 @@ fun HomePageContent( } }, modifier = - Modifier + rowModifier .fillMaxWidth() .focusGroup() - .focusRequester(rowFocusRequesters[rowIndex]) - .animateItem(), + .focusRequester(rowFocusRequesters[rowIndex]), horizontalPadding = viewOptions.spacing.dp, cardContent = { index, item, cardModifier, onClick, onLongClick -> val onFocus = @@ -441,12 +477,40 @@ fun HomePageContent( .onKeyEvent { onKey(it) }, ) }, + showViewMore = showViewMore && row.showViewMore, + viewMoreCardContent = { mod -> + HomePageViewMoreCard( + isEpisode = row.items.last()?.type == BaseItemKind.EPISODE, + onClick = { + onClickViewMore.invoke( + RowColumn( + rowIndex, + r.items.size, + ), + r, + ) + }, + onLongClick = {}, + viewOptions = viewOptions, + modifier = + mod.onFocusChanged { + if (it.isFocused) { + currentOnFocusPosition.invoke( + RowColumn( + rowIndex, + r.items.size, + ), + ) + } + }, + ) + }, ) } else if (showEmptyRows) { FocusableItemRow( - title = r.title, + title = r.title.getString(), subtitle = stringResource(R.string.no_results), - modifier = Modifier.animateItem(), + modifier = rowModifier, ) } } @@ -483,14 +547,35 @@ fun HomePageHeader( modifier: Modifier = Modifier, ) { val isEpisode = item?.type == BaseItemKind.EPISODE - val dto = item?.data + val title = + remember(item?.id) { + when (item?.type) { + BaseItemKind.EPISODE, + BaseItemKind.SEASON, + -> item.data.seriesName + + else -> item?.title + } + } + val subtitle = + remember(item?.id) { + when (item?.type) { + BaseItemKind.SEASON, + BaseItemKind.EPISODE, + -> item.name + + else -> null + } + } HomePageHeader( - title = item?.title, - subtitle = if (isEpisode) dto?.name else null, - overview = dto?.overview, + title = title, + subtitle = subtitle, + overview = item?.data?.overview, overviewTwoLines = isEpisode, - quickDetails = item?.ui?.quickDetails ?: AnnotatedString(""), + quickDetails = item?.ui?.quickDetails, + ratings = rememberSourceRatings(item), timeRemaining = item?.timeRemainingOrRuntime, + endsAt = item?.data?.endDate, showLogo = showLogo, logoImageUrl = rememberLogoUrl(item), modifier = modifier, @@ -503,8 +588,10 @@ fun HomePageHeader( subtitle: String?, overview: String?, overviewTwoLines: Boolean, - quickDetails: AnnotatedString?, + quickDetails: QuickDetailsData?, + ratings: SourceRatings?, timeRemaining: Duration?, + endsAt: DateTime?, showLogo: Boolean, logoImageUrl: String?, modifier: Modifier = Modifier, @@ -528,7 +615,7 @@ fun HomePageHeader( if (subtitle != null) { EpisodeName(subtitle) } - QuickDetails(quickDetails ?: AnnotatedString(""), timeRemaining) + QuickDetails(quickDetails, timeRemaining, endsAt = endsAt, ratings = ratings) val overviewModifier = Modifier .padding(0.dp) @@ -651,3 +738,29 @@ fun HomePageCardContent( } } } + +@Composable +fun HomePageViewMoreCard( + isEpisode: Boolean, + onClick: () -> Unit, + onLongClick: () -> Unit, + viewOptions: HomeRowViewOptions, + modifier: Modifier, +) { + val aspectRatio = + remember(isEpisode, viewOptions) { + if (isEpisode) { + viewOptions.episodeAspectRatio + } else { + viewOptions.aspectRatio + } + } + ViewMoreCard( + onClick = onClick, + onLongClick = onLongClick, + modifier = modifier, + aspectRatio = aspectRatio, + size = DpSize(height = viewOptions.heightDp.dp, width = Dp.Unspecified), + showTitle = viewOptions.showTitles, + ) +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt index d3722331..d02ba907 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt @@ -6,30 +6,36 @@ import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.HomeRowConfig +import com.github.damontecres.wholphin.data.model.ServerUserConfig import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.DatePlayedService import com.github.damontecres.wholphin.services.FavoriteWatchManager +import com.github.damontecres.wholphin.services.HomeDataService import com.github.damontecres.wholphin.services.HomePageResolvedSettings import com.github.damontecres.wholphin.services.HomeSettingsService import com.github.damontecres.wholphin.services.LatestNextUpService import com.github.damontecres.wholphin.services.MediaManagementService -import com.github.damontecres.wholphin.services.MediaReportService import com.github.damontecres.wholphin.services.NavDrawerService import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.ServerReportService import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.services.deleteItem -import com.github.damontecres.wholphin.services.tvAccess +import com.github.damontecres.wholphin.ui.collectLatestIn +import com.github.damontecres.wholphin.ui.combinePair import com.github.damontecres.wholphin.ui.data.RowColumn import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.showToast +import com.github.damontecres.wholphin.ui.util.EmptyStringProvider import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.HomeRowLoadingState import com.github.damontecres.wholphin.util.LoadingState +import com.github.damontecres.wholphin.util.WholphinDispatchers import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.flow.MutableStateFlow @@ -41,6 +47,7 @@ import kotlinx.coroutines.selects.select import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withContext +import org.jellyfin.sdk.api.client.exception.InvalidStatusException import org.jellyfin.sdk.model.api.BaseItemKind import timber.log.Timber import java.util.UUID @@ -53,9 +60,10 @@ class HomeViewModel @param:ApplicationContext private val context: Context, val navigationManager: NavigationManager, val serverRepository: ServerRepository, - val mediaReportService: MediaReportService, + val serverReportService: ServerReportService, private val navDrawerService: NavDrawerService, private val homeSettingsService: HomeSettingsService, + private val homeDataService: HomeDataService, private val favoriteWatchManager: FavoriteWatchManager, private val datePlayedService: DatePlayedService, private val backdropService: BackdropService, @@ -66,129 +74,211 @@ class HomeViewModel private val _state = MutableStateFlow(HomeState.EMPTY) val state: StateFlow<HomeState> = _state + private var dataLoadingJob: Job? = null + init { datePlayedService.invalidateAll() -// init() + serverRepository.currentUserDtoFlow + .combinePair(homeSettingsService.currentSettings) + .collectLatestIn(viewModelScope) { (userDto, settings) -> + Timber.v( + "Got new userDto & settings, userId=%s, settings?=%s", + userDto?.id, + settings != HomePageResolvedSettings.EMPTY, + ) +// Timber.v("userDto=%s", userDto) + dataLoadingJob?.cancel() + if (userDto == null) { + Timber.d("UserDto is null") + _state.update { HomeState.EMPTY } + return@collectLatestIn + } + if (settings == HomePageResolvedSettings.EMPTY) { + Timber.d("Home settings are empty") + _state.update { HomeState.EMPTY } + return@collectLatestIn + } + if (userDto.id != settings.userId) { + Timber.d("User IDs don't match: %s vs %s", userDto?.id, settings.userId) + _state.update { HomeState.EMPTY } + return@collectLatestIn + } + if (state.value.settings.userId != settings.userId) { + Timber.d("User changed") + _state.update { HomeState.EMPTY } + } + dataLoadingJob = + viewModelScope.launchIO { + loadHomeRows(userDto, settings) + } + } } fun init() { viewModelScope.launchIO { Timber.d("init HomeViewModel") - try { - val preferences = userPreferencesService.getCurrent() - val prefs = preferences.appPreferences.homePagePreferences - - serverRepository.currentUserDto.value?.let { userDto -> - val libraries = - navDrawerService.getAllUserLibraries(userDto.id, userDto.tvAccess) + serverRepository.currentUserDto?.let { userDto -> + if (dataLoadingJob?.isActive == false) { val settings = homeSettingsService.currentSettings.first { it != HomePageResolvedSettings.EMPTY } - val state = state.value - - // Refreshing if a load has already occurred and the rows haven't significantly changed - val refresh = - state.loadingState == LoadingState.Success && state.settings == settings - Timber.v( - "refresh=%s, state.loadingState=%s, %s rows", - refresh, - state.loadingState, - settings.rows.size, - ) - _state.update { - it.copy( - loadingState = if (refresh) LoadingState.Success else LoadingState.Loading, - refreshState = LoadingState.Loading, - settings = settings, - homeRows = - if (refresh) { - it.homeRows - } else { - List(settings.rows.size) { HomeRowLoadingState.Pending("") } - }, + if (userDto.id == settings.userId) { + dataLoadingJob = + viewModelScope.launchIO { + loadHomeRows(userDto, settings) + } + } else { + Timber.d( + "Init user IDs don't match: %s vs %s", + userDto.id, + settings.userId, ) } - - val semaphore = Semaphore(4) - - val deferred = - settings.rows - .map { row -> - viewModelScope.async(Dispatchers.IO) { - semaphore.withPermit { - Timber.v("Fetching row: %s", row) - try { - homeSettingsService.fetchDataForRow( - row = row.config, - scope = viewModelScope, - prefs = prefs, - userDto = userDto, - libraries = libraries, - limit = prefs.maxItemsPerRow, - isRefresh = refresh, - ) - } catch (ex: Exception) { - Timber.e(ex, "Error on row %s", row) - HomeRowLoadingState.Error( - row.title, - exception = ex, - ) - } - } - } - } - - if (refresh) { - // Replace rows as they complete - val remaining = deferred.withIndex().toMutableList() - while (remaining.isNotEmpty()) { - val (rowIndex, rowData) = - select { - // "Return" the first remaining that is completed - remaining - .forEach { (rowIndex, deferred) -> - deferred.onAwait { rowIndex to it } - } - } - Timber.v("Got row data index=%s", rowIndex) - remaining.removeIf { it.index == rowIndex } - _state.update { state -> - val newRows = - state.homeRows.toMutableList().apply { - set(rowIndex, rowData) - } - state.copy( - homeRows = newRows, - ) - } - } - _state.update { - it.copy( - loadingState = LoadingState.Success, - refreshState = LoadingState.Success, - ) - } - } else { - val rows = deferred.awaitAll() - Timber.v("Got all rows") - _state.update { - it.copy( - loadingState = LoadingState.Success, - refreshState = LoadingState.Success, - homeRows = rows, - ) - } - } - Timber.d("Home page load complete") - } - } catch (ex: Exception) { - Timber.e(ex, "Exception during home page loading") - if (state.value.loadingState == LoadingState.Success) { - showToast(context, "Error refreshing home: ${ex.localizedMessage}") - _state.update { it.copy(refreshState = LoadingState.Error(ex)) } } else { - _state.update { - it.copy(loadingState = LoadingState.Error(ex)) + Timber.v("Data loading job is active") + } + } + } + } + + suspend fun loadHomeRows( + userDto: ServerUserConfig, + settings: HomePageResolvedSettings, + ) { + Timber.i("Starting loadHomeRows") + try { + val preferences = userPreferencesService.getCurrent() + val prefs = preferences.appPreferences.homePagePreferences + + val libraries = + navDrawerService.getAllUserLibraries(userDto.id, userDto.tvAccess) + + val state = state.value + + // Refreshing if a load has already occurred and the rows haven't significantly changed + val refresh = + state.loadingState == LoadingState.Success && state.settings == settings + Timber.v( + "refresh=%s, state.loadingState=%s, %s rows", + refresh, + state.loadingState, + settings.rows.size, + ) + _state.update { + it.copy( + loadingState = if (refresh) LoadingState.Success else LoadingState.Loading, + refreshState = LoadingState.Loading, + settings = settings, + homeRows = + if (refresh) { + it.homeRows + } else { + List(settings.rows.size) { + HomeRowLoadingState.Pending(EmptyStringProvider) + } + }, + ) + } + + val semaphore = Semaphore(4) + + val deferred = + settings.rows + .map { row -> + viewModelScope.async(WholphinDispatchers.IO) { + semaphore.withPermit { + Timber.v("Fetching row: %s", row) + try { + homeDataService.fetchDataForRow( + row = row.config, + scope = viewModelScope, + prefs = prefs, + userDto = userDto, + libraries = libraries, + limit = prefs.maxItemsPerRow, + isRefresh = refresh, + ) + } catch (ex: InvalidStatusException) { + if (ex.status == 404) { + Timber.w(ex, "404 on row %s", row) + HomeRowLoadingState.Success( + row.title, + emptyList(), + ) + } else { + Timber.e( + ex, + "Error %s on row %s", + ex.status, + row, + ) + HomeRowLoadingState.Error( + row.title, + exception = ex, + ) + } + } catch (ex: Exception) { + Timber.e(ex, "Error on row %s", row) + HomeRowLoadingState.Error( + row.title, + exception = ex, + ) + } + } + } } + + if (refresh) { + // Replace rows as they complete + val remaining = deferred.withIndex().toMutableList() + while (remaining.isNotEmpty()) { + val (rowIndex, rowData) = + select { + // "Return" the first remaining that is completed + remaining + .forEach { (rowIndex, deferred) -> + deferred.onAwait { rowIndex to it } + } + } + Timber.v("Got row data index=%s", rowIndex) + remaining.removeIf { it.index == rowIndex } + _state.update { state -> + val newRows = + state.homeRows.toMutableList().apply { + set(rowIndex, rowData) + } + state.copy( + homeRows = newRows, + ) + } + } + _state.update { + it.copy( + loadingState = LoadingState.Success, + refreshState = LoadingState.Success, + ) + } + } else { + val rows = deferred.awaitAll() + Timber.v("Got all rows") + _state.update { + it.copy( + loadingState = LoadingState.Success, + refreshState = LoadingState.Success, + homeRows = rows, + ) + } + } + Timber.d("Home page load complete") + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Exception during home page loading") + if (state.value.loadingState == LoadingState.Success) { + showToast(context, "Error refreshing home: ${ex.localizedMessage}") + _state.update { it.copy(refreshState = LoadingState.Error(ex)) } + } else { + _state.update { + it.copy(loadingState = LoadingState.Error(ex)) } } } @@ -197,9 +287,9 @@ class HomeViewModel fun setWatched( itemId: UUID, played: Boolean, - ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { + ) = viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { favoriteWatchManager.setWatched(itemId, played) - withContext(Dispatchers.Main) { + withContext(WholphinDispatchers.Main) { init() } } @@ -207,9 +297,9 @@ class HomeViewModel fun setFavorite( itemId: UUID, favorite: Boolean, - ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { + ) = viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { favoriteWatchManager.setFavorite(itemId, favorite) - withContext(Dispatchers.Main) { + withContext(WholphinDispatchers.Main) { init() } } @@ -253,7 +343,7 @@ class HomeViewModel fun removeFromNextUp(item: BaseItem) { if (item.type == BaseItemKind.EPISODE) { viewModelScope.launchDefault { - serverRepository.currentUser.value?.id?.let { userId -> + serverRepository.currentUser?.id?.let { userId -> latestNextUpService.removeFromNextUp(userId, item) init() } @@ -275,7 +365,7 @@ data class HomeState( HomeState( LoadingState.Pending, LoadingState.Pending, - listOf(), + emptyList(), HomePageResolvedSettings.EMPTY, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/SearchPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/SearchPage.kt deleted file mode 100644 index 2c4f729e..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/SearchPage.kt +++ /dev/null @@ -1,657 +0,0 @@ -package com.github.damontecres.wholphin.ui.main - -import androidx.activity.compose.BackHandler -import androidx.annotation.StringRes -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.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusDirection -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.focusRestorer -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.input.key.Key -import androidx.compose.ui.input.key.KeyEventType -import androidx.compose.ui.input.key.key -import androidx.compose.ui.input.key.onPreviewKeyEvent -import androidx.compose.ui.input.key.type -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.platform.LocalSoftwareKeyboardController -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel -import androidx.lifecycle.compose.LifecycleResumeEffect -import androidx.lifecycle.viewModelScope -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.DiscoverItem -import com.github.damontecres.wholphin.data.model.SeerrItemType -import com.github.damontecres.wholphin.preferences.UserPreferences -import com.github.damontecres.wholphin.services.NavigationManager -import com.github.damontecres.wholphin.services.SeerrService -import com.github.damontecres.wholphin.ui.AspectRatios -import com.github.damontecres.wholphin.ui.Cards -import com.github.damontecres.wholphin.ui.SlimItemFields -import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard -import com.github.damontecres.wholphin.ui.cards.EpisodeCard -import com.github.damontecres.wholphin.ui.cards.ItemRow -import com.github.damontecres.wholphin.ui.cards.SeasonCard -import com.github.damontecres.wholphin.ui.components.SearchEditTextBox -import com.github.damontecres.wholphin.ui.components.VoiceInputManager -import com.github.damontecres.wholphin.ui.components.VoiceSearchButton -import com.github.damontecres.wholphin.ui.data.RowColumn -import com.github.damontecres.wholphin.ui.isNotNullOrBlank -import com.github.damontecres.wholphin.ui.launchIO -import com.github.damontecres.wholphin.ui.nav.Destination -import com.github.damontecres.wholphin.ui.onMain -import com.github.damontecres.wholphin.ui.rememberPosition -import com.github.damontecres.wholphin.ui.setValueOnMain -import com.github.damontecres.wholphin.ui.tryRequestFocus -import com.github.damontecres.wholphin.util.ApiRequestPager -import com.github.damontecres.wholphin.util.ExceptionHandler -import com.github.damontecres.wholphin.util.GetItemsRequestHandler -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Semaphore -import kotlinx.coroutines.sync.withPermit -import kotlinx.coroutines.withContext -import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.model.api.BaseItemKind -import org.jellyfin.sdk.model.api.request.GetItemsRequest -import timber.log.Timber -import javax.inject.Inject - -@HiltViewModel -class SearchViewModel - @Inject - constructor( - val api: ApiClient, - val navigationManager: NavigationManager, - private val seerrService: SeerrService, - val voiceInputManager: VoiceInputManager, - ) : ViewModel() { - val voiceState = voiceInputManager.state - val soundLevel = voiceInputManager.soundLevel - val partialResult = voiceInputManager.partialResult - - val movies = MutableLiveData<SearchResult>(SearchResult.NoQuery) - val series = MutableLiveData<SearchResult>(SearchResult.NoQuery) - val episodes = MutableLiveData<SearchResult>(SearchResult.NoQuery) - val collections = MutableLiveData<SearchResult>(SearchResult.NoQuery) - val albums = MutableLiveData<SearchResult>(SearchResult.NoQuery) - val artists = MutableLiveData<SearchResult>(SearchResult.NoQuery) - val songs = MutableLiveData<SearchResult>(SearchResult.NoQuery) - val seerrResults = MutableLiveData<SearchResult>(SearchResult.NoQuery) - - private var currentQuery: String? = null - - private val semaphore = Semaphore(4) - - fun search(query: String?) { - if (currentQuery == query) { - return - } - currentQuery = query - if (query.isNotNullOrBlank()) { - movies.value = SearchResult.Searching - series.value = SearchResult.Searching - episodes.value = SearchResult.Searching - collections.value = SearchResult.Searching - searchInternal(query, BaseItemKind.MOVIE, movies) - searchInternal(query, BaseItemKind.SERIES, series) - searchInternal(query, BaseItemKind.EPISODE, episodes) - searchInternal(query, BaseItemKind.MUSIC_ALBUM, albums) - searchInternal(query, BaseItemKind.MUSIC_ARTIST, artists) - searchInternal(query, BaseItemKind.AUDIO, songs) - searchInternal(query, BaseItemKind.BOX_SET, collections) - searchSeerr(query) - } else { - movies.value = SearchResult.NoQuery - series.value = SearchResult.NoQuery - episodes.value = SearchResult.NoQuery - collections.value = SearchResult.NoQuery - seerrResults.value = SearchResult.NoQuery - } - } - - private fun searchInternal( - query: String, - type: BaseItemKind, - target: MutableLiveData<SearchResult>, - ) { - viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { - try { - semaphore.withPermit { - val request = - GetItemsRequest( - searchTerm = query, - recursive = true, - includeItemTypes = listOf(type), - fields = SlimItemFields, - limit = 25, - ) - val pager = - ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope) - pager.init() - withContext(Dispatchers.Main) { - target.value = SearchResult.Success(pager) - } - } - } catch (ex: Exception) { - Timber.e(ex, "Exception searching for $type") - withContext(Dispatchers.Main) { - target.value = SearchResult.Error(ex) - } - } - } - } - - private fun searchSeerr(query: String) { - viewModelScope.launchIO { - if (seerrService.active.first()) { - seerrResults.setValueOnMain(SearchResult.Searching) - val results = - seerrService - .search(query) - .map { seerrService.createDiscoverItem(it) } - .filter { it.type == SeerrItemType.MOVIE || it.type == SeerrItemType.TV } - seerrResults.setValueOnMain(SearchResult.SuccessSeerr(results)) - } - } - } - - init { - addCloseable(voiceInputManager) - } - - fun getHints(query: String) { - // TODO -// api.searchApi.getSearchHints() - } - } - -sealed interface SearchResult { - data object NoQuery : SearchResult - - data object Searching : SearchResult - - data class Error( - val ex: Exception, - ) : SearchResult - - data class Success( - val items: List<BaseItem?>, - ) : SearchResult - - data class SuccessSeerr( - val items: List<DiscoverItem>, - ) : SearchResult -} - -private const val SEARCH_ROW = 0 -private const val MOVIE_ROW = SEARCH_ROW + 1 -private const val SERIES_ROW = MOVIE_ROW + 1 -private const val EPISODE_ROW = SERIES_ROW + 1 -private const val ALBUM_ROW = EPISODE_ROW + 1 -private const val ARTIST_ROW = ALBUM_ROW + 1 -private const val SONG_ROW = ARTIST_ROW + 1 -private const val COLLECTION_ROW = SONG_ROW + 1 -private const val SEERR_ROW = COLLECTION_ROW + 1 - -/** Delay for focus to settle after voice search dialog dismisses. */ -private const val VOICE_RESULT_FOCUS_DELAY_MS = 350L - -@Composable -fun SearchPage( - userPreferences: UserPreferences, - modifier: Modifier = Modifier, - viewModel: SearchViewModel = hiltViewModel(), -) { - val focusManager = LocalFocusManager.current - val keyboardController = LocalSoftwareKeyboardController.current - val movies by viewModel.movies.observeAsState(SearchResult.NoQuery) - val collections by viewModel.collections.observeAsState(SearchResult.NoQuery) - val series by viewModel.series.observeAsState(SearchResult.NoQuery) - val episodes by viewModel.episodes.observeAsState(SearchResult.NoQuery) - val albums by viewModel.albums.observeAsState(SearchResult.NoQuery) - val artists by viewModel.artists.observeAsState(SearchResult.NoQuery) - val songs by viewModel.songs.observeAsState(SearchResult.NoQuery) - val seerrResults by viewModel.seerrResults.observeAsState(SearchResult.NoQuery) - -// val query = rememberTextFieldState() - var query by rememberSaveable { mutableStateOf("") } - val focusRequesters = remember { List(SEERR_ROW + 1) { FocusRequester() } } - - var position by rememberPosition(0, 0) - var searchClicked by rememberSaveable { mutableStateOf(false) } - var immediateSearchQuery by rememberSaveable { mutableStateOf<String?>(null) } - - LifecycleResumeEffect(Unit) { - onPauseOrDispose { - viewModel.voiceInputManager.stopListening() - } - } - - fun triggerImmediateSearch(searchQuery: String) { - immediateSearchQuery = searchQuery - searchClicked = true - viewModel.search(searchQuery) - } - - LaunchedEffect(query) { - when { - immediateSearchQuery == query -> { - immediateSearchQuery = null - } - - else -> { - delay(750L) - viewModel.search(query) - } - } - } - LaunchedEffect(Unit) { - focusRequesters.getOrNull(position.row)?.tryRequestFocus() - } - val onClickItem = { index: Int, item: BaseItem -> - viewModel.navigationManager.navigateTo(item.destination()) - } - - LaunchedEffect(searchClicked, movies, collections, series, episodes, seerrResults) { - if (!searchClicked) return@LaunchedEffect - - withContext(Dispatchers.IO) { - // Want to focus on the first successful row after all of the ones before it are finished searching - val results = listOf(movies, collections, series, episodes, seerrResults) - val firstSuccess = - results.indexOfFirst { it is SearchResult.Success || it is SearchResult.SuccessSeerr } - if (firstSuccess >= 0) { - val anyBeforeSearching = - results.subList(0, firstSuccess).any { it is SearchResult.Searching } - if (!anyBeforeSearching) { - // 0-th row is the search bar - position = RowColumn(firstSuccess + 1, 0) - onMain { focusRequesters[firstSuccess + 1].tryRequestFocus() } - } - } - } - } - - LazyColumn( - contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 44.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - modifier = modifier.focusGroup(), - ) { - item { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier.fillMaxWidth(), - ) { - var isSearchActive by remember { mutableStateOf(false) } - var isTextFieldFocused by remember { mutableStateOf(false) } - val textFieldFocusRequester = remember { FocusRequester() } - - BackHandler(isTextFieldFocused) { - when { - isSearchActive -> { - isSearchActive = false - keyboardController?.hide() - } - - else -> { - focusManager.moveFocus(FocusDirection.Next) - } - } - } - - Row( - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically, - modifier = - Modifier - .focusGroup() - .focusRestorer(textFieldFocusRequester) - .focusRequester(focusRequesters[SEARCH_ROW]), - ) { - VoiceSearchButton( - onSpeechResult = { spokenText -> - query = spokenText - triggerImmediateSearch(spokenText) - }, - voiceInputManager = viewModel.voiceInputManager, - ) - - SearchEditTextBox( - value = query, - onValueChange = { - isSearchActive = true - query = it - }, - onSearchClick = { triggerImmediateSearch(query) }, - readOnly = !isSearchActive, - modifier = - Modifier - .focusRequester(textFieldFocusRequester) - .onFocusChanged { state -> - isTextFieldFocused = state.isFocused - if (!state.isFocused) isSearchActive = false - }.onPreviewKeyEvent { event -> - val isActivationKey = - event.key in listOf(Key.DirectionCenter, Key.Enter) - if (event.type == KeyEventType.KeyUp && isActivationKey && !isSearchActive) { - isSearchActive = true - keyboardController?.show() - true - } else { - false - } - }, - ) - } - } - } - searchResultRow( - title = R.string.movies, - result = movies, - rowIndex = MOVIE_ROW, - position = position, - focusRequester = focusRequesters[MOVIE_ROW], - onClickItem = onClickItem, - onClickPosition = { position = it }, - modifier = Modifier.fillMaxWidth(), - ) - searchResultRow( - title = R.string.tv_shows, - result = series, - rowIndex = SERIES_ROW, - position = position, - focusRequester = focusRequesters[SERIES_ROW], - onClickItem = onClickItem, - onClickPosition = { position = it }, - modifier = Modifier.fillMaxWidth(), - ) - searchResultRow( - title = R.string.episodes, - result = episodes, - rowIndex = EPISODE_ROW, - position = position, - focusRequester = focusRequesters[EPISODE_ROW], - onClickItem = onClickItem, - onClickPosition = { position = it }, - modifier = Modifier.fillMaxWidth(), - cardContent = @Composable { index, item, mod, onClick, onLongClick -> - EpisodeCard( - item = item, - onClick = { - position = RowColumn(EPISODE_ROW, index) - onClick.invoke() - }, - onLongClick = onLongClick, - imageHeight = 140.dp, - showImageOverlay = true, - modifier = mod.padding(horizontal = 8.dp), - ) - }, - ) - searchResultRow( - title = R.string.albums, - result = albums, - rowIndex = ALBUM_ROW, - position = position, - focusRequester = focusRequesters[ALBUM_ROW], - onClickItem = onClickItem, - onClickPosition = { position = it }, - modifier = Modifier.fillMaxWidth(), - cardContent = { index, item, mod, onClick, onLongClick -> - SeasonCard( - item = item, - onClick = { - position = RowColumn(ALBUM_ROW, index) - onClick.invoke() - }, - onLongClick = onLongClick, - imageHeight = Cards.heightEpisode, - aspectRatio = AspectRatios.SQUARE, - showImageOverlay = true, - modifier = mod, - ) - }, - ) - searchResultRow( - title = R.string.artists, - result = artists, - rowIndex = COLLECTION_ROW, - position = position, - focusRequester = focusRequesters[COLLECTION_ROW], - onClickItem = onClickItem, - onClickPosition = { position = it }, - modifier = Modifier.fillMaxWidth(), - cardContent = { index, item, mod, onClick, onLongClick -> - SeasonCard( - item = item, - onClick = { - position = RowColumn(ALBUM_ROW, index) - onClick.invoke() - }, - onLongClick = onLongClick, - imageHeight = Cards.heightEpisode, - aspectRatio = AspectRatios.SQUARE, - showImageOverlay = true, - modifier = mod, - ) - }, - ) - searchResultRow( - title = R.string.songs, - result = songs, - rowIndex = SONG_ROW, - position = position, - focusRequester = focusRequesters[SONG_ROW], - onClickItem = onClickItem, - onClickPosition = { position = it }, - modifier = Modifier.fillMaxWidth(), - cardContent = { index, item, mod, onClick, onLongClick -> - SeasonCard( - item = item, - onClick = { - position = RowColumn(ALBUM_ROW, index) - onClick.invoke() - }, - onLongClick = onLongClick, - imageHeight = Cards.heightEpisode, - aspectRatio = AspectRatios.SQUARE, - showImageOverlay = true, - modifier = mod, - ) - }, - ) - searchResultRow( - title = R.string.collections, - result = collections, - rowIndex = COLLECTION_ROW, - position = position, - focusRequester = focusRequesters[COLLECTION_ROW], - onClickItem = onClickItem, - onClickPosition = { position = it }, - modifier = Modifier.fillMaxWidth(), - ) - searchResultRow( - title = R.string.discover, - result = seerrResults, - rowIndex = SEERR_ROW, - position = position, - focusRequester = focusRequesters[SEERR_ROW], - onClickItem = { _, _ -> - // no-op - }, - onClickDiscover = { _, item -> - val dest = - if (item.jellyfinItemId != null && item.type.baseItemKind != null) { - Destination.MediaItem( - itemId = item.jellyfinItemId, - type = item.type.baseItemKind, - ) - } else { - Destination.DiscoveredItem(item) - } - viewModel.navigationManager.navigateTo(dest) - }, - onClickPosition = { position = it }, - modifier = Modifier.fillMaxWidth(), - ) - } -} - -fun LazyListScope.searchResultRow( - @StringRes title: Int, - result: SearchResult, - rowIndex: Int, - position: RowColumn, - focusRequester: FocusRequester, - onClickItem: (Int, BaseItem) -> Unit, - onClickPosition: (RowColumn) -> Unit, - modifier: Modifier = Modifier, - onClickDiscover: ((Int, DiscoverItem) -> Unit)? = null, - cardContent: @Composable ( - index: Int, - item: BaseItem?, - modifier: Modifier, - onClick: () -> Unit, - onLongClick: () -> Unit, - ) -> Unit = @Composable { index, item, mod, onClick, onLongClick -> - SeasonCard( - item = item, - onClick = { - onClickPosition.invoke(RowColumn(rowIndex, index)) - onClick.invoke() - }, - onLongClick = onLongClick, - imageHeight = Cards.height2x3, - showImageOverlay = true, - modifier = mod, - ) - }, -) { - item { - when (val r = result) { - is SearchResult.Error -> { - SearchResultPlaceholder( - title = stringResource(title), - message = r.ex.localizedMessage ?: "Error occurred during search", - messageColor = MaterialTheme.colorScheme.error, - modifier = Modifier, - ) - } - - SearchResult.NoQuery -> { - // no-op - } - - SearchResult.Searching -> { - SearchResultPlaceholder( - title = stringResource(title), - message = stringResource(R.string.searching), - modifier = modifier, - ) - } - - is SearchResult.Success -> { - if (r.items.isEmpty()) { - SearchResultPlaceholder( - title = stringResource(title), - message = stringResource(R.string.no_results), - modifier = modifier, - ) - } else { - ItemRow( - title = stringResource(title), - items = r.items, - onClickItem = onClickItem, - onLongClickItem = { _, _ -> }, - modifier = modifier.focusRequester(focusRequester), - cardContent = cardContent, - ) - } - } - - is SearchResult.SuccessSeerr -> { - if (r.items.isEmpty()) { - SearchResultPlaceholder( - title = stringResource(title), - message = stringResource(R.string.no_results), - modifier = modifier, - ) - } else { - ItemRow( - title = stringResource(title), - items = r.items, - onClickItem = { index, item -> - onClickPosition.invoke(RowColumn(rowIndex, index)) - onClickDiscover?.invoke(index, item) - }, - onLongClickItem = { _, _ -> }, - modifier = modifier.focusRequester(focusRequester), - cardContent = { index: Int, item: DiscoverItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit -> - DiscoverItemCard( - item = item, - onClick = onClick, - onLongClick = onLongClick, - showOverlay = true, - modifier = mod, - ) - }, - ) - } - } - } - } -} - -@Composable -fun SearchResultPlaceholder( - title: String, - message: String, - modifier: Modifier = Modifier, - messageColor: Color = MaterialTheme.colorScheme.onBackground, -) { - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = modifier.padding(bottom = 32.dp), - ) { - Text( - text = title, - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onBackground, - ) - Text( - text = message, - style = MaterialTheme.typography.titleMedium, - color = messageColor, - ) - } -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeLibraryRowTypeList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeLibraryRowTypeList.kt index 8c603911..c72d69a8 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeLibraryRowTypeList.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeLibraryRowTypeList.kt @@ -1,12 +1,9 @@ package com.github.damontecres.wholphin.ui.main.settings import androidx.annotation.StringRes -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -16,7 +13,6 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp import androidx.tv.material3.ListItem import androidx.tv.material3.Text import com.github.damontecres.wholphin.R @@ -36,9 +32,7 @@ fun HomeLibraryRowTypeList( LaunchedEffect(Unit) { firstFocus.tryRequestFocus() } Column(modifier = modifier) { TitleText(stringResource(R.string.add_row_for, library.name)) - LazyColumn( - contentPadding = PaddingValues(8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), + HomeSettingsLazyColumn( modifier = modifier .fillMaxHeight() @@ -136,7 +130,7 @@ enum class LibraryRowType( GENRES(R.string.genres), STUDIOS(R.string.studios), TV_CHANNELS(R.string.channels), - TV_PROGRAMS(R.string.live_tv), + TV_PROGRAMS(R.string.watch_live), RECENTLY_RECORDED(R.string.recently_recorded), COLLECTION(R.string.collection), PLAYLIST(R.string.playlist), diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowConfigAction.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowConfigAction.kt new file mode 100644 index 00000000..2a44c8f4 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowConfigAction.kt @@ -0,0 +1,7 @@ +package com.github.damontecres.wholphin.ui.main.settings + +sealed interface HomeRowConfigAction { + data object Combine : HomeRowConfigAction + + data object Split : HomeRowConfigAction +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowPresets.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowPresets.kt index 5714723e..922ae3cc 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowPresets.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowPresets.kt @@ -1,10 +1,8 @@ package com.github.damontecres.wholphin.ui.main.settings -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -17,10 +15,12 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.HomeRowViewOptions +import com.github.damontecres.wholphin.data.model.HomeRowViewOptions.Companion.genreDefault import com.github.damontecres.wholphin.preferences.PrefContentScale import com.github.damontecres.wholphin.ui.AspectRatio import com.github.damontecres.wholphin.ui.components.ViewOptionImageType import com.github.damontecres.wholphin.ui.tryRequestFocus +import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.CollectionType data class HomeRowPresets( @@ -61,6 +61,48 @@ data class HomeRowPresets( -> HomeRowViewOptions() } + fun getByItemType(type: BaseItemKind): HomeRowViewOptions = + when (type) { + BaseItemKind.MOVIE, + BaseItemKind.BOX_SET, + -> movieLibrary + + BaseItemKind.SERIES, + BaseItemKind.SEASON, + -> tvLibrary + + BaseItemKind.EPISODE -> continueWatching + + BaseItemKind.VIDEO, + BaseItemKind.MUSIC_VIDEO, + BaseItemKind.PHOTO, + BaseItemKind.PHOTO_ALBUM, + -> videoLibrary + + BaseItemKind.PLAYLIST -> playlist + + BaseItemKind.PERSON -> movieLibrary + + BaseItemKind.MUSIC_ARTIST, + BaseItemKind.MUSIC_ALBUM, + BaseItemKind.AUDIO, + -> musicLibrary + + BaseItemKind.TV_CHANNEL, + BaseItemKind.LIVE_TV_CHANNEL, + BaseItemKind.PROGRAM, + BaseItemKind.TV_PROGRAM, + BaseItemKind.LIVE_TV_PROGRAM, + -> liveTv + + BaseItemKind.GENRE, + BaseItemKind.MUSIC_GENRE, + BaseItemKind.STUDIO, + -> genreDefault.copy(heightDp = genreSize) + + else -> movieLibrary + } + companion object { val WholphinDefault by lazy { HomeRowPresets( @@ -249,9 +291,8 @@ fun HomeRowPresetsContent( LaunchedEffect(Unit) { focusRequesters[0].tryRequestFocus() } Column(modifier = modifier) { TitleText(stringResource(R.string.display_presets)) - LazyColumn( + HomeSettingsLazyColumn( contentPadding = PaddingValues(8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), modifier = modifier .fillMaxHeight() diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowSettings.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowSettings.kt index 7ac43548..48901b0f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowSettings.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowSettings.kt @@ -1,17 +1,32 @@ package com.github.damontecres.wholphin.ui.main.settings +import android.view.Gravity import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed 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.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.DialogProperties +import androidx.compose.ui.window.DialogWindowProvider +import androidx.tv.material3.ListItem +import androidx.tv.material3.Text import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.model.HomeRowConfig import com.github.damontecres.wholphin.data.model.HomeRowViewOptions import com.github.damontecres.wholphin.preferences.AppChoicePreference import com.github.damontecres.wholphin.preferences.AppClickablePreference @@ -21,18 +36,31 @@ import com.github.damontecres.wholphin.preferences.AppSwitchPreference import com.github.damontecres.wholphin.preferences.PrefContentScale import com.github.damontecres.wholphin.ui.AspectRatio import com.github.damontecres.wholphin.ui.Cards +import com.github.damontecres.wholphin.ui.FontAwesome +import com.github.damontecres.wholphin.ui.components.BasicDialog import com.github.damontecres.wholphin.ui.components.ViewOptionImageType +import com.github.damontecres.wholphin.ui.components.toAnnotatedString +import com.github.damontecres.wholphin.ui.data.BoxSetSortOptions +import com.github.damontecres.wholphin.ui.data.SortAndDirection +import com.github.damontecres.wholphin.ui.data.getStringRes import com.github.damontecres.wholphin.ui.ifElse +import com.github.damontecres.wholphin.ui.preferences.ClickPreference import com.github.damontecres.wholphin.ui.preferences.ComposablePreference import com.github.damontecres.wholphin.ui.preferences.PreferenceGroup +import com.github.damontecres.wholphin.ui.preferences.PreferenceTitle import com.github.damontecres.wholphin.ui.tryRequestFocus +import org.jellyfin.sdk.model.api.ItemSortBy +import org.jellyfin.sdk.model.api.SortOrder @Composable fun HomeRowSettings( title: String, + config: HomeRowConfig, preferenceOptions: List<PreferenceGroup<HomeRowViewOptions>>, viewOptions: HomeRowViewOptions, onViewOptionsChange: (HomeRowViewOptions) -> Unit, + onConfigChange: (HomeRowConfig) -> Unit, + onConfigAction: (HomeRowConfigAction) -> Unit, onApplyApplyAll: () -> Unit, modifier: Modifier = Modifier, defaultViewOptions: HomeRowViewOptions = HomeRowViewOptions(), @@ -41,11 +69,11 @@ fun HomeRowSettings( LaunchedEffect(Unit) { firstFocus.tryRequestFocus() } Column(modifier = modifier) { TitleText(title) - LazyColumn { + HomeSettingsLazyColumn { preferenceOptions.forEachIndexed { groupIndex, prefGroup -> if (preferenceOptions.size > 1) { item { - TitleText(stringResource(prefGroup.title)) + SubTitleText(stringResource(prefGroup.title)) } } itemsIndexed(prefGroup.preferences) { index, pref -> @@ -95,6 +123,135 @@ fun HomeRowSettings( ) } } + addAdditionalConfig( + config = config, + onConfigChange = onConfigChange, + onConfigAction = onConfigAction, + ) + } + } +} + +private fun LazyListScope.addAdditionalConfig( + config: HomeRowConfig, + onConfigChange: (HomeRowConfig) -> Unit, + onConfigAction: (HomeRowConfigAction) -> Unit, +) { + when (config) { + is HomeRowConfig.ContinueWatchingCombined -> { + item { + ClickPreference( + title = stringResource(R.string.split_into_separate_rows), + onClick = { onConfigAction.invoke(HomeRowConfigAction.Split) }, + ) + } + } + + is HomeRowConfig.ContinueWatching, + is HomeRowConfig.NextUp, + -> { + item { + ClickPreference( + title = stringResource(R.string.combine_continue_next), + onClick = { onConfigAction.invoke(HomeRowConfigAction.Combine) }, + ) + } + } + + is HomeRowConfig.ByParent -> { + item { + val sortAndDirection = + remember(config) { + config.sort ?: SortAndDirection( + ItemSortBy.DEFAULT, + SortOrder.ASCENDING, + ) + } + var showDialog by remember { mutableStateOf(false) } + ListItem( + selected = false, + onClick = { showDialog = true }, + headlineContent = { + PreferenceTitle(stringResource(R.string.sort)) + }, + supportingContent = { + Text( + text = sortAndDirection.toAnnotatedString(), + ) + }, + ) + if (showDialog) { + BasicDialog( + onDismissRequest = { showDialog = false }, + properties = DialogProperties(usePlatformDefaultWidth = false), + elevation = 5.dp, + ) { + val dialogWindowProvider = LocalView.current.parent as? DialogWindowProvider + dialogWindowProvider?.window?.let { window -> + window.setGravity(Gravity.START) + } + ByParentSortDialogContent( + current = sortAndDirection, + onClick = { + val newSort = + if (config.sort?.sort == it) { + config.sort.flip() + } else if (it == ItemSortBy.DEFAULT) { + null + } else { + SortAndDirection(it, SortOrder.ASCENDING) + } + onConfigChange.invoke(config.copy(sort = newSort)) + showDialog = false + }, + modifier = Modifier.width(320.dp), + ) + } + } + } + } + + else -> {} + } +} + +@Composable +fun ByParentSortDialogContent( + current: SortAndDirection, + onClick: (ItemSortBy) -> Unit, + modifier: Modifier = Modifier, +) { + LazyColumn( + contentPadding = PaddingValues(8.dp), + modifier = modifier, + ) { + items(BoxSetSortOptions) { sort -> + ListItem( + selected = sort == current.sort, + onClick = { onClick.invoke(sort) }, + leadingContent = { + Text( + fontFamily = FontAwesome, + text = + if (sort == current.sort) { + stringResource( + if (current.direction == SortOrder.ASCENDING) { + R.string.fa_caret_up + } else { + R.string.fa_caret_down + }, + ) + } else { + "" + }, + ) + }, + headlineContent = { + Text( + text = stringResource(getStringRes(sort)), + ) + }, + ) } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsAddRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsAddRow.kt index 97249aa4..439b2505 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsAddRow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsAddRow.kt @@ -1,11 +1,8 @@ package com.github.damontecres.wholphin.ui.main.settings import androidx.annotation.StringRes -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable @@ -15,7 +12,6 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.ui.ifElse @@ -31,9 +27,7 @@ fun HomeSettingsAddRow( // LaunchedEffect(Unit) { firstFocus.tryRequestFocus() } Column(modifier = modifier) { TitleText(stringResource(R.string.add_row)) - LazyColumn( - contentPadding = PaddingValues(8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), + HomeSettingsLazyColumn( modifier = modifier .fillMaxHeight() diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsDestination.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsDestination.kt index ad433c23..ae958a93 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsDestination.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsDestination.kt @@ -33,6 +33,9 @@ sealed interface HomeSettingsDestination : NavKey { @Serializable data object GlobalSettings : HomeSettingsDestination + @Serializable + data object SourceSettings : HomeSettingsDestination + @Serializable data object Presets : HomeSettingsDestination } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsFavoriteList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsFavoriteList.kt index 5bf3056d..96c7583c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsFavoriteList.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsFavoriteList.kt @@ -1,10 +1,7 @@ package com.github.damontecres.wholphin.ui.main.settings -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -14,14 +11,15 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.ui.formatTypeName import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.tryRequestFocus import org.jellyfin.sdk.model.api.BaseItemKind @Composable fun HomeSettingsFavoriteList( + favoriteTypeOptions: List<BaseItemKind>, onClick: (BaseItemKind) -> Unit, modifier: Modifier = Modifier, firstFocus: FocusRequester = remember { FocusRequester() }, @@ -31,18 +29,16 @@ fun HomeSettingsFavoriteList( TitleText( stringResource(R.string.add_row_for, stringResource(R.string.favorites)), ) - LazyColumn( - contentPadding = PaddingValues(8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), + HomeSettingsLazyColumn( modifier = modifier .fillMaxHeight() .focusRestorer(firstFocus), ) { - itemsIndexed(favoriteOptionsList) { index, type -> + itemsIndexed(favoriteTypeOptions) { index, type -> HomeSettingsListItem( selected = false, - headlineText = stringResource(favoriteOptions[type]!!), + headlineText = stringResource(formatTypeName(type)), onClick = { onClick.invoke(type) }, modifier = Modifier.ifElse(index == 0, Modifier.focusRequester(firstFocus)), ) @@ -50,17 +46,3 @@ fun HomeSettingsFavoriteList( } } } - -val favoriteOptions by lazy { - mapOf( - BaseItemKind.MOVIE to R.string.movies, - BaseItemKind.SERIES to R.string.tv_shows, - BaseItemKind.EPISODE to R.string.episodes, - BaseItemKind.VIDEO to R.string.videos, - BaseItemKind.PLAYLIST to R.string.playlists, - BaseItemKind.PERSON to R.string.people, - BaseItemKind.MUSIC_ARTIST to R.string.artists, - BaseItemKind.MUSIC_ALBUM to R.string.albums, - ) -} -val favoriteOptionsList by lazy { favoriteOptions.keys.toList() } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsGlobal.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsGlobal.kt index 35c17a7e..74c546b5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsGlobal.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsGlobal.kt @@ -1,10 +1,8 @@ package com.github.damontecres.wholphin.ui.main.settings -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.KeyboardArrowUp @@ -18,7 +16,6 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp import androidx.tv.material3.Icon import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text @@ -34,10 +31,6 @@ fun HomeSettingsGlobal( preferences: AppPreferences, onPreferenceChange: (AppPreferences) -> Unit, onClickResize: (Int) -> Unit, - onClickSave: () -> Unit, - onClickLoad: () -> Unit, - onClickLoadWeb: () -> Unit, - onClickReset: () -> Unit, onClickViewNextUp: () -> Unit, modifier: Modifier = Modifier, ) { @@ -51,8 +44,7 @@ fun HomeSettingsGlobal( modifier = Modifier.fillMaxWidth(), ) HorizontalDivider() - LazyColumn( - verticalArrangement = Arrangement.spacedBy(8.dp), + HomeSettingsLazyColumn( modifier = modifier .fillMaxHeight() @@ -94,6 +86,18 @@ fun HomeSettingsGlobal( modifier = Modifier, ) } + item { + ComposablePreference( + preference = AppPreference.HomeClickToPlay, + value = AppPreference.HomeClickToPlay.getter.invoke(preferences), + onValueChange = { + val newPrefs = AppPreference.HomeClickToPlay.setter.invoke(preferences, it) + onPreferenceChange.invoke(newPrefs) + }, + onNavigate = {}, + modifier = Modifier, + ) + } item { HorizontalDivider() } item { HomeSettingsListItem( @@ -138,62 +142,6 @@ fun HomeSettingsGlobal( modifier = Modifier, ) } - item { - HomeSettingsListItem( - selected = false, - headlineText = stringResource(R.string.save_to_server), - leadingContent = { - Text( - text = stringResource(R.string.fa_cloud_arrow_up), - fontFamily = FontAwesome, - ) - }, - onClick = onClickSave, - modifier = Modifier, - ) - } - item { - HomeSettingsListItem( - selected = false, - headlineText = stringResource(R.string.load_from_server), - leadingContent = { - Text( - text = stringResource(R.string.fa_cloud_arrow_down), - fontFamily = FontAwesome, - ) - }, - onClick = onClickLoad, - modifier = Modifier, - ) - } - item { - HomeSettingsListItem( - selected = false, - headlineText = stringResource(R.string.load_from_web_client), - leadingContent = { - Text( - text = stringResource(R.string.fa_download), - fontFamily = FontAwesome, - ) - }, - onClick = onClickLoadWeb, - modifier = Modifier, - ) - } - item { - HomeSettingsListItem( - selected = false, - headlineText = stringResource(R.string.reset), - leadingContent = { - Text( - text = stringResource(R.string.fa_arrows_rotate), - fontFamily = FontAwesome, - ) - }, - onClick = onClickReset, - modifier = Modifier, - ) - } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsListItem.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsListItem.kt index 4f265dbb..ec35cdb9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsListItem.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsListItem.kt @@ -1,7 +1,13 @@ package com.github.damontecres.wholphin.ui.main.settings import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable import androidx.compose.runtime.NonRestartableComposable import androidx.compose.ui.Modifier @@ -57,3 +63,21 @@ fun HomeSettingsListItem( glow = glow, interactionSource = interactionSource, ) + +@Composable +@NonRestartableComposable +fun HomeSettingsLazyColumn( + modifier: Modifier = Modifier, + state: LazyListState = rememberLazyListState(), + contentPadding: PaddingValues = PaddingValues(8.dp), + verticalArrangement: Arrangement.Vertical = Arrangement.spacedBy(0.dp), + content: LazyListScope.() -> Unit, +) { + LazyColumn( + modifier = modifier, + state = state, + contentPadding = contentPadding, + verticalArrangement = verticalArrangement, + content = content, + ) +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt index 3ec26f92..0bd6b7db 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt @@ -1,5 +1,6 @@ package com.github.damontecres.wholphin.ui.main.settings +import androidx.activity.compose.BackHandler import androidx.annotation.StringRes import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -36,12 +37,13 @@ import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.components.BasicDialog import com.github.damontecres.wholphin.ui.components.ConfirmDialog import com.github.damontecres.wholphin.ui.data.RowColumn -import com.github.damontecres.wholphin.ui.detail.search.SearchForDialog +import com.github.damontecres.wholphin.ui.detail.possibleFavoriteTypes import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.main.HomePageContent import com.github.damontecres.wholphin.ui.main.settings.HomeSettingsDestination.ChooseRowType import com.github.damontecres.wholphin.ui.main.settings.HomeSettingsDestination.RowSettings import com.github.damontecres.wholphin.ui.rememberPosition +import com.github.damontecres.wholphin.ui.search.SearchForDialog import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.HomeRowLoadingState import kotlinx.coroutines.Job @@ -63,11 +65,14 @@ fun HomeSettingsPage( var showConfirmDialog by remember { mutableStateOf<ShowConfirm?>(null) } var searchForDialog by remember { mutableStateOf<BaseItemKind?>(null) } var showRemovedNextUpDialog by remember { mutableStateOf(false) } + var showSaveDialog by remember { mutableStateOf(false) } val state by viewModel.state.collectAsState() + val serverPluginActive by viewModel.serverPluginActive.collectAsState() var position by rememberPosition(0, 0) // TODO discover rows val discoverEnabled = false // by viewModel.discoverEnabled.collectAsState(false) + val settingsHaveChanged by viewModel.settingsChanged.collectAsState() // Adds a row, waits until its done loading, then scrolls to the new row fun addRow( @@ -85,6 +90,10 @@ fun HomeSettingsPage( } } + BackHandler(settingsHaveChanged) { + showSaveDialog = true + } + Row( horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = modifier, @@ -136,6 +145,7 @@ fun HomeSettingsPage( position = RowColumn(index, 0) } }, + onClickSourceSettings = { backStack.add(HomeSettingsDestination.SourceSettings) }, modifier = destModifier, ) } @@ -221,7 +231,7 @@ fun HomeSettingsPage( } } HomeRowSettings( - title = row.title, + title = row.title.getString(), preferenceOptions = preferenceOptions, viewOptions = row.config.viewOptions, defaultViewOptions = defaultViewOptions, @@ -232,6 +242,22 @@ fun HomeSettingsPage( viewModel.updateViewOptionsForAll(row.config.viewOptions) }, modifier = destModifier, + config = row.config, + onConfigChange = { + viewModel.onConfigChange(row, it) + }, + onConfigAction = { + viewModel.onConfigAction(row, it) + when (it) { + HomeRowConfigAction.Combine, + HomeRowConfigAction.Split, + -> { + backStack.removeAt( + backStack.lastIndex, + ) + } + } + }, ) } @@ -240,7 +266,10 @@ fun HomeSettingsPage( } HomeSettingsDestination.ChooseFavorite -> { + val favoriteTypeOptions = + remember { state.libraries.possibleFavoriteTypes(true) } HomeSettingsFavoriteList( + favoriteTypeOptions = favoriteTypeOptions, onClick = { type -> addRow { viewModel.addFavoriteRow(type) } }, @@ -262,6 +291,16 @@ fun HomeSettingsPage( } }, onClickResize = { viewModel.resizeCards(it) }, + onClickViewNextUp = { + showRemovedNextUpDialog = true + }, + modifier = destModifier, + ) + } + + HomeSettingsDestination.SourceSettings -> { + HomeSettingsSourcePage( + source = state.source, onClickSave = { showConfirmDialog = ShowConfirm(R.string.overwrite_server_settings) { @@ -274,6 +313,13 @@ fun HomeSettingsPage( viewModel.loadFromRemote() } }, + serverPluginActive = serverPluginActive, + onClickLoadPlugin = { + showConfirmDialog = + ShowConfirm(R.string.overwrite_local_settings) { + viewModel.loadFromRemotePlugin() + } + }, onClickLoadWeb = { showConfirmDialog = ShowConfirm(R.string.overwrite_local_settings) { @@ -286,9 +332,6 @@ fun HomeSettingsPage( addRow(false) { viewModel.resetToDefault() } } }, - onClickViewNextUp = { - showRemovedNextUpDialog = true - }, modifier = destModifier, ) } @@ -318,6 +361,7 @@ fun HomeSettingsPage( takeFocus = false, showEmptyRows = true, showLogo = preferences.appPreferences.interfacePreferences.showLogos, + showViewMore = false, modifier = Modifier .fillMaxHeight() @@ -354,6 +398,29 @@ fun HomeSettingsPage( ) } } + if (showSaveDialog) { + SaveHomeSettingsDialog( + onDismissRequest = { showSaveDialog = false }, + onSaveLocal = { + scope.launch { + viewModel.saveToLocal().join() + viewModel.navigationManager.goBack() + } + }, + onSaveServer = { + scope.launch { + viewModel.saveToRemote().join() + viewModel.navigationManager.goBack() + } + }, + onDiscard = { + scope.launch { + viewModel.discardChanges().join() + viewModel.navigationManager.goBack() + } + }, + ) + } } data class ShowConfirm( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsRowList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsRowList.kt index 9eadc615..72289463 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsRowList.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsRowList.kt @@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.wrapContentWidth -import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons @@ -50,6 +49,11 @@ enum class MoveDirection { DOWN, } +private const val POS_ADD_ROW = 0 +private const val POS_SOURCE = POS_ADD_ROW + 1 +private const val POS_SETTINGS = POS_SOURCE + 1 +private const val POS_PRESETS = POS_SETTINGS + 1 + @Composable fun HomeSettingsRowList( state: HomePageSettingsState, @@ -59,13 +63,14 @@ fun HomeSettingsRowList( onClickPresets: () -> Unit, onClickMove: (MoveDirection, Int) -> Unit, onClickDelete: (Int) -> Unit, + onClickSourceSettings: () -> Unit, modifier: Modifier, ) { val focusManager = LocalFocusManager.current val scope = rememberCoroutineScope() val listState = rememberLazyListState() - val itemsBeforeRows = 4 + val itemsBeforeRows = POS_PRESETS + 1 // +1 for divider val focusRequesters = remember(state.rows.size) { List(itemsBeforeRows + state.rows.size) { FocusRequester() } } @@ -76,13 +81,12 @@ fun HomeSettingsRowList( } Column(modifier = modifier) { TitleText(stringResource(R.string.customize_home)) - LazyColumn( + HomeSettingsLazyColumn( state = listState, - verticalArrangement = Arrangement.spacedBy(8.dp), modifier = modifier .fillMaxHeight() - .focusRestorer(focusRequesters[0]), + .focusRestorer(focusRequesters[POS_ADD_ROW]), ) { item { HomeSettingsListItem( @@ -95,10 +99,30 @@ fun HomeSettingsRowList( ) }, onClick = { - position = 0 + position = POS_ADD_ROW onClickAdd.invoke() }, - modifier = Modifier.focusRequester(focusRequesters[0]), + modifier = Modifier.focusRequester(focusRequesters[POS_ADD_ROW]), + ) + } + item { + HomeSettingsListItem( + selected = false, + headlineText = "Source", + leadingContent = { + Text( + text = stringResource(R.string.fa_download), + fontFamily = FontAwesome, + ) + }, + supportingContent = { + Text(text = stringResource(state.source.stringResId)) + }, + onClick = { + position = POS_SOURCE + onClickSourceSettings.invoke() + }, + modifier = Modifier.focusRequester(focusRequesters[POS_SOURCE]), ) } item { @@ -112,10 +136,10 @@ fun HomeSettingsRowList( ) }, onClick = { - position = 1 + position = POS_SETTINGS onClickSettings.invoke() }, - modifier = Modifier.focusRequester(focusRequesters[1]), + modifier = Modifier.focusRequester(focusRequesters[POS_SETTINGS]), ) } item { @@ -134,10 +158,10 @@ fun HomeSettingsRowList( ) }, onClick = { - position = 2 + position = POS_PRESETS onClickPresets.invoke() }, - modifier = Modifier.focusRequester(focusRequesters[2]), + modifier = Modifier.focusRequester(focusRequesters[POS_PRESETS]), ) } item { @@ -209,7 +233,7 @@ fun HomeRowConfigContent( ) { HomeSettingsListItem( selected = false, - headlineText = config.title, + headlineText = config.title.getString(), onClick = onClick, modifier = Modifier.weight(1f), ) @@ -255,6 +279,24 @@ fun HomeRowConfigContent( fun TitleText( title: String, modifier: Modifier = Modifier, +) { + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Start, + modifier = + modifier + .fillMaxWidth() + .padding(top = 8.dp, bottom = 4.dp), + ) +} + +@Composable +@NonRestartableComposable +fun SubTitleText( + title: String, + modifier: Modifier = Modifier, ) { Text( text = title, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsSourcePage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsSourcePage.kt new file mode 100644 index 00000000..8139b651 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsSourcePage.kt @@ -0,0 +1,111 @@ +package com.github.damontecres.wholphin.ui.main.settings + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRestorer +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +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.services.HomePageSettingsSource +import com.github.damontecres.wholphin.ui.FontAwesome +import com.github.damontecres.wholphin.ui.tryRequestFocus + +@Composable +fun HomeSettingsSourcePage( + source: HomePageSettingsSource, + onClickSave: () -> Unit, + onClickLoad: () -> Unit, + onClickLoadWeb: () -> Unit, + onClickReset: () -> Unit, + serverPluginActive: Boolean, + onClickLoadPlugin: () -> Unit, + modifier: Modifier = Modifier, +) { + val firstFocus: FocusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { firstFocus.tryRequestFocus() } + Column(modifier = modifier) { + Text( + text = stringResource(R.string.settings), + style = MaterialTheme.typography.titleLarge, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + LazyColumn( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = + modifier + .fillMaxHeight() + .focusRestorer(firstFocus), + ) { + item { + HomeSettingsListItem( + selected = false, + headlineText = stringResource(R.string.load_from_server), + leadingContent = { + Text( + text = stringResource(R.string.fa_cloud_arrow_down), + fontFamily = FontAwesome, + ) + }, + onClick = onClickLoad, + modifier = Modifier, + ) + } + if (serverPluginActive) { + item { + HomeSettingsListItem( + selected = false, + headlineText = stringResource(R.string.load_from_server_plugin), + leadingContent = { + Text( + text = stringResource(R.string.fa_download), + fontFamily = FontAwesome, + ) + }, + onClick = onClickLoadPlugin, + modifier = Modifier, + ) + } + } + item { + HomeSettingsListItem( + selected = false, + headlineText = stringResource(R.string.load_from_web_client), + leadingContent = { + Text( + text = stringResource(R.string.fa_download), + fontFamily = FontAwesome, + ) + }, + onClick = onClickLoadWeb, + modifier = Modifier, + ) + } + item { + HomeSettingsListItem( + selected = false, + headlineText = stringResource(R.string.reset), + leadingContent = { + Text( + text = stringResource(R.string.fa_arrows_rotate), + fontFamily = FontAwesome, + ) + }, + onClick = onClickReset, + modifier = Modifier, + ) + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt index c4874aa0..1082a23f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt @@ -24,30 +24,42 @@ import com.github.damontecres.wholphin.data.model.HomeRowViewOptions import com.github.damontecres.wholphin.data.model.SUPPORTED_HOME_PAGE_SETTINGS_VERSION import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.services.BackdropService +import com.github.damontecres.wholphin.services.HomeDataService import com.github.damontecres.wholphin.services.HomePageResolvedSettings +import com.github.damontecres.wholphin.services.HomePageSettingsSource import com.github.damontecres.wholphin.services.HomeRowConfigDisplay import com.github.damontecres.wholphin.services.HomeSettingsService import com.github.damontecres.wholphin.services.NavDrawerService +import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.SeerrServerRepository +import com.github.damontecres.wholphin.services.ServerPluginApi import com.github.damontecres.wholphin.services.UnsupportedHomeSettingsVersionException import com.github.damontecres.wholphin.services.UserPreferencesService -import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope -import com.github.damontecres.wholphin.services.tvAccess -import com.github.damontecres.wholphin.ui.AspectRatio +import com.github.damontecres.wholphin.services.getRecentlyAddedTitle +import com.github.damontecres.wholphin.services.viewOptionsForCollectionType +import com.github.damontecres.wholphin.ui.formatTypeName +import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.showToast +import com.github.damontecres.wholphin.ui.util.ResArgStringProvider +import com.github.damontecres.wholphin.ui.util.ResProviderStringProvider +import com.github.damontecres.wholphin.ui.util.ResStringProvider +import com.github.damontecres.wholphin.ui.util.StringStringProvider import com.github.damontecres.wholphin.util.HomeRowLoadingState import com.github.damontecres.wholphin.util.LoadingState +import com.github.damontecres.wholphin.util.WholphinDispatchers import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit @@ -69,37 +81,55 @@ class HomeSettingsViewModel @param:ApplicationContext private val context: Context, private val api: ApiClient, private val homeSettingsService: HomeSettingsService, + private val homeDataService: HomeDataService, private val serverRepository: ServerRepository, private val userPreferencesService: UserPreferencesService, private val navDrawerService: NavDrawerService, private val backdropService: BackdropService, private val seerrServerRepository: SeerrServerRepository, + private val serverPluginApi: ServerPluginApi, val preferencesDataStore: DataStore<AppPreferences>, - @param:IoCoroutineScope private val ioScope: CoroutineScope, + val navigationManager: NavigationManager, ) : ViewModel() { private val _state = MutableStateFlow(HomePageSettingsState.EMPTY) val state: StateFlow<HomePageSettingsState> = _state + val serverPluginActive get() = serverRepository.serverPluginInstalled + val currentUser + get() = + serverRepository.currentUserFlow.filterNotNull().stateIn( + viewModelScope, + SharingStarted.Eagerly, + serverRepository.currentUser!!, + ) + val settingsChanged = + state + .map { + HomePageSettings( + rows = it.rows.map { it.config }, + version = SUPPORTED_HOME_PAGE_SETTINGS_VERSION, + ) != originalSettings + }.stateIn(viewModelScope, SharingStarted.Eagerly, false) + private var idCounter by Delegates.notNull<Int>() val discoverEnabled = seerrServerRepository.active - private var originalLocalSettings: HomePageSettings? = null - private var originalRemoteSettings: HomePageSettings? = null + private var originalSettings: HomePageSettings? = null init { - addCloseable { saveToLocal() } viewModelScope.launchIO { - val userDto = serverRepository.currentUserDto.value ?: return@launchIO + val user = serverRepository.currentUser ?: return@launchIO + val userDto = serverRepository.currentUserDto ?: return@launchIO val libraries = navDrawerService.getAllUserLibraries(userDto.id, userDto.tvAccess) val currentSettings = homeSettingsService.currentSettings.first { it != HomePageResolvedSettings.EMPTY } - originalLocalSettings = homeSettingsService.loadFromLocal(userDto.id) - originalRemoteSettings = homeSettingsService.loadFromServer(userDto.id) + originalSettings = currentSettings.asHomePageSettings() Timber.v("currentSettings=%s", currentSettings) idCounter = currentSettings.rows.maxOfOrNull { it.id }?.plus(1) ?: 0 _state.update { it.copy( + source = currentSettings.source, libraries = libraries, rows = currentSettings.rows, ) @@ -118,16 +148,16 @@ class HomeSettingsViewModel val limit = 8 val semaphore = Semaphore(4) val rows = - serverRepository.currentUserDto.value?.let { userDto -> + serverRepository.currentUserDto?.let { userDto -> val prefs = userPreferencesService.getCurrent().appPreferences.homePagePreferences state.value .let { state -> state.rows .map { row -> - viewModelScope.async(Dispatchers.IO) { + viewModelScope.async(WholphinDispatchers.IO) { semaphore.withPermit { try { - homeSettingsService.fetchDataForRow( + homeDataService.fetchDataForRow( row = row.config, scope = viewModelScope, prefs = prefs, @@ -222,7 +252,7 @@ class HomeSettingsViewModel MetaRowType.CONTINUE_WATCHING -> { HomeRowConfigDisplay( id = id, - title = context.getString(R.string.continue_watching), + title = ResStringProvider(R.string.continue_watching), config = ContinueWatching(), ) } @@ -230,7 +260,7 @@ class HomeSettingsViewModel MetaRowType.NEXT_UP -> { HomeRowConfigDisplay( id = id, - title = context.getString(R.string.next_up), + title = ResStringProvider(R.string.next_up), config = NextUp(), ) } @@ -238,7 +268,7 @@ class HomeSettingsViewModel MetaRowType.COMBINED_CONTINUE_WATCHING -> { HomeRowConfigDisplay( id = id, - title = context.getString(R.string.combine_continue_next), + title = ResStringProvider(R.string.combine_continue_next), config = ContinueWatchingCombined(), ) } @@ -268,30 +298,18 @@ class HomeSettingsViewModel rowType: LibraryRowType, ): Job = viewModelScope.launchIO { - val viewOptions = - when (library.collectionType) { - CollectionType.MUSIC -> { - HomeRowViewOptions(aspectRatio = AspectRatio.SQUARE) - } - - CollectionType.HOMEVIDEOS, - CollectionType.MUSICVIDEOS, - -> { - HomeRowViewOptions( - aspectRatio = AspectRatio.WIDE, - ) - } - - else -> { - HomeRowViewOptions() - } - } + val viewOptions = viewOptionsForCollectionType(library.collectionType) val id = idCounter++ val newRow = when (rowType) { LibraryRowType.RECENTLY_ADDED -> { val title = - library.name.let { context.getString(R.string.recently_added_in, it) } + library.name.let { + ResArgStringProvider( + R.string.recently_added_in, + it, + ) + } HomeRowConfigDisplay( id = id, title = title, @@ -302,7 +320,7 @@ class HomeSettingsViewModel LibraryRowType.RECENTLY_RELEASED -> { val title = library.name.let { - context.getString( + ResArgStringProvider( R.string.recently_released_in, it, ) @@ -315,7 +333,8 @@ class HomeSettingsViewModel } LibraryRowType.GENRES -> { - val title = library.name.let { context.getString(R.string.genres_in, it) } + val title = + library.name.let { ResArgStringProvider(R.string.genres_in, it) } HomeRowConfigDisplay( id = id, title = title, @@ -325,7 +344,7 @@ class HomeSettingsViewModel LibraryRowType.STUDIOS -> { val title = - library.name.let { context.getString(R.string.studios_in, it) } + library.name.let { ResArgStringProvider(R.string.studios_in, it) } HomeRowConfigDisplay( id = id, title = title, @@ -335,7 +354,12 @@ class HomeSettingsViewModel LibraryRowType.SUGGESTIONS -> { val title = - library.name.let { context.getString(R.string.suggestions_for, it) } + library.name.let { + ResArgStringProvider( + R.string.suggestions_for, + it, + ) + } HomeRowConfigDisplay( id = id, title = title, @@ -344,7 +368,7 @@ class HomeSettingsViewModel } LibraryRowType.TV_CHANNELS -> { - val title = context.getString(R.string.channels) + val title = ResStringProvider(R.string.channels) HomeRowConfigDisplay( id = id, title = title, @@ -356,7 +380,7 @@ class HomeSettingsViewModel } LibraryRowType.TV_PROGRAMS -> { - val title = context.getString(R.string.watch_live) + val title = ResStringProvider(R.string.watch_live) HomeRowConfigDisplay( id = id, title = title, @@ -365,7 +389,7 @@ class HomeSettingsViewModel } LibraryRowType.RECENTLY_RECORDED -> { - val title = context.getString(R.string.recently_recorded) + val title = getRecentlyAddedTitle(library.name) HomeRowConfigDisplay( id = id, title = title, @@ -396,11 +420,15 @@ class HomeSettingsViewModel HomeRowConfigDisplay( id = id, title = - context.getString( - R.string.favorite_items, - context.getString(favoriteOptions[type]!!), + ResProviderStringProvider( + R.string.favorite_items_title, + ResStringProvider(formatTypeName(type)), + ), + config = + HomeRowConfig.Favorite( + kind = type, + viewOptions = HomeRowPresets.WholphinDefault.getByItemType(type), ), - config = HomeRowConfig.Favorite(type), ) updateState { it.copy( @@ -420,7 +448,7 @@ class HomeSettingsViewModel val newRow = HomeRowConfigDisplay( id = id, - title = parent.name ?: "", + title = StringStringProvider(parent.name ?: ""), config = HomeRowConfig.ByParent( parentId = parent.id, @@ -491,9 +519,9 @@ class HomeSettingsViewModel } } - fun saveToRemote() { + fun saveToRemote(): Job = viewModelScope.launchIO { - serverRepository.currentUser.value?.let { user -> + serverRepository.currentUser?.let { user -> Timber.d("Saving home settings to remote") val rows = state.value.rows.map { it.config } val settings = @@ -501,6 +529,10 @@ class HomeSettingsViewModel try { Timber.d("saveToRemote") homeSettingsService.saveToServer(user.id, settings) + homeSettingsService.updateCurrent( + HomePageSettingsSource.SERVER_PROFILE, + settings, + ) showSaveToast() } catch (ex: Exception) { Timber.e(ex) @@ -508,11 +540,10 @@ class HomeSettingsViewModel } } } - } fun loadFromRemote() { viewModelScope.launchIO { - serverRepository.currentUser.value?.let { user -> + serverRepository.currentUser?.let { user -> Timber.d("Loading home settings from remote") try { _state.update { it.copy(loading = LoadingState.Loading) } @@ -525,7 +556,10 @@ class HomeSettingsViewModel } idCounter = newRows.maxOfOrNull { it.id }?.plus(1) ?: 0 _state.update { - it.copy(rows = newRows) + it.copy( + source = HomePageSettingsSource.SERVER_PROFILE, + rows = newRows, + ) } } else { Timber.v("No remote settings") @@ -546,7 +580,7 @@ class HomeSettingsViewModel fun loadFromRemoteWeb() { viewModelScope.launchIO { - serverRepository.currentUser.value?.let { user -> + serverRepository.currentUser?.let { user -> Timber.d("Loading home settings from web") try { _state.update { it.copy(loading = LoadingState.Loading) } @@ -555,7 +589,10 @@ class HomeSettingsViewModel Timber.v("Got web settings") idCounter = result.rows.maxOfOrNull { it.id }?.plus(1) ?: 0 _state.update { - it.copy(rows = result.rows) + it.copy( + source = HomePageSettingsSource.LOCAL, + rows = result.rows, + ) } } else { Timber.v("No web settings") @@ -570,34 +607,19 @@ class HomeSettingsViewModel } } - fun saveToLocal() { - // This uses injected ioScope so that it will still run when the page is closing - ioScope.launchIO { - serverRepository.currentUser.value?.let { user -> + fun saveToLocal(): Job = + viewModelScope.launchIO { + serverRepository.currentUser?.let { user -> val rows = state.value.rows.map { it.config } val settings = HomePageSettings(rows = rows, SUPPORTED_HOME_PAGE_SETTINGS_VERSION) try { Timber.d("saveToLocal") - // Only save if there are changes based on original source - val shouldSave = - if (originalLocalSettings != null) { - originalLocalSettings != settings - } else if (originalRemoteSettings != null) { - originalRemoteSettings != settings - } else { - true - } - if (shouldSave) { - homeSettingsService.saveToLocal(user.id, settings) - homeSettingsService.updateCurrent(settings) - showSaveToast() - } else { - Timber.d("No changes") - } - } catch (ex: UnsupportedHomeSettingsVersionException) { - Timber.w(ex, "Overwriting local settings") homeSettingsService.saveToLocal(user.id, settings) + homeSettingsService.updateCurrent( + HomePageSettingsSource.LOCAL, + settings, + ) showSaveToast() } catch (ex: Exception) { Timber.e(ex) @@ -605,13 +627,11 @@ class HomeSettingsViewModel } } } - } private fun updateState(update: (HomePageSettingsState) -> HomePageSettingsState) { _state.update { update.invoke(it) } - homeSettingsService.currentSettings.update { HomePageResolvedSettings(state.value.rows) } } fun resizeCards(relative: Int) { @@ -640,12 +660,32 @@ class HomeSettingsViewModel fun resetToDefault() = viewModelScope.launchIO { - val userId = serverRepository.currentUser.value?.id ?: return@launchIO + val user = serverRepository.currentUser ?: return@launchIO _state.update { it.copy(loading = LoadingState.Loading) } - val result = homeSettingsService.createDefault(userId) + val result = + if (serverPluginActive.value) { + try { + val settings = + homeSettingsService.fetchSettingsFor( + user.id, + HomePageSettingsSource.PLUGIN, + ) + settings + } catch (ex: Exception) { + Timber.e(ex, "Plugin is active, but error fetching home settings") + null + } + } else { + null + } ?: homeSettingsService.createDefault(user.id) + idCounter = result.rows.maxOfOrNull { it.id }?.plus(1) ?: 0 _state.update { - it.copy(rows = result.rows, rowData = emptyList()) + it.copy( + source = result.source, + rows = result.rows, + rowData = emptyList(), + ) } fetchRowData() } @@ -692,17 +732,7 @@ class HomeSettingsViewModel } is HomeRowConfig.Favorite -> { - val viewOptions = - when (it.config.kind) { - BaseItemKind.MOVIE -> preset.movieLibrary - BaseItemKind.SERIES -> preset.tvLibrary - BaseItemKind.EPISODE -> preset.continueWatching - BaseItemKind.VIDEO -> preset.videoLibrary - BaseItemKind.PLAYLIST -> preset.playlist - BaseItemKind.PERSON -> preset.movieLibrary - BaseItemKind.MUSIC_ARTIST, BaseItemKind.MUSIC_ALBUM -> preset.musicLibrary - else -> preset.movieLibrary - } + val viewOptions = preset.getByItemType(it.config.kind) it.config.updateViewOptions(viewOptions) } @@ -747,6 +777,10 @@ class HomeSettingsViewModel is HomeRowConfig.TvChannels -> { it.config.updateViewOptions(preset.liveTv) } + + is HomeRowConfig.CustomEndpoint -> { + it.config + } } it.copy(config = newConfig) } @@ -767,10 +801,145 @@ class HomeSettingsViewModel } } } + + fun loadFromRemotePlugin() { + viewModelScope.launchIO { + Timber.d("Loading home settings from server plugin") + try { + // TODO should this remove local settings? + // TODO how to set up priorities + _state.update { it.copy(loading = LoadingState.Loading) } + val result = serverPluginApi.fetchHomePageSettings() + if (result != null) { + Timber.v("Got remote settings") + val newRows = + result.rows.mapIndexed { index, config -> + homeSettingsService.resolve(index, config) + } + idCounter = newRows.maxOfOrNull { it.id }?.plus(1) ?: 0 + _state.update { + it.copy( + source = HomePageSettingsSource.PLUGIN, + rows = newRows, + ) + } + } else { + Timber.v("No server plugin settings") + showToast(context, "No server plugin settings found") + } + fetchRowData() + } catch (ex: UnsupportedHomeSettingsVersionException) { + // TODO + Timber.w(ex) + showToast(context, "Error: ${ex.localizedMessage}") + } catch (ex: Exception) { + Timber.e(ex) + showToast(context, "Error: ${ex.localizedMessage}") + } + } + } + + fun onConfigAction( + row: HomeRowConfigDisplay, + action: HomeRowConfigAction, + ) { + viewModelScope.launchDefault { + when (action) { + HomeRowConfigAction.Combine -> { + val index = + state.value.rows + .indexOf(row) + .coerceAtLeast(0) + val rowsToRemove = + state.value.rows + .filter { it.config is ContinueWatching || it.config is NextUp } + + updateState { + it.copy( + loading = LoadingState.Loading, + rows = + it.rows.toMutableList().apply { + set( + index, + HomeRowConfigDisplay( + id = it.rows[index].id, + title = ResStringProvider(R.string.combine_continue_next), + config = ContinueWatchingCombined(row.config.viewOptions), + ), + ) + removeAll(rowsToRemove) + }, + ) + } + fetchRowData() + } + + HomeRowConfigAction.Split -> { + val index = + state.value.rows + .indexOf(row) + .coerceAtLeast(0) + updateState { + it.copy( + loading = LoadingState.Loading, + rows = + it.rows.toMutableList().apply { + set( + index, + HomeRowConfigDisplay( + id = it.rows[index].id, + title = ResStringProvider(R.string.continue_watching), + config = ContinueWatching(row.config.viewOptions), + ), + ) + add( + index + 1, + HomeRowConfigDisplay( + id = idCounter++, + title = ResStringProvider(R.string.next_up), + config = NextUp(row.config.viewOptions), + ), + ) + }, + ) + } + fetchRowData() + } + } + } + } + + fun onConfigChange( + row: HomeRowConfigDisplay, + config: HomeRowConfig, + ) { + viewModelScope.launchDefault { + val index = + state.value.rows + .indexOf(row) + .coerceAtLeast(0) + updateState { + it.copy( + loading = LoadingState.Loading, + rows = + it.rows.toMutableList().apply { + set( + index, + row.copy(config = config), + ) + }, + ) + } + fetchRowData() + } + } + + fun discardChanges(): Job = Job().apply { complete() } } data class HomePageSettingsState( val loading: LoadingState, + val source: HomePageSettingsSource, val rows: List<HomeRowConfigDisplay>, val rowData: List<HomeRowLoadingState>, val libraries: List<Library>, @@ -779,6 +948,7 @@ data class HomePageSettingsState( val EMPTY = HomePageSettingsState( LoadingState.Pending, + HomePageSettingsSource.UNSET, emptyList(), emptyList(), emptyList(), diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/RemovedNextUpContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/RemovedNextUpContent.kt index 605f76d5..e7b3c07c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/RemovedNextUpContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/RemovedNextUpContent.kt @@ -24,7 +24,6 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.ViewModel -import androidx.lifecycle.asFlow import androidx.lifecycle.viewModelScope import androidx.tv.material3.Icon import androidx.tv.material3.ListItem @@ -75,7 +74,7 @@ class RemovedNextUpContentViewModel init { viewModelScope.launchDefault { - serverRepository.currentUser.asFlow().collectLatest { user -> + serverRepository.currentUserFlow.collectLatest { user -> _state.update { RemovedNextUpState() } if (user == null) { return@collectLatest @@ -105,7 +104,7 @@ class RemovedNextUpContentViewModel } fun remove(item: RemovedItem) { - serverRepository.currentUser.value?.let { user -> + serverRepository.currentUser?.let { user -> viewModelScope.launchDefault { mutex.withLock { _state.update { it.copy(removedEnabled = false) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/SaveHomeSettingsDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/SaveHomeSettingsDialog.kt new file mode 100644 index 00000000..c7531b6d --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/SaveHomeSettingsDialog.kt @@ -0,0 +1,54 @@ +package com.github.damontecres.wholphin.ui.main.settings + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalResources +import androidx.compose.ui.unit.dp +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.ui.components.DialogItem +import com.github.damontecres.wholphin.ui.components.DialogParams +import com.github.damontecres.wholphin.ui.components.DialogPopup + +@Composable +fun SaveHomeSettingsDialog( + onDismissRequest: () -> Unit, + onSaveLocal: () -> Unit, + onSaveServer: () -> Unit, + onDiscard: () -> Unit, +) { + val resources = LocalResources.current + val params = + remember(resources) { + DialogParams( + fromLongClick = false, + title = resources.getString(R.string.save), + items = + listOf( + DialogItem( + text = R.string.save_to_local, + iconStringRes = R.string.fa_cloud_arrow_down, + onClick = onSaveLocal, + ), + DialogItem( + text = R.string.save_to_server, + iconStringRes = R.string.fa_cloud_arrow_up, + onClick = onSaveServer, + ), + DialogItem( + text = "Discard", + icon = Icons.Default.Delete, + iconColor = Color.Red, + onClick = onDiscard, + ), + ), + ) + } + DialogPopup( + params = params, + onDismissRequest = onDismissRequest, + elevation = 3.dp, + ) +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Backdrop.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Backdrop.kt index 81028501..188ad347 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Backdrop.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Backdrop.kt @@ -23,6 +23,8 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.LayoutDirection import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.tv.material3.MaterialTheme @@ -75,6 +77,7 @@ fun Backdrop( useExistingImageAsPlaceholder: Boolean = false, crossfadeDuration: Duration = 800.milliseconds, ) { + val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl val baseBackgroundColor = MaterialTheme.colorScheme.background if (backdrop.hasColors && (backdropStyle == BackdropStyle.BACKDROP_DYNAMIC_COLOR || backdropStyle == BackdropStyle.UNRECOGNIZED) @@ -100,12 +103,14 @@ fun Backdrop( .fillMaxSize() .drawBehind { drawRect(color = baseBackgroundColor) + val start = if (isRtl) size.width else 0f + val end = if (isRtl) 0f else size.width // Top Left (Vibrant/Muted) drawRect( brush = Brush.radialGradient( colors = listOf(animSecondary, Color.Transparent), - center = Offset(0f, 0f), + center = Offset(start, 0f), radius = size.width * 0.8f, ), ) @@ -114,7 +119,7 @@ fun Backdrop( brush = Brush.radialGradient( colors = listOf(animPrimary, Color.Transparent), - center = Offset(size.width, size.height), + center = Offset(end, size.height), radius = size.width * 0.8f, ), ) @@ -127,7 +132,7 @@ fun Backdrop( baseBackgroundColor, Color.Transparent, ), - center = Offset(0f, size.height), + center = Offset(start, size.height), radius = size.width * 0.8f, ), ) @@ -136,7 +141,7 @@ fun Backdrop( brush = Brush.radialGradient( colors = listOf(animTertiary, Color.Transparent), - center = Offset(size.width, 0f), + center = Offset(end, 0f), radius = size.width * 0.8f, ), ) @@ -165,6 +170,7 @@ fun Backdrop( .fillMaxWidth(.7f) .graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen) .drawWithContent { + val start = if (isRtl) size.width else 0f drawContent() if (drawerIsOpen) { drawRect( @@ -190,7 +196,7 @@ fun Backdrop( brush = Brush.horizontalGradient( colors = listOf(Color.Transparent, Color.Black), - startX = 0f, + startX = start, endX = size.width * 0.6f, ), blendMode = BlendMode.DstIn, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt index 9aed36cd..5bef2675 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt @@ -2,18 +2,23 @@ package com.github.damontecres.wholphin.ui.nav -import androidx.annotation.StringRes import androidx.navigation3.runtime.NavKey +import com.github.damontecres.wholphin.data.filter.DiscoverFilter import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.CollectionFolderFilter import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.data.model.GetItemsFilter +import com.github.damontecres.wholphin.data.model.HomeRowConfig import com.github.damontecres.wholphin.preferences.PlayerBackend +import com.github.damontecres.wholphin.ui.components.ViewOptions import com.github.damontecres.wholphin.ui.data.SortAndDirection import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption +import com.github.damontecres.wholphin.ui.util.StringProvider import com.github.damontecres.wholphin.util.DiscoverRequestType +import com.github.damontecres.wholphin.util.RequestHandler import com.github.damontecres.wholphin.util.SEERR_PAGE_SIZE +import kotlinx.serialization.Contextual import kotlinx.serialization.Serializable import kotlinx.serialization.UseSerializers import org.jellyfin.sdk.model.api.BaseItemKind @@ -30,6 +35,9 @@ import java.util.UUID sealed class Destination( val fullScreen: Boolean = false, ) : NavKey { + @Serializable + data class Wolfskin(val route: String) : Destination(false) + @Serializable data class Home( val id: Long = 0L, @@ -49,7 +57,11 @@ sealed class Destination( ) : Destination(true) @Serializable - data object Search : Destination() + data class Search( + val query: String = "", + ) : Destination() + + data object UserAppPreferences : Destination(true) @Serializable data class SeriesOverview( @@ -65,6 +77,7 @@ sealed class Destination( val itemId: UUID, val type: BaseItemKind, val collectionType: CollectionType? = null, + val initialSongId: UUID? = null, ) : Destination() { constructor(item: BaseItem) : this(item.id, item.type, item.data.collectionType) } @@ -80,6 +93,7 @@ sealed class Destination( val positionMs: Long, val forceTranscoding: Boolean = false, val backend: PlayerBackend? = null, + val shuffle: Boolean = false, ) : Destination(true) { constructor(item: BaseItem) : this(item.id, item.resumeMs) } @@ -102,18 +116,28 @@ sealed class Destination( val parentType: BaseItemKind, val filter: CollectionFolderFilter, val recursive: Boolean, + val collectionType: CollectionType, ) : Destination(false) @Serializable - data class ItemGrid( - val title: String?, - @param:StringRes val titleRes: Int?, - val itemIds: List<UUID>, + data class ItemGrid<T>( + val title: StringProvider, + @Contextual val request: T, + val requestHandler: RequestHandler<T>, + val initialPosition: Int = 0, + val viewOptions: ViewOptions = ViewOptions(), + ) : Destination(false) + + @Serializable + data class MoreHomeRow( + val title: StringProvider, + val config: HomeRowConfig, + val initialPosition: Int, ) : Destination(false) @Serializable data class Slideshow( - val parentId: UUID, + val parentId: UUID?, val index: Int, val filter: CollectionFolderFilter, val sortAndDirection: SortAndDirection, @@ -130,11 +154,16 @@ sealed class Destination( @Serializable data class DiscoveredItem( val item: DiscoverItem, + // Index to scroll to on the person page grid + val startIndex: Int = 0, ) : Destination(false) + @Serializable data class DiscoverMoreResult( val type: DiscoverRequestType, val startIndex: Int = SEERR_PAGE_SIZE, + val initialFilter: DiscoverFilter = DiscoverFilter(), + val titleOverride: StringProvider? = null, ) : Destination(false) @Serializable diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt index 22dfc570..3e449875 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt @@ -13,6 +13,7 @@ import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.components.ItemGrid import com.github.damontecres.wholphin.ui.components.LicenseInfo import com.github.damontecres.wholphin.ui.data.MovieSortOptions +import com.github.damontecres.wholphin.ui.data.rememberSortOptions import com.github.damontecres.wholphin.ui.detail.CollectionFolderGeneric import com.github.damontecres.wholphin.ui.detail.CollectionFolderLiveTv import com.github.damontecres.wholphin.ui.detail.CollectionFolderMovie @@ -23,6 +24,7 @@ import com.github.damontecres.wholphin.ui.detail.CollectionFolderRecordings import com.github.damontecres.wholphin.ui.detail.CollectionFolderTv import com.github.damontecres.wholphin.ui.detail.DebugPage import com.github.damontecres.wholphin.ui.detail.FavoritesPage +import com.github.damontecres.wholphin.ui.detail.HomeRowGrid import com.github.damontecres.wholphin.ui.detail.PersonPage import com.github.damontecres.wholphin.ui.detail.PlaylistDetails import com.github.damontecres.wholphin.ui.detail.collection.CollectionDetails @@ -34,17 +36,19 @@ import com.github.damontecres.wholphin.ui.detail.movie.MovieDetails import com.github.damontecres.wholphin.ui.detail.music.AlbumDetailsPage import com.github.damontecres.wholphin.ui.detail.music.ArtistDetailsPage import com.github.damontecres.wholphin.ui.detail.music.NowPlayingPage +import com.github.damontecres.wholphin.ui.detail.music.SongDetailsPage import com.github.damontecres.wholphin.ui.detail.series.SeriesDetails import com.github.damontecres.wholphin.ui.detail.series.SeriesOverview import com.github.damontecres.wholphin.ui.discover.DiscoverPage import com.github.damontecres.wholphin.ui.discover.DiscoverRequestGrid import com.github.damontecres.wholphin.ui.main.HomePage -import com.github.damontecres.wholphin.ui.main.SearchPage import com.github.damontecres.wholphin.ui.main.settings.HomeSettingsPage import com.github.damontecres.wholphin.ui.playback.PlayExternalPage import com.github.damontecres.wholphin.ui.playback.PlaybackPage import com.github.damontecres.wholphin.ui.preferences.PreferencesPage import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleStylePage +import com.github.damontecres.wholphin.ui.preferences.user.UserProfilePreferencesPage +import com.github.damontecres.wholphin.ui.search.SearchPage import com.github.damontecres.wholphin.ui.setup.InstallUpdatePage import com.github.damontecres.wholphin.ui.slideshow.SlideshowPage import org.jellyfin.sdk.model.api.BaseItemKind @@ -65,6 +69,10 @@ fun DestinationContent( LaunchedEffect(Unit) { onClearBackdrop.invoke() } } when (destination) { + is Destination.Wolfskin -> { + org.justinvisser.wolfskin.tv.WolfskinDestinationContent(destination.route, modifier) + } + is Destination.Home -> { HomePage( preferences = preferences, @@ -126,6 +134,10 @@ fun DestinationContent( ) } + Destination.UserAppPreferences -> { + UserProfilePreferencesPage(modifier) + } + is Destination.SeriesOverview -> { SeriesOverview( preferences = preferences, @@ -250,6 +262,7 @@ fun DestinationContent( AlbumDetailsPage( preferences = preferences, itemId = destination.itemId, + initialSongId = destination.initialSongId, modifier = modifier, ) } @@ -263,6 +276,15 @@ fun DestinationContent( ) } + BaseItemKind.AUDIO -> { + LaunchedEffect(Unit) { onClearBackdrop.invoke() } + SongDetailsPage( + preferences = preferences, + itemId = destination.itemId, + modifier = modifier, + ) + } + else -> { Timber.w("Unsupported item type: ${destination.type}") Text("Unsupported item type: ${destination.type}", modifier) @@ -285,6 +307,16 @@ fun DestinationContent( BaseItemKind.STUDIO -> DefaultForStudiosFilterOptions else -> throw IllegalArgumentException("Unsupported parentType ${destination.parentType}") }, + sortOptions = rememberSortOptions(destination.collectionType), + modifier = modifier, + ) + } + + is Destination.MoreHomeRow -> { + LaunchedEffect(Unit) { onClearBackdrop.invoke() } + HomeRowGrid( + preferences = preferences, + destination = destination, modifier = modifier, ) } @@ -299,9 +331,10 @@ fun DestinationContent( ) } - is Destination.ItemGrid -> { + is Destination.ItemGrid<*> -> { LaunchedEffect(Unit) { onClearBackdrop.invoke() } ItemGrid( + preferences, destination, modifier, ) @@ -333,9 +366,10 @@ fun DestinationContent( LicenseInfo(modifier) } - Destination.Search -> { + is Destination.Search -> { LaunchedEffect(Unit) { onClearBackdrop.invoke() } SearchPage( + initialQuery = destination.query, userPreferences = preferences, modifier = modifier, ) @@ -346,6 +380,7 @@ fun DestinationContent( } Destination.Discover -> { + LaunchedEffect(Unit) { onClearBackdrop.invoke() } DiscoverPage( preferences = preferences, modifier = modifier, @@ -374,6 +409,7 @@ fun DestinationContent( LaunchedEffect(Unit) { onClearBackdrop.invoke() } DiscoverPersonPage( person = destination.item, + startIndex = destination.startIndex, modifier = modifier, ) } @@ -391,6 +427,8 @@ fun DestinationContent( LaunchedEffect(Unit) { onClearBackdrop.invoke() } DiscoverRequestGrid( destination = destination, + showTitle = true, + positionCallback = { _, _ -> }, modifier = modifier, ) } 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 26068182..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 @@ -35,7 +35,6 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment @@ -56,7 +55,6 @@ import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.findViewTreeViewModelStoreOwner import androidx.lifecycle.viewModelScope @@ -69,7 +67,6 @@ import androidx.tv.material3.NavigationDrawerItemDefaults import androidx.tv.material3.NavigationDrawerScope import androidx.tv.material3.ProvideTextStyle import androidx.tv.material3.Text -import androidx.tv.material3.surfaceColorAtElevation import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.data.model.JellyfinUser @@ -83,16 +80,19 @@ import com.github.damontecres.wholphin.services.SetupDestination import com.github.damontecres.wholphin.services.SetupNavigationManager import com.github.damontecres.wholphin.ui.FontAwesome import com.github.damontecres.wholphin.ui.components.TimeDisplay +import com.github.damontecres.wholphin.ui.enableMarquee import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption -import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.setup.UserIconCardImage import com.github.damontecres.wholphin.ui.spacedByWithFooter import com.github.damontecres.wholphin.ui.theme.LocalTheme import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.ui.tryRequestFocus import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.imageApi import org.jellyfin.sdk.model.api.CollectionType @@ -111,16 +111,15 @@ class NavDrawerViewModel val backdropService: BackdropService, private val musicService: MusicService, ) : ViewModel() { - val state = navDrawerService.state + val serviceState = navDrawerService.state - val selectedIndex = MutableLiveData(-1) - val moreExpanded = MutableLiveData(false) + private val _state = MutableStateFlow(NavDrawerState()) + val state: StateFlow<NavDrawerState> = _state fun onClickDrawerItem( index: Int, item: NavDrawerItem, ) { - if (item !is NavDrawerItem.More) setShowMore(false) when (item) { NavDrawerItem.Favorites -> { setIndex(index) @@ -130,7 +129,7 @@ class NavDrawerViewModel } NavDrawerItem.More -> { - setShowMore(!moreExpanded.value!!) + setShowMore(!state.value.moreExpanded) } NavDrawerItem.Discover -> { @@ -148,11 +147,11 @@ class NavDrawerViewModel } fun setIndex(index: Int) { - selectedIndex.value = index + _state.update { it.copy(selectedIndex = index) } } fun setShowMore(value: Boolean) { - moreExpanded.value = value + _state.update { it.copy(moreExpanded = value) } } fun getUserImage(user: JellyfinUser): String = api.imageApi.getUserImageUrl(user.id) @@ -163,48 +162,40 @@ class NavDrawerViewModel fun updateSelectedIndex() { viewModelScope.launchDefault { val asDestinations = - ( - state.value.items + - listOf( - NavDrawerItem.More, - NavDrawerItem.Discover, - ) + state.value.moreItems - ).map { - if (it is ServerNavDrawerItem) { - it.destination - } else if (it is NavDrawerItem.Favorites) { - Destination.Favorites - } else if (it is NavDrawerItem.Discover) { - Destination.Discover - } else { - null + buildList { + addAll(serviceState.value.items) + addAll(serviceState.value.moreItems) + }.map { + when (it) { + is ServerNavDrawerItem -> it.destination + is NavDrawerItem.Favorites -> Destination.Favorites + is NavDrawerItem.Discover -> Destination.Discover + else -> null } } val backstack = navigationManager.backStack.toList().reversed() for (i in 0..<backstack.size) { val key = backstack[i] - if (key is Destination) { - val index = - if (key is Destination.Home) { - HOME_INDEX - } else if (key is Destination.Search) { - SEARCH_INDEX - } else if (key is Destination.NowPlaying) { - NOW_PLAYING_INDEX + val index = + if (key is Destination.Home) { + HOME_INDEX + } else if (key is Destination.Search) { + SEARCH_INDEX + } else if (key is Destination.NowPlaying) { + NOW_PLAYING_INDEX + } else { + val idx = asDestinations.indexOf(key) + if (idx >= 0) { + idx } else { - val idx = asDestinations.indexOf(key) - if (idx >= 0) { - idx - } else { - null - } + null } - Timber.v("Found $index => $key") - if (index != null) { - selectedIndex.setValueOnMain(index) - break } + Timber.v("Found $index => $key") + if (index != null) { + _state.update { it.copy(selectedIndex = index) } + break } } } @@ -218,6 +209,11 @@ class NavDrawerViewModel } } +data class NavDrawerState( + val selectedIndex: Int = -1, + val moreExpanded: Boolean = false, +) + /** * An item that can be shown in the nav drawer * @@ -303,10 +299,11 @@ fun NavDrawer( drawerState.setValue(DrawerValue.Open) focusRequester.requestFocus() } + val serviceState by viewModel.serviceState.collectAsState() val state by viewModel.state.collectAsState() - val moreExpanded by viewModel.moreExpanded.observeAsState(false) + val moreExpanded = state.moreExpanded // A negative index is a built-in page, >=0 is a library - val selectedIndex by viewModel.selectedIndex.observeAsState(-1) + val selectedIndex = state.selectedIndex BackHandler(enabled = moreExpanded && drawerState.currentValue == DrawerValue.Open) { viewModel.setShowMore(false) @@ -361,14 +358,14 @@ fun NavDrawer( modifier = Modifier, ) AnimatedVisibility( - visible = state.nowPlayingEnabled, + visible = serviceState.nowPlayingEnabled, enter = expandVertically(expandFrom = Alignment.Top), exit = shrinkVertically(shrinkTowards = Alignment.Top), ) { val interactionSource = remember { MutableInteractionSource() } IconNavItem( text = stringResource(R.string.now_playing), - subtext = state.nowPlayingTitle, + subtext = serviceState.nowPlayingTitle, icon = Icons.Default.PlayArrow, selected = selectedIndex == NOW_PLAYING_INDEX, drawerOpen = isOpen, @@ -413,7 +410,7 @@ fun NavDrawer( interactionSource = interactionSource, onClick = { viewModel.setIndex(SEARCH_INDEX) - viewModel.navigationManager.navigateToFromDrawer(Destination.Search) + viewModel.navigationManager.navigateToFromDrawer(Destination.Search()) }, modifier = Modifier @@ -436,6 +433,7 @@ fun NavDrawer( viewModel.setIndex(HOME_INDEX) if (destination is Destination.Home) { viewModel.navigationManager.reloadHome() + onClearBackdrop.invoke() } else { viewModel.navigationManager.goToHome() } @@ -448,34 +446,32 @@ fun NavDrawer( ), ) } - itemsIndexed(state.items) { index, it -> - if (it !is NavDrawerItem.Discover || state.discoverEnabled) { - val interactionSource = remember { MutableInteractionSource() } - NavItem( - library = it, - selected = selectedIndex == index, - moreExpanded = moreExpanded, - drawerOpen = isOpen, - interactionSource = interactionSource, - onClick = { - viewModel.onClickDrawerItem(index, it) - }, - modifier = - Modifier - .ifElse( - selectedIndex == index, - Modifier.focusRequester(focusRequester), - ), - ) - } + itemsIndexed(serviceState.items) { index, it -> + val interactionSource = remember { MutableInteractionSource() } + NavItem( + library = it, + selected = selectedIndex == index, + moreExpanded = moreExpanded, + drawerOpen = isOpen, + interactionSource = interactionSource, + onClick = { + viewModel.onClickDrawerItem(index, it) + }, + modifier = + Modifier + .ifElse( + selectedIndex == index, + Modifier.focusRequester(focusRequester), + ), + ) } - if (state.moreItems.isNotEmpty()) { + if (serviceState.moreItems.isNotEmpty()) { item { - val index = state.items.size + val index = serviceState.items.size val interactionSource = remember { MutableInteractionSource() } NavItem( library = NavDrawerItem.More, - selected = selectedIndex == index, + selected = false, moreExpanded = moreExpanded, drawerOpen = isOpen, interactionSource = interactionSource, @@ -492,37 +488,35 @@ fun NavDrawer( } } if (moreExpanded) { - itemsIndexed(state.moreItems) { index, it -> + itemsIndexed(serviceState.moreItems) { index, it -> val adjustedIndex = - remember(state) { (index + state.items.size + 1) } - if (it !is NavDrawerItem.Discover || state.discoverEnabled) { - val interactionSource = remember { MutableInteractionSource() } - NavItem( - library = it, - selected = selectedIndex == adjustedIndex, - moreExpanded = moreExpanded, - drawerOpen = isOpen, - onClick = { - viewModel.onClickDrawerItem( - adjustedIndex, - it, - ) + remember(serviceState) { (index + serviceState.items.size) } + val interactionSource = remember { MutableInteractionSource() } + NavItem( + library = it, + selected = selectedIndex == adjustedIndex, + moreExpanded = moreExpanded, + drawerOpen = isOpen, + onClick = { + viewModel.onClickDrawerItem( + adjustedIndex, + it, + ) + }, + containerColor = + if (isOpen) { + MaterialTheme.colorScheme.surface.copy(alpha = .5f) + } else { + Color.Unspecified }, - containerColor = - if (isOpen) { - MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp) - } else { - Color.Unspecified - }, - interactionSource = interactionSource, - modifier = - Modifier - .ifElse( - selectedIndex == adjustedIndex, - Modifier.focusRequester(focusRequester), - ), - ) - } + interactionSource = interactionSource, + modifier = + Modifier + .ifElse( + selectedIndex == adjustedIndex, + Modifier.focusRequester(focusRequester), + ), + ) } } item { @@ -580,6 +574,7 @@ fun NavigationDrawerScope.ProfileIcon( modifier: Modifier = Modifier, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, ) { + val focused by interactionSource.collectIsFocusedAsState() NavigationDrawerItem( modifier = modifier, selected = false, @@ -602,7 +597,7 @@ fun NavigationDrawerScope.ProfileIcon( interactionSource = interactionSource, ) { Text( - modifier = Modifier, + modifier = Modifier.enableMarquee(focused), text = user.name ?: user.id.toString(), maxLines = 1, ) @@ -646,7 +641,7 @@ fun NavigationDrawerScope.IconNavItem( interactionSource = interactionSource, ) { Text( - modifier = Modifier, + modifier = Modifier.enableMarquee(focused), text = text, maxLines = 1, ) @@ -740,7 +735,7 @@ fun NavigationDrawerScope.NavItem( interactionSource = interactionSource, ) { Text( - modifier = Modifier, + modifier = Modifier.enableMarquee(focused), text = library.name(context), maxLines = 1, ) @@ -779,6 +774,8 @@ fun navItemColor( AppThemeColors.BLUE, AppThemeColors.GREEN, AppThemeColors.ORANGE, + AppThemeColors.RED, + AppThemeColors.BROWN, -> MaterialTheme.colorScheme.border AppThemeColors.BOLD_BLUE, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/CurrentMediaInfo.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/CurrentMediaInfo.kt index 40b5e89b..380901a1 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/CurrentMediaInfo.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/CurrentMediaInfo.kt @@ -4,7 +4,10 @@ import androidx.core.net.toUri import androidx.media3.common.MediaMetadata import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.Chapter +import com.github.damontecres.wholphin.ui.isNotNullOrBlank import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.MediaSourceInfo +import org.jellyfin.sdk.model.api.MediaStreamType import org.jellyfin.sdk.model.api.TrickplayInfo import org.jellyfin.sdk.model.extensions.ticks @@ -26,6 +29,40 @@ data class CurrentMediaInfo( } } +/** + * Gets a sorted list of subtitle streams in a [MediaSourceInfo] + */ +internal fun PlaybackViewModel.getSubtitleStreams(mediaSource: MediaSourceInfo): List<SimpleMediaStream> { + val subtitleLanguagePreference = + serverRepository.currentUserDto + ?.configuration + ?.subtitleLanguagePreference + return mediaSource.mediaStreams + ?.filter { it.type == MediaStreamType.SUBTITLE } + .let { + if (subtitleLanguagePreference.isNotNullOrBlank()) { + it?.sortedByDescending { it.language != null && subtitleLanguagePreference == it.language } + } else { + it + } + }?.map { + // TODO should use a string provider instead + SimpleMediaStream.from(context.resources, it, true) + }.orEmpty() +} + +/** + * Gets a list of audio streams in a [MediaSourceInfo] + */ +internal fun PlaybackViewModel.getAudioStreams(mediaSource: MediaSourceInfo): List<SimpleMediaStream> = + mediaSource.mediaStreams + ?.filter { it.type == MediaStreamType.AUDIO } + ?.map { + SimpleMediaStream.from(context.resources, it, true) + } +// ?.sortedWith(compareBy<AudioStream> { it.language }.thenByDescending { it.channels }) + .orEmpty() + fun BaseItem.toMediaMetadata(imageUrl: String?): MediaMetadata = MediaMetadata .Builder() diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/CurrentPlayback.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/CurrentPlayback.kt index b0f51e56..54663af6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/CurrentPlayback.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/CurrentPlayback.kt @@ -1,11 +1,14 @@ package com.github.damontecres.wholphin.ui.playback import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.TrackIndex import com.github.damontecres.wholphin.preferences.PlayerBackend import com.github.damontecres.wholphin.util.TrackSupport import org.jellyfin.sdk.model.api.MediaSourceInfo import org.jellyfin.sdk.model.api.PlayMethod import org.jellyfin.sdk.model.api.TranscodingInfo +import org.jellyfin.sdk.model.serializer.toUUIDOrNull +import java.util.UUID import kotlin.time.Duration /** @@ -25,4 +28,12 @@ data class CurrentPlayback( val audioDecoder: String? = null, val transcodeInfo: TranscodingInfo? = null, val subtitleDelay: Duration = Duration.ZERO, -) + val audioIndex: Int = TrackIndex.UNSPECIFIED, + val subtitleIndex: Int = TrackIndex.UNSPECIFIED, +) { + val audioIndexEnabled = audioIndex >= 0 + val subtitleIndexEnabled = subtitleIndex >= 0 + + val itemId: UUID get() = item.id + val sourceId: UUID? = mediaSourceInfo.id?.toUUIDOrNull() +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/DownloadSubtitlesDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/DownloadSubtitlesDialog.kt index d4039f72..2ec02410 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/DownloadSubtitlesDialog.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/DownloadSubtitlesDialog.kt @@ -1,21 +1,15 @@ package com.github.damontecres.wholphin.ui.playback import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Star -import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -25,11 +19,9 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.dp @@ -37,15 +29,13 @@ import androidx.tv.material3.Icon import androidx.tv.material3.ListItem import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text -import androidx.tv.material3.surfaceColorAtElevation 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.abbreviateNumber -import com.github.damontecres.wholphin.ui.components.DialogItem -import com.github.damontecres.wholphin.ui.components.DialogItemDivider import com.github.damontecres.wholphin.ui.components.EditTextBox import com.github.damontecres.wholphin.ui.components.ErrorMessage -import com.github.damontecres.wholphin.ui.ifElse +import com.github.damontecres.wholphin.ui.theme.WholphinTheme import com.github.damontecres.wholphin.ui.tryRequestFocus import org.jellyfin.sdk.model.api.RemoteSubtitleInfo @@ -55,51 +45,41 @@ fun DownloadSubtitlesContent( language: String, onSearch: (String) -> Unit, onClickDownload: (RemoteSubtitleInfo) -> Unit, - onDismissRequest: () -> Unit, modifier: Modifier = Modifier, ) { when (val s = state) { + SubtitleSearchStatus.Inactive -> {} + SubtitleSearchStatus.Searching -> { - Wrapper { - Text( - text = stringResource(R.string.searching), - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface, - ) - } + Text( + text = stringResource(R.string.searching), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + modifier = modifier, + ) } SubtitleSearchStatus.Downloading -> { - Wrapper { - Text( - text = stringResource(R.string.downloading), - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface, - ) - } + Text( + text = stringResource(R.string.downloading), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + modifier = modifier, + ) } is SubtitleSearchStatus.Error -> { - Wrapper { ErrorMessage(null, s.ex, modifier) } + ErrorMessage(null, s.ex, modifier) } is SubtitleSearchStatus.Success -> { - val dialogItems = convertRemoteSubtitles(s.options, onClickDownload) val focusRequester = remember { FocusRequester() } LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } - val elevatedContainerColor = - MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp) Column( verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = - modifier - .graphicsLayer { - this.clip = true - this.shape = RoundedCornerShape(28.0.dp) - }.drawBehind { drawRect(color = elevatedContainerColor) } - .padding(PaddingValues(24.dp)), + modifier = modifier, ) { Text( text = stringResource(R.string.search_and_download_subtitles), @@ -126,51 +106,35 @@ fun DownloadSubtitlesContent( onSearch(lang) }, ), - // onKeyboardAction = { -// onSearch(lang.text.toString()) -// }, keyboardOptions = KeyboardOptions( imeAction = ImeAction.Search, ), ) } - if (dialogItems.isEmpty()) { + if (s.options.isEmpty()) { Text( text = stringResource(R.string.no_subtitles_found), style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface, ) } else { + val focusRequesters = + remember(s.options.size) { List(s.options.size) { FocusRequester() } } + LaunchedEffect(Unit) { + focusRequesters.firstOrNull()?.tryRequestFocus() + } LazyColumn( modifier = Modifier, ) { - itemsIndexed(dialogItems) { index, item -> - when (item) { - is DialogItemDivider -> { - HorizontalDivider(Modifier.height(16.dp)) - } - - is DialogItem -> { - ListItem( - selected = false, - enabled = item.enabled, - onClick = { - item.onClick.invoke() - }, - headlineContent = item.headlineContent, - overlineContent = item.overlineContent, - supportingContent = item.supportingContent, - leadingContent = item.leadingContent, - trailingContent = item.trailingContent, - modifier = - Modifier.ifElse( - index == 0, - Modifier.focusRequester(focusRequester), - ), - ) - } - } + itemsIndexed(s.options) { index, item -> + SubtitleInfo( + subtitle = item, + onClick = onClickDownload, + modifier = + Modifier + .focusRequester(focusRequesters[index]), + ) } } } @@ -180,54 +144,56 @@ fun DownloadSubtitlesContent( } @Composable -private fun Wrapper(content: @Composable BoxScope.() -> Unit) { - val elevatedContainerColor = - MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp) - Box( - modifier = - Modifier - .graphicsLayer { - this.clip = true - this.shape = RoundedCornerShape(28.0.dp) - }.drawBehind { drawRect(color = elevatedContainerColor) } - .padding(PaddingValues(24.dp)), - content = content, - ) -} - -fun convertRemoteSubtitles( - options: List<RemoteSubtitleInfo>, +fun SubtitleInfo( + subtitle: RemoteSubtitleInfo, onClick: (RemoteSubtitleInfo) -> Unit, -) = options.map { op -> - DialogItem( - onClick = { onClick.invoke(op) }, + modifier: Modifier = Modifier, +) { + ListItem( + selected = false, + onClick = { onClick.invoke(subtitle) }, headlineContent = { Text( - text = op.name ?: "", + text = subtitle.name ?: "", ) }, + overlineContent = + if (subtitle.isHashMatch == true) { + { + Text(stringResource(R.string.hash_matches)) + } + } else { + null + }, supportingContent = { - val strings = - buildList { - op.providerName?.let(::add) - op.threeLetterIsoLanguageName?.let(::add) - if (op.forced == true) { - add(stringResource(R.string.forced_track)) - } - add( - pluralStringResource( - R.plurals.downloads, - op.downloadCount ?: 0, - abbreviateNumber(op.downloadCount ?: 0), - ), - ) + val resources = LocalResources.current + val details = + remember(resources, subtitle) { + val strings = + buildList { + subtitle.providerName?.let(::add) + if (subtitle.forced == true) { + add(resources.getString(R.string.forced_track)) + } + if (subtitle.hearingImpaired == true) { + add(resources.getString(R.string.subtitles_hearing_impaired)) + } + add( + resources.getQuantityString( + R.plurals.downloads, + subtitle.downloadCount ?: 0, + abbreviateNumber(subtitle.downloadCount ?: 0), + ), + ) + } + strings.joinToString(" - ") } Text( - text = strings.joinToString(" - "), + text = details, ) }, trailingContent = { - op.communityRating?.let { rating -> + subtitle.communityRating?.let { rating -> Row( horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically, @@ -243,5 +209,43 @@ fun convertRemoteSubtitles( } } }, + modifier = modifier, ) } + +@PreviewTvSpec +@Composable +fun SubtitleInfoPreview() { + WholphinTheme { + Column(Modifier.width(400.dp)) { + SubtitleInfo( + onClick = {}, + subtitle = + RemoteSubtitleInfo( + name = "Filename.mkv", + providerName = "OpenSubs", + threeLetterIsoLanguageName = "eng", + forced = true, + hearingImpaired = false, + isHashMatch = true, + communityRating = 7.5f, + downloadCount = 10_500, + ), + ) + SubtitleInfo( + onClick = {}, + subtitle = + RemoteSubtitleInfo( + name = "Filename.mkv", + providerName = "OpenSubs", + threeLetterIsoLanguageName = "eng", + forced = false, + hearingImpaired = true, + isHashMatch = false, + communityRating = 7.5f, + downloadCount = 10_500, + ), + ) + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/KeyIdentifier.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/KeyIdentifier.kt index 1915e02b..eed379e7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/KeyIdentifier.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/KeyIdentifier.kt @@ -32,6 +32,14 @@ fun isControllerMedia(event: KeyEvent) = event.key == Key.ButtonL1 || event.key == Key.ButtonL2 +fun isDpadLeft(event: KeyEvent) = + event.key == Key.DirectionLeft || + event.key == Key.SystemNavigationLeft + +fun isDpadRight(event: KeyEvent) = + event.key == Key.DirectionRight || + event.key == Key.SystemNavigationRight + fun isSkipBack(event: KeyEvent) = event.key == Key.DirectionLeft || event.key == Key.ButtonL1 || diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/NextUpEpisode.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/NextUpEpisode.kt index f032c8b7..d93ffda6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/NextUpEpisode.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/NextUpEpisode.kt @@ -10,10 +10,8 @@ import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons @@ -21,6 +19,7 @@ import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -29,7 +28,10 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -47,10 +49,17 @@ import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.ui.AppColors import com.github.damontecres.wholphin.ui.AspectRatios import com.github.damontecres.wholphin.ui.PreviewTvSpec +import com.github.damontecres.wholphin.ui.dot +import com.github.damontecres.wholphin.ui.formatDuration +import com.github.damontecres.wholphin.ui.formatTime import com.github.damontecres.wholphin.ui.theme.WholphinTheme import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.ui.util.Clock +import com.github.damontecres.wholphin.ui.util.LocalClock import kotlin.time.Duration +import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds +import kotlin.time.toJavaDuration /** * Layout for showing the next up episode during playback @@ -64,6 +73,7 @@ fun NextUpEpisode( timeLeft: Duration?, modifier: Modifier = Modifier, aspectRatio: Float = AspectRatios.WIDE, + runtime: Duration? = null, ) { val interactionSource = remember { MutableInteractionSource() } val focusRequester = remember { FocusRequester() } @@ -122,9 +132,36 @@ fun NextUpEpisode( text = description ?: "", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface, + maxLines = 4, overflow = TextOverflow.Ellipsis, modifier = Modifier, ) + runtime?.let { + val context = LocalContext.current + val resources = LocalResources.current + val now by LocalClock.current.now + val text = + remember(now, runtime) { + buildAnnotatedString { + append(resources.formatDuration(runtime)) + dot() + val endsAt = now.plus(runtime.toJavaDuration()) + val endTimeStr = formatTime(context, endsAt) + append(resources.getString(R.string.ends_at, endTimeStr)) + } + } + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = + Modifier + .padding(top = 4.dp) + .align(Alignment.Start), + ) + } } } } @@ -189,34 +226,41 @@ fun NextUpCard( @Composable private fun NextUpEpisodePreview() { WholphinTheme(true) { + val theme = MaterialTheme.colorScheme val previewHandler = AsyncImagePreviewHandler { - ColorImage(Color.Blue.toArgb()) + ColorImage(theme.surfaceVariant.toArgb()) + } + Box( + modifier = Modifier.fillMaxSize().background(Color.Black), + ) { + CompositionLocalProvider( + LocalAsyncImagePreviewHandler provides previewHandler, + LocalClock provides Clock(), + ) { + NextUpEpisode( + title = "Episode Title", + description = + "This is the description of the episode. It might be long. " + + "It might be very, very long and overflow the available space. " + + "But it just keeps going and going.", + imageUrl = "", + onClick = {}, + aspectRatio = AspectRatios.WIDE, + timeLeft = 30.seconds, + runtime = 32.minutes + 20.seconds, + modifier = + Modifier + .padding(8.dp) + .fillMaxHeight(.4f) + .fillMaxWidth(.66f) + .align(Alignment.BottomCenter) + .background( + MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp), + shape = RoundedCornerShape(8.dp), + ), + ) } - - CompositionLocalProvider(LocalAsyncImagePreviewHandler provides previewHandler) { - NextUpEpisode( - title = "Episode Title", - description = - "This is the description of the episode. It might be long. " + - "It might be very, very long and overflow the available space. " + - "But it just keeps going and going.", - imageUrl = "", - onClick = {}, - aspectRatio = AspectRatios.FOUR_THREE, - timeLeft = 30.seconds, - modifier = - Modifier - .padding(16.dp) - .height(200.dp) - .width(400.dp) -// .fillMaxWidth(.4f) -// .align(Alignment.BottomCenter) - .background( - MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp), - shape = RoundedCornerShape(8.dp), - ), - ) } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayExternalPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayExternalPage.kt index 915fc5a7..421cc279 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayExternalPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayExternalPage.kt @@ -25,6 +25,7 @@ import com.github.damontecres.wholphin.data.ItemPlaybackDao import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.PlaylistItem +import com.github.damontecres.wholphin.data.model.SeriesTrackChoiceType import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.PlaylistCreationResult @@ -81,9 +82,11 @@ class PlayExternalViewModel ) : ViewModel() { val launched = savedStateHandle.getMutableStateFlow("launched", false) val state = MutableStateFlow(PlayExternalState()) + private var watchScope: String? = null fun init(destination: Destination) { Timber.v("init called: %s", destination) + watchScope = "${serverRepository.currentServer?.id}:${serverRepository.currentUser?.id}" state.update { it.copy(loading = LoadingState.Loading) } viewModelScope.launchDefault { val prefs = userPreferencesService.getCurrent() @@ -142,7 +145,7 @@ class PlayExternalViewModel throw IllegalArgumentException("Item is not playable and not PlaybackList: ${queriedItem.type}") } val playbackConfig = - serverRepository.currentUser.value?.let { user -> + serverRepository.currentUser?.let { user -> itemPlaybackDao.getItem(user, playlistItem.id)?.let { Timber.v("Fetched itemPlayback from DB: %s", it) if (it.sourceId != null) { @@ -158,7 +161,11 @@ class PlayExternalViewModel is PlaylistItem.Media -> playlistItem.item } val mediaSource = streamChoiceService.chooseSource(item.data, playbackConfig) - val plc = streamChoiceService.getPlaybackLanguageChoice(item.data) + val subtitleStc = + streamChoiceService.getSeriesTrackChoices( + item.data, + SeriesTrackChoiceType.SUBTITLE, + ) if (mediaSource == null) { Timber.w("Media source is null") return@launchDefault @@ -168,13 +175,14 @@ class PlayExternalViewModel val subtitleIndex = streamChoiceService .chooseSubtitleStream( + item = item.data, source = mediaSource, audioStream = null, - seriesId = item.data.seriesId, itemPlayback = playbackConfig, - plc = plc, + stc = subtitleStc, prefs = prefs, - )?.index + )?.stream + ?.index val externalSubtitles = mediaSource.mediaStreams ?.filter { it.isExternal } @@ -237,6 +245,8 @@ class PlayExternalViewModel } // VLC + // A zero position alone does not override VLC's saved resume position. + putExtra("from_start", positionMs == 0L) if (subtitleUrls.isNotEmpty()) { putExtra("subtitles_location", subtitleUrls.first().toString()) } @@ -338,15 +348,19 @@ class PlayExternalViewModel } } Timber.v("Result position: %s", position?.milliseconds) + if (position != null && position < 0L) { + Timber.w("Unknown stop position for external playback") + } if (position != null || result.data?.action != null) { api.playStateApi.reportPlaybackStopped( PlaybackStopInfo( itemId = itemId, mediaSourceId = mediaSourceId, - positionTicks = position?.milliseconds?.inWholeTicks, + positionTicks = position?.milliseconds?.inWholeTicks?.takeIf { it >= 0 }, failed = false, ), ) + watchScope?.let { com.github.damontecres.wholphin.util.PlaybackWatchState.stopped(context, it) } } } else { Timber.w( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt index 5b0bd5d8..3165246f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt @@ -3,6 +3,7 @@ package com.github.damontecres.wholphin.ui.playback import android.view.Gravity import androidx.compose.foundation.background import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth @@ -11,18 +12,26 @@ import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.input.key.type import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties @@ -33,13 +42,16 @@ import androidx.tv.material3.Text import androidx.tv.material3.surfaceColorAtElevation import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.TrackIndex +import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.ui.AppColors import com.github.damontecres.wholphin.ui.components.SelectedLeadingContent +import com.github.damontecres.wholphin.ui.formatMaxBitrate import com.github.damontecres.wholphin.ui.indexOfFirstOrNull import com.github.damontecres.wholphin.ui.playback.overlay.BottomDialog import com.github.damontecres.wholphin.ui.playback.overlay.BottomDialogItem import com.github.damontecres.wholphin.ui.playback.overlay.PlaybackAction import com.github.damontecres.wholphin.ui.tryRequestFocus +import kotlinx.coroutines.launch import kotlin.time.Duration enum class PlaybackDialogType { @@ -50,6 +62,7 @@ enum class PlaybackDialogType { PLAYBACK_SPEED, VIDEO_SCALE, SUBTITLE_DELAY, + BITRATE, } data class PlaybackSettings( @@ -63,6 +76,7 @@ data class PlaybackSettings( val subtitleDelay: Duration, val hasSubtitleDownloadPermission: Boolean, val playbackSpeedEnabled: Boolean, + val maxBitrate: Long, ) /** @@ -85,6 +99,10 @@ fun PlaybackDialog( onPlaybackActionClick: (PlaybackAction) -> Unit, onChangeSubtitleDelay: (Duration) -> Unit, ) { + val isLtr = LocalLayoutDirection.current == LayoutDirection.Ltr + // TODO, shouldn't this work out of the box? + val leftGravity = remember(isLtr) { if (isLtr) Gravity.START else Gravity.END } + val rightGravity = remember(isLtr) { if (isLtr) Gravity.END else Gravity.START } when (type) { PlaybackDialogType.DEBUG -> { throw IllegalStateException("Should not open a dialog with " + PlaybackDialogType.DEBUG) @@ -113,7 +131,7 @@ fun PlaybackDialog( onDismissRequest.invoke() onPlaybackActionClick.invoke(PlaybackAction.SearchCaptions) }, - gravity = Gravity.END, + gravity = rightGravity, ) } @@ -148,6 +166,13 @@ fun PlaybackDialog( ), ) } + add( + BottomDialogItem( + data = PlaybackDialogType.BITRATE, + headline = stringResource(R.string.max_bitrate), + supporting = remember(settings.maxBitrate) { formatMaxBitrate(settings.maxBitrate) }, + ), + ) add( BottomDialogItem( data = PlaybackDialogType.DEBUG, @@ -167,7 +192,7 @@ fun PlaybackDialog( onClickPlaybackDialogType(choice.data) } }, - gravity = Gravity.START, + gravity = leftGravity, ) } @@ -182,7 +207,7 @@ fun PlaybackDialog( onSelectChoice = { _, choice -> onPlaybackActionClick.invoke(PlaybackAction.ToggleAudio(choice.index)) }, - gravity = Gravity.END, + gravity = rightGravity, ) } @@ -205,7 +230,7 @@ fun PlaybackDialog( onSelectChoice = { _, value -> onPlaybackActionClick.invoke(PlaybackAction.PlaybackSpeed(value.data)) }, - gravity = Gravity.START, + gravity = leftGravity, ) } @@ -228,7 +253,7 @@ fun PlaybackDialog( onSelectChoice = { _, choice -> onPlaybackActionClick.invoke(PlaybackAction.Scale(choice.data)) }, - gravity = Gravity.START, + gravity = leftGravity, ) } @@ -257,6 +282,29 @@ fun PlaybackDialog( } } } + + PlaybackDialogType.BITRATE -> { + val choices = + AppPreference.bitrateValues.map { bitrate -> + BottomDialogItem( + data = bitrate, + headline = formatMaxBitrate(bitrate), + supporting = null, + ) + } + BottomDialog( + choices = choices, + currentChoice = choices.firstOrNull { it.data == settings.maxBitrate }, + onDismissRequest = { + onControllerInteraction.invoke() + onDismissRequest.invoke() + }, + onSelectChoice = { _, choice -> + onPlaybackActionClick.invoke(PlaybackAction.ChangeBitrate(choice.data)) + }, + gravity = leftGravity, + ) + } } } @@ -270,6 +318,24 @@ fun SubtitleChoiceBottomDialog( hasDownloadPermission: Boolean, currentChoice: Int? = null, ) { + val numberBefore = 2 + val initialChoice = + remember(choices) { + choices.indexOfFirstOrNull { it.index == currentChoice } + } + val scope = rememberCoroutineScope() + val listState = + rememberLazyListState( + initialFirstVisibleItemIndex = initialChoice?.plus(numberBefore) ?: 0, + ) + val firstFocusRequester = remember { FocusRequester() } + val lastFocusRequester = remember { FocusRequester() } + val choiceFocusRequesters = remember(choices.size) { List(choices.size) { FocusRequester() } } + LaunchedEffect(Unit) { + val focusRequester = + initialChoice?.let { choiceFocusRequesters.getOrNull(it) } ?: firstFocusRequester + focusRequester.tryRequestFocus() + } // TODO enforcing a width ends up ignore the gravity Dialog( onDismissRequest = onDismissRequest, @@ -292,6 +358,7 @@ fun SubtitleChoiceBottomDialog( ), ) { LazyColumn( + state = listState, modifier = Modifier .fillMaxWidth() @@ -301,8 +368,11 @@ fun SubtitleChoiceBottomDialog( horizontalAlignment = Alignment.CenterHorizontally, ) { item { + val interactionSource = remember { MutableInteractionSource() } + val focused by interactionSource.collectIsFocusedAsState() ListItem( selected = currentChoice == TrackIndex.DISABLED, + interactionSource = interactionSource, onClick = { onSelectChoice(TrackIndex.DISABLED) }, @@ -315,6 +385,19 @@ fun SubtitleChoiceBottomDialog( ) }, supportingContent = {}, + modifier = + Modifier + .focusRequester(firstFocusRequester) + .onKeyEvent { + if (focused && isUp(it) && it.type == KeyEventType.KeyDown) { + scope.launch { + listState.animateScrollToItem(Int.MAX_VALUE) + lastFocusRequester.tryRequestFocus() + } + return@onKeyEvent true + } + false + }, ) } item { @@ -333,6 +416,9 @@ fun SubtitleChoiceBottomDialog( }, supportingContent = {}, ) + if (choices.isNotEmpty()) { + HorizontalDivider() + } } itemsIndexed(choices) { index, choice -> val interactionSource = remember { MutableInteractionSource() } @@ -353,10 +439,13 @@ fun SubtitleChoiceBottomDialog( if (choice.streamTitle != null) Text(choice.displayTitle) }, interactionSource = interactionSource, + modifier = Modifier.focusRequester(choiceFocusRequesters[index]), ) } item { HorizontalDivider() + val interactionSource = remember { MutableInteractionSource() } + val focused by interactionSource.collectIsFocusedAsState() ListItem( selected = false, enabled = hasDownloadPermission, @@ -368,6 +457,20 @@ fun SubtitleChoiceBottomDialog( ) }, supportingContent = {}, + interactionSource = interactionSource, + modifier = + Modifier + .focusRequester(lastFocusRequester) + .onKeyEvent { + if (focused && isDown(it) && it.type == KeyEventType.KeyDown) { + scope.launch { + listState.animateScrollToItem(0) + firstFocusRequester.tryRequestFocus() + } + return@onKeyEvent true + } + false + }, ) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackKeyHandler.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackKeyHandler.kt index a7cd4bb1..1659d4a7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackKeyHandler.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackKeyHandler.kt @@ -7,6 +7,7 @@ import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.type import androidx.media3.common.Player import androidx.media3.common.util.Util +import com.github.damontecres.wholphin.preferences.DpadSeekMode import com.github.damontecres.wholphin.ui.seekBack import com.github.damontecres.wholphin.ui.seekForward import kotlin.time.Duration @@ -23,11 +24,17 @@ class PlaybackKeyHandler( private val getDurationMs: () -> Long, private val controllerViewState: ControllerViewState, private val updateSkipIndicator: (Long) -> Unit, + private val clearSkipIndicator: () -> Unit, private val skipBackOnResume: Duration?, private val oneClickPause: Boolean, private val onInteraction: () -> Unit, private val onStop: () -> Unit, private val onPlaybackDialogTypeClick: (PlaybackDialogType) -> Unit, + private val isDpadSeekVisible: () -> Boolean = { false }, + private val onDpadSeek: (Long) -> Unit = { }, + private val dpadSeekMode: DpadSeekMode, + private val onSeekToNext: () -> Unit, + private val onPSeekToPrevious: () -> Unit, ) { private var leftHandledByRepeat = false private var rightHandledByRepeat = false @@ -47,11 +54,12 @@ class PlaybackKeyHandler( if (isDirectionalDpad(it) || isEnterKey(it) || isControllerMedia(it)) { if (!controllerViewState.controlsVisible) { if (skipWithLeftRight && isSkipBack(it)) { - updateSkipIndicator(-seekBack.inWholeMilliseconds) - player.seekBack(seekBack) + seekBy(-seekBack) } else if (skipWithLeftRight && isSkipForward(it)) { - player.seekForward(seekForward) - updateSkipIndicator(seekForward.inWholeMilliseconds) + seekBy(seekForward) + } else if (isEnterKey(it) && isDpadSeekVisible()) { + // If d-pad seek bar is visible, hide it + clearSkipIndicator.invoke() } else if (oneClickPause && isEnterKey(it)) { val wasPlaying = player.isPlaying Util.handlePlayPauseButtonAction(player) @@ -83,11 +91,11 @@ class PlaybackKeyHandler( } Key.MediaNext -> { - if (player.isCommandAvailable(Player.COMMAND_SEEK_TO_NEXT)) player.seekToNext() + onSeekToNext.invoke() } Key.MediaPrevious -> { - if (player.isCommandAvailable(Player.COMMAND_SEEK_TO_PREVIOUS)) player.seekToPrevious() + onPSeekToPrevious.invoke() } Key.Captions -> { @@ -169,12 +177,24 @@ class PlaybackKeyHandler( ) { if (isBack) { val skipDuration = seekBack * multiplier - player.seekBack(skipDuration) - updateSkipIndicator(-skipDuration.inWholeMilliseconds) + seekBy(-skipDuration) } else { val skipDuration = seekForward * multiplier - player.seekForward(skipDuration) - updateSkipIndicator(skipDuration.inWholeMilliseconds) + seekBy(skipDuration) + } + } + + private fun seekBy(duration: Duration) { + val durationMs = duration.inWholeMilliseconds + if (dpadSeekMode == DpadSeekMode.SEEKBAR_TRICKPLAY) { + onDpadSeek.invoke(durationMs) + } else { + if (duration < Duration.ZERO) { + player.seekBack(-duration) + } else { + player.seekForward(duration) + } + updateSkipIndicator(durationMs) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt index 5e748396..86d64651 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt @@ -1,14 +1,18 @@ package com.github.damontecres.wholphin.ui.playback import android.view.Gravity -import android.view.View import android.view.ViewGroup import android.widget.FrameLayout import androidx.activity.compose.BackHandler -import androidx.annotation.Dimension import androidx.annotation.OptIn import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.background import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.Box @@ -24,10 +28,10 @@ import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableLongStateOf @@ -46,13 +50,12 @@ import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.onSizeChanged -import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.text.intl.Locale +import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView -import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import androidx.core.view.children import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @@ -66,38 +69,38 @@ import androidx.media3.ui.compose.state.rememberPlayPauseButtonState import androidx.media3.ui.compose.state.rememberPresentationState import androidx.tv.material3.MaterialTheme import androidx.tv.material3.surfaceColorAtElevation -import com.github.damontecres.wholphin.data.model.ItemPlayback -import com.github.damontecres.wholphin.data.model.Playlist +import com.github.damontecres.wholphin.mpv.MpvPlayer import com.github.damontecres.wholphin.preferences.AssPlaybackMode +import com.github.damontecres.wholphin.preferences.DpadSeekMode import com.github.damontecres.wholphin.preferences.PlayerBackend import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.skipBackOnResume +import com.github.damontecres.wholphin.ui.AppColors import com.github.damontecres.wholphin.ui.AspectRatios import com.github.damontecres.wholphin.ui.LocalImageUrlService +import com.github.damontecres.wholphin.ui.components.BasicDialog import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.playback.overlay.DpadSeekOverlay import com.github.damontecres.wholphin.ui.playback.overlay.PauseIndicator import com.github.damontecres.wholphin.ui.playback.overlay.PlaybackAction import com.github.damontecres.wholphin.ui.playback.overlay.PlaybackOverlay import com.github.damontecres.wholphin.ui.playback.overlay.SkipIndicator import com.github.damontecres.wholphin.ui.playback.overlay.SkipSegmentButton import com.github.damontecres.wholphin.ui.playback.overlay.rememberSeekBarState -import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings.applyToMpv +import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings.applyTo import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings.calculateEdgeSize -import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings.toSubtitleStyle import com.github.damontecres.wholphin.ui.seasonEpisode import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.Media3SubtitleOverride -import com.github.damontecres.wholphin.util.mpv.MpvPlayer import io.github.peerless2012.ass.media.widget.AssSubtitleView -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import org.jellyfin.sdk.model.extensions.ticks import timber.log.Timber -import java.util.UUID import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds @@ -121,9 +124,8 @@ fun PlaybackPage( viewModel.release() } } - - val loading by viewModel.loading.observeAsState(LoadingState.Loading) - when (val st = loading) { + val state by viewModel.state.collectAsState() + when (val st = state.loading) { is LoadingState.Error -> { ErrorMessage(st, modifier) } @@ -137,7 +139,7 @@ fun PlaybackPage( LoadingState.Success -> { val playerState by viewModel.currentPlayer.collectAsState() PlaybackPageContent( - playerState = playerState!!, + playerInstance = playerState!!, preferences = preferences, destination = destination, viewModel = viewModel, @@ -150,53 +152,26 @@ fun PlaybackPage( @OptIn(UnstableApi::class) @Composable fun PlaybackPageContent( - playerState: PlayerState, + playerInstance: PlayerInstance, preferences: UserPreferences, destination: Destination, modifier: Modifier = Modifier, viewModel: PlaybackViewModel, ) { - val player = playerState.player - val playerBackend = playerState.backend + val state by viewModel.state.collectAsState() + val subtitleSearchState by viewModel.subtitleSearchState.collectAsState() + val player = playerInstance.player + val playerBackend = playerInstance.backend val prefs = preferences.appPreferences.playbackPreferences val scope = rememberCoroutineScope() - val configuration = LocalConfiguration.current val density = LocalDensity.current - val mediaInfo by viewModel.currentMediaInfo.observeAsState() - val userDto by viewModel.currentUserDto.observeAsState() + LaunchedEffect(density) { viewModel.updateDensity(density) } + val userDto by viewModel.currentUserDto.collectAsState() - val currentPlayback by viewModel.currentPlayback.collectAsState() - val currentItemPlayback by viewModel.currentItemPlayback.observeAsState( - ItemPlayback( - userId = -1, - itemId = UUID.randomUUID(), - ), - ) - val currentSegment by viewModel.currentSegment.collectAsState() - val analyticsState by viewModel.analyticsState.collectAsState() - - val cues by viewModel.subtitleCues.observeAsState(listOf()) var showDebugInfo by remember { mutableStateOf(prefs.showDebugInfo) } - val nextUp by viewModel.nextUp.observeAsState(null) - val playlist by viewModel.playlist.observeAsState(Playlist(listOf())) - - val subtitleSearch by viewModel.subtitleSearchStatus.observeAsState(null) - val subtitleSearchLanguage by viewModel.subtitleSearchLanguage.observeAsState(Locale.current.language) - var playbackDialog by remember { mutableStateOf<PlaybackDialogType?>(null) } - LaunchedEffect(player) { - if (playerBackend == PlayerBackend.MPV) { - scope.launch(Dispatchers.IO + ExceptionHandler()) { - // MPV can't play HDR, so always use regular settings - preferences.appPreferences.interfacePreferences.subtitlesPreferences.applyToMpv( - configuration, - density, - ) - } - } - } var contentScale by remember(playerBackend) { mutableStateOf( @@ -210,12 +185,24 @@ fun PlaybackPageContent( var playbackSpeed by remember { mutableFloatStateOf(1.0f) } LaunchedEffect(playbackSpeed) { player.setPlaybackSpeed(playbackSpeed) } - val subtitleDelay = currentPlayback?.subtitleDelay ?: Duration.ZERO - LaunchedEffect(subtitleDelay) { - (player as? MpvPlayer)?.subtitleDelay = subtitleDelay + LaunchedEffect(state.currentPlayback?.subtitleDelay) { + (player as? MpvPlayer)?.subtitleDelay = + state.currentPlayback?.subtitleDelay ?: Duration.ZERO } val presentationState = rememberPresentationState(player, false) + val playbackState by rememberPlayerState(player) + var showBuffering by remember { mutableStateOf(false) } + LaunchedEffect(playbackState) { + if (playbackState == PlayerState.BUFFERING) { + // Delay before showing the loading indicator + // So if buffering is quick, the UI won't flash + delay(250) + showBuffering = true + } else { + showBuffering = false + } + } val scaledModifier = Modifier.resizeWithContentScale(contentScale, presentationState.videoSizeDp) val focusRequester = remember { FocusRequester() } @@ -240,25 +227,52 @@ fun PlaybackPageContent( skipIndicatorDuration += delta skipPosition = player.currentPosition } + val onDpadSeek: (Long) -> Unit = { deltaMs -> + if (skipIndicatorDuration == 0L) { + skipPosition = player.currentPosition + } + if ((skipIndicatorDuration > 0 && deltaMs < 0) || + (skipIndicatorDuration < 0 && deltaMs > 0) + ) { + skipIndicatorDuration = 0L + } + skipIndicatorDuration += deltaMs + skipPosition = + (skipPosition + deltaMs).coerceIn( + minimumValue = 0L, + maximumValue = player.duration.coerceAtLeast(0L), + ) + seekBarState.onValueChange(skipPosition) + } val keyHandler = - PlaybackKeyHandler( - player = player, - controlsEnabled = nextUp == null, - skipWithLeftRight = true, - seekForward = preferences.appPreferences.playbackPreferences.skipForwardMs.milliseconds, - seekBack = preferences.appPreferences.playbackPreferences.skipBackMs.milliseconds, - getDurationMs = { player.duration.coerceAtLeast(0L) }, - controllerViewState = controllerViewState, - updateSkipIndicator = updateSkipIndicator, - skipBackOnResume = preferences.appPreferences.playbackPreferences.skipBackOnResume, - onInteraction = viewModel::reportInteraction, - oneClickPause = preferences.appPreferences.playbackPreferences.oneClickPause, - onStop = { - player.stop() - viewModel.navigationManager.goBack() - }, - onPlaybackDialogTypeClick = { playbackDialog = it }, - ) + remember(preferences) { + PlaybackKeyHandler( + player = player, + controlsEnabled = state.nextUp == null, + skipWithLeftRight = true, + seekForward = preferences.appPreferences.playbackPreferences.skipForwardMs.milliseconds, + seekBack = preferences.appPreferences.playbackPreferences.skipBackMs.milliseconds, + getDurationMs = { player.duration.coerceAtLeast(0L) }, + controllerViewState = controllerViewState, + updateSkipIndicator = updateSkipIndicator, + clearSkipIndicator = { skipIndicatorDuration = 0 }, + skipBackOnResume = preferences.appPreferences.playbackPreferences.skipBackOnResume, + onInteraction = viewModel::reportInteraction, + oneClickPause = preferences.appPreferences.playbackPreferences.oneClickPause, + onStop = { + player.stop() + viewModel.navigationManager.goBack() + }, + onPlaybackDialogTypeClick = { playbackDialog = it }, + isDpadSeekVisible = { + prefs.dpadSeekMode == DpadSeekMode.SEEKBAR_TRICKPLAY && skipIndicatorDuration != 0L + }, + onDpadSeek = onDpadSeek, + dpadSeekMode = prefs.dpadSeekMode, + onSeekToNext = viewModel::playNextUp, + onPSeekToPrevious = viewModel::playPrevious, + ) + } val onPlaybackActionClick: (PlaybackAction) -> Unit = { when (it) { @@ -302,27 +316,31 @@ fun PlaybackPageContent( PlaybackAction.Previous -> { val pos = player.currentPosition - if (pos < player.maxSeekToPreviousPosition && playlist.hasPrevious()) { + if (pos < player.maxSeekToPreviousPosition && state.hasPrevious) { viewModel.playPrevious() } else { player.seekToPrevious() } } + + is PlaybackAction.ChangeBitrate -> { + viewModel.changeMaxBitrate(it.maxBitrate) + } } } val showSegment = - currentSegment?.interacted == false && - nextUp == null && !controllerViewState.controlsVisible && skipIndicatorDuration == 0L + state.currentSegment?.interacted == false && + state.nextUp == null && !controllerViewState.controlsVisible && skipIndicatorDuration == 0L BackHandler(showSegment) { - viewModel.updateSegment(currentSegment?.segment?.id, true) + viewModel.updateSegment(state.currentSegment?.segment?.id, true) } Box( modifier - .background(if (nextUp == null) Color.Black else MaterialTheme.colorScheme.background), + .background(if (state.nextUp == null) Color.Black else MaterialTheme.colorScheme.background), ) { - val playerSize by animateFloatAsState(if (nextUp == null) 1f else .6f) + val playerSize by animateFloatAsState(if (state.nextUp == null) 1f else .6f) Box( modifier = Modifier @@ -349,10 +367,59 @@ fun PlaybackPageContent( ) { LoadingPage(focusEnabled = false) } + } else { + AnimatedVisibility( + visible = showBuffering, + enter = fadeIn(tween(easing = LinearEasing)), + exit = fadeOut(tween(easing = LinearEasing)), + modifier = Modifier.matchParentSize(), + ) { + LoadingPage( + focusEnabled = false, + modifier = + Modifier + .fillMaxSize() + .background(AppColors.TransparentBlack25), + ) + } + } + + AnimatedVisibility( + visible = + !controllerViewState.controlsVisible && + skipIndicatorDuration != 0L && + prefs.dpadSeekMode == DpadSeekMode.SEEKBAR_TRICKPLAY, + enter = fadeIn() + slideInVertically { it / 2 }, + exit = fadeOut() + slideOutVertically { it }, + modifier = + Modifier + .align(Alignment.BottomCenter), + ) { + CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) { + DpadSeekOverlay( + player = player, + seekPositionMs = skipPosition, + trickplayInfo = state.currentMediaInfo.trickPlayInfo, + trickplayUrlFor = viewModel::getTrickplayUrl, + modifier = + Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 16.dp) + .fillMaxWidth(.95f), + ) + } + // Clear the overlay after a delay + LaunchedEffect(skipIndicatorDuration) { + delay(1.5.seconds) + skipIndicatorDuration = 0L + } } // If D-pad skipping, show the amount skipped in an animation - if (!controllerViewState.controlsVisible && skipIndicatorDuration != 0L) { + if (!controllerViewState.controlsVisible && skipIndicatorDuration != 0L && + prefs.dpadSeekMode != DpadSeekMode.SEEKBAR_TRICKPLAY + ) { + // Skip time mode: show seek distance indicator SkipIndicator( durationMs = skipIndicatorDuration, onFinish = { @@ -364,22 +431,35 @@ fun PlaybackPageContent( .padding(bottom = 70.dp), ) // Show a small progress bar along the bottom of the screen - val showSkipProgress = true // TODO get from preferences - if (showSkipProgress) { + if (prefs.dpadSeekMode == DpadSeekMode.SEEKBAR_MINIMAL) { val percent = skipPosition.toFloat() / player.duration.toFloat() - Box( - modifier = + CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) { + Box( Modifier - .align(Alignment.BottomStart) - .background(MaterialTheme.colorScheme.border) - .clip(RectangleShape) - .height(3.dp) - .fillMaxWidth(percent), - ) + .fillMaxWidth() + .align(Alignment.BottomCenter), + ) { + Box( + modifier = + Modifier + .align(Alignment.BottomStart) + .background(MaterialTheme.colorScheme.border) + .clip(RectangleShape) + .height(3.dp) + .fillMaxWidth(percent), + ) + } + } } } - if (!controllerViewState.controlsVisible && skipIndicatorDuration == 0L) { + val controlsVisible = + remember(controllerViewState.controlsVisible, playbackDialog, subtitleSearchState) { + controllerViewState.controlsVisible || + playbackDialog != null || + subtitleSearchState.status != SubtitleSearchStatus.Inactive + } + if (!controlsVisible && skipIndicatorDuration == 0L) { PauseIndicator( player = player, modifier = @@ -396,12 +476,12 @@ fun PlaybackPageContent( .padding(WindowInsets.systemBars.asPaddingValues()) .fillMaxSize() .background(Color.Transparent), - item = currentPlayback?.item, + item = state.currentPlayback?.item, player = player, controllerViewState = controllerViewState, showPlay = playPauseState.showPlay, previousEnabled = true, - nextEnabled = playlist.hasNext(), + nextEnabled = state.hasNext, seekEnabled = true, seekForward = preferences.appPreferences.playbackPreferences.skipForwardMs.milliseconds, seekBack = preferences.appPreferences.playbackPreferences.skipBackMs.milliseconds, @@ -410,23 +490,24 @@ fun PlaybackPageContent( onClickPlaybackDialogType = { playbackDialog = it }, onSeekBarChange = seekBarState::onValueChange, showDebugInfo = showDebugInfo, - currentPlayback = currentPlayback, - chapters = mediaInfo?.chapters ?: listOf(), - trickplayInfo = mediaInfo?.trickPlayInfo, + currentPlayback = state.currentPlayback, + chapters = state.currentMediaInfo.chapters, + trickplayInfo = state.currentMediaInfo.trickPlayInfo, trickplayUrlFor = viewModel::getTrickplayUrl, - playlist = playlist, - onClickPlaylist = { - viewModel.playItemInPlaylist(it) - }, - currentSegment = currentSegment?.segment, + queue = remember(state.playlistIndex, state.playlist) { state.upcomingItems }, + onClickPlaylist = viewModel::playItemInPlaylist, + currentSegment = state.currentSegment?.segment, showClock = preferences.appPreferences.interfacePreferences.showClock, - analyticsState = analyticsState, + analyticsState = state.analyticsState, ) + var subtitleStyleAppliedForHdr by remember { mutableStateOf(state.currentMediaInfo.videoStream?.hdr == true) } val subtitleSettings = - remember(mediaInfo) { - Timber.v("subtitle choice: ${mediaInfo?.videoStream?.hdr}") - if (mediaInfo?.videoStream?.hdr == true) { + remember(state.currentMediaInfo) { + Timber.v("subtitle choice: ${state.currentMediaInfo.videoStream?.hdr}") + val useSeparate = + preferences.appPreferences.interfacePreferences.subtitlesPreferences.useSeparateHdr + if (useSeparate && state.currentMediaInfo.videoStream?.hdr == true) { preferences.appPreferences.interfacePreferences.hdrSubtitlesPreferences } else { preferences.appPreferences.interfacePreferences.subtitlesPreferences @@ -437,20 +518,22 @@ fun PlaybackPageContent( // Subtitles val subtitleMaxSize by animateFloatAsState(if (controllerViewState.controlsVisible) .7f else 1f) - val isImageSubtitles = remember(cues) { cues.firstOrNull()?.bitmap != null } - var cueCount by remember { mutableIntStateOf(0) } + val isImageSubtitles = + remember(state.subtitleCues) { state.subtitleCues.firstOrNull()?.bitmap != null } - val subtitleVisible = skipIndicatorDuration == 0L && currentItemPlayback.subtitleIndexEnabled && !presentationState.coverSurface + // Reset cueCount when density changes to force re-applying subtitle style + var cueCount by remember(density) { mutableIntStateOf(0) } + + val subtitleVisible = + skipIndicatorDuration == 0L && + state.currentPlayback?.subtitleIndexEnabled == true && + !presentationState.coverSurface AndroidView( factory = { context -> SubtitleView(context).apply { - subtitleSettings.let { - setStyle(it.toSubtitleStyle()) - setFixedTextSize(Dimension.SP, it.fontSize.toFloat()) - setBottomPaddingFraction(it.margin.toFloat() / 100f) - } - playerState.assHandler?.let { assHandler -> + subtitleSettings.applyTo(this) + playerInstance.assHandler?.let { assHandler -> if (prefs.overrides.assPlaybackMode == AssPlaybackMode.ASS_LIBASS) { Timber.v("Adding AssSubtitleView") addView( @@ -468,12 +551,18 @@ fun PlaybackPageContent( } }, update = { subtitleView -> - subtitleView.setCues(cues) - if (cues.size > cueCount) { + if (subtitleStyleAppliedForHdr != state.currentMediaInfo.videoStream?.hdr) { + // If the media has switched between SDR & HDR, reapply subtitle settings + Timber.v("Switching SDR/HDR, reapplying subtitle style") + subtitleSettings.applyTo(subtitleView) + } + subtitleView.setCues(state.subtitleCues) + if (state.subtitleCues.size > cueCount) { + Timber.i("Applying subtitle style to SubtitleView") // The output creates a painter for each cue, so need to apply the changes when the number of cues increases Media3SubtitleOverride(subtitleSettings.calculateEdgeSize(density)) .apply(subtitleView) - cueCount = cues.size + cueCount = state.subtitleCues.size } subtitleView.children.firstOrNull { it is AssSubtitleView }?.let { (it as? AssSubtitleView)?.apply { @@ -521,7 +610,7 @@ fun PlaybackPageContent( .padding(40.dp) .align(Alignment.BottomEnd), ) { - currentSegment?.let { segment -> + state.currentSegment?.let { segment -> val focusRequester = remember { FocusRequester() } LaunchedEffect(Unit) { focusRequester.tryRequestFocus() @@ -539,7 +628,7 @@ fun PlaybackPageContent( } // Next up episode - BackHandler(nextUp != null) { + BackHandler(state.nextUp != null) { if (player.isPlaying) { scope.launch(ExceptionHandler()) { viewModel.cancelUpNextEpisode() @@ -549,12 +638,12 @@ fun PlaybackPageContent( } } AnimatedVisibility( - nextUp != null, + state.nextUp != null, modifier = Modifier .align(Alignment.BottomCenter), ) { - nextUp?.let { + state.nextUp?.let { var autoPlayEnabled by remember { mutableStateOf(viewModel.shouldAutoPlayNextUp()) } var timeLeft by remember { mutableLongStateOf( @@ -595,6 +684,7 @@ fun PlaybackPageContent( viewModel.playNextUp() }, timeLeft = if (autoPlayEnabled) timeLeft.seconds else null, + runtime = it.data.runTimeTicks?.ticks, modifier = Modifier .padding(8.dp) @@ -611,7 +701,7 @@ fun PlaybackPageContent( } } - subtitleSearch?.let { state -> + if (subtitleSearchState.status != SubtitleSearchStatus.Inactive) { val wasPlaying = remember { player.isPlaying } LaunchedEffect(Unit) { player.pause() @@ -622,28 +712,29 @@ fun PlaybackPageContent( } viewModel.cancelSubtitleSearch() } - Dialog( + BasicDialog( onDismissRequest = onDismissRequest, properties = DialogProperties( usePlatformDefaultWidth = false, ), ) { - DownloadSubtitlesContent( - state = state, - language = subtitleSearchLanguage, - onSearch = { lang -> - viewModel.searchForSubtitles(lang) - }, - onClickDownload = { - viewModel.downloadAndSwitchSubtitles(it.id, wasPlaying) - }, - onDismissRequest = onDismissRequest, - modifier = - Modifier - .widthIn(max = 640.dp) - .heightIn(max = 400.dp), - ) + Box(modifier = Modifier.padding(24.dp)) { + DownloadSubtitlesContent( + state = subtitleSearchState.status, + language = subtitleSearchState.language, + onSearch = { lang -> + viewModel.searchForSubtitles(lang) + }, + onClickDownload = { + viewModel.downloadAndSwitchSubtitles(it.id, wasPlaying) + }, + modifier = + Modifier + .widthIn(max = 640.dp) + .heightIn(max = 400.dp), + ) + } } } @@ -653,18 +744,19 @@ fun PlaybackPageContent( settings = PlaybackSettings( showDebugInfo = showDebugInfo, - audioIndex = currentItemPlayback?.audioIndex, - audioStreams = mediaInfo?.audioStreams.orEmpty(), - subtitleIndex = currentItemPlayback?.subtitleIndex, - subtitleStreams = mediaInfo?.subtitleStreams.orEmpty(), + audioIndex = state.currentPlayback?.audioIndex, + audioStreams = state.currentMediaInfo.audioStreams, + subtitleIndex = state.currentPlayback?.subtitleIndex, + subtitleStreams = state.currentMediaInfo.subtitleStreams, playbackSpeed = playbackSpeed, contentScale = contentScale, - subtitleDelay = subtitleDelay, + subtitleDelay = state.currentPlayback?.subtitleDelay ?: Duration.ZERO, hasSubtitleDownloadPermission = remember(userDto) { userDto?.policy?.let { it.isAdministrator || it.enableSubtitleManagement } == true }, // TODO Passing through audio prevents changing playback speed // See https://github.com/damontecres/Wholphin/issues/164 - playbackSpeedEnabled = playerBackend == PlayerBackend.MPV || currentPlayback?.audioDecoder != null, + playbackSpeedEnabled = playerBackend == PlayerBackend.MPV || state.currentPlayback?.audioDecoder != null, + maxBitrate = state.maxBitrate, ), onDismissRequest = { playbackDialog = diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackState.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackState.kt new file mode 100644 index 00000000..cca3a0d0 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackState.kt @@ -0,0 +1,61 @@ +package com.github.damontecres.wholphin.ui.playback + +import androidx.compose.ui.text.intl.Locale +import androidx.media3.common.Player +import androidx.media3.common.text.Cue +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.ItemPlayback +import com.github.damontecres.wholphin.data.model.Playlist +import com.github.damontecres.wholphin.data.model.PlaylistItem +import com.github.damontecres.wholphin.preferences.PlayerBackend +import com.github.damontecres.wholphin.ui.formatBitrate +import com.github.damontecres.wholphin.util.LoadingState +import io.github.peerless2012.ass.media.AssHandler +import org.jellyfin.sdk.model.api.MediaSegmentDto + +data class PlaybackState( + val loading: LoadingState = LoadingState.Loading, + val currentMediaInfo: CurrentMediaInfo = CurrentMediaInfo.EMPTY, + val currentPlayback: CurrentPlayback? = null, + val currentItemPlayback: ItemPlayback? = null, + val currentSegment: MediaSegmentState? = null, + val analyticsState: AnalyticsState = AnalyticsState(), + val subtitleCues: List<Cue> = emptyList(), + val nextUp: BaseItem? = null, + val playlistIndex: Int = 0, + val playlist: Playlist = Playlist(emptyList()), + val maxBitrate: Long = -1L, +) { + val hasNext: Boolean get() = (playlistIndex + 1) < playlist.items.size + val hasPrevious: Boolean get() = playlistIndex > 0 + val upcomingItems: List<PlaylistItem> + get() = + playlist.items.subList( + (playlistIndex + 1).coerceAtMost(playlist.items.size), + playlist.items.size, + ) + + fun nextItem(): PlaylistItem? = playlist.items.getOrNull(playlistIndex + 1) +} + +data class PlayerInstance( + val player: Player, + val backend: PlayerBackend, + val assHandler: AssHandler?, +) + +data class MediaSegmentState( + val segment: MediaSegmentDto, + val interacted: Boolean, +) + +data class AnalyticsState( + val bitrate: String = formatBitrate(0), + val bitrateEstimate: String = formatBitrate(0), + val droppedFrames: Int = 0, +) + +data class SubtitleSearchState( + val status: SubtitleSearchStatus = SubtitleSearchStatus.Inactive, + val language: String = Locale.current.language, +) 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 4a73afd9..9cbbe551 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 @@ -5,9 +5,9 @@ import android.media.MediaCodecList import android.os.Build import android.widget.Toast import androidx.annotation.OptIn -import androidx.compose.ui.text.intl.Locale +import androidx.annotation.VisibleForTesting +import androidx.compose.ui.unit.Density import androidx.core.net.toUri -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.media3.common.C @@ -17,9 +17,9 @@ import androidx.media3.common.MimeTypes import androidx.media3.common.PlaybackException import androidx.media3.common.Player import androidx.media3.common.Tracks -import androidx.media3.common.text.Cue import androidx.media3.common.text.CueGroup import androidx.media3.common.util.UnstableApi +import androidx.media3.common.util.Util import androidx.media3.exoplayer.DecoderCounters import androidx.media3.exoplayer.DecoderReuseEvaluation import androidx.media3.exoplayer.ExoPlayer @@ -33,15 +33,17 @@ import com.github.damontecres.wholphin.data.ItemPlaybackRepository import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.Chapter -import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.data.model.Playlist import com.github.damontecres.wholphin.data.model.PlaylistItem +import com.github.damontecres.wholphin.data.model.SeriesTrackChoiceType import com.github.damontecres.wholphin.data.model.TrackIndex +import com.github.damontecres.wholphin.mpv.MpvPlayer import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.PlayerBackend import com.github.damontecres.wholphin.preferences.ShowNextUpWhen import com.github.damontecres.wholphin.preferences.SkipSegmentBehavior import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.preferences.enabled import com.github.damontecres.wholphin.services.DatePlayedService import com.github.damontecres.wholphin.services.DeviceProfileService import com.github.damontecres.wholphin.services.ImageUrlService @@ -55,20 +57,21 @@ import com.github.damontecres.wholphin.services.ScreensaverService import com.github.damontecres.wholphin.services.StreamChoiceService import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.ui.formatBitrate +import com.github.damontecres.wholphin.ui.gt import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.onMain +import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings.applyToMpv import com.github.damontecres.wholphin.ui.seekBack import com.github.damontecres.wholphin.ui.seekForward -import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.showToast -import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.PlaybackItemState import com.github.damontecres.wholphin.util.TrackActivityPlaybackListener +import com.github.damontecres.wholphin.util.WholphinDispatchers import com.github.damontecres.wholphin.util.checkForSupport import com.github.damontecres.wholphin.util.mpv.mpvDeviceProfile import com.github.damontecres.wholphin.util.profile.Codec @@ -79,14 +82,16 @@ import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext -import io.github.peerless2012.ass.media.AssHandler import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive @@ -105,7 +110,6 @@ import org.jellyfin.sdk.api.sockets.subscribe import org.jellyfin.sdk.model.DeviceInfo import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.ImageType -import org.jellyfin.sdk.model.api.MediaSegmentDto import org.jellyfin.sdk.model.api.MediaSegmentType import org.jellyfin.sdk.model.api.MediaStreamType import org.jellyfin.sdk.model.api.MediaType @@ -113,6 +117,7 @@ import org.jellyfin.sdk.model.api.PlayMethod import org.jellyfin.sdk.model.api.PlaybackInfoDto import org.jellyfin.sdk.model.api.PlaystateCommand import org.jellyfin.sdk.model.api.PlaystateMessage +import org.jellyfin.sdk.model.api.PlaystateRequest import org.jellyfin.sdk.model.api.TrickplayInfo import org.jellyfin.sdk.model.api.VideoRange import org.jellyfin.sdk.model.api.VideoRangeType @@ -140,8 +145,8 @@ class PlaybackViewModel val navigationManager: NavigationManager, private val playlistCreator: PlaylistCreator, private val itemPlaybackDao: ItemPlaybackDao, - private val serverRepository: ServerRepository, - private val itemPlaybackRepository: ItemPlaybackRepository, + internal val serverRepository: ServerRepository, + internal val itemPlaybackRepository: ItemPlaybackRepository, private val playerFactory: PlayerFactory, private val datePlayedService: DatePlayedService, private val deviceInfo: DeviceInfo, @@ -161,12 +166,15 @@ class PlaybackViewModel fun create(destination: Destination): PlaybackViewModel } - val currentPlayer = MutableStateFlow<PlayerState?>(null) + val currentPlayer = MutableStateFlow<PlayerInstance?>(null) internal lateinit var player: Player private var mediaSession: MediaSession? = null - internal val mutex = Mutex() + + // Mutex & Job for changing playlist index + private val playlistMutex = Mutex() + private var playlistJob: Job? = null val controllerViewState = ControllerViewState( @@ -174,40 +182,36 @@ class PlaybackViewModel true, ) - val loading = MutableLiveData<LoadingState>(LoadingState.Loading) + private val _state = MutableStateFlow(PlaybackState()) + val state: StateFlow<PlaybackState> = _state - val currentMediaInfo = MutableLiveData<CurrentMediaInfo>(CurrentMediaInfo.EMPTY) - val currentPlayback = MutableStateFlow<CurrentPlayback?>(null) - val currentItemPlayback = MutableLiveData<ItemPlayback>() - val currentSegment = MutableStateFlow<MediaSegmentState?>(null) - val analyticsState = MutableStateFlow(AnalyticsState()) - - val subtitleCues = MutableLiveData<List<Cue>>(listOf()) - - private lateinit var preferences: UserPreferences + internal lateinit var preferences: UserPreferences internal lateinit var itemId: UUID internal lateinit var currentItem: PlaylistItem internal var forceTranscoding: Boolean = false private var activityListener: TrackActivityPlaybackListener? = null + private var trackChangeListener: TracksChangedListener? = null private val jobs = mutableListOf<Job>() + private var subscribeJob: Job? = null - val nextUp = MutableLiveData<BaseItem?>() private val isPlaylist = destination is Destination.PlaybackList - val playlist = MutableLiveData<Playlist>(Playlist(listOf())) - val subtitleSearchStatus = MutableLiveData<SubtitleSearchStatus?>(null) - val subtitleSearchLanguage = MutableLiveData<String>(Locale.current.language) + val subtitleSearchState = MutableStateFlow(SubtitleSearchState()) - val currentUserDto = serverRepository.currentUserDto + val currentUserDto = serverRepository.currentUserDtoFlow + + @VisibleForTesting + val initJob: Job init { - viewModelScope.launchIO { - addCloseable { - screensaverService.keepScreenOn(false) - disconnectPlayer() + initJob = + viewModelScope.launchIO { + addCloseable { + screensaverService.keepScreenOn(false) + disconnectPlayer() + } + init() } - init() - } } private fun disconnectPlayer() { @@ -219,6 +223,9 @@ class PlaybackViewModel it.release() player.removeListener(it) } + this@PlaybackViewModel.trackChangeListener?.let { + player.removeListener(it) + } player.release() mediaSession?.release() } @@ -250,18 +257,18 @@ class PlaybackViewModel Timber.d("Selected backend: %s", playerBackend) if (currentPlayer.value?.backend != playerBackend) { Timber.i("Switching player backend to %s", playerBackend) - withContext(Dispatchers.Main) { + withContext(WholphinDispatchers.Main) { disconnectPlayer() } val playerCreation = playerFactory.createVideoPlayer( playerBackend, - preferences.appPreferences.playbackPreferences, + preferences.appPreferences, ) this.player = playerCreation.player currentPlayer.update { - PlayerState(playerCreation.player, playerBackend, playerCreation.assHandler) + PlayerInstance(playerCreation.player, playerBackend, playerCreation.assHandler) } configurePlayer() } @@ -270,17 +277,14 @@ class PlaybackViewModel private fun configurePlayer() { player.addListener(this) (player as? ExoPlayer)?.addAnalyticsListener(this) - jobs.add(subscribe()) + subscribeToWebSocket() jobs.add(listenForTranscodeReason()) val sessionPlayer = MediaSessionPlayer( player, preferences.appPreferences.playbackPreferences, ) - mediaSession = - MediaSession - .Builder(context, sessionPlayer) - .build() + mediaSession = playerFactory.createMediaSession(sessionPlayer) } /** @@ -288,8 +292,16 @@ class PlaybackViewModel */ private suspend fun init() { musicService.stop() - nextUp.setValueOnMain(null) this.preferences = userPreferencesService.getCurrent() + val maxBitrate = + preferences.appPreferences.playbackPreferences.maxBitrate + .takeIf { it > 0 } ?: AppPreference.DEFAULT_BITRATE + _state.update { + it.copy( + nextUp = null, + maxBitrate = maxBitrate, + ) + } if (preferences.appPreferences.playbackPreferences.refreshRateSwitching) { addCloseable { refreshRateService.resetRefreshRate() } } @@ -323,19 +335,34 @@ class PlaybackViewModel val playlistItem = if (queriedItem.type.playable) { PlaylistItem.Media(BaseItem(queriedItem, false)) - } else if (destination is Destination.PlaybackList) { + } else { val playlistResult = - playlistCreator.createFrom( - item = queriedItem, - startIndex = destination.startIndex ?: 0, - sortAndDirection = destination.sortAndDirection, - shuffled = destination.shuffle, - recursive = destination.recursive, - filter = destination.filter, - ) + if (destination is Destination.PlaybackList) { + playlistCreator.createFrom( + item = queriedItem, + startIndex = destination.startIndex ?: 0, + sortAndDirection = destination.sortAndDirection, + shuffled = destination.shuffle, + recursive = destination.recursive, + filter = destination.filter, + ) + } else { + val shuffled = + if (destination is Destination.Playback) { + destination.shuffle + } else { + false + } + // Try to create a playlist + playlistCreator.createFrom( + item = queriedItem, + recursive = true, + shuffled = shuffled, + ) + } when (val r = playlistResult) { is PlaylistCreationResult.Error -> { - loading.setValueOnMain(LoadingState.Error(r.message, r.ex)) + _state.update { it.copy(loading = LoadingState.Error(r.message, r.ex)) } return } @@ -345,16 +372,12 @@ class PlaybackViewModel navigationManager.goBack() return } - if (preferences.appPreferences.playbackPreferences.showNextUpWhen != ShowNextUpWhen.NEXT_UP_NEVER) { - withContext(Dispatchers.Main) { - this@PlaybackViewModel.playlist.value = r.playlist - } + _state.update { + it.copy(playlist = r.playlist) } r.playlist.items.first() } } - } else { - throw IllegalArgumentException("Item is not playable and not PlaybackList: ${queriedItem.type}") } viewModelScope.launch(ExceptionHandler()) { controllerViewState.observe() } @@ -365,7 +388,7 @@ class PlaybackViewModel api.userLibraryApi .getIntros( itemId = playlistItem.id, - userId = serverRepository.currentUser.value?.id, + userId = serverRepository.currentUser?.id, ).content.items .map { PlaylistItem.Intro(BaseItem(it)) @@ -376,13 +399,9 @@ class PlaybackViewModel val firstItem = if (intros.isNotEmpty()) { Timber.v("Got %s intros", intros.size) - val currentPlaylist = - this@PlaybackViewModel - .playlist.value - ?.items - .orEmpty() - val newPlaylist = Playlist(intros + currentPlaylist) - this@PlaybackViewModel.playlist.setValueOnMain(newPlaylist) + _state.update { + it.copy(playlist = Playlist(intros + it.playlist.items)) + } intros.first() } else { playlistItem @@ -398,20 +417,24 @@ class PlaybackViewModel playNextUp() } - if (!isPlaylist && preferences.appPreferences.playbackPreferences.showNextUpWhen != ShowNextUpWhen.NEXT_UP_NEVER) { + if (!isPlaylist) { val result = playlistCreator.createFrom(queriedItem) if (result is PlaylistCreationResult.Success && result.playlist.items.isNotEmpty()) { - val currentPlaylist = - this@PlaybackViewModel - .playlist.value - ?.items - .orEmpty() - val newPlaylist = Playlist(currentPlaylist + result.playlist.items) - this@PlaybackViewModel.playlist.setValueOnMain(newPlaylist) + _state.update { + it.copy( + playlist = Playlist(it.playlist.items + result.playlist.items), + ) + } } } } + internal fun updateCurrentPlayback(block: (CurrentPlayback?) -> CurrentPlayback?) { + _state.update { + it.copy(currentPlayback = block.invoke(it.currentPlayback)) + } + } + /** * Play an item * @@ -425,7 +448,7 @@ class PlaybackViewModel positionMs: Long, forceTranscoding: Boolean = this.forceTranscoding, ): Boolean = - withContext(Dispatchers.IO) { + withContext(WholphinDispatchers.IO) { val item = when (playlistItem) { is PlaylistItem.Intro -> playlistItem.item @@ -436,7 +459,7 @@ class PlaybackViewModel // New item, so we can clear the media segment tracker & subtitle cues resetSegmentState() - this@PlaybackViewModel.subtitleCues.setValueOnMain(listOf()) + _state.update { it.copy(subtitleCues = emptyList()) } viewModelScope.launchIO { // Starting playback, so want to invalidate the last played timestamp for this item @@ -459,7 +482,7 @@ class PlaybackViewModel // Use the provided playback parameters or else check if the database has some val itemPlayback = - serverRepository.currentUser.value?.let { user -> + serverRepository.currentUser?.let { user -> itemPlaybackDao.getItem(user, base.id)?.let { Timber.v("Fetched itemPlayback from DB: %s", it) if (it.sourceId != null) { @@ -469,10 +492,25 @@ class PlaybackViewModel } } } - val mediaSource = streamChoiceService.chooseSource(base, itemPlayback) - val plc = streamChoiceService.getPlaybackLanguageChoice(base) + val mediaSource = + if (!isLiveTv) { + streamChoiceService.chooseSource(base, itemPlayback) + } else { + null + } - if (mediaSource == null) { + val audioStc = + streamChoiceService.getSeriesTrackChoices( + base, + SeriesTrackChoiceType.AUDIO, + ) + val subtitleStc = + streamChoiceService.getSeriesTrackChoices( + base, + SeriesTrackChoiceType.AUDIO, + ) + + if (mediaSource == null && !isLiveTv) { showToast( context, "Item has no media sources, skipping...", @@ -482,7 +520,8 @@ class PlaybackViewModel } val videoStream = - mediaSource.mediaStreams + mediaSource + ?.mediaStreams ?.firstOrNull { it.type == MediaStreamType.VIDEO } ?.let { val isHdr = @@ -495,69 +534,50 @@ class PlaybackViewModel // Create the correct player for the media createPlayer(videoStream?.hdr == true, videoStream?.is4k == true) - val subtitleLanguagePreference = - serverRepository.currentUserDto.value - ?.configuration - ?.subtitleLanguagePreference - val subtitleStreams = - mediaSource.mediaStreams - ?.filter { it.type == MediaStreamType.SUBTITLE } - .let { - if (subtitleLanguagePreference.isNotNullOrBlank()) { - it?.sortedByDescending { it.language != null && subtitleLanguagePreference == it.language } - } else { - it - } - }?.map { - SimpleMediaStream.from(context, it, true) - }.orEmpty() - val audioStreams = - mediaSource.mediaStreams - ?.filter { it.type == MediaStreamType.AUDIO } - ?.map { - SimpleMediaStream.from(context, it, true) - } -// ?.sortedWith(compareBy<AudioStream> { it.language }.thenByDescending { it.channels }) - .orEmpty() - val audioStream = - streamChoiceService - .chooseAudioStream( - source = mediaSource, - seriesId = base.seriesId, - itemPlayback = itemPlayback, - plc = plc, - prefs = preferences, - ) - val audioIndex = audioStream?.index + val subtitleStreams = mediaSource?.let { getSubtitleStreams(mediaSource) }.orEmpty() + val audioStreams = mediaSource?.let { getAudioStreams(mediaSource) }.orEmpty() + val audioResult = + mediaSource?.let { + streamChoiceService + .chooseAudioStream( + source = mediaSource, + item = base, + itemPlayback = itemPlayback, + stc = audioStc, + prefs = preferences, + ) + } + val audioIndex = audioResult?.stream?.index val subtitleIndex = - streamChoiceService - .chooseSubtitleStream( - source = mediaSource, - audioStream = audioStream, - seriesId = base.seriesId, - itemPlayback = itemPlayback, - plc = plc, - prefs = preferences, - )?.index + mediaSource?.let { + streamChoiceService + .chooseSubtitleStream( + item = base, + source = mediaSource, + audioStream = audioResult?.stream, + itemPlayback = itemPlayback, + stc = subtitleStc, + prefs = preferences, + )?.stream + ?.index + } + Timber.d( + "Selected mediaSource=%s, audioIndex=%s, subtitleIndex=%s", + mediaSource?.id, + audioIndex, + subtitleIndex, + ) - Timber.d("Selected mediaSource=${mediaSource.id}, audioIndex=$audioIndex, subtitleIndex=$subtitleIndex") - - val itemPlaybackToUse = - itemPlayback ?: ItemPlayback( - rowId = -1, - userId = -1, - itemId = base.id, - sourceId = if (!isLiveTv) mediaSource.id?.toUUIDOrNull() else null, - audioIndex = audioIndex ?: TrackIndex.UNSPECIFIED, - subtitleIndex = subtitleIndex ?: TrackIndex.UNSPECIFIED, - ) val trickPlayInfo = - item.data.trickplay - ?.get(mediaSource.id) - ?.values - ?.firstOrNull() + mediaSource?.id?.let { sourceId -> + item.data.trickplay + ?.get(sourceId) + ?.values + ?.firstOrNull() + } + trickPlayInfo?.let { trickplayInfo -> mediaSource.runTimeTicks?.ticks?.let { duration -> viewModelScope.launchIO { @@ -570,27 +590,30 @@ class PlaybackViewModel } } - val chapters = Chapter.fromDto(base, api) - withContext(Dispatchers.Main) { - this@PlaybackViewModel.currentItemPlayback.value = itemPlaybackToUse - updateCurrentMedia { - CurrentMediaInfo( - sourceId = mediaSource.id, - videoStream = videoStream, - audioStreams = audioStreams, - subtitleStreams = subtitleStreams, - chapters = chapters, - trickPlayInfo = trickPlayInfo, - ) - } - + val chapters = Chapter.fromDto(base) + _state.update { + it.copy( + currentItemPlayback = itemPlayback, + currentPlayback = null, + ) + } + updateCurrentMedia { + CurrentMediaInfo( + sourceId = mediaSource?.id, + videoStream = videoStream, + audioStreams = audioStreams, + subtitleStreams = subtitleStreams, + chapters = chapters, + trickPlayInfo = trickPlayInfo, + ) + } + withContext(WholphinDispatchers.Main) { changeStreams( - item, - itemPlaybackToUse, - audioIndex, - subtitleIndex, - if (positionMs > 0) positionMs else C.TIME_UNSET, - itemPlayback != null, // If it was passed in, then it was not queried from the database + item = item, + audioIndex = audioIndex, + subtitleIndex = subtitleIndex, + positionMs = if (positionMs > 0) positionMs else C.TIME_UNSET, + sourceId = mediaSource?.id, enableDirectPlay = !forceTranscoding, enableDirectStream = !forceTranscoding, ) @@ -607,229 +630,276 @@ class PlaybackViewModel @OptIn(UnstableApi::class) internal suspend fun changeStreams( item: BaseItem, - currentItemPlayback: ItemPlayback = this@PlaybackViewModel.currentItemPlayback.value!!, + sourceId: String?, audioIndex: Int?, subtitleIndex: Int?, positionMs: Long = 0, - userInitiated: Boolean, enableDirectPlay: Boolean = !this.forceTranscoding, enableDirectStream: Boolean = !this.forceTranscoding, - ) = withContext(Dispatchers.IO) { - val itemId = item.id + maxBitrate: Long = this.state.value.maxBitrate, + ): Unit = + withContext(WholphinDispatchers.IO) { + val itemId = item.id - val currentPlayback = this@PlaybackViewModel.currentPlayback.value - if (currentPlayback != null && currentPlayback.item.id == item.id && currentPlayback.playMethod == PlayMethod.DIRECT_PLAY) { - val wasSuccessful = - changeStreamsDirectPlay( - currentPlayback = currentPlayback, - currentItemPlayback = currentItemPlayback, - audioIndex = audioIndex, - subtitleIndex = subtitleIndex, - userInitiated = userInitiated, - ) - if (wasSuccessful) return@withContext - } + trackChangeListener?.let { onMain { player.removeListener(it) } } + trackChangeListener = null - Timber.d( - "changeStreams: userInitiated=$userInitiated, audioIndex=$audioIndex, subtitleIndex=$subtitleIndex, " + - "enableDirectPlay=$enableDirectPlay, enableDirectStream=$enableDirectStream, positionMs=$positionMs", - ) - - val maxBitrate = - preferences.appPreferences.playbackPreferences.maxBitrate - .takeIf { it > 0 } ?: AppPreference.DEFAULT_BITRATE - val response by - api.mediaInfoApi - .getPostedPlaybackInfo( - itemId, - PlaybackInfoDto( - startTimeTicks = null, - deviceProfile = - if (currentPlayer.value!!.backend == PlayerBackend.EXO_PLAYER) { - deviceProfileService.getOrCreateDeviceProfile( - preferences.appPreferences.playbackPreferences, - serverRepository.currentServer.value?.serverVersion, - ) - } else { - mpvDeviceProfile - }, - maxAudioChannels = null, - audioStreamIndex = audioIndex, - subtitleStreamIndex = subtitleIndex, - mediaSourceId = currentItemPlayback.sourceId?.toServerString(), - alwaysBurnInSubtitleWhenTranscoding = false, - maxStreamingBitrate = maxBitrate.toInt(), - enableDirectPlay = enableDirectPlay, - enableDirectStream = enableDirectStream, - allowVideoStreamCopy = enableDirectStream, - allowAudioStreamCopy = enableDirectStream, - enableTranscoding = true, - autoOpenLiveStream = true, - ), - ) - if (response.errorCode != null) { - loading.setValueOnMain(LoadingState.Error(response.errorCode?.serialName)) - return@withContext - } - val source = response.mediaSources.firstOrNull() - source?.let { source -> - val mediaUrl = - if (source.supportsDirectPlay) { - if (source.isRemote && source.path.isNotNullOrBlank()) { - Timber.i("Playback is remote for source: %s", source.id) - source.path - } else { - api.videosApi.getVideoStreamUrl( - itemId = itemId, - mediaSourceId = source.id, - static = true, - tag = source.eTag, - playSessionId = response.playSessionId, + state.value.currentPlayback?.let { currentPlayback -> + if (currentPlayback.item.id == item.id && + currentPlayback.playMethod == PlayMethod.DIRECT_PLAY && + enableDirectPlay && + state.value.maxBitrate == maxBitrate + ) { + val wasSuccessful = + changeStreamsDirectPlay( + currentPlayback = currentPlayback, + audioIndex = audioIndex, + subtitleIndex = subtitleIndex, ) - } - } else if (source.supportsDirectStream) { - source.transcodingUrl?.let(api::createUrl) - } else { - source.transcodingUrl?.let(api::createUrl) + if (wasSuccessful) return@withContext } - if (mediaUrl.isNullOrBlank()) { - loading.setValueOnMain( - LoadingState.Error("Unable to get media URL from the server. Do you have permission to view and/or transcode?"), - ) + } + + Timber.i( + "changeStreams (%s): audioIndex=%s, subtitleIndex=%s, enableDirectPlay=%s, enableDirectStream=%s, positionMs=%s, maxBitrate=%s", + itemId, + audioIndex, + subtitleIndex, + enableDirectPlay, + enableDirectStream, + positionMs, + maxBitrate, + ) + _state.update { it.copy(maxBitrate = maxBitrate) } + + val response by + api.mediaInfoApi + .getPostedPlaybackInfo( + itemId, + PlaybackInfoDto( + startTimeTicks = null, + deviceProfile = + if (currentPlayer.value!!.backend == PlayerBackend.EXO_PLAYER) { + deviceProfileService.getOrCreateDeviceProfile( + preferences.appPreferences, + serverRepository.currentServer?.serverVersion, + ) + } else { + mpvDeviceProfile + }, + maxAudioChannels = null, + audioStreamIndex = audioIndex, + subtitleStreamIndex = subtitleIndex, + mediaSourceId = sourceId, + alwaysBurnInSubtitleWhenTranscoding = false, + maxStreamingBitrate = maxBitrate.toInt(), + enableDirectPlay = enableDirectPlay, + enableDirectStream = enableDirectStream, + allowVideoStreamCopy = enableDirectStream, + allowAudioStreamCopy = enableDirectStream, + enableTranscoding = true, + autoOpenLiveStream = true, + ), + ) + if (response.errorCode != null) { + Timber.e("Error in PostedPlaybackInfo: %s", response.errorCode) + _state.update { it.copy(loading = LoadingState.Error(response.errorCode?.serialName)) } return@withContext } - val transcodeType = - when { -// playerBackend == PlayerBackend.MPV -> PlayMethod.DIRECT_PLAY - source.supportsDirectPlay -> PlayMethod.DIRECT_PLAY - - source.supportsDirectStream -> PlayMethod.DIRECT_STREAM - - source.supportsTranscoding -> PlayMethod.TRANSCODE - - else -> throw Exception("No supported playback method") - } - Timber.i("Playback decision for $itemId: $transcodeType") - - val externalSubtitleCount = source.externalSubtitlesCount - - val externalSubtitle = - source.findExternalSubtitle(subtitleIndex)?.let { - it.deliveryUrl?.let { deliveryUrl -> - var flags = 0 - if (it.isForced) flags = flags.or(C.SELECTION_FLAG_FORCED) - if (it.isDefault) flags = flags.or(C.SELECTION_FLAG_DEFAULT) - MediaItem.SubtitleConfiguration - .Builder( - api.createUrl(deliveryUrl).toUri(), - ).setId("e:${it.index}") - .setMimeType(subtitleMimeTypes[it.codec]) - .setLanguage(it.language) - .setLabel(it.title) - .setSelectionFlags(flags) - .build() - } - } - - Timber.v("subtitleIndex=$subtitleIndex, externalSubtitleCount=$externalSubtitleCount, externalSubtitle=$externalSubtitle") - - val mediaItem = - MediaItem - .Builder() - .setMediaId(itemId.toString()) - .setMediaMetadata( - item.toMediaMetadata( - imageUrlService.getItemImageUrl( - item, - ImageType.PRIMARY, - useSeriesForPrimary = true, - ), - ), - ).setUri(mediaUrl.toUri()) - .setSubtitleConfigurations(listOfNotNull(externalSubtitle)) - .apply { - when (source.container) { - Codec.Container.HLS -> setMimeType(MimeTypes.APPLICATION_M3U8) - Codec.Container.DASH -> setMimeType(MimeTypes.APPLICATION_MPD) + val source = response.mediaSources.firstOrNull() + source?.let { source -> + val mediaUrl = + if (source.supportsDirectPlay) { + if (source.isRemote && source.path.isNotNullOrBlank()) { + Timber.i("Playback is remote for source: %s", source.id) + source.path + } else { + api.videosApi.getVideoStreamUrl( + itemId = itemId, + mediaSourceId = source.id, + liveStreamId = source.liveStreamId, + static = true, + tag = source.eTag, + playSessionId = response.playSessionId, + ) } - }.build() - - val playback = - CurrentPlayback( - item = item, - tracks = listOf(), - backend = currentPlayer.value!!.backend, - playMethod = transcodeType, - playSessionId = response.playSessionId, - liveStreamId = source.liveStreamId, - mediaSourceInfo = source, - ) - - preferences.appPreferences.playbackPreferences.let { prefs -> - source.mediaStreams - ?.firstOrNull { it.type == MediaStreamType.VIDEO } - ?.let { stream -> - refreshRateService.changeRefreshRate( - stream = stream, - switchRefreshRate = prefs.refreshRateSwitching, - switchResolution = prefs.resolutionSwitching, + } else if (source.supportsDirectStream) { + source.transcodingUrl?.let(api::createUrl) + } else { + source.transcodingUrl?.let(api::createUrl) + } + if (mediaUrl.isNullOrBlank()) { + _state.update { + it.copy( + loading = + LoadingState.Error( + "Unable to get media URL from the server. Do you have permission to view and/or transcode?", + ), ) } - } - withContext(Dispatchers.Main) { - // TODO, don't need to release & recreate when switching streams - this@PlaybackViewModel.activityListener?.let { - it.release() - player.removeListener(it) + return@withContext } + val transcodeType = + when { +// playerBackend == PlayerBackend.MPV -> PlayMethod.DIRECT_PLAY + source.supportsDirectPlay -> PlayMethod.DIRECT_PLAY - val playbackItemState = PlaybackItemState(playback, currentItemPlayback) - val activityListener = - TrackActivityPlaybackListener( - api = api, - player = player, - getState = { playbackItemState }, - ) - player.addListener(activityListener) - this@PlaybackViewModel.activityListener = activityListener + source.supportsDirectStream -> PlayMethod.DIRECT_STREAM - loading.value = LoadingState.Success - this@PlaybackViewModel.currentPlayback.update { playback } - player.setMediaItem( - mediaItem, - positionMs, + source.supportsTranscoding -> PlayMethod.TRANSCODE + + else -> throw Exception("No supported playback method") + } + Timber.i("Playback decision for $itemId: $transcodeType") + + val externalSubtitleCount = source.externalSubtitlesCount + + val externalSubtitle = + source.findExternalSubtitle(subtitleIndex)?.let { + Timber.v("Using externally delivered subtitle tracks %s", subtitleIndex) + it.deliveryUrl?.let { deliveryUrl -> + var flags = 0 + if (it.isForced) flags = flags.or(C.SELECTION_FLAG_FORCED) + if (it.isDefault) flags = flags.or(C.SELECTION_FLAG_DEFAULT) + MediaItem.SubtitleConfiguration + .Builder( + api.createUrl(deliveryUrl).toUri(), + ).setId("e:${it.index}") + .setMimeType(subtitleMimeTypes[it.codec]) + .setLanguage(it.language) + .setLabel(it.title) + .setSelectionFlags(flags) + .build() + } + } + + Timber.v( + "subtitleIndex=$subtitleIndex, externalSubtitleCount=$externalSubtitleCount, externalSubtitle=$externalSubtitle", ) - if (audioIndex != null || subtitleIndex != null) { - val onTracksChangedListener = - object : Player.Listener { - override fun onTracksChanged(tracks: Tracks) { - Timber.v("onTracksChanged: $tracks") - if (tracks.groups.isNotEmpty()) { - val result = - TrackSelectionUtils.createTrackSelections( - player.trackSelectionParameters, - player.currentTracks, - currentPlayer.value!!.backend, - source.supportsDirectPlay, - audioIndex.takeIf { transcodeType == PlayMethod.DIRECT_PLAY }, - subtitleIndex, - source, - ) - Timber.v("onTracksChanged: %s", result) - if (result.bothSelected) { - player.trackSelectionParameters = - result.trackSelectionParameters - player.removeListener(this) - } - viewModelScope.launchIO { loadSubtitleDelay() } - } + + val mediaItem = + MediaItem + .Builder() + .setMediaId(itemId.toString()) + .setMediaMetadata( + item.toMediaMetadata( + imageUrlService.getItemImageUrl( + item, + ImageType.PRIMARY, + useSeriesForPrimary = true, + ), + ), + ).setUri(mediaUrl.toUri()) + .setSubtitleConfigurations(listOfNotNull(externalSubtitle)) + .apply { + when (source.container) { + Codec.Container.HLS -> setMimeType(MimeTypes.APPLICATION_M3U8) + Codec.Container.DASH -> setMimeType(MimeTypes.APPLICATION_MPD) + } + }.build() + + val playback = + CurrentPlayback( + item = item, + tracks = listOf(), + backend = currentPlayer.value!!.backend, + playMethod = transcodeType, + playSessionId = response.playSessionId, + liveStreamId = source.liveStreamId, + mediaSourceInfo = source, + audioIndex = audioIndex ?: TrackIndex.DISABLED, + subtitleIndex = subtitleIndex ?: TrackIndex.DISABLED, + ) + + preferences.appPreferences.playbackPreferences.let { prefs -> + source.mediaStreams + ?.firstOrNull { it.type == MediaStreamType.VIDEO } + ?.let { stream -> + refreshRateService.changeRefreshRate( + stream = stream, + switchRefreshRate = prefs.refreshRateSwitching, + switchResolution = prefs.resolutionSwitching, + ) + } + } + withContext(WholphinDispatchers.Main) { + // TODO, don't need to release & recreate when switching streams + this@PlaybackViewModel.activityListener?.let { + it.release() + player.removeListener(it) + } + + val playbackItemState = PlaybackItemState(playback) + val watchScope = "${serverRepository.currentServer?.id}:${serverRepository.currentUser?.id}" + val activityListener = + TrackActivityPlaybackListener( + api = api, + player = player, + onStopped = { com.github.damontecres.wholphin.util.PlaybackWatchState.stopped(context, watchScope) }, + getState = { playbackItemState }, + ) + player.addListener(activityListener) + this@PlaybackViewModel.activityListener = activityListener + + _state.update { + it.copy( + loading = LoadingState.Success, + currentPlayback = playback, + ) + } + player.setMediaItem( + mediaItem, + positionMs, + ) + val onFailure: () -> Unit = { + if (player is MpvPlayer && externalSubtitle != null) { + // MpvPlayer may change tracks more than once to add external subtitles + trackChangeListener?.let { player.addListener(it) } + } else { + viewModelScope.launchIO { + changeStreams( + item = item, + sourceId = sourceId, + audioIndex = audioIndex, + subtitleIndex = subtitleIndex, + positionMs = positionMs, + enableDirectPlay = false, + enableDirectStream = true, + ) } } - player.addListener(onTracksChangedListener) + } + val onTracksChangedListener = + if (transcodeType == PlayMethod.DIRECT_PLAY && (audioIndex != null || subtitleIndex != null)) { + TracksChangedListener( + player = player, + audioIndex = audioIndex, + subtitleIndex = subtitleIndex, + source = source, + onFailure = onFailure, + ) + } else if (externalSubtitle != null) { + TracksChangedListener( + player = player, + audioIndex = null, // Do not manually activate audio + subtitleIndex = subtitleIndex, + source = source, + onFailure = onFailure, + ) + } else { + null + } + onTracksChangedListener?.let { + player.addListener(it) + this@PlaybackViewModel.trackChangeListener = it + } + + if (subtitleIndex != null) { + viewModelScope.launchIO { loadSubtitleDelay() } + } } } } - } /** * If direct playing, can try to switch tracks without playback restarting @@ -838,26 +908,35 @@ class PlaybackViewModel @OptIn(UnstableApi::class) private suspend fun changeStreamsDirectPlay( currentPlayback: CurrentPlayback, - currentItemPlayback: ItemPlayback, audioIndex: Int?, subtitleIndex: Int?, - userInitiated: Boolean, ): Boolean = - withContext(Dispatchers.IO) { - // TODO there's probably no reason why we can't add external subtitles? + withContext(WholphinDispatchers.IO) { Timber.v("changeStreams direct play") + // TODO Better way to handle unsupported types in general is needed + // This is a workaround for switching to a non AC3 track when the user wants audio transcoded to AC3 + if (preferences.appPreferences.experimentalPreferences.enabled { preferAc3Surround } && audioIndex != null) { + currentPlayback.mediaSourceInfo.mediaStreams + .orEmpty() + .firstOrNull { it.index == audioIndex } + ?.let { + if (it.channels.gt(2) && it.codec != Codec.Audio.AC3) { + // User wants to transcode audio into AC3 + return@withContext false + } + } + } + val source = currentPlayback.mediaSourceInfo val externalSubtitle = source.findExternalSubtitle(subtitleIndex) if (externalSubtitle == null) { val result = - withContext(Dispatchers.Main) { + withContext(WholphinDispatchers.Main) { TrackSelectionUtils.createTrackSelections( onMain { player.trackSelectionParameters }, onMain { player.currentTracks }, - currentPlayer.value!!.backend, - true, audioIndex, subtitleIndex, source, @@ -865,37 +944,14 @@ class PlaybackViewModel } if (result.bothSelected) { onMain { player.trackSelectionParameters = result.trackSelectionParameters } - // TODO lots of duplicate code in this block Timber.d("Changes tracks audio=$audioIndex, subtitle=$subtitleIndex") - val itemPlayback = - currentItemPlayback.copy( - sourceId = source.id?.toUUIDOrNull(), - audioIndex = audioIndex ?: TrackIndex.UNSPECIFIED, - // Preserve special constants (ONLY_FORCED, DISABLED) instead of resolved index - subtitleIndex = - if (currentItemPlayback.subtitleIndex < 0) { - currentItemPlayback.subtitleIndex - } else { - subtitleIndex ?: TrackIndex.DISABLED - }, + _state.update { + it.copy( + currentPlayback = + (it.currentPlayback ?: currentPlayback).copy( + tracks = checkForSupport(onMain { player.currentTracks }), + ), ) - if (userInitiated) { - viewModelScope.launchIO { - Timber.v("Saving user initiated item playback: %s", itemPlayback) - val updated = itemPlaybackRepository.saveItemPlayback(itemPlayback) - withContext(Dispatchers.Main) { - this@PlaybackViewModel.currentItemPlayback.value = updated - } - } - } - withContext(Dispatchers.Main) { - this@PlaybackViewModel.currentPlayback.update { - (it ?: currentPlayback).copy( - tracks = checkForSupport(player.currentTracks), - ) - } - - this@PlaybackViewModel.currentItemPlayback.value = itemPlayback } loadSubtitleDelay() return@withContext true @@ -908,78 +964,112 @@ class PlaybackViewModel fun changeAudioStream(index: Int) { viewModelScope.launchIO { - Timber.d("Changing audio track to %s", index) - val itemPlayback = - itemPlaybackRepository.saveTrackSelection( - item = currentItem.item, - itemPlayback = currentItemPlayback.value!!, - trackIndex = index, - type = MediaStreamType.AUDIO, - ) - this@PlaybackViewModel.currentItemPlayback.setValueOnMain(itemPlayback) - - // Resolve ONLY_FORCED to actual track based on new audio language - val source = currentPlayback.value?.mediaSourceInfo - val resolvedSubtitleIndex = - if (source != null) { - streamChoiceService.resolveSubtitleIndex( - source = source, - audioStreamIndex = index, - seriesId = currentItem.item.data.seriesId, - subtitleIndex = itemPlayback.subtitleIndex, - prefs = preferences, + val currentPlayback = state.value.currentPlayback + if (currentPlayback != null) { + Timber.d("Changing audio track to %s", index) + saveTrackSelection(index, MediaStreamType.AUDIO) + _state.update { + it.copy( + currentPlayback = it.currentPlayback?.copy(audioIndex = index), ) - } else { - itemPlayback.subtitleIndex.takeIf { it >= 0 } } - changeStreams( - currentItem.item, - itemPlayback, - index, - resolvedSubtitleIndex, - onMain { player.currentPosition }, - true, - ) + // Resolve ONLY_FORCED to actual track based on new audio language + val resolvedSubtitleIndex = + streamChoiceService.resolveSubtitleIndex( + source = currentPlayback.mediaSourceInfo, + audioStreamIndex = index, + item = currentItem.item.data, + subtitleIndex = currentPlayback.subtitleIndex, + prefs = preferences, + ) + + changeStreams( + item = currentItem.item, + sourceId = currentPlayback.mediaSourceInfo.id, + audioIndex = index, + subtitleIndex = resolvedSubtitleIndex, + positionMs = onMain { player.currentPosition }, + enableDirectPlay = true, + ) + } else { + Timber.w("Trying to change audio, but currentPlayback is null") + } } } fun changeSubtitleStream(index: Int): Job = viewModelScope.launchIO { - Timber.d("Changing subtitle track to %s", index) - val itemPlayback = - itemPlaybackRepository.saveTrackSelection( - item = currentItem.item, - itemPlayback = currentItemPlayback.value!!, - trackIndex = index, - type = MediaStreamType.SUBTITLE, - ) - this@PlaybackViewModel.currentItemPlayback.setValueOnMain(itemPlayback) + val currentPlayback = state.value.currentPlayback + if (currentPlayback != null) { + Timber.d("Changing subtitle track to %s", index) + saveTrackSelection(index, MediaStreamType.SUBTITLE) + _state.update { + it.copy( + currentPlayback = it.currentPlayback?.copy(subtitleIndex = index), + ) + } - // Resolve ONLY_FORCED to actual track index for playback - val source = currentPlayback.value?.mediaSourceInfo - val resolvedIndex = - if (source != null) { + // Resolve ONLY_FORCED to actual track index for playback + val resolvedIndex = streamChoiceService.resolveSubtitleIndex( - source = source, - audioStreamIndex = itemPlayback.audioIndex, - seriesId = currentItem.item.data.seriesId, + source = currentPlayback.mediaSourceInfo, + audioStreamIndex = currentPlayback.audioIndex, + item = currentItem.item.data, subtitleIndex = index, prefs = preferences, ) - } else { - index.takeIf { it >= 0 } - } - changeStreams( - currentItem.item, - itemPlayback, - itemPlayback.audioIndex, - resolvedIndex, - onMain { player.currentPosition }, - true, + changeStreams( + item = currentItem.item, + sourceId = currentPlayback.mediaSourceInfo.id, + audioIndex = currentPlayback.audioIndex, + subtitleIndex = resolvedIndex, + positionMs = onMain { player.currentPosition }, + enableDirectPlay = true, + ) + } else { + Timber.w("Trying to change subtitle, but currentPlayback is null") + } + } + + fun changeMaxBitrate(newMaxBitrate: Long) { + viewModelScope.launchIO { + val currentPlayback = state.value.currentPlayback + if (currentPlayback != null) { + Timber.d("Changing bitrate to %s", newMaxBitrate) + changeStreams( + item = currentItem.item, + sourceId = currentPlayback.mediaSourceInfo.id, + audioIndex = currentPlayback.audioIndex, + subtitleIndex = currentPlayback.subtitleIndex, + positionMs = onMain { player.currentPosition }, + enableDirectPlay = true, + maxBitrate = newMaxBitrate, + ) + } else { + Timber.w("Trying to change bitrate, but currentPlayback is null") + } + } + } + + internal suspend fun saveTrackSelection( + trackIndex: Int, + type: MediaStreamType, + ) { + val itemPlayback = + itemPlaybackRepository.saveTrackSelection( + item = currentItem.item, + itemPlayback = state.value.currentItemPlayback, + trackIndex = trackIndex, + type = type, + ) + _state.update { + it.copy( + currentItemPlayback = itemPlayback, ) } + } private suspend fun prefetchTrickplay( duration: Duration, @@ -1003,8 +1093,12 @@ class PlaybackViewModel fun getTrickplayUrl( index: Int, - trickPlayInfo: TrickplayInfo? = currentMediaInfo.value?.trickPlayInfo, - mediaSourceId: UUID? = currentItemPlayback.value?.sourceId, + trickPlayInfo: TrickplayInfo? = state.value.currentMediaInfo.trickPlayInfo, + mediaSourceId: UUID? = + state.value.currentPlayback + ?.mediaSourceInfo + ?.id + ?.toUUIDOrNull(), ): String? = trickPlayInfo?.let { val itemId = currentItem.id @@ -1020,20 +1114,32 @@ class PlaybackViewModel if (playbackState == Player.STATE_ENDED) { Timber.v("Playback state is STATE_ENDED") viewModelScope.launchDefault { - when (val nextItem = playlist.value?.peek()) { + when (val nextItem = state.value.nextItem()) { is PlaylistItem.Intro -> { Timber.v("Next item is intro, so playing immediately") playNextUp() } is PlaylistItem.Media -> { - if (currentItem is PlaylistItem.Intro) { - Timber.v("Current item is intro, so playing next up immediately") - playNextUp() - } else { - Timber.v("Setting next up to ${nextItem.id}") - withContext(Dispatchers.Main) { - nextUp.value = nextItem.item + val prefs = preferences.appPreferences.playbackPreferences + when { + currentItem is PlaylistItem.Intro -> { + Timber.v("Current item is intro, so playing next up immediately") + playNextUp() + } + + prefs.showNextUpWhen == ShowNextUpWhen.NEXT_UP_NEVER && !prefs.autoPlayNext -> { + Timber.v("Never show or auto play next up, returning") + navigationManager.goBack() + } + + prefs.showNextUpWhen != ShowNextUpWhen.NEXT_UP_NEVER -> { + Timber.v("Setting next up to ${nextItem.id}") + _state.update { it.copy(nextUp = nextItem.item) } + } + + else -> { + controllerViewState.showControls() } } } @@ -1048,7 +1154,8 @@ class PlaybackViewModel } // Variables for tracking segment state - private var segmentJob: Job? = null + @VisibleForTesting + internal var segmentJob: Job? = null private val autoSkippedSegments = mutableSetOf<UUID>() private val outroShownSegments = mutableSetOf<UUID>() @@ -1059,7 +1166,7 @@ class PlaybackViewModel segmentJob?.cancel() autoSkippedSegments.clear() outroShownSegments.clear() - currentSegment.value = null + _state.update { it.copy(currentSegment = null) } } /** @@ -1085,8 +1192,7 @@ class PlaybackViewModel currentSegment.itemId == this@PlaybackViewModel.itemId ) { if (currentSegment.id != - this@PlaybackViewModel - .currentSegment.value + state.value.currentSegment ?.segment ?.id ) { @@ -1097,19 +1203,17 @@ class PlaybackViewModel currentSegment.type, ) } - val playlist = this@PlaybackViewModel.playlist.value + val state = state.value if (currentSegment.type == MediaSegmentType.OUTRO && prefs.showNextUpWhen == ShowNextUpWhen.DURING_CREDITS && - playlist != null && playlist.hasNext() && + state.hasNext && outroShownSegments.add(currentSegment.id) ) { - val nextItem = playlist.peek() + val nextItem = state.nextItem() if (nextItem is PlaylistItem.Media) { Timber.v("Setting next up during outro to ${nextItem?.id}") - withContext(Dispatchers.Main) { - nextUp.value = nextItem.item - } + _state.update { it.copy(nextUp = nextItem.item) } } } else { val behavior = @@ -1121,36 +1225,32 @@ class PlaybackViewModel MediaSegmentType.INTRO -> prefs.skipIntros MediaSegmentType.UNKNOWN -> SkipSegmentBehavior.IGNORE } - withContext(Dispatchers.Main) { - when (behavior) { - SkipSegmentBehavior.AUTO_SKIP -> { - if (autoSkippedSegments.add(currentSegment.id)) { - onMain { player.seekTo(currentSegment.endTicks.ticks.inWholeMilliseconds + 1) } - } - this@PlaybackViewModel.currentSegment.update { + withContext(WholphinDispatchers.Main) { + val newSegment = + when (behavior) { + SkipSegmentBehavior.AUTO_SKIP -> { + if (autoSkippedSegments.add(currentSegment.id)) { + onMain { player.seekTo(currentSegment.endTicks.ticks.inWholeMilliseconds + 1) } + } MediaSegmentState(currentSegment, true) } - } - SkipSegmentBehavior.ASK_TO_SKIP -> { - this@PlaybackViewModel.currentSegment.update { + SkipSegmentBehavior.ASK_TO_SKIP -> { MediaSegmentState( currentSegment, autoSkippedSegments.contains(currentSegment.id), ) } - } - else -> { - this@PlaybackViewModel.currentSegment.value = null + else -> { + null + } } - } + _state.update { it.copy(currentSegment = newSegment) } } } } else if (currentSegment == null) { - withContext(Dispatchers.Main) { - this@PlaybackViewModel.currentSegment.value = null - } + _state.update { it.copy(currentSegment = null) } } } } @@ -1162,17 +1262,13 @@ class PlaybackViewModel dismissed: Boolean, ) { viewModelScope.launchDefault { - val segment = currentSegment.value?.segment + val segment = state.value.currentSegment?.segment if (segment != null && segment.id == segmentId) { autoSkippedSegments.add(segment.id) if (dismissed) { - currentSegment.update { - it?.copy(interacted = true) - } + _state.update { it.copy(currentSegment = it.currentSegment?.copy(interacted = true)) } } else { - currentSegment.update { - null - } + _state.update { it.copy(currentSegment = null) } onMain { player.seekTo(segment.endTicks.ticks.inWholeMilliseconds + 1) } } } @@ -1181,11 +1277,11 @@ class PlaybackViewModel private fun listenForTranscodeReason(): Job = viewModelScope.launchIO { - currentPlayback.collectLatest { - if (it != null) { + state.map { it.currentPlayback }.collectLatest { + if (it != null && it.playMethod == PlayMethod.TRANSCODE && it.transcodeInfo == null) { try { var transcodeInfo = it.transcodeInfo - while (isActive && it.playMethod == PlayMethod.TRANSCODE && transcodeInfo == null) { + while (isActive && transcodeInfo == null) { delay(2.seconds) transcodeInfo = api.sessionApi @@ -1196,13 +1292,13 @@ class PlaybackViewModel if (transcodeInfo == null) delay(3.seconds) } Timber.v("transcodeInfo=$transcodeInfo") - currentPlayback.update { current -> + updateCurrentPlayback { current -> current?.copy(transcodeInfo = transcodeInfo) } } catch (ex: Exception) { if (ex !is CancellationException) { Timber.w(ex, "Exception trying to get session info") - currentPlayback.update { current -> + updateCurrentPlayback { current -> current?.copy(transcodeInfo = null) } } @@ -1232,57 +1328,85 @@ class PlaybackViewModel } fun playNextUp() { - playlist.value?.let { - if (it.hasNext()) { - viewModelScope.launchDefault { + viewModelScope.launchDefault { + playlistMutex.withLock { + val state = state.value + if (state.hasNext) { cancelUpNextEpisode() - val item = it.getAndAdvance() - val played = play(item, 0) - if (!played) { - playNextUp() - } + val nextIndex = state.playlistIndex + 1 + val item = state.playlist.items[nextIndex] + _state.update { it.copy(playlistIndex = nextIndex) } + playlistJob?.cancel() + playlistJob = + viewModelScope.launchDefault { + val played = play(item, 0) + if (!played) { + playNextUp() + } + } + } else { + Timber.w("Attempting to play next, but there are no more items") + return@launchDefault } } } } fun playPrevious() { - playlist.value?.let { - if (it.hasPrevious()) { - viewModelScope.launchDefault { + viewModelScope.launchDefault { + playlistMutex.withLock { + val state = state.value + if (state.hasPrevious) { cancelUpNextEpisode() - val item = it.getPreviousAndReverse() - val played = play(item, 0) - if (!played) { - playPrevious() - } + val previousIndex = state.playlistIndex - 1 + val item = state.playlist.items[previousIndex] + _state.update { it.copy(playlistIndex = previousIndex) } + playlistJob?.cancel() + playlistJob = + viewModelScope.launchDefault { + val played = play(item, 0) + if (!played) { + playPrevious() + } + } + } else { + Timber.w("Attempting to play previous, but there is none") + return@launchDefault } } } } suspend fun cancelUpNextEpisode() { - nextUp.setValueOnMain(null) + _state.update { it.copy(nextUp = null) } } fun playItemInPlaylist(item: BaseItem) { - playlist.value?.let { playlist -> - viewModelScope.launchIO { - val toPlay = playlist.advanceTo(item.id) - if (toPlay != null) { - val played = play(toPlay, 0) - if (!played) { - playNextUp() - } + viewModelScope.launchDefault { + playlistMutex.withLock { + val state = state.value + val index = state.playlist.items.indexOfFirst { it.id == item.id } + if (index in state.playlist.items.indices) { + val toPlay = state.playlist.items[index] + _state.update { it.copy(playlistIndex = index) } + playlistJob?.cancel() + playlistJob = + viewModelScope.launchDefault { + val played = play(toPlay, 0) + if (!played) { + playNextUp() + } + } } else { - // TODO + Timber.w("Item not found in playlist %s", item.id) + return@launchDefault } } } } override fun onTracksChanged(tracks: Tracks) { - currentPlayback.update { + updateCurrentPlayback { it?.copy( tracks = checkForSupport(tracks), ) @@ -1290,36 +1414,42 @@ class PlaybackViewModel } override fun onCues(cueGroup: CueGroup) { - subtitleCues.value = cueGroup.cues + _state.update { it.copy(subtitleCues = cueGroup.cues) } } override fun onPlayerError(error: PlaybackException) { Timber.e(error, "Playback error") - viewModelScope.launch(Dispatchers.Main + ExceptionHandler()) { - currentPlayback.value?.let { + viewModelScope.launch(WholphinDispatchers.Main + ExceptionHandler()) { + state.value.currentPlayback?.let { when (it.playMethod) { PlayMethod.TRANSCODE -> { - loading.setValueOnMain( - LoadingState.Error( - "Error during playback", - error, - ), - ) + _state.update { + it.copy( + loading = + LoadingState.Error( + "Error during playback", + error, + ), + ) + } } PlayMethod.DIRECT_STREAM, PlayMethod.DIRECT_PLAY -> { Timber.w("Playback error during ${it.playMethod}, falling back to transcoding") + val currentPlayback = state.value.currentPlayback + if (currentPlayback == null) { + Timber.w("Playback error, currentPlayback is null") + } changeStreams( - currentItem.item, - currentItemPlayback.value!!, - currentItemPlayback.value?.audioIndex, - currentItemPlayback.value?.subtitleIndex, - player.currentPosition, - false, + item = currentItem.item, + sourceId = currentPlayback?.mediaSourceInfo?.id, + audioIndex = currentPlayback?.audioIndex, + subtitleIndex = currentPlayback?.subtitleIndex, + positionMs = player.currentPosition, enableDirectPlay = false, enableDirectStream = false, ) - withContext(Dispatchers.Main) { + withContext(WholphinDispatchers.Main) { player.prepare() player.play() } @@ -1335,70 +1465,91 @@ class PlaybackViewModel activityListener = null } - fun subscribe(): Job = - api.webSocket - .subscribe<PlaystateMessage>() - .onEach { message -> - message.data?.let { - withContext(Dispatchers.Main) { - when (it.command) { - PlaystateCommand.STOP -> { - release() - navigationManager.goBack() - } - - PlaystateCommand.PAUSE -> { - player.pause() - } - - PlaystateCommand.UNPAUSE -> { - player.play() - } - - PlaystateCommand.NEXT_TRACK -> { - playNextUp() - } - - PlaystateCommand.PREVIOUS_TRACK -> { - playPrevious() - } - - PlaystateCommand.SEEK -> { - it.seekPositionTicks?.ticks?.let { - player.seekTo( - it.inWholeMilliseconds, - ) + fun subscribeToWebSocket() { + subscribeJob?.cancel() + try { + subscribeJob = + viewModelScope.launch { + coroutineScope { + api.webSocket + .subscribe<PlaystateMessage>() + .onEach { message -> + message.data?.let { request -> + Timber.v("Received playstate request: %s", request) + withContext(WholphinDispatchers.Main) { + handlePlaystateRequest(request) + } } - } - - PlaystateCommand.REWIND -> { - player.seekBack( - preferences.appPreferences.playbackPreferences.skipBackMs.milliseconds, - ) - } - - PlaystateCommand.FAST_FORWARD -> { - player.seekForward( - preferences.appPreferences.playbackPreferences.skipForwardMs.milliseconds, - ) - } - - PlaystateCommand.PLAY_PAUSE -> { - if (player.isPlaying) player.pause() else player.play() - } - } + }.catch { ex -> + Timber.e(ex, "Error in websocket subscription") + }.launchIn(this) } } - }.launchIn(viewModelScope) + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Error in playback websocket subscription") + if (viewModelScope.isActive) { + subscribeToWebSocket() + } + } + } + + private suspend fun handlePlaystateRequest(request: PlaystateRequest) = + withContext(WholphinDispatchers.Main) { + when (request.command) { + PlaystateCommand.STOP -> { + release() + navigationManager.goBack() + } + + PlaystateCommand.PAUSE -> { + player.pause() + } + + PlaystateCommand.UNPAUSE -> { + player.play() + } + + PlaystateCommand.NEXT_TRACK -> { + playNextUp() + } + + PlaystateCommand.PREVIOUS_TRACK -> { + playPrevious() + } + + PlaystateCommand.SEEK -> { + request.seekPositionTicks?.ticks?.let { + player.seekTo(it.inWholeMilliseconds) + } + } + + PlaystateCommand.REWIND -> { + player.seekBack( + preferences.appPreferences.playbackPreferences.skipBackMs.milliseconds, + ) + } + + PlaystateCommand.FAST_FORWARD -> { + player.seekForward( + preferences.appPreferences.playbackPreferences.skipForwardMs.milliseconds, + ) + } + + PlaystateCommand.PLAY_PAUSE -> { + Util.handlePlayPauseButtonAction(player) + } + } + } /** * Atomically update [currentMediaInfo] */ internal suspend fun updateCurrentMedia(block: (CurrentMediaInfo) -> CurrentMediaInfo) = - withContext(Dispatchers.IO) { - mutex.withLock { - val newMediaInfo = block.invoke(currentMediaInfo.value!!) - currentMediaInfo.setValueOnMain(newMediaInfo) + withContext(WholphinDispatchers.Default) { + _state.update { + it.copy(currentMediaInfo = block.invoke(it.currentMediaInfo)) } } @@ -1421,7 +1572,7 @@ class PlaybackViewModel } else { decoderName } - currentPlayback.update { + updateCurrentPlayback { when (type) { MediaType.VIDEO -> it?.copy(videoDecoder = decoderString) MediaType.AUDIO -> it?.copy(audioDecoder = decoderString) @@ -1446,7 +1597,7 @@ class PlaybackViewModel decoderCounters: DecoderCounters, ) { Timber.d("onVideoDisabled") - currentPlayback.update { it?.copy(videoDecoder = null) } + updateCurrentPlayback { it?.copy(videoDecoder = null) } } override fun onVideoInputFormatChanged( @@ -1490,21 +1641,21 @@ class PlaybackViewModel decoderCounters: DecoderCounters, ) { Timber.d("decoder: onAudioDisabled") - currentPlayback.update { it?.copy(audioDecoder = null) } + updateCurrentPlayback { it?.copy(audioDecoder = null) } } private var subtitleDelaySaveJob: Job? = null fun updateSubtitleDelay(delta: Duration) { subtitleDelaySaveJob?.cancel() - currentPlayback.update { + updateCurrentPlayback { it?.let { val newDelay = it.subtitleDelay + delta val result = it.copy(subtitleDelay = it.subtitleDelay + delta) subtitleDelaySaveJob = viewModelScope.launchIO { // Debounce & save - currentItemPlayback.value?.let { item -> + state.value.currentItemPlayback?.let { item -> delay(1500) itemPlaybackRepository.saveTrackModifications( item.itemId, @@ -1519,18 +1670,18 @@ class PlaybackViewModel } suspend fun loadSubtitleDelay() { - currentItemPlayback.value?.let { + state.value.currentPlayback?.let { if (it.subtitleIndexEnabled) { val result = - itemPlaybackRepository.getTrackModifications(it.itemId, it.subtitleIndex) + itemPlaybackRepository.getTrackModifications(it.item.id, it.subtitleIndex) if (result != null) { Timber.v( "Loading subtitle delay %s for track=%s, itemId=%s", result.delayMs, it.subtitleIndex, - it.itemId, + it.item.id, ) - currentPlayback.update { it?.copy(subtitleDelay = result.delayMs.milliseconds) } + updateCurrentPlayback { it?.copy(subtitleDelay = result.delayMs.milliseconds) } } } } @@ -1540,6 +1691,37 @@ class PlaybackViewModel screensaverService.keepScreenOn(isPlaying) } + override fun onAvailableCommandsChanged(availableCommands: Player.Commands) { + val player = this@PlaybackViewModel.player + if (availableCommands.contains(Player.COMMAND_PREPARE) && player is MpvPlayer) { + // MpvPlayer is initialized, so configure subtitles + Timber.i("Applying subtitle config to MPV") + viewModelScope.launchDefault { + val configuration = context.resources.configuration + val density = Density(context.resources.displayMetrics.density) + preferences.appPreferences.interfacePreferences.subtitlesPreferences.applyToMpv( + configuration, + density, + ) + } + } + } + + fun updateDensity(density: Density) { + Timber.d("Density changed") + viewModelScope.launchDefault { + val availableCommands = onMain { player.availableCommands } + if (availableCommands.contains(Player.COMMAND_PREPARE) && player is MpvPlayer) { + Timber.i("Applying density change for subtitle config to MPV") + val configuration = context.resources.configuration + preferences.appPreferences.interfacePreferences.subtitlesPreferences.applyToMpv( + configuration, + density, + ) + } + } + } + override fun onBandwidthEstimate( eventTime: AnalyticsListener.EventTime, totalLoadTimeMs: Int, @@ -1553,10 +1735,13 @@ class PlaybackViewModel bitrateEstimate, ) if (totalLoadTimeMs > 0 && totalBytesLoaded > 0) { - analyticsState.update { + _state.update { it.copy( - bitrate = formatBitrate((totalBytesLoaded.toDouble() / (totalLoadTimeMs / 1000.0) * 8).roundToInt()), - bitrateEstimate = formatBitrate(bitrateEstimate.toInt()), + analyticsState = + it.analyticsState.copy( + bitrate = formatBitrate((totalBytesLoaded.toDouble() / (totalLoadTimeMs / 1000.0) * 8).roundToInt()), + bitrateEstimate = formatBitrate(bitrateEstimate.toInt()), + ), ) } } @@ -1567,24 +1752,13 @@ class PlaybackViewModel droppedFrames: Int, elapsedMs: Long, ) { -// Timber.v("onDroppedVideoFrames: droppedFrames=%s", droppedFrames) - analyticsState.update { it.copy(droppedFrames = it.droppedFrames + droppedFrames) } + _state.update { + it.copy( + analyticsState = + it.analyticsState.copy( + droppedFrames = it.analyticsState.droppedFrames + droppedFrames, + ), + ) + } } } - -data class PlayerState( - val player: Player, - val backend: PlayerBackend, - val assHandler: AssHandler?, -) - -data class MediaSegmentState( - val segment: MediaSegmentDto, - val interacted: Boolean, -) - -data class AnalyticsState( - val bitrate: String = formatBitrate(0), - val bitrateEstimate: String = formatBitrate(0), - val droppedFrames: Int = 0, -) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayerState.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayerState.kt new file mode 100644 index 00000000..5b00aacc --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayerState.kt @@ -0,0 +1,42 @@ +package com.github.damontecres.wholphin.ui.playback + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.media3.common.Player +import androidx.media3.common.listenTo + +/** + * Remembers the [Player]'s state as it changes. Useful for changing UI if the player is buffering. + * + * @see Player.State + * @see PlayerState + */ +@Composable +fun rememberPlayerState(player: Player): State<PlayerState> { + val state = remember(player) { mutableStateOf(getPlayerState(player.playbackState)) } + LaunchedEffect(player) { + player.listenTo(Player.EVENT_PLAYBACK_STATE_CHANGED) { + state.value = getPlayerState(player.playbackState) + } + } + return state +} + +private fun getPlayerState( + @Player.State value: Int, +): PlayerState = PlayerState.entries.first { it.value == value } + +/** + * Represents [Player.State] integers as an Enum + */ +enum class PlayerState( + @param:Player.State val value: Int, +) { + IDLE(Player.STATE_IDLE), + BUFFERING(Player.STATE_BUFFERING), + READY(Player.STATE_READY), + ENDED(Player.STATE_ENDED), +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SimpleMediaStream.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SimpleMediaStream.kt index d430258a..04699005 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SimpleMediaStream.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SimpleMediaStream.kt @@ -1,6 +1,6 @@ package com.github.damontecres.wholphin.ui.playback -import android.content.Context +import android.content.res.Resources import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.util.StreamFormatting.mediaStreamDisplayTitle import org.jellyfin.sdk.model.api.MediaStream @@ -17,14 +17,14 @@ data class SimpleMediaStream( ) { companion object { fun from( - context: Context, + resources: Resources, mediaStream: MediaStream, includeFlags: Boolean = true, ): SimpleMediaStream = SimpleMediaStream( index = mediaStream.index, streamTitle = mediaStream.title?.takeIf { it.isNotNullOrBlank() }, - displayTitle = mediaStreamDisplayTitle(context, mediaStream, includeFlags), + displayTitle = mediaStreamDisplayTitle(resources, mediaStream, includeFlags), ) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SubtitleSearchUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SubtitleSearchUtils.kt index b2e82eae..c08759c0 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SubtitleSearchUtils.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SubtitleSearchUtils.kt @@ -7,21 +7,27 @@ import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.PlaylistItem import com.github.damontecres.wholphin.data.model.TrackIndex +import com.github.damontecres.wholphin.services.getPreferredLanguage +import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.onMain -import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.showToast -import kotlinx.coroutines.Dispatchers +import com.github.damontecres.wholphin.util.WholphinDispatchers import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.update import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.extensions.subtitleApi import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.model.api.MediaSourceInfo import org.jellyfin.sdk.model.api.MediaStreamType import org.jellyfin.sdk.model.api.RemoteSubtitleInfo +import org.jellyfin.sdk.model.serializer.toUUIDOrNull import timber.log.Timber +import kotlin.time.Duration.Companion.milliseconds sealed interface SubtitleSearchStatus { + data object Inactive : SubtitleSearchStatus + data object Searching : SubtitleSearchStatus data object Downloading : SubtitleSearchStatus @@ -39,28 +45,43 @@ sealed interface SubtitleSearchStatus { /** * Trigger a search for subtitles in the given language for the currently playing media */ -fun PlaybackViewModel.searchForSubtitles(language: String = Locale.current.language) { - subtitleSearchStatus.value = SubtitleSearchStatus.Searching - subtitleSearchLanguage.value = language +fun PlaybackViewModel.searchForSubtitles(language: String? = null) { + val language = + language ?: getPreferredLanguage( + MediaStreamType.SUBTITLE, + preferences, + serverRepository + .currentUserDto + ?.configuration, + )?.takeIf { it.isNotNullOrBlank() } + ?: Locale.current.language + + subtitleSearchState.update { + it.copy( + status = SubtitleSearchStatus.Searching, + language = language, + ) + } viewModelScope.launchIO { try { - currentItemPlayback.value?.itemId?.let { - Timber.v("Searching for remote subtitles for %s", it) + state.value.currentPlayback?.itemId?.let { itemId -> + Timber.v("Searching for remote subtitles for %s", itemId) val results = api.subtitleApi .searchRemoteSubtitles( - itemId = it, + itemId = itemId, language = language, ).content .sortedWith( - compareByDescending<RemoteSubtitleInfo> { it.communityRating } + compareByDescending<RemoteSubtitleInfo> { it.isHashMatch } + .thenByDescending { it.communityRating } .thenByDescending { it.downloadCount }, ) - subtitleSearchStatus.setValueOnMain(SubtitleSearchStatus.Success(results)) + subtitleSearchState.update { it.copy(status = SubtitleSearchStatus.Success(results)) } } } catch (ex: Exception) { Timber.e(ex, "Exception while searching for subtitles") - subtitleSearchStatus.setValueOnMain(SubtitleSearchStatus.Error(null, ex)) + subtitleSearchState.update { it.copy(status = SubtitleSearchStatus.Error(null, ex)) } } } } @@ -73,27 +94,32 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles( wasPlaying: Boolean, ) { if (subtitleId == null) { - subtitleSearchStatus.value = SubtitleSearchStatus.Error("Subtitle has no ID", null) + subtitleSearchState.update { + it.copy( + status = + SubtitleSearchStatus.Error( + "Subtitle has no ID", + null, + ), + ) + } } else { - subtitleSearchStatus.value = SubtitleSearchStatus.Downloading + subtitleSearchState.update { it.copy(status = SubtitleSearchStatus.Downloading) } viewModelScope.launchIO { try { - currentItemPlayback.value?.let { + state.value.currentPlayback?.let { currentPlayback -> Timber.v( "Downloading remote subtitles for itemId=%s, sourceId=%s: %s", - it.itemId, - it.sourceId, + currentPlayback.itemId, + currentPlayback.sourceId, subtitleId, ) api.subtitleApi.downloadRemoteSubtitles( - itemId = it.sourceId ?: it.itemId, + itemId = currentPlayback.sourceId ?: currentPlayback.itemId, subtitleId = subtitleId, ) - val currentSource = - this@downloadAndSwitchSubtitles.currentPlayback.value?.mediaSourceInfo val currentSubtitleStreams = - currentSource - ?.mediaStreams + currentPlayback.mediaSourceInfo.mediaStreams ?.filter { it.type == MediaStreamType.SUBTITLE } .orEmpty() val externalPaths = currentSubtitleStreams.map { it.path } @@ -106,14 +132,16 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles( // The server triggers a refresh in the background, so query periodically for the item until its updated while (maxAttempts > 0 && subtitleCount == newCount) { maxAttempts-- - delay(1500) - val base = BaseItem(api.userLibraryApi.getItem(itemId = it.itemId).content) + delay(1500.milliseconds) + val base = + BaseItem(api.userLibraryApi.getItem(itemId = currentPlayback.itemId).content) currentItem = when (currentItem) { is PlaylistItem.Intro -> PlaylistItem.Intro(base) is PlaylistItem.Media -> PlaylistItem.Media(base) } - mediaSource = streamChoiceService.chooseSource(currentItem.item.data, it) + mediaSource = + base.data.mediaSources?.firstOrNull { it.id?.toUUIDOrNull() == currentPlayback.sourceId } if (mediaSource == null) { // This shouldn't happen, but just in case showToast( @@ -144,33 +172,48 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles( stream.isExternal && stream.path !in externalPaths } if (newStream != null) { - var audioIndex = currentItemPlayback.value?.audioIndex - if (audioIndex != null && audioIndex != TrackIndex.UNSPECIFIED) { - // User has picked a specific audio track - // Since, now adding a new external subtitle track, need to adjust the audio index as well + var audioIndex = currentPlayback.audioIndex + if (audioIndex != TrackIndex.UNSPECIFIED && audioIndex >= newStream.index) { + // User has previously picked a specific audio track + // If the new external subtitle track was added before the audio track, need to adjust the audio index as well Timber.v("New external subtitle, audioIndex=$audioIndex, adding 1") audioIndex += 1 + state.value.currentItemPlayback?.let { currentItemPlayback -> + if (currentItemPlayback.audioIndex != TrackIndex.UNSPECIFIED) { + Timber.d("User has a previously saved audio index that needs to be updated") + saveTrackSelection(audioIndex, MediaStreamType.AUDIO) + } + } } + saveTrackSelection(newStream.index, MediaStreamType.SUBTITLE) updateCurrentMedia { it.copy( - subtitleStreams = - subtitlesStreams.map { - SimpleMediaStream.from(context, it, true) - }, + sourceId = mediaSource.id, + audioStreams = getAudioStreams(mediaSource), + subtitleStreams = getSubtitleStreams(mediaSource), ) } + updateCurrentPlayback { + it?.copy( + item = currentItem.item, + mediaSourceInfo = mediaSource, + audioIndex = audioIndex, + subtitleIndex = newStream.index, + ) + } + this@downloadAndSwitchSubtitles.changeStreams( - currentItem.item, - currentItemPlayback.value!!, - audioIndex, - newStream.index, - onMain { player.currentPosition }, - true, + item = currentItem.item, + sourceId = currentPlayback.mediaSourceInfo.id, + audioIndex = audioIndex, + subtitleIndex = newStream.index, + positionMs = onMain { player.currentPosition }, + enableDirectPlay = true, ) } } - subtitleSearchStatus.setValueOnMain(null) - withContext(Dispatchers.Main) { + subtitleSearchState.update { it.copy(status = SubtitleSearchStatus.Inactive) } + withContext(WholphinDispatchers.Main) { if (wasPlaying) { player.play() } @@ -178,12 +221,20 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles( } } catch (ex: Exception) { Timber.e(ex, "Exception while downloading subtitles: $subtitleId") - subtitleSearchStatus.setValueOnMain(SubtitleSearchStatus.Error(null, ex)) + subtitleSearchState.update { + it.copy( + status = + SubtitleSearchStatus.Error( + null, + ex, + ), + ) + } } } } } fun PlaybackViewModel.cancelSubtitleSearch() { - subtitleSearchStatus.value = null + subtitleSearchState.update { it.copy(status = SubtitleSearchStatus.Inactive) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/TrackSelectionUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/TrackSelectionUtils.kt index cfbbe6b7..7b377399 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/TrackSelectionUtils.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/TrackSelectionUtils.kt @@ -7,13 +7,11 @@ import androidx.media3.common.TrackSelectionOverride import androidx.media3.common.TrackSelectionParameters import androidx.media3.common.Tracks import androidx.media3.common.util.UnstableApi -import com.github.damontecres.wholphin.preferences.PlayerBackend +import com.github.damontecres.wholphin.ui.indexOfFirstOrNull import org.jellyfin.sdk.model.api.MediaSourceInfo import org.jellyfin.sdk.model.api.MediaStream import org.jellyfin.sdk.model.api.MediaStreamType import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod -import timber.log.Timber -import kotlin.math.max /** * Functions for selecting which audio & subtitle tracks to activate in the [androidx.media3.common.Player] @@ -23,202 +21,159 @@ object TrackSelectionUtils { fun createTrackSelections( trackSelectionParams: TrackSelectionParameters, tracks: Tracks, - playerBackend: PlayerBackend, - supportsDirectPlay: Boolean, audioIndex: Int?, subtitleIndex: Int?, source: MediaSourceInfo, ): TrackSelectionResult { - val embeddedSubtitleCount = source.embeddedSubtitleCount - val externalSubtitleCount = source.externalSubtitlesCount - val paramsBuilder = trackSelectionParams.buildUpon() - val groups = tracks.groups - val subtitleSelected = if (subtitleIndex != null && subtitleIndex >= 0) { val subtitleIsExternal = source.findExternalSubtitle(subtitleIndex) != null - if (subtitleIsExternal || supportsDirectPlay) { - val chosenTrack = - if (subtitleIsExternal && playerBackend == PlayerBackend.EXO_PLAYER) { - groups.firstOrNull { group -> - group.type == C.TRACK_TYPE_TEXT && group.isSupported && - (0..<group.mediaTrackGroup.length) - .mapNotNull { - group.getTrackFormat(it).id - }.any { it.endsWith("e:$subtitleIndex") } - } - } else { - val actualEmbeddedCount = - groups - .filter { group -> - group.type == C.TRACK_TYPE_TEXT && - (0..<group.mediaTrackGroup.length) - .mapNotNull { - group.getTrackFormat(it).id - }.none { it.contains("e:") } - }.size - val indexToFind = - calculateIndexToFind( - subtitleIndex, - MediaStreamType.SUBTITLE, - playerBackend, - embeddedSubtitleCount, - externalSubtitleCount, - subtitleIsExternal, - actualEmbeddedCount, - source, - ) - Timber.v("Chosen subtitle ($subtitleIndex/$indexToFind) track") - // subtitleIndex - externalSubtitleCount + 1 - groups.firstOrNull { group -> - group.type == C.TRACK_TYPE_TEXT && group.isSupported && - (0..<group.mediaTrackGroup.length) - .filter { - if (subtitleIsExternal) { - group.getTrackFormat(0).id?.contains("e:") == true - } else { - group.getTrackFormat(0).id?.contains("e:") == false - } - }.map { - group.getTrackFormat(it).idAsInt - }.contains(indexToFind) - } + val chosenTrack = + if (subtitleIsExternal) { + tracks.groups.firstOrNull { group -> + group.type == C.TRACK_TYPE_TEXT && group.isExternal } - - Timber.v("Chosen subtitle ($subtitleIndex) track: $chosenTrack") - chosenTrack?.let { + } else { + val playerIndex = + getPlayerIndex(subtitleIndex, source, MediaStreamType.SUBTITLE) + if (playerIndex != null) { + tracks.groups + .filter { group -> + group.type == C.TRACK_TYPE_TEXT && + group.length >= 1 && + !group.isExternal + } + // TODO why are exoplayer tracks out of order sometimes? + .sortedById() + .getOrNull(playerIndex) + } else { + null + } + } + when { + chosenTrack != null && chosenTrack.isSupported -> { paramsBuilder + .clearOverridesOfType(C.TRACK_TYPE_TEXT) .setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false) .setOverrideForType( - TrackSelectionOverride( - chosenTrack.mediaTrackGroup, - 0, - ), + TrackSelectionOverride(chosenTrack.mediaTrackGroup, 0), ) + TrackSelected.SELECTED + } + + chosenTrack != null && !chosenTrack.isSupported -> { + TrackSelected.UNSUPPORTED + } + + else -> { + TrackSelected.NOT_FOUND } - chosenTrack != null - } else { - false } } else { paramsBuilder + .clearOverridesOfType(C.TRACK_TYPE_TEXT) .setTrackTypeDisabled(C.TRACK_TYPE_TEXT, true) - - true + TrackSelected.SELECTED } + val audioSelected = - if (audioIndex != null && audioIndex >= 0 && supportsDirectPlay) { - val indexToFind = - calculateIndexToFind( - audioIndex, - MediaStreamType.AUDIO, - playerBackend, - embeddedSubtitleCount, - externalSubtitleCount, - false, - null, - source, - ) + if (audioIndex != null && audioIndex >= 0) { + val playerIndex = getPlayerIndex(audioIndex, source, MediaStreamType.AUDIO) val chosenTrack = - groups.firstOrNull { group -> - group.type == C.TRACK_TYPE_AUDIO && group.isSupported && - (0..<group.mediaTrackGroup.length) - .map { - group.getTrackFormat(it).idAsInt - }.contains(indexToFind) + if (playerIndex != null) { + tracks.groups + .filter { group -> + group.type == C.TRACK_TYPE_AUDIO && group.length >= 1 + } + // TODO why are exoplayer tracks out of order sometimes? + .sortedById() + .getOrNull(playerIndex) + } else { + null + } + when { + chosenTrack != null && chosenTrack.isSupported -> { + paramsBuilder + .clearOverridesOfType(C.TRACK_TYPE_AUDIO) + .setTrackTypeDisabled(C.TRACK_TYPE_AUDIO, false) + .setOverrideForType( + TrackSelectionOverride(chosenTrack.mediaTrackGroup, 0), + ) + TrackSelected.SELECTED + } + + chosenTrack != null && !chosenTrack.isSupported -> { + TrackSelected.UNSUPPORTED + } + + else -> { + TrackSelected.NOT_FOUND } - Timber.v("Chosen audio ($audioIndex/$indexToFind) track: $chosenTrack") - chosenTrack?.let { - paramsBuilder - .setTrackTypeDisabled(C.TRACK_TYPE_AUDIO, false) - .setOverrideForType( - TrackSelectionOverride( - chosenTrack.mediaTrackGroup, - 0, - ), - ) } - chosenTrack != null } else { - true + TrackSelected.SELECTED } return TrackSelectionResult(paramsBuilder.build(), audioSelected, subtitleSelected) } - /** - * Maps the server provided index to the track index based on the [PlayerBackend] and other stream information - */ - private fun calculateIndexToFind( + private fun getPlayerIndex( serverIndex: Int, - type: MediaStreamType, - playerBackend: PlayerBackend, - embeddedSubtitleCount: Int, - externalSubtitleCount: Int, - subtitleIsExternal: Boolean, - actualEmbeddedCount: Int?, source: MediaSourceInfo, - ): Int = - when (playerBackend) { - PlayerBackend.EXO_PLAYER, - PlayerBackend.UNRECOGNIZED, - -> { - serverIndex - externalSubtitleCount + 1 + type: MediaStreamType, + ): Int? { + val playerIndex = + source.mediaStreams + .orEmpty() + .filter { it.type == type } + .let { + if (type == MediaStreamType.SUBTITLE) { + it.filter { it.deliveryMethod == SubtitleDeliveryMethod.EMBED || it.deliveryMethod == SubtitleDeliveryMethod.HLS } + } else { + it + } + }.indexOfFirstOrNull { it.index == serverIndex } + return playerIndex + } + + val Tracks.Group.trackFormats: List<Format> + @OptIn(UnstableApi::class) + get() = + (0..<mediaTrackGroup.length) + .mapNotNull { + getTrackFormat(it) + } + + private val Tracks.Group.isExternal: Boolean + get() = + trackFormats.any { + it.id?.contains(":e:") == true } - // TODO MPV could use literal indexes because they are stored in the track format ID - PlayerBackend.PREFER_MPV, - PlayerBackend.MPV, - -> { - when (type) { - MediaStreamType.VIDEO -> { - serverIndex - externalSubtitleCount + 1 - } - - MediaStreamType.AUDIO -> { - val videoStreamsBeforeAudioCount = - source.mediaStreams - .orEmpty() - .indexOfFirst { it.type == MediaStreamType.AUDIO } - externalSubtitleCount - serverIndex - externalSubtitleCount - videoStreamsBeforeAudioCount + 1 - } - - MediaStreamType.SUBTITLE -> { - if (subtitleIsExternal) { - // Need to account for the actual embedded count because if the library - // disables embedded subtitles, they still exist in the direct played file, - // but not included in the MediaStreams list - serverIndex + max(actualEmbeddedCount ?: 0, embeddedSubtitleCount) + 1 + private fun Iterable<Tracks.Group>.sortedById(): List<Tracks.Group> = + mapNotNull { track -> + if (track.isExternal) { + // Should be filtered out before calling this though + track to listOf(Int.MAX_VALUE, Int.MAX_VALUE) + } else { + track.trackFormats[0] + .id + ?.split(":", "/") + ?.map { it.toIntOrNull() ?: Int.MAX_VALUE } + ?.let { + if (it.size >= 2) { + track to it + } else if (it.isEmpty()) { + track to listOf(Int.MAX_VALUE, Int.MAX_VALUE) } else { - val videoStreamCount = source.videoStreamCount - val audioStreamCount = source.audioStreamCount - serverIndex - externalSubtitleCount - videoStreamCount - audioStreamCount + 1 + track to listOf(it[0], Int.MAX_VALUE) } } - - else -> { - throw UnsupportedOperationException("Cannot calculate index for $type") - } - } } - - PlayerBackend.EXTERNAL_PLAYER -> { - throw IllegalStateException("Cannot calculate tracks external playback") - } - } + }.sortedWith(compareBy<Pair<Tracks.Group, List<Int>>> { it.second[0] }.thenBy { it.second[1] }) + .map { it.first } } -val Format.idAsInt: Int? - @OptIn(UnstableApi::class) - get() = - id?.let { - if (it.contains(":")) { - it.split(":").last().toIntOrNull() - } else { - it.toIntOrNull() - } - } - /** * Returns the number of external subtitle streams there are */ @@ -270,8 +225,18 @@ fun List<MediaStream>.findExternalSubtitle(subtitleIndex: Int?): MediaStream? = */ data class TrackSelectionResult( val trackSelectionParameters: TrackSelectionParameters, - val audioSelected: Boolean, - val subtitleSelected: Boolean, + val audio: TrackSelected, + val subtitle: TrackSelected, ) { - val bothSelected: Boolean = audioSelected && subtitleSelected + val audioSelected: Boolean get() = audio == TrackSelected.SELECTED + + val subtitleSelected: Boolean get() = subtitle == TrackSelected.SELECTED + + val bothSelected: Boolean get() = audioSelected && subtitleSelected +} + +enum class TrackSelected { + SELECTED, + NOT_FOUND, + UNSUPPORTED, } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/TracksChangedListener.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/TracksChangedListener.kt new file mode 100644 index 00000000..c5192153 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/TracksChangedListener.kt @@ -0,0 +1,41 @@ +package com.github.damontecres.wholphin.ui.playback + +import androidx.media3.common.Player +import androidx.media3.common.Tracks +import org.jellyfin.sdk.model.api.MediaSourceInfo +import timber.log.Timber + +class TracksChangedListener( + val player: Player, + val audioIndex: Int?, + val subtitleIndex: Int?, + val source: MediaSourceInfo, + val onFailure: () -> Unit, +) : Player.Listener { + override fun onTracksChanged(tracks: Tracks) { + Timber.v("onTracksChanged: $tracks") + if (tracks.groups.isNotEmpty()) { + val result = + TrackSelectionUtils.createTrackSelections( + player.trackSelectionParameters, + player.currentTracks, + audioIndex, + subtitleIndex, + source, + ) + Timber.v("onTracksChanged: %s", result) + player.removeListener(this) + if (result.bothSelected) { + player.trackSelectionParameters = + result.trackSelectionParameters + } else { + // Fall back to transcoding + Timber.w( + "Failed to select tracks, falling back to transcoding: %s", + result, + ) + onFailure.invoke() + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/ChapterRowOverlay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/ChapterRowOverlay.kt index 2270e5f7..fa32950b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/ChapterRowOverlay.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/ChapterRowOverlay.kt @@ -22,14 +22,15 @@ import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.media3.common.Player import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.Chapter -import com.github.damontecres.wholphin.data.model.Playlist import com.github.damontecres.wholphin.ui.cards.ChapterCard import com.github.damontecres.wholphin.ui.components.HiddenFocusBox import com.github.damontecres.wholphin.ui.ifElse @@ -42,11 +43,11 @@ fun ChapterRowOverlay( player: Player, controllerViewState: ControllerViewState, chapters: List<Chapter>, - playlist: Playlist, - aspectRatio: Float, + hasNext: Boolean, onChangeState: (OverlayViewState) -> Unit, modifier: Modifier = Modifier, ) { + val isLtr = LocalLayoutDirection.current == LayoutDirection.Ltr val chapterInteractionSources = remember(chapters.size) { List(chapters.size) { MutableInteractionSource() } } val bringIntoViewRequester = remember { BringIntoViewRequester() } @@ -113,10 +114,7 @@ fun ChapterRowOverlay( if (isFocused) controllerViewState.pulseControls() } ChapterCard( - name = chapter.name, - position = chapter.position, - imageUrl = chapter.imageUrl, - aspectRatio = aspectRatio, + chapter = chapter, onClick = { player.seekTo(chapter.position.inWholeMilliseconds) controllerViewState.hideControls() @@ -135,13 +133,16 @@ fun ChapterRowOverlay( index == 0, Modifier.focusProperties { // Prevent scrolling left on first card to prevent moving down - left = FocusRequester.Cancel + left = + if (isLtr) FocusRequester.Cancel else FocusRequester.Default + right = + if (isLtr) FocusRequester.Default else FocusRequester.Cancel }, ), ) } } - if (playlist.hasNext()) { + if (hasNext) { Text( text = stringResource(R.string.queue), style = MaterialTheme.typography.titleLarge, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/DpadSeekOverlay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/DpadSeekOverlay.kt new file mode 100644 index 00000000..0a3ed634 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/DpadSeekOverlay.kt @@ -0,0 +1,71 @@ +package com.github.damontecres.wholphin.ui.playback.overlay + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.media3.common.Player +import org.jellyfin.sdk.model.api.TrickplayInfo + +/** + * The compact overlay shown during D-Pad seeking in trickplay mode. + */ +@Composable +fun DpadSeekOverlay( + player: Player, + seekPositionMs: Long, + trickplayInfo: TrickplayInfo?, + trickplayUrlFor: (Int) -> String?, + modifier: Modifier = Modifier, +) { + val durationMs = player.duration.coerceAtLeast(0L) + val seekProgressPercent = + if (durationMs > 0) { + (seekPositionMs.toDouble() / durationMs).toFloat().coerceIn(0f, 1f) + } else { + 0f + } + val bufferedProgress = + if (durationMs > 0) { + (player.bufferedPosition.toDouble() / durationMs).toFloat().coerceIn(0f, 1f) + } else { + 0f + } + + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(0.dp), + ) { + Box( + modifier = Modifier.fillMaxWidth(), + ) { + TrickplayPreview( + seekProgressMs = seekPositionMs, + trickplayInfo = trickplayInfo, + trickplayUrlFor = trickplayUrlFor, + modifier = + Modifier + .align(Alignment.BottomStart) + .offsetByPercent(xPercentage = seekProgressPercent) + .padding(bottom = 8.dp), + ) + } + StaticSeekBarImpl( + progress = seekProgressPercent, + bufferedProgress = bufferedProgress, + durationMs = durationMs, + modifier = Modifier.fillMaxWidth(), + ) + SeekTimecodes( + positionMs = seekPositionMs, + durationMs = durationMs, + modifier = Modifier.fillMaxWidth(), + ) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackController.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackController.kt index 0de48d15..2909e666 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackController.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackController.kt @@ -10,9 +10,7 @@ import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable @@ -24,6 +22,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp @@ -34,7 +33,7 @@ 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.ui.getTimeFormatter +import com.github.damontecres.wholphin.ui.formatTime import com.github.damontecres.wholphin.ui.playback.ControllerViewState import com.github.damontecres.wholphin.ui.playback.PlaybackDialogType import kotlinx.coroutines.delay @@ -117,7 +116,7 @@ fun PlaybackController( } else -> { - Spacer(Modifier.height(32.dp)) + Unit } } } @@ -201,8 +200,9 @@ fun Controller( ) } + val context = LocalContext.current var endTimeStr by remember { mutableStateOf("...") } - LaunchedEffect(player) { + LaunchedEffect(player, context) { while (isActive) { val remaining = (player.duration - player.currentPosition) @@ -210,12 +210,12 @@ fun Controller( .toLong() .milliseconds val endTime = LocalTime.now().plusSeconds(remaining.inWholeSeconds) - endTimeStr = getTimeFormatter().format(endTime) + endTimeStr = formatTime(context, endTime) delay(1.seconds) } } Text( - text = "Ends $endTimeStr", + text = stringResource(R.string.ends_at, endTimeStr), color = MaterialTheme.colorScheme.onSurface, style = MaterialTheme.typography.labelLarge, modifier = diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackControls.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackControls.kt index f423b8ca..45fcdd4c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackControls.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackControls.kt @@ -22,11 +22,13 @@ import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.relocation.BringIntoViewRequester import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf @@ -42,10 +44,13 @@ import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.isSpecified import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog @@ -68,6 +73,7 @@ import com.github.damontecres.wholphin.ui.PreviewTvSpec import com.github.damontecres.wholphin.ui.components.Button import com.github.damontecres.wholphin.ui.components.SelectedLeadingContent import com.github.damontecres.wholphin.ui.components.TextButton +import com.github.damontecres.wholphin.ui.formatDuration import com.github.damontecres.wholphin.ui.indexOfFirstOrNull import com.github.damontecres.wholphin.ui.playback.ControllerViewState import com.github.damontecres.wholphin.ui.playback.PlaybackDialogType @@ -117,6 +123,10 @@ sealed interface PlaybackAction { data object Previous : PlaybackAction data object Next : PlaybackAction + + data class ChangeBitrate( + val maxBitrate: Long, + ) : PlaybackAction } /** @@ -187,6 +197,7 @@ fun PlaybackControls( onClickPlaybackDialogType = onClickPlaybackDialogType, modifier = Modifier.align(Alignment.CenterStart), ) + PlaybackButtons( player = player, initialFocusRequester = initialFocusRequester, @@ -200,6 +211,7 @@ fun PlaybackControls( skipBackOnResume = skipBackOnResume, modifier = Modifier.align(Alignment.Center), ) + Row( modifier = Modifier.align(Alignment.CenterEnd), ) { @@ -268,27 +280,53 @@ fun SeekBar( seekBack = seekBack, seekForward = seekForward, ) - Row( + SeekTimecodes( + positionMs = position, + durationMs = player.duration, modifier = Modifier.fillMaxWidth(), + ) + } +} + +@Composable +fun SeekTimecodes( + positionMs: Long, + durationMs: Long, + modifier: Modifier = Modifier, +) { + val currentLayoutDirection = LocalLayoutDirection.current + CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) { + Row( + modifier = modifier, horizontalArrangement = Arrangement.SpaceBetween, ) { - val remaining = ((player.duration - position) / 1000).seconds - Text( - text = (position / 1000).seconds.toString(), - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.labelLarge, - modifier = - Modifier - .padding(8.dp), - ) - Text( - text = "-$remaining", - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.labelLarge, - modifier = - Modifier - .padding(8.dp), - ) + val resources = LocalResources.current + val positionSec = positionMs / 1000 + val remainingSec = (durationMs - positionMs) / 1000 + val positionText = + remember(positionSec) { resources.formatDuration(positionSec.seconds) } + val remainingText = + remember(remainingSec) { "-${resources.formatDuration(remainingSec.seconds)}" } + CompositionLocalProvider(LocalLayoutDirection provides currentLayoutDirection) { + Text( + text = positionText, + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.labelLarge, + modifier = + Modifier + .padding(8.dp), + ) + } + CompositionLocalProvider(LocalLayoutDirection provides currentLayoutDirection) { + Text( + text = remainingText, + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.labelLarge, + modifier = + Modifier + .padding(8.dp), + ) + } } } } @@ -368,60 +406,62 @@ fun PlaybackButtons( seekForward: Duration, modifier: Modifier = Modifier, ) { - Row( - modifier = modifier.focusGroup(), - horizontalArrangement = Arrangement.spacedBy(buttonSpacing), - ) { - PlaybackButton( - iconRes = R.drawable.baseline_skip_previous_24, - onClick = { - onControllerInteraction.invoke() - onPlaybackActionClick.invoke(PlaybackAction.Previous) - }, - enabled = previousEnabled, - onControllerInteraction = onControllerInteraction, - ) - PlaybackButton( - iconRes = R.drawable.baseline_fast_rewind_24, - onClick = { - onControllerInteraction.invoke() - player.seekBack(seekBack) - }, - onControllerInteraction = onControllerInteraction, - ) - PlaybackButton( - modifier = Modifier.focusRequester(initialFocusRequester), - iconRes = if (showPlay) R.drawable.baseline_play_arrow_24 else R.drawable.baseline_pause_24, - onClick = { - onControllerInteraction.invoke() - if (showPlay) { - player.play() - skipBackOnResume?.let { - player.seekBack(it) + CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) { + Row( + modifier = modifier.focusGroup(), + horizontalArrangement = Arrangement.spacedBy(buttonSpacing), + ) { + PlaybackButton( + iconRes = R.drawable.baseline_skip_previous_24, + onClick = { + onControllerInteraction.invoke() + onPlaybackActionClick.invoke(PlaybackAction.Previous) + }, + enabled = previousEnabled, + onControllerInteraction = onControllerInteraction, + ) + PlaybackButton( + iconRes = R.drawable.baseline_fast_rewind_24, + onClick = { + onControllerInteraction.invoke() + player.seekBack(seekBack) + }, + onControllerInteraction = onControllerInteraction, + ) + PlaybackButton( + modifier = Modifier.focusRequester(initialFocusRequester), + iconRes = if (showPlay) R.drawable.baseline_play_arrow_24 else R.drawable.baseline_pause_24, + onClick = { + onControllerInteraction.invoke() + if (showPlay) { + player.play() + skipBackOnResume?.let { + player.seekBack(it) + } + } else { + player.pause() } - } else { - player.pause() - } - }, - onControllerInteraction = onControllerInteraction, - ) - PlaybackButton( - iconRes = R.drawable.baseline_fast_forward_24, - onClick = { - onControllerInteraction.invoke() - player.seekForward(seekForward) - }, - onControllerInteraction = onControllerInteraction, - ) - PlaybackButton( - iconRes = R.drawable.baseline_skip_next_24, - onClick = { - onControllerInteraction.invoke() - onPlaybackActionClick.invoke(PlaybackAction.Next) - }, - enabled = nextEnabled, - onControllerInteraction = onControllerInteraction, - ) + }, + onControllerInteraction = onControllerInteraction, + ) + PlaybackButton( + iconRes = R.drawable.baseline_fast_forward_24, + onClick = { + onControllerInteraction.invoke() + player.seekForward(seekForward) + }, + onControllerInteraction = onControllerInteraction, + ) + PlaybackButton( + iconRes = R.drawable.baseline_skip_next_24, + onClick = { + onControllerInteraction.invoke() + onPlaybackActionClick.invoke(PlaybackAction.Next) + }, + enabled = nextEnabled, + onControllerInteraction = onControllerInteraction, + ) + } } } @@ -526,12 +566,19 @@ fun <T> BottomDialog( gravity: Int, currentChoice: BottomDialogItem<T>? = null, ) { + val initialIndex = + remember(choices, currentChoice) { choices.indexOfFirstOrNull { it == currentChoice } } ?: 0 + val listState = + rememberLazyListState( + initialFirstVisibleItemIndex = initialIndex, + ) val focusRequesters = remember(choices.size) { List(choices.size) { FocusRequester() } } + val bringIntoViewRequesters = + remember(choices.size) { List(choices.size) { BringIntoViewRequester() } } if (currentChoice != null) { LaunchedEffect(Unit) { - choices.indexOfFirstOrNull { it == currentChoice }?.let { - focusRequesters.getOrNull(it)?.tryRequestFocus() - } + bringIntoViewRequesters.getOrNull(initialIndex)?.bringIntoView() + focusRequesters.getOrNull(initialIndex)?.tryRequestFocus() } } // TODO enforcing a width ends up ignore the gravity @@ -556,6 +603,7 @@ fun <T> BottomDialog( ), ) { LazyColumn( + state = listState, modifier = Modifier .fillMaxWidth() @@ -589,7 +637,10 @@ fun <T> BottomDialog( } }, interactionSource = interactionSource, - modifier = Modifier.focusRequester(focusRequesters[index]), + modifier = + Modifier + .bringIntoViewRequester(bringIntoViewRequesters[index]) + .focusRequester(focusRequesters[index]), ) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackDebugOverlay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackDebugOverlay.kt index 1420ffb6..2aecdc65 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackDebugOverlay.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackDebugOverlay.kt @@ -36,7 +36,7 @@ import com.github.damontecres.wholphin.ui.theme.WholphinTheme import com.github.damontecres.wholphin.util.TrackSupport import com.github.damontecres.wholphin.util.TrackSupportReason import com.github.damontecres.wholphin.util.TrackType -import kotlinx.coroutines.Dispatchers +import com.github.damontecres.wholphin.util.WholphinDispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.withContext @@ -90,7 +90,7 @@ fun PlaybackDebugOverlay( ) val memoryUsed by produceState("") { - withContext(Dispatchers.Default) { + withContext(WholphinDispatchers.Default) { while (isActive) { val runtime = Runtime.getRuntime() val total = runtime.totalMemory() diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackOverlay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackOverlay.kt index 96adc4df..d00fefea 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackOverlay.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackOverlay.kt @@ -25,8 +25,8 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableLongStateOf @@ -41,19 +41,17 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.media3.common.Player -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Text import coil3.compose.AsyncImage import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.Chapter -import com.github.damontecres.wholphin.data.model.Playlist -import com.github.damontecres.wholphin.data.model.aspectRatioFloat +import com.github.damontecres.wholphin.data.model.PlaylistItem import com.github.damontecres.wholphin.ui.AppColors -import com.github.damontecres.wholphin.ui.AspectRatios import com.github.damontecres.wholphin.ui.LocalImageUrlService import com.github.damontecres.wholphin.ui.components.TimeDisplay import com.github.damontecres.wholphin.ui.isNotNullOrBlank @@ -65,7 +63,6 @@ import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.api.MediaSegmentDto import org.jellyfin.sdk.model.api.TrickplayInfo import kotlin.time.Duration -import kotlin.time.Duration.Companion.seconds /** * The overlay during playback showing controls, seek preview image, debug info, etc @@ -91,10 +88,10 @@ fun PlaybackOverlay( currentPlayback: CurrentPlayback?, currentSegment: MediaSegmentDto?, analyticsState: AnalyticsState, + queue: List<PlaylistItem>, modifier: Modifier = Modifier, trickplayInfo: TrickplayInfo? = null, trickplayUrlFor: (Int) -> String? = { null }, - playlist: Playlist = Playlist(listOf(), 0), onClickPlaylist: (BaseItem) -> Unit = {}, seekBarInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() }, ) { @@ -106,11 +103,11 @@ fun PlaybackOverlay( val density = LocalDensity.current val titleHeight = - remember(item?.title) { + remember(item?.title, density) { if (item?.title.isNotNullOrBlank()) with(density) { titleTextSize.toDp() } else 0.dp } val subtitleHeight = - remember(item?.subtitleLong) { + remember(item?.subtitleLong, density) { if (item?.subtitleLong.isNotNullOrBlank()) with(density) { subtitleTextSize.toDp() } else 0.dp } @@ -180,10 +177,10 @@ fun PlaybackOverlay( } } val nextState = - remember(chapters, playlist) { + remember(chapters, nextEnabled) { if (chapters.isNotEmpty()) { OverlayViewState.CHAPTERS - } else if (playlist.hasNext()) { + } else if (nextEnabled) { OverlayViewState.QUEUE } else { null @@ -225,8 +222,7 @@ fun PlaybackOverlay( player = player, controllerViewState = controllerViewState, chapters = chapters, - playlist = playlist, - aspectRatio = item?.data?.aspectRatioFloat ?: AspectRatios.WIDE, + hasNext = nextEnabled, onChangeState = onChangeState, modifier = Modifier @@ -237,9 +233,9 @@ fun PlaybackOverlay( } OverlayViewState.QUEUE -> { - if (playlist.hasNext()) { + if (nextEnabled) { QueueRowOverlay( - playlist = playlist, + queue = queue, controllerViewState = controllerViewState, nextState = remember(chapters) { @@ -261,60 +257,41 @@ fun PlaybackOverlay( } } - // Trickplay - AnimatedVisibility( - visible = controllerViewState.controlsVisible && seekProgressPercent >= 0 && seekBarFocused, - enter = - expandVertically( - spring( - stiffness = Spring.StiffnessMedium, - visibilityThreshold = IntSize.VisibilityThreshold, - ), - ) + fadeIn(), - exit = shrinkVertically() + fadeOut(), - ) { - Box( - modifier = - Modifier - .align(Alignment.Center) - .fillMaxWidth(.95f), + CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) { + // Trickplay + AnimatedVisibility( + visible = controllerViewState.controlsVisible && seekProgressPercent >= 0 && seekBarFocused, + enter = + expandVertically( + spring( + stiffness = Spring.StiffnessMedium, + visibilityThreshold = IntSize.VisibilityThreshold, + ), + ) + fadeIn(), + exit = shrinkVertically() + fadeOut(), ) { - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - horizontalAlignment = Alignment.CenterHorizontally, + Box( modifier = Modifier - .align(Alignment.BottomStart) - .offsetByPercent( - xPercentage = seekProgressPercent.coerceIn(0f, 1f), - ).padding(bottom = controllerHeight - titleHeight - subtitleHeight), + .align(Alignment.Center) + .fillMaxWidth(.95f), ) { - if (trickplayInfo != null) { - val tilesPerImage = trickplayInfo.tileWidth * trickplayInfo.tileHeight - val index = - (seekProgressMs / trickplayInfo.interval).toInt() / tilesPerImage - val imageUrl = remember(index) { trickplayUrlFor(index) } - - if (imageUrl != null) { - SeekPreviewImage( - modifier = Modifier, - previewImageUrl = imageUrl, - seekProgressMs = seekProgressMs, - trickPlayInfo = trickplayInfo, - ) - } - } - Text( - text = (seekProgressMs / 1000L).seconds.toString(), - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.labelLarge, + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier - .background( - Color.Black.copy(alpha = 0.6f), - shape = RoundedCornerShape(4.dp), - ).padding(horizontal = 8.dp, vertical = 4.dp), - ) + .align(Alignment.BottomStart) + .offsetByPercent( + xPercentage = seekProgressPercent.coerceIn(0f, 1f), + ).padding(bottom = controllerHeight - titleHeight - subtitleHeight), + ) { + TrickplayPreview( + seekProgressMs = seekProgressMs, + trickplayInfo = trickplayInfo, + trickplayUrlFor = trickplayUrlFor, + ) + } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/QueueRowOverlay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/QueueRowOverlay.kt index a44d1fc7..92b7afd1 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/QueueRowOverlay.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/QueueRowOverlay.kt @@ -22,7 +22,7 @@ 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.Playlist +import com.github.damontecres.wholphin.data.model.PlaylistItem import com.github.damontecres.wholphin.ui.cards.SeasonCard import com.github.damontecres.wholphin.ui.components.HiddenFocusBox import com.github.damontecres.wholphin.ui.ifElse @@ -31,14 +31,13 @@ import com.github.damontecres.wholphin.ui.tryRequestFocus @Composable fun QueueRowOverlay( - playlist: Playlist, + queue: List<PlaylistItem>, controllerViewState: ControllerViewState, nextState: OverlayViewState, onChangeState: (OverlayViewState) -> Unit, onClickPlaylist: (BaseItem) -> Unit, modifier: Modifier = Modifier, ) { - val items = remember { playlist.upcomingItems() } val focusRequester = remember { FocusRequester() } val hiddenFocusRequester = remember { FocusRequester() } LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } @@ -70,7 +69,7 @@ fun QueueRowOverlay( } }, ) { - itemsIndexed(items) { index, item -> + itemsIndexed(queue) { index, item -> val interactionSource = remember { MutableInteractionSource() } val isFocused = diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/SeekBar.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/SeekBar.kt index 1ca67657..d2aae720 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/SeekBar.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/SeekBar.kt @@ -18,7 +18,6 @@ package com.github.damontecres.wholphin.ui.playback.overlay * limitations under the License. */ -import android.view.KeyEvent import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.Canvas import androidx.compose.foundation.focusable @@ -48,7 +47,10 @@ import androidx.compose.ui.unit.dp import androidx.tv.material3.MaterialTheme import com.github.damontecres.wholphin.ui.playback.ControllerViewState import com.github.damontecres.wholphin.ui.playback.calculateSeekAccelerationMultiplier +import com.github.damontecres.wholphin.ui.playback.isDpadLeft +import com.github.damontecres.wholphin.ui.playback.isDpadRight import kotlinx.coroutines.FlowPreview +import timber.log.Timber import kotlin.time.Duration /** @@ -104,6 +106,30 @@ fun SteppedSeekBarImpl( ) } +/** + * A read-only seek bar for showing progress without taking focus or handling D-Pad input. + * + * This is used by overlays where seeking is handled elsewhere and the bar is only a visual indicator. + */ +@Composable +fun StaticSeekBarImpl( + progress: Float, + durationMs: Long, + bufferedProgress: Float, + modifier: Modifier = Modifier, +) { + SeekBarDisplay( + enabled = false, + progress = progress, + bufferedProgress = bufferedProgress, + durationMs = durationMs, + onLeft = {}, + onRight = {}, + interactionSource = remember { MutableInteractionSource() }, + modifier = modifier, + ) +} + /** * A seek (or scrubber) bar which seeks forward or back a fixed amount of time per move */ @@ -192,70 +218,69 @@ private fun SeekBarDisplay( .height(animatedIndicatorHeight) .padding(horizontal = 4.dp) .onPreviewKeyEvent { event -> - when (event.nativeKeyEvent.keyCode) { - KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_SYSTEM_NAVIGATION_LEFT -> { - when (event.type) { - KeyEventType.KeyDown -> { - val repeatCount = event.nativeKeyEvent.repeatCount - if (repeatCount > 0) { - leftHandledByRepeat = true - onLeft.invoke( - calculateSeekAccelerationMultiplier( - repeatCount = repeatCount, - durationMs = durationMs, - ), - ) - } else { - leftHandledByRepeat = false - } - } - - KeyEventType.KeyUp -> { - if (!leftHandledByRepeat) { - onLeft.invoke(1) - } + if (!isDpadLeft(event) && !isDpadRight(event)) { + Timber.v("Ignoring %s", event) + return@onPreviewKeyEvent false + } + if (isDpadLeft(event)) { + when (event.type) { + KeyEventType.KeyDown -> { + val repeatCount = event.nativeKeyEvent.repeatCount + if (repeatCount > 0) { + leftHandledByRepeat = true + onLeft.invoke( + calculateSeekAccelerationMultiplier( + repeatCount = repeatCount, + durationMs = durationMs, + ), + ) + } else { leftHandledByRepeat = false } - - else -> { - return@onPreviewKeyEvent false - } } - return@onPreviewKeyEvent true - } - KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_SYSTEM_NAVIGATION_RIGHT -> { - when (event.type) { - KeyEventType.KeyDown -> { - val repeatCount = event.nativeKeyEvent.repeatCount - if (repeatCount > 0) { - rightHandledByRepeat = true - onRight.invoke( - calculateSeekAccelerationMultiplier( - repeatCount = repeatCount, - durationMs = durationMs, - ), - ) - } else { - rightHandledByRepeat = false - } + KeyEventType.KeyUp -> { + if (!leftHandledByRepeat) { + onLeft.invoke(1) } + leftHandledByRepeat = false + } - KeyEventType.KeyUp -> { - if (!rightHandledByRepeat) { - onRight.invoke(1) - } + else -> { + return@onPreviewKeyEvent false + } + } + return@onPreviewKeyEvent true + } else { + when (event.type) { + KeyEventType.KeyDown -> { + val repeatCount = event.nativeKeyEvent.repeatCount + if (repeatCount > 0) { + rightHandledByRepeat = true + onRight.invoke( + calculateSeekAccelerationMultiplier( + repeatCount = repeatCount, + durationMs = durationMs, + ), + ) + } else { rightHandledByRepeat = false } - - else -> { - return@onPreviewKeyEvent false - } } - return@onPreviewKeyEvent true + + KeyEventType.KeyUp -> { + if (!rightHandledByRepeat) { + onRight.invoke(1) + } + rightHandledByRepeat = false + } + + else -> { + return@onPreviewKeyEvent false + } } + return@onPreviewKeyEvent true } - false }.focusable(enabled = enabled, interactionSource = interactionSource), onDraw = { val yOffset = size.height.div(2) @@ -282,7 +307,6 @@ private fun SeekBarDisplay( start = Offset(x = 0f, y = yOffset), end = Offset( -// x = size.width.times(if (isSelected) seekProgress else progress), x = size.width.times(progress), y = yOffset, ), @@ -292,7 +316,11 @@ private fun SeekBarDisplay( drawCircle( color = Color.White, radius = size.height + 2, - center = Offset(x = size.width.times(progress), y = yOffset), + center = + Offset( + x = size.width.times(progress), + y = yOffset, + ), ) }, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/TrickplayPreview.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/TrickplayPreview.kt new file mode 100644 index 00000000..4871a77a --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/TrickplayPreview.kt @@ -0,0 +1,63 @@ +package com.github.damontecres.wholphin.ui.playback.overlay + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalResources +import androidx.compose.ui.unit.dp +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.github.damontecres.wholphin.ui.formatDuration +import org.jellyfin.sdk.model.api.TrickplayInfo +import kotlin.time.Duration.Companion.seconds + +@Composable +fun TrickplayPreview( + seekProgressMs: Long, + trickplayInfo: TrickplayInfo?, + trickplayUrlFor: (Int) -> String?, + modifier: Modifier = Modifier, +) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier, + ) { + if (trickplayInfo != null) { + val tilesPerImage = trickplayInfo.tileWidth * trickplayInfo.tileHeight + val index = + (seekProgressMs / trickplayInfo.interval).toInt() / tilesPerImage + val imageUrl = remember(index) { trickplayUrlFor(index) } + + if (imageUrl != null) { + SeekPreviewImage( + modifier = Modifier, + previewImageUrl = imageUrl, + seekProgressMs = seekProgressMs, + trickPlayInfo = trickplayInfo, + ) + } + } + val resources = LocalResources.current + val seekProgressSec = seekProgressMs / 1000L + val seekText = remember(seekProgressSec) { resources.formatDuration(seekProgressSec.seconds) } + Text( + text = seekText, + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.labelLarge, + modifier = + Modifier + .background( + Color.Black.copy(alpha = 0.6f), + shape = RoundedCornerShape(4.dp), + ).padding(horizontal = 8.dp, vertical = 4.dp), + ) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/ComposablePreference.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/ComposablePreference.kt index 6d96667a..4a7338e2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/ComposablePreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/ComposablePreference.kt @@ -199,6 +199,7 @@ fun <T> ComposablePreference( is AppMultiChoicePreference<*, *> -> { val values = stringArrayResource(preference.displayValues) + val subtitles = preference.displayValuesSubtitles?.let { stringArrayResource(it) } val summary = preference.summary?.let { stringResource(it) } ?: preference.summary(context, value) @@ -219,6 +220,9 @@ fun <T> ComposablePreference( valueDisplay = { index, _ -> Text(values[index]) }, + subtitleDisplay = { index, _ -> + subtitles?.getOrNull(index)?.let { Text(it) } + }, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/LocaleChoiceDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/LocaleChoiceDialog.kt new file mode 100644 index 00000000..b352c550 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/LocaleChoiceDialog.kt @@ -0,0 +1,194 @@ +package com.github.damontecres.wholphin.ui.preferences + +import android.content.Context +import android.content.res.XmlResourceParser +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.tv.material3.Text +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.ui.components.DialogItem +import com.github.damontecres.wholphin.ui.components.DialogPopupContent +import com.github.damontecres.wholphin.ui.components.ErrorMessage +import com.github.damontecres.wholphin.ui.components.LoadingPage +import com.github.damontecres.wholphin.ui.components.SelectedLeadingContent +import com.github.damontecres.wholphin.ui.launchDefault +import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.util.LoadingState +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.update +import timber.log.Timber +import java.util.Locale +import javax.inject.Inject + +@HiltViewModel +class LocaleChoiceViewModel + @Inject + constructor( + @param:ApplicationContext private val context: Context, + private val serverRepository: ServerRepository, + ) : ViewModel() { + fun changeLocale(locale: Locale) { + viewModelScope.launchDefault { + serverRepository.current.value?.let { + Timber.i("Updating user's locale to %s", locale.toLanguageTag()) + val newUser = it.user.copy(uiLanguage = locale.toLanguageTag()) + serverRepository.changeUser(it.server, newUser) + } + } + } + + private val _state = MutableStateFlow(LocaleChoiceState()) + val state: StateFlow<LocaleChoiceState> = _state + + init { + viewModelScope.launchDefault { + serverRepository.currentUser?.let { + val availableLocales = extractAvailableLocales() + Timber.v("availableLocales=%s", availableLocales) + _state.update { + it.copy( + loading = LoadingState.Success, + availableLocales = availableLocales, + ) + } + } + } + viewModelScope.launchDefault { + serverRepository.currentUserFlow.collectLatest { user -> + val userLocale = user?.uiLanguage?.let { Locale.forLanguageTag(it) } ?: Locale.getDefault() + _state.update { it.copy(userLocale = userLocale) } + } + } + } + + /** + * Get the available locales that can be used. + * + * Defaults to `context.assets.locales` (all locales) if can't find app specific ones + */ + private fun extractAvailableLocales(): List<Locale> { + val id = + context.resources.getIdentifier( + "_generated_res_locale_config", + "xml", + context.packageName, + ) + return if (id != 0) { + try { + val locales = mutableListOf<Locale>() + // This is kind of a hack, the generated locale config is not available, + // programmatically, so this code parses the raw XML + context.resources.getXml(id).use { parser -> + var eventType: Int = parser.eventType + while (eventType != XmlResourceParser.END_DOCUMENT) { + if (eventType == XmlResourceParser.START_TAG) { + val tagName: String? = parser.name + + // Check for a specific tag name + if ("locale" == tagName) { + val attr = + parser.getAttributeValue( + "http://schemas.android.com/apk/res/android", + "name", + ) + attr?.let { locales.add(Locale.forLanguageTag(it)) } + } + } + eventType = parser.next() + } + } + locales + } catch (ex: Exception) { + Timber.w(ex, "Exception while parsing generated available locales") + context.assets.locales.map { Locale.forLanguageTag(it) } + } + } else { + context.assets.locales.map { Locale.forLanguageTag(it) } + } + } + } + +data class LocaleChoiceState( + val loading: LoadingState = LoadingState.Pending, + val availableLocales: List<Locale> = emptyList(), + val userLocale: Locale = Locale.getDefault(), +) + +@Composable +fun LocaleChoiceDialog( + onDismissRequest: () -> Unit, + viewModel: LocaleChoiceViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsState() + Dialog( + onDismissRequest = onDismissRequest, + properties = DialogProperties(), + ) { + when (val st = state.loading) { + is LoadingState.Error -> { + ErrorMessage(st) + } + + LoadingState.Loading, + LoadingState.Pending, + -> { + LoadingPage() + } + + LoadingState.Success -> { + val focusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + val dialogItems = + remember(state.userLocale, state.availableLocales) { + val userLanguage = state.userLocale.toLanguageTag() + state.availableLocales.map { locale -> + val tag = locale.toLanguageTag() + DialogItem( + selected = tag == userLanguage, + leadingContent = { + Box { + SelectedLeadingContent(tag == userLanguage) + } + }, + headlineContent = { + Text(locale.getDisplayName(locale)) + }, + supportingContent = { + Text(locale.getDisplayName(Locale.ENGLISH)) + }, + dismissOnClick = true, + onClick = { + viewModel.changeLocale(locale) + }, + ) + } + } + DialogPopupContent( + title = stringResource(R.string.user_interface_language), + dialogItems = dialogItems, + waiting = false, + onDismissRequest = onDismissRequest, + modifier = Modifier.focusRequester(focusRequester), + ) + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/MultiChoicePreference.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/MultiChoicePreference.kt index bb34207f..4a9787a0 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/MultiChoicePreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/MultiChoicePreference.kt @@ -25,6 +25,7 @@ fun <T> MultiChoicePreference( modifier: Modifier = Modifier, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, valueDisplay: @Composable (index: Int, item: T) -> Unit = { _, item -> Text(item.toString()) }, + subtitleDisplay: @Composable (index: Int, item: T) -> Unit = { _, item -> }, ) { // val values = stringArrayResource(preference.displayValues).toList() // val summary = @@ -60,6 +61,7 @@ fun <T> MultiChoicePreference( possibleValues.mapIndexed { index, item -> DialogItem( headlineContent = { valueDisplay.invoke(index, item) }, + supportingContent = { subtitleDisplay.invoke(index, item) }, trailingContent = { Switch( checked = selectedValues.contains(item), diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/NavDrawerPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/NavDrawerPreference.kt index 89fce8fb..7deeb95c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/NavDrawerPreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/NavDrawerPreference.kt @@ -16,11 +16,14 @@ import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -28,7 +31,6 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.datastore.core.DataStore import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -41,9 +43,7 @@ import com.github.damontecres.wholphin.data.ServerPreferencesDao import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.NavDrawerPinnedItem import com.github.damontecres.wholphin.data.model.NavPinType -import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.services.BackdropService -import com.github.damontecres.wholphin.services.NavDrawerItemState import com.github.damontecres.wholphin.services.NavDrawerService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.SeerrServerRepository @@ -57,15 +57,14 @@ import com.github.damontecres.wholphin.ui.main.settings.MoveDirection import com.github.damontecres.wholphin.ui.nav.NavDrawerItem import com.github.damontecres.wholphin.ui.theme.WholphinTheme import com.github.damontecres.wholphin.util.ExceptionHandler -import com.github.damontecres.wholphin.util.RememberTabManager +import com.github.damontecres.wholphin.util.WholphinDispatchers import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import org.jellyfin.sdk.api.client.ApiClient import javax.inject.Inject data class NavDrawerPin( @@ -153,6 +152,8 @@ fun NavDrawerPreferenceDialog( onMoveUp: (Int) -> Unit, onMoveDown: (Int) -> Unit, ) { + val scope = rememberCoroutineScope() + val bringIntoViewRequesters = remember { List(items.size) { BringIntoViewRequester() } } BasicDialog( onDismissRequest = onDismissRequest, elevation = 3.dp, @@ -169,6 +170,13 @@ fun NavDrawerPreferenceDialog( modifier = Modifier.padding(bottom = 8.dp), ) val listState = rememberLazyListState() + + fun ensureVisible(index: Int) { + val idx = index.coerceIn(items.indices) + scope.launch { + bringIntoViewRequesters[idx].bringIntoView() + } + } LazyColumn( state = listState, verticalArrangement = Arrangement.spacedBy(0.dp), @@ -180,9 +188,18 @@ fun NavDrawerPreferenceDialog( moveUpAllowed = index > 0, moveDownAllowed = index < items.lastIndex, onClick = { onClick.invoke(index) }, - onMoveUp = { onMoveUp.invoke(index) }, - onMoveDown = { onMoveDown.invoke(index) }, - modifier = Modifier.animateItem(), + onMoveUp = { + onMoveUp.invoke(index) + ensureVisible(index - 1) + }, + onMoveDown = { + onMoveDown.invoke(index) + ensureVisible(index + 1) + }, + modifier = + Modifier + .animateItem() + .bringIntoViewRequester(bringIntoViewRequesters[index]), ) } } @@ -210,7 +227,7 @@ fun NavDrawerPreferenceListItem( modifier = Modifier .fillMaxWidth() - .heightIn(min = 40.dp, max = 88.dp), + .heightIn(min = 40.dp), ) { ListItem( selected = false, @@ -282,11 +299,8 @@ class NavDrawerPreferencesViewModel @Inject constructor( @param:ApplicationContext private val context: Context, - private val api: ApiClient, - val preferenceDataStore: DataStore<AppPreferences>, val navigationManager: NavigationManager, val backdropService: BackdropService, - private val rememberTabManager: RememberTabManager, private val serverRepository: ServerRepository, private val navDrawerService: NavDrawerService, private val serverPreferencesDao: ServerPreferencesDao, @@ -297,13 +311,13 @@ class NavDrawerPreferencesViewModel init { viewModelScope.launchDefault { val state = navDrawerService.state.value - val user = serverRepository.currentUser.value + val user = serverRepository.currentUser val seerr = seerrServerRepository.active.firstOrNull() - if (state == NavDrawerItemState.EMPTY || user == null || seerr == null) { + if (user == null || seerr == null) { return@launchDefault } val navDrawerPins = - withContext(Dispatchers.IO) { + withContext(WholphinDispatchers.IO) { serverPreferencesDao .getNavDrawerPinnedItems(user) .associateBy { it.itemId } @@ -337,8 +351,8 @@ class NavDrawerPreferencesViewModel fun save() { viewModelScope.launchIO(ExceptionHandler(true)) { - serverRepository.currentUser.value?.let { user -> - serverRepository.currentUserDto.value?.let { userDto -> + serverRepository.currentUser?.let { user -> + serverRepository.currentUserDto?.let { userDto -> if (user.id == userDto.id) { val toSave = state.value.mapIndexed { index, item -> @@ -350,7 +364,8 @@ class NavDrawerPreferencesViewModel ) } serverPreferencesDao.saveNavDrawerPinnedItems(*toSave.toTypedArray()) - navDrawerService.updateNavDrawer(user, userDto) + val discoverActive = seerrServerRepository.active.firstOrNull() == true + navDrawerService.updateNavDrawer(user, userDto, discoverActive) } else { throw IllegalStateException("User IDs do not match") } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferenceUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferenceUtils.kt index 06399fe9..81f4833b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferenceUtils.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferenceUtils.kt @@ -1,6 +1,13 @@ package com.github.damontecres.wholphin.ui.preferences import androidx.annotation.StringRes +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.res.stringResource +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.model.SeerrAuthMethod +import com.github.damontecres.wholphin.data.model.SeerrServer +import com.github.damontecres.wholphin.data.model.SeerrUser import com.github.damontecres.wholphin.preferences.AppPreference import kotlinx.serialization.Serializable @@ -37,9 +44,54 @@ enum class PreferenceScreenOption { MPV, SCREENSAVER, SKIP_SEGMENTS, + EXPERIMENTAL, + HDR_FORMATS, ; companion object { fun fromString(name: String?) = entries.firstOrNull { it.name == name } ?: BASIC } } + +@Composable +fun rememberSeerrDialogBody( + user: SeerrUser, + server: SeerrServer, + pluginProvided: Boolean, + ex: Exception? = null, +): String { + val username = + when (user.authMethod) { + SeerrAuthMethod.LOCAL, + SeerrAuthMethod.JELLYFIN, + -> user.username ?: stringResource(R.string.unknown) + + SeerrAuthMethod.API_KEY -> stringResource(R.string.api_key) + } + val method = + when (user.authMethod) { + SeerrAuthMethod.LOCAL -> R.string.seerr_local_user + SeerrAuthMethod.JELLYFIN -> R.string.seerr_jellyfin_user + SeerrAuthMethod.API_KEY -> 0 + }.let { + if (it != 0) " (${stringResource(it)})" else "" + } + + val body = + """ + ${stringResource(R.string.url)}: ${server.url} + ${stringResource(R.string.username)}: $username$method + """.trimIndent() + + if (pluginProvided) { + "\n\n${stringResource(R.string.managed_by_server)}" + } else { + "" + } + + if (ex != null) { + "\n\n${stringResource(R.string.error)}: ${ex.localizedMessage}" + } else { + "" + } + + return remember { body } +} 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 52d182e1..0e388213 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt @@ -26,7 +26,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -37,10 +36,12 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.tv.material3.ListItem import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import androidx.tv.material3.surfaceColorAtElevation @@ -49,13 +50,18 @@ import coil3.imageLoader import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.AppSwitchPreference import com.github.damontecres.wholphin.preferences.ExoPlayerPreferences +import com.github.damontecres.wholphin.preferences.ExperimentalPreference +import com.github.damontecres.wholphin.preferences.HdrFormatPreferences import com.github.damontecres.wholphin.preferences.MpvPreferences import com.github.damontecres.wholphin.preferences.PlayerBackend import com.github.damontecres.wholphin.preferences.ScreensaverPreference import com.github.damontecres.wholphin.preferences.SkipSegmentPreferences import com.github.damontecres.wholphin.preferences.advancedPreferences import com.github.damontecres.wholphin.preferences.basicPreferences +import com.github.damontecres.wholphin.preferences.experimentalPreferences +import com.github.damontecres.wholphin.preferences.lazyListWrapScrolling import com.github.damontecres.wholphin.preferences.screensaverPreferences import com.github.damontecres.wholphin.preferences.updatePlaybackPreferences import com.github.damontecres.wholphin.services.Release @@ -67,14 +73,15 @@ import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.ScrollableDialog import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.indexOfFirstOrNull -import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.playOnClickSound import com.github.damontecres.wholphin.ui.playSoundOnFocus import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings +import com.github.damontecres.wholphin.ui.serverSupportedMessage import com.github.damontecres.wholphin.ui.setup.ReleaseNotes import com.github.damontecres.wholphin.ui.setup.UpdateViewModel -import com.github.damontecres.wholphin.ui.setup.seerr.AddSeerServerDialog +import com.github.damontecres.wholphin.ui.setup.seerr.SeerrDialog +import com.github.damontecres.wholphin.ui.setup.seerr.SeerrDialogMode import com.github.damontecres.wholphin.ui.setup.seerr.SwitchSeerrViewModel import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.ui.tryRequestFocus @@ -83,6 +90,7 @@ import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.LoadingState import kotlinx.coroutines.launch import timber.log.Timber +import java.util.Locale @Composable fun PreferencesContent( @@ -95,21 +103,30 @@ fun PreferencesContent( onFocus: (Int, Int) -> Unit = { _, _ -> }, ) { val context = LocalContext.current + val resources = LocalResources.current val scope = rememberCoroutineScope() val focusRequester = remember { FocusRequester() } + val firstFocusRequester = remember { FocusRequester() } + val lastFocusRequester = remember { FocusRequester() } + var focusedIndex by rememberSaveable { mutableStateOf(Pair(0, 0)) } val state = rememberLazyListState() var preferences by remember { mutableStateOf(initialPreferences) } - val currentUser by viewModel.currentUser.observeAsState() - val currentServer by seerrVm.currentSeerrServer.collectAsState(null) + val currentUser by viewModel.currentUser.collectAsState() var showPinFlow by remember { mutableStateOf(false) } var showVersionDialog by remember { mutableStateOf(false) } val players by viewModel.externalPlayers.collectAsState() + val serverSupported by viewModel.serverSupported.collectAsState() + val serverVersionMessage = + remember(resources, serverSupported) { + resources.serverSupportedMessage(serverSupported.second, serverSupported.first) + } var cacheUsage by remember { mutableStateOf(CacheUsage(0, 0, 0)) } val seerrConnection by viewModel.seerrConnection.collectAsState() var seerrDialogMode by remember { mutableStateOf<SeerrDialogMode>(SeerrDialogMode.None) } var showQuickConnectDialog by remember { mutableStateOf(false) } + var showLocaleChoiceDialog by remember { mutableStateOf(false) } LaunchedEffect(Unit) { viewModel.preferenceDataStore.data.collect { @@ -125,16 +142,20 @@ fun PreferencesContent( updateCache = false } - val release by updateVM.release.observeAsState(null) - LaunchedEffect(Unit) { + val updateState by updateVM.state.collectAsState() + val release = updateState.release + LaunchedEffect(preferences.updateUrl, preferences.autoCheckForUpdates) { if (UpdateChecker.ACTIVE && preferences.autoCheckForUpdates) { - updateVM.init(preferences.updateUrl) + updateVM.init() } } - val movementSounds = true + val movementSounds = false val installedVersion = updateVM.currentVersion - val updateAvailable = release?.version?.isGreaterThan(installedVersion) ?: false + val updateAvailable = + remember(updateState.release) { + updateState.release?.version?.isGreaterThan(installedVersion) ?: false + } val prefList = when (preferenceScreenOption) { @@ -144,6 +165,8 @@ fun PreferencesContent( PreferenceScreenOption.MPV -> MpvPreferences PreferenceScreenOption.SCREENSAVER -> screensaverPreferences PreferenceScreenOption.SKIP_SEGMENTS -> SkipSegmentPreferences + PreferenceScreenOption.EXPERIMENTAL -> experimentalPreferences + PreferenceScreenOption.HDR_FORMATS -> HdrFormatPreferences } val screenTitle = when (preferenceScreenOption) { @@ -153,6 +176,8 @@ fun PreferencesContent( PreferenceScreenOption.MPV -> R.string.mpv_options PreferenceScreenOption.SCREENSAVER -> R.string.screensaver_settings PreferenceScreenOption.SKIP_SEGMENTS -> R.string.skip_behavior + PreferenceScreenOption.EXPERIMENTAL -> R.string.experimental_settings + PreferenceScreenOption.HDR_FORMATS -> R.string.hdr_formats } var visible by remember { mutableStateOf(false) } @@ -177,6 +202,12 @@ fun PreferencesContent( } } + val showUpdate = + UpdateChecker.ACTIVE && + preferenceScreenOption == PreferenceScreenOption.BASIC && + preferences.autoCheckForUpdates && + updateAvailable + AnimatedVisibility( visible = visible, enter = fadeIn() + slideInHorizontally { it / 2 }, @@ -206,12 +237,23 @@ fun PreferencesContent( contentPadding = PaddingValues(16.dp), modifier = Modifier.fillMaxSize(), ) { - if (UpdateChecker.ACTIVE && - preferenceScreenOption == PreferenceScreenOption.BASIC && - preferences.autoCheckForUpdates && - updateAvailable - ) { + serverVersionMessage?.let { message -> item { + ListItem( + selected = false, + enabled = true, + onClick = {}, + headlineContent = { + PreferenceTitle(message, color = MaterialTheme.colorScheme.error) + }, + modifier = Modifier, + ) + } + } + if (showUpdate) { + item { + val interactionSource = remember { MutableInteractionSource() } + val focused = interactionSource.collectIsFocusedAsState().value val updateFocusRequester = remember { FocusRequester() } LaunchedEffect(Unit) { if (focusedIndex.first == 0 && focusedIndex.second == 0) { @@ -226,13 +268,24 @@ fun PreferencesContent( viewModel.navigationManager.navigateTo(Destination.UpdateApp) }, summary = release?.version?.toString(), + interactionSource = interactionSource, modifier = Modifier .focusRequester(updateFocusRequester) - .playSoundOnFocus(movementSounds), + .focusRequester(firstFocusRequester) + .playSoundOnFocus(movementSounds) + .lazyListWrapScrolling( + state = state, + focused = focused, + isFirst = true, + isLast = false, + firstFocusRequester = firstFocusRequester, + lastFocusRequester = lastFocusRequester, + ), ) } } + prefList.forEachIndexed { groupIndex, group -> item { Text( @@ -245,6 +298,9 @@ fun PreferencesContent( .fillMaxWidth() .padding(top = 8.dp, bottom = 4.dp), ) + if (group.title == R.string.about) { + WolfskinAbout() + } } val groupPreferences = group.preferences + @@ -254,9 +310,25 @@ fun PreferencesContent( .flatten() groupPreferences.forEachIndexed { prefIndex, pref -> pref as AppPreference<AppPreferences, Any> + val isFirst = groupIndex == 0 && prefIndex == 0 && !showUpdate + val isLast = + groupIndex == prefList.lastIndex && prefIndex == groupPreferences.lastIndex item { val interactionSource = remember { MutableInteractionSource() } val focused = interactionSource.collectIsFocusedAsState().value + val focusModifier = + Modifier + .ifElse( + groupIndex == focusedIndex.first && prefIndex == focusedIndex.second, + Modifier.focusRequester(focusRequester), + ).lazyListWrapScrolling( + state, + focused, + isFirst, + isLast, + firstFocusRequester, + lastFocusRequester, + ) LaunchedEffect(focused) { if (focused) { focusedIndex = Pair(groupIndex, prefIndex) @@ -276,12 +348,7 @@ fun PreferencesContent( }, summary = installedVersion.toString(), interactionSource = interactionSource, - modifier = - Modifier - .ifElse( - groupIndex == focusedIndex.first && prefIndex == focusedIndex.second, - Modifier.focusRequester(focusRequester), - ), + modifier = focusModifier, ) } @@ -304,7 +371,7 @@ fun PreferencesContent( ) } } else { - updateVM.init(preferences.updateUrl) + updateVM.init() } }, onLongClick = { @@ -318,12 +385,7 @@ fun PreferencesContent( null }, interactionSource = interactionSource, - modifier = - Modifier - .ifElse( - groupIndex == focusedIndex.first && prefIndex == focusedIndex.second, - Modifier.focusRequester(focusRequester), - ), + modifier = focusModifier, ) } @@ -349,7 +411,7 @@ fun PreferencesContent( updateCache = true } }, - modifier = Modifier, + modifier = focusModifier, summary = summary, onLongClick = {}, interactionSource = interactionSource, @@ -360,7 +422,7 @@ fun PreferencesContent( NavDrawerPreference( title = stringResource(pref.title), summary = pref.summary(context, null), - modifier = Modifier, + modifier = focusModifier, interactionSource = interactionSource, ) } @@ -371,7 +433,7 @@ fun PreferencesContent( onClick = { viewModel.sendAppLogs() }, - modifier = Modifier, + modifier = focusModifier, summary = pref.summary(context, null), onLongClick = {}, interactionSource = interactionSource, @@ -384,23 +446,54 @@ fun PreferencesContent( onClick = { viewModel.resetSubtitleSettings() }, - modifier = Modifier, + modifier = focusModifier, summary = pref.summary(context, null), onLongClick = {}, interactionSource = interactionSource, ) } - AppPreference.RequireProfilePin -> { - SwitchPreference( + AppPreference.ProtectProfilePreference -> { + val summary = + when { + currentUser?.requireLogin == true -> R.string.require_login + currentUser?.hasPin == true -> R.string.require_pin_code + else -> R.string.none + } + ChoicePreference( title = stringResource(pref.title), - value = currentUser?.pin.isNotNullOrBlank(), - onClick = { - showPinFlow = true + summary = stringResource(summary), + interactionSource = interactionSource, + possibleValues = + listOf( + stringResource(R.string.none), + stringResource(R.string.require_pin_code), + stringResource(R.string.require_login), + ), + selectedIndex = + when { + currentUser?.requireLogin == true -> ProfileProtection.LOGIN + currentUser?.hasPin == true -> ProfileProtection.PIN + else -> ProfileProtection.NONE + }.ordinal, + onValueChange = { + currentUser?.let { user -> + when (ProfileProtection.entries[it]) { + ProfileProtection.NONE -> { + viewModel.removeLoginAndPin(user) + } + + ProfileProtection.PIN -> { + showPinFlow = true + } + + ProfileProtection.LOGIN -> { + viewModel.setRequireLogin(user) + } + } + } }, - summaryOn = stringResource(R.string.enabled), - summaryOff = null, - modifier = Modifier, + modifier = focusModifier, ) } @@ -409,10 +502,11 @@ fun PreferencesContent( title = stringResource(pref.title), onClick = { seerrDialogMode = - when (val conn = seerrConnection) { + when (val conn = seerrConnection.connection) { is SeerrConnectionStatus.Error -> { SeerrDialogMode.Error( - conn.server.url, + conn.server, + conn.user, conn.ex, ) } @@ -423,22 +517,20 @@ fun PreferencesContent( is SeerrConnectionStatus.Success -> { SeerrDialogMode.Remove( - conn.current.server.url, + conn.current, ) } } }, - modifier = Modifier, + modifier = focusModifier, summary = - when (seerrConnection) { + when (seerrConnection.connection) { is SeerrConnectionStatus.Error -> { stringResource(R.string.voice_error_server) } SeerrConnectionStatus.NotConfigured -> { - stringResource( - R.string.add_server, - ) + stringResource(R.string.add_server) } is SeerrConnectionStatus.Success -> { @@ -459,7 +551,7 @@ fun PreferencesContent( showQuickConnectDialog = true } }, - modifier = Modifier, + modifier = focusModifier, summary = pref.summary(context, null), onLongClick = {}, interactionSource = interactionSource, @@ -472,7 +564,7 @@ fun PreferencesContent( onClick = { viewModel.screensaverService.start() }, - modifier = Modifier, + modifier = focusModifier, summary = pref.summary(context, null), onLongClick = {}, interactionSource = interactionSource, @@ -488,6 +580,7 @@ fun PreferencesContent( ChoicePreference( title = stringResource(pref.title), summary = players[selectedIndex].name, + interactionSource = interactionSource, possibleValues = players, selectedIndex = selectedIndex, onValueChange = { index -> @@ -515,9 +608,68 @@ fun PreferencesContent( Text(item.name) } }, + modifier = focusModifier, ) } + AppPreference.UserInterfaceLanguage -> { + val locale = + remember(currentUser?.uiLanguage) { + currentUser?.uiLanguage?.let { Locale.forLanguageTag(it) } + ?: Locale.getDefault() + } + ClickPreference( + title = stringResource(pref.title), + onClick = { + showLocaleChoiceDialog = true + }, + modifier = focusModifier, + summary = locale.getDisplayName(locale), + onLongClick = null, + interactionSource = interactionSource, + ) + } + + ExperimentalPreference.Enable -> { + var showConfirm by remember { mutableStateOf(false) } + pref as AppSwitchPreference<AppPreferences> + val value = pref.getter.invoke(preferences) + SwitchPreference( + title = stringResource(pref.title), + value = value, + onClick = { + if (value) { + scope.launch(ExceptionHandler()) { + preferences = + viewModel.preferenceDataStore.updateData { prefs -> + pref.setter.invoke(prefs, false) + } + } + } else { + showConfirm = true + } + }, + summaryOn = pref.summaryOn?.let { stringResource(pref.summaryOn) }, + summaryOff = pref.summaryOff?.let { stringResource(pref.summaryOff) }, + ) + if (showConfirm) { + ConfirmDialog( + title = stringResource(R.string.confirm_enable_experimental_title), + body = stringResource(R.string.confirm_enable_experimental_body), + onCancel = { showConfirm = false }, + onConfirm = { + showConfirm = false + scope.launch(ExceptionHandler()) { + preferences = + viewModel.preferenceDataStore.updateData { prefs -> + pref.setter.invoke(prefs, true) + } + } + }, + ) + } + } + else -> { val value = pref.getter.invoke(preferences) ComposablePreference( @@ -525,7 +677,7 @@ fun PreferencesContent( value = value, onNavigate = viewModel.navigationManager::navigateTo, onValueChange = { newValue -> - val validation = pref.validate(newValue) + val validation = pref.validate(preferences, newValue) when (validation) { is PreferenceValidation.Invalid -> { // TODO? @@ -548,12 +700,7 @@ fun PreferencesContent( } }, interactionSource = interactionSource, - modifier = - Modifier - .ifElse( - groupIndex == focusedIndex.first && prefIndex == focusedIndex.second, - Modifier.focusRequester(focusRequester), - ), + modifier = focusModifier, ) } } @@ -578,64 +725,15 @@ fun PreferencesContent( ) } } - when (val mode = seerrDialogMode) { - is SeerrDialogMode.Remove -> { - ConfirmDialog( - title = stringResource(R.string.remove_seerr_server), - body = mode.serverUrl, - onCancel = { seerrDialogMode = SeerrDialogMode.None }, - onConfirm = { - seerrVm.removeServer() - seerrDialogMode = SeerrDialogMode.None - }, - ) - } - - SeerrDialogMode.Add -> { - val currentUser by seerrVm.currentUser.observeAsState() - val status by seerrVm.serverConnectionStatus.collectAsState(LoadingState.Pending) - val serverAddedMessage = stringResource(R.string.seerr_server_added) - LaunchedEffect(status) { - if (status == LoadingState.Success) { - Toast.makeText(context, serverAddedMessage, Toast.LENGTH_SHORT).show() - seerrDialogMode = SeerrDialogMode.None - } - } - AddSeerServerDialog( - currentUsername = currentUser?.name, - status = status, - onSubmit = seerrVm::submitServer, - onResetStatus = seerrVm::resetStatus, - onDismissRequest = { seerrDialogMode = SeerrDialogMode.None }, - ) - } - - is SeerrDialogMode.Error -> { - val errorStr = stringResource(R.string.voice_error_server) - val body = - remember(mode) { - """ - ${mode.serverUrl} - - $errorStr: ${mode.ex.localizedMessage} - """.trimIndent() - } - ConfirmDialog( - title = stringResource(R.string.remove_seerr_server), - body = body, - onCancel = { seerrDialogMode = SeerrDialogMode.None }, - onConfirm = { - seerrVm.removeServer() - seerrDialogMode = SeerrDialogMode.None - }, - bodyColor = MaterialTheme.colorScheme.error, - ) - } - - SeerrDialogMode.None -> {} - } } + SeerrDialog( + seerrDialogMode = seerrDialogMode, + seerrConnection = seerrConnection, + onDismissRequest = { seerrDialogMode = SeerrDialogMode.None }, + seerrVm = seerrVm, + ) + if (showQuickConnectDialog) { val quickConnectStatus by viewModel.quickConnectStatus.collectAsState(LoadingState.Pending) val successMessage = stringResource(R.string.quick_connect_success) @@ -693,6 +791,11 @@ fun PreferencesContent( } } } + if (showLocaleChoiceDialog) { + LocaleChoiceDialog( + onDismissRequest = { showLocaleChoiceDialog = false }, + ) + } } @Composable @@ -711,6 +814,8 @@ fun PreferencesPage( PreferenceScreenOption.MPV, PreferenceScreenOption.SCREENSAVER, PreferenceScreenOption.SKIP_SEGMENTS, + PreferenceScreenOption.EXPERIMENTAL, + PreferenceScreenOption.HDR_FORMATS, -> { PreferencesContent( initialPreferences, @@ -730,18 +835,3 @@ data class CacheUsage( val imageMemoryMax: Long, val imageDiskUsed: Long, ) - -private sealed interface SeerrDialogMode { - data object None : SeerrDialogMode - - data object Add : SeerrDialogMode - - data class Remove( - val serverUrl: String, - ) : SeerrDialogMode - - data class Error( - val serverUrl: String, - val ex: Exception, - ) : SeerrDialogMode -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt index 32d085a5..2e0ce392 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt @@ -12,6 +12,7 @@ import androidx.datastore.core.DataStore import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.WholphinApplication import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.preferences.AppPreferences @@ -22,21 +23,22 @@ import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.Release import com.github.damontecres.wholphin.services.ScreensaverService import com.github.damontecres.wholphin.services.SeerrServerRepository +import com.github.damontecres.wholphin.services.ServerReportService import com.github.damontecres.wholphin.services.UpdateChecker -import com.github.damontecres.wholphin.ui.detail.DebugViewModel.Companion.sendAppLogs import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.ui.setup.ServerVersionSupported import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.LoadingState -import com.github.damontecres.wholphin.util.RememberTabManager import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.model.ClientInfo -import org.jellyfin.sdk.model.DeviceInfo import timber.log.Timber import javax.inject.Inject @@ -50,15 +52,23 @@ class PreferencesViewModel val navigationManager: NavigationManager, val backdropService: BackdropService, val screensaverService: ScreensaverService, - private val rememberTabManager: RememberTabManager, private val serverRepository: ServerRepository, private val seerrServerRepository: SeerrServerRepository, - private val deviceInfo: DeviceInfo, - private val clientInfo: ClientInfo, private val updateChecker: UpdateChecker, - ) : ViewModel(), - RememberTabManager by rememberTabManager { - val currentUser get() = serverRepository.currentUser + private val serverReportService: ServerReportService, + ) : ViewModel() { + val currentUser = + serverRepository.currentUserFlow.stateIn( + viewModelScope, + SharingStarted.Eagerly, + null, + ) + val currentUserDto = + serverRepository.currentUserDtoFlow.stateIn( + viewModelScope, + SharingStarted.Eagerly, + null, + ) val seerrConnection = seerrServerRepository.connection @@ -69,6 +79,23 @@ class PreferencesViewModel val externalPlayers = MutableStateFlow<List<ExternalPlayerApp>>(emptyList()) + val serverSupported = + serverRepository.currentServerFlow + .map { + val serverVersion = it?.serverVersion + serverVersion to + when { + serverVersion == null -> ServerVersionSupported.NOT_SUPPORTED + serverVersion < WholphinApplication.minimumServerVersion -> ServerVersionSupported.NOT_SUPPORTED + serverVersion < WholphinApplication.newMinimumServerVersion -> ServerVersionSupported.NOT_SUPPORTED_IN_FUTURE + else -> ServerVersionSupported.SUPPORTED + } + }.stateIn( + viewModelScope, + SharingStarted.Eagerly, + null to ServerVersionSupported.SUPPORTED, + ) + init { viewModelScope.launchIO { val fakeIntent = @@ -87,7 +114,7 @@ class PreferencesViewModel } fun sendAppLogs() { - sendAppLogs(context, api, clientInfo, deviceInfo) + viewModelScope.launchIO { serverReportService.sendAppLogs() } } fun resetSubtitleSettings() { @@ -101,7 +128,19 @@ class PreferencesViewModel pin: String?, ) { viewModelScope.launchIO(ExceptionHandler(autoToast = true)) { - serverRepository.setUserPin(user, pin) + serverRepository.updateUserAuth(user, pin, false) + } + } + + fun setRequireLogin(user: JellyfinUser) { + viewModelScope.launchIO(ExceptionHandler(autoToast = true)) { + serverRepository.updateUserAuth(user, null, true) + } + } + + fun removeLoginAndPin(user: JellyfinUser) { + viewModelScope.launchIO(ExceptionHandler(autoToast = true)) { + serverRepository.updateUserAuth(user, null, false) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/SetPinFlow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/SetPinFlow.kt index 3f71e7e4..2c6bcd55 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/SetPinFlow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/SetPinFlow.kt @@ -21,6 +21,12 @@ enum class PinFlowState { CONFIRM_PIN, } +enum class ProfileProtection { + NONE, + PIN, + LOGIN, +} + @Composable fun SetPinFlow( currentPin: String?, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/SliderPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/SliderPreference.kt index ce686708..459ec479 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/SliderPreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/SliderPreference.kt @@ -8,9 +8,9 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable @@ -18,7 +18,6 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.tv.material3.MaterialTheme import androidx.tv.material3.ProvideTextStyle @@ -36,7 +35,6 @@ fun SliderPreference( modifier: Modifier = Modifier, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, summaryBelow: Boolean = false, - heightAdjustment: Dp = 0.dp, additionalSummary: @Composable (ColumnScope.() -> Unit)? = null, ) { val focused = interactionSource.collectIsFocusedAsState().value @@ -49,16 +47,15 @@ fun SliderPreference( val contentColor = contentColorFor(background) Column( - verticalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.SpaceEvenly, modifier = modifier -// .height(80.dp) // not dense - .height(72.dp + heightAdjustment) // dense + .defaultMinSize(minHeight = 72.dp) .fillMaxWidth() .background(background, shape = RoundedCornerShape(8.dp)) - .padding(PaddingValues(horizontal = 12.dp, vertical = 10.dp)), // dense, + .padding(PaddingValues(horizontal = 16.dp, vertical = 12.dp)), ) { - PreferenceTitle(title, color = contentColor) + PreferenceTitle(title, color = contentColor, modifier = Modifier.padding(bottom = 8.dp)) ProvideTextStyle(PreferenceSummaryStyle.copy(color = contentColor)) { additionalSummary?.invoke(this) 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 372af78a..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 @@ -79,6 +79,8 @@ fun SwitchColors(): SwitchColors { AppThemeColors.ORANGE, AppThemeColors.BOLD_BLUE, AppThemeColors.OLED_BLACK, + AppThemeColors.RED, + AppThemeColors.BROWN, -> { SwitchDefaults.colors( checkedThumbColor = MaterialTheme.colorScheme.onPrimary, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/WolfskinAbout.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/WolfskinAbout.kt new file mode 100644 index 00000000..a0814180 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/WolfskinAbout.kt @@ -0,0 +1,28 @@ +package com.github.damontecres.wholphin.ui.preferences + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.ui.components.TextButton + +/** Fork identity and a focusable link to the upstream project. */ +@Composable +fun WolfskinAbout() { + val uriHandler = LocalUriHandler.current + val upstreamUrl = stringResource(R.string.wolfskin_upstream_url) + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = stringResource(R.string.app_name_long), + style = MaterialTheme.typography.headlineMedium, + ) + TextButton(onClick = { uriHandler.openUri(upstreamUrl) }) { + Text(stringResource(R.string.wolfskin_upstream_credit)) + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/subtitle/SubtitlePreferencesContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/subtitle/SubtitlePreferencesContent.kt index 8abc6dbe..32da9613 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/subtitle/SubtitlePreferencesContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/subtitle/SubtitlePreferencesContent.kt @@ -10,11 +10,14 @@ import androidx.compose.foundation.background import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -37,6 +40,7 @@ import androidx.tv.material3.Text import androidx.tv.material3.surfaceColorAtElevation import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.SubtitlePreferences +import com.github.damontecres.wholphin.preferences.lazyListWrapScrolling import com.github.damontecres.wholphin.preferences.resetSubtitles import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.preferences.ClickPreference @@ -61,6 +65,8 @@ fun SubtitlePreferencesContent( val context = LocalContext.current val scope = rememberCoroutineScope() val focusRequester = remember { FocusRequester() } + val firstFocusRequester = remember { FocusRequester() } + val lastFocusRequester = remember { FocusRequester() } var focusedIndex by rememberSaveable { mutableStateOf(Pair(0, 0)) } val state = rememberLazyListState() @@ -79,112 +85,133 @@ fun SubtitlePreferencesContent( LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } - LazyColumn( - state = state, - horizontalAlignment = Alignment.Start, - verticalArrangement = Arrangement.spacedBy(0.dp), - contentPadding = PaddingValues(16.dp), + Column( modifier = Modifier.background(MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp)), ) { - stickyHeader { - Text( - text = title, - style = MaterialTheme.typography.headlineSmall, - color = MaterialTheme.colorScheme.onSurface, - textAlign = TextAlign.Center, - modifier = - Modifier - .fillMaxWidth() - .padding(vertical = 8.dp), - ) - } - prefList.forEachIndexed { groupIndex, group -> - item { - Text( - text = stringResource(group.title), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - textAlign = TextAlign.Start, - modifier = - Modifier - .fillMaxWidth() - .padding(top = 8.dp, bottom = 4.dp), - ) - } - val groupPreferences = - group.preferences + - group.conditionalPreferences - .filter { it.condition.invoke(preferences) } - .map { it.preferences } - .flatten() - groupPreferences.forEachIndexed { prefIndex, pref -> - pref as AppPreference<SubtitlePreferences, Any> + Text( + text = title, + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + ) + LazyColumn( + state = state, + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.spacedBy(0.dp), + contentPadding = PaddingValues(16.dp), + ) { + prefList.forEachIndexed { groupIndex, group -> item { - val interactionSource = remember { MutableInteractionSource() } - val focused = interactionSource.collectIsFocusedAsState().value - LaunchedEffect(focused) { - if (focused) { - focusedIndex = Pair(groupIndex, prefIndex) - onFocus.invoke(groupIndex, prefIndex) + Text( + text = stringResource(group.title), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Start, + modifier = + Modifier + .fillMaxWidth() + .padding(top = 8.dp, bottom = 4.dp), + ) + } + val groupPreferences = + group.preferences + + group.conditionalPreferences + .filter { it.condition.invoke(preferences) } + .map { it.preferences } + .flatten() + groupPreferences.forEachIndexed { prefIndex, pref -> + pref as AppPreference<SubtitlePreferences, Any> + item { + val isFirst = groupIndex == 0 && prefIndex == 0 + val isLast = + groupIndex == prefList.lastIndex && prefIndex == groupPreferences.lastIndex + val interactionSource = remember { MutableInteractionSource() } + val bringIntoViewRequester = remember { BringIntoViewRequester() } + val focused = interactionSource.collectIsFocusedAsState().value + LaunchedEffect(focused) { + if (focused) { + focusedIndex = Pair(groupIndex, prefIndex) + onFocus.invoke(groupIndex, prefIndex) + } } - } - when (pref) { - SubtitleSettings.Reset -> { - ClickPreference( - title = stringResource(pref.title), - onClick = { - scope.launch(ExceptionHandler()) { - val newValue = - SubtitlePreferences - .newBuilder() - .apply { resetSubtitles() } - .build() - onPreferenceChange.invoke(newValue) - } - }, - interactionSource = interactionSource, - ) + if (preferences.useSeparateHdr && pref == SubtitleSettings.SeparateHdr) { + LaunchedEffect(Unit) { + bringIntoViewRequester.bringIntoView() + } } + val focusModifier = + Modifier + .ifElse( + groupIndex == focusedIndex.first && prefIndex == focusedIndex.second, + Modifier.focusRequester(focusRequester), + ).bringIntoViewRequester(bringIntoViewRequester) + .lazyListWrapScrolling( + state, + focused, + isFirst, + isLast, + firstFocusRequester, + lastFocusRequester, + ) - else -> { - val value = pref.getter.invoke(preferences) - ComposablePreference( - preference = pref, - value = value, - onNavigate = viewModel.navigationManager::navigateTo, - onValueChange = { newValue -> - val validation = pref.validate(newValue) - when (validation) { - is PreferenceValidation.Invalid -> { - // TODO? - Toast - .makeText( - context, - validation.message, - Toast.LENGTH_SHORT, - ).show() + when (pref) { + SubtitleSettings.Reset -> { + ClickPreference( + title = stringResource(pref.title), + onClick = { + scope.launch(ExceptionHandler()) { + val newValue = + SubtitlePreferences + .newBuilder() + .apply { resetSubtitles() } + .build() + onPreferenceChange.invoke(newValue) } + }, + interactionSource = interactionSource, + modifier = focusModifier, + ) + } - PreferenceValidation.Valid -> { - scope.launch(ExceptionHandler()) { - onPreferenceChange.invoke( - pref.setter( - preferences, - newValue, - ), - ) + else -> { + val value = pref.getter.invoke(preferences) + ComposablePreference( + preference = pref, + value = value, + onNavigate = viewModel.navigationManager::navigateTo, + onValueChange = { newValue -> + val validation = pref.validate(preferences, newValue) + when (validation) { + is PreferenceValidation.Invalid -> { + // TODO? + Toast + .makeText( + context, + validation.message, + Toast.LENGTH_SHORT, + ).show() + } + + PreferenceValidation.Valid -> { + scope.launch(ExceptionHandler()) { + onPreferenceChange.invoke( + pref.setter( + preferences, + newValue, + ), + ) + } } } - } - }, - interactionSource = interactionSource, - modifier = - Modifier - .ifElse( - groupIndex == focusedIndex.first && prefIndex == focusedIndex.second, - Modifier.focusRequester(focusRequester), - ), - ) + }, + interactionSource = interactionSource, + modifier = focusModifier, + ) + } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/subtitle/SubtitleSettings.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/subtitle/SubtitleSettings.kt index b7f3d764..a737fe2a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/subtitle/SubtitleSettings.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/subtitle/SubtitleSettings.kt @@ -4,6 +4,7 @@ import android.content.res.Configuration import android.graphics.Typeface import android.os.Build import android.view.Display +import androidx.annotation.Dimension import androidx.annotation.OptIn import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb @@ -12,7 +13,9 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.media3.common.util.UnstableApi import androidx.media3.ui.CaptionStyleCompat +import androidx.media3.ui.SubtitleView import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.mpv.MPVLib import com.github.damontecres.wholphin.preferences.AppChoicePreference import com.github.damontecres.wholphin.preferences.AppClickablePreference import com.github.damontecres.wholphin.preferences.AppDestinationPreference @@ -20,11 +23,13 @@ import com.github.damontecres.wholphin.preferences.AppSliderPreference import com.github.damontecres.wholphin.preferences.AppSwitchPreference import com.github.damontecres.wholphin.preferences.BackgroundStyle import com.github.damontecres.wholphin.preferences.EdgeStyle +import com.github.damontecres.wholphin.preferences.InterfacePreferences import com.github.damontecres.wholphin.preferences.SubtitlePreferences +import com.github.damontecres.wholphin.preferences.resetSubtitles import com.github.damontecres.wholphin.ui.indexOfFirstOrNull import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.preferences.ConditionalPreferences import com.github.damontecres.wholphin.ui.preferences.PreferenceGroup -import com.github.damontecres.wholphin.util.mpv.MPVLib import com.github.damontecres.wholphin.util.mpv.setPropertyColor import timber.log.Timber @@ -245,6 +250,16 @@ object SubtitleSettings { setter = { prefs, _ -> prefs }, ) + val SeparateHdr = + AppSwitchPreference<SubtitlePreferences>( + title = R.string.different_for_hdr, + defaultValue = false, + getter = { it.useSeparateHdr }, + setter = { prefs, value -> + prefs.update { useSeparateHdr = value } + }, + ) + val HdrSettings = AppDestinationPreference<SubtitlePreferences>( title = R.string.hdr_subtitle_style, @@ -299,7 +314,14 @@ object SubtitleSettings { title = R.string.hdr, preferences = listOf( - HdrSettings, + SeparateHdr, + ), + conditionalPreferences = + listOf( + ConditionalPreferences( + condition = { it.useSeparateHdr }, + preferences = listOf(HdrSettings), + ), ), ), ) @@ -333,6 +355,13 @@ object SubtitleSettings { ) } + @OptIn(UnstableApi::class) + fun SubtitlePreferences.applyTo(view: SubtitleView) { + view.setStyle(toSubtitleStyle()) + view.setFixedTextSize(Dimension.SP, fontSize.toFloat()) + view.setBottomPaddingFraction(margin.toFloat() / 100f) + } + fun SubtitlePreferences.calculateEdgeSize(density: Density): Float = with(density) { (edgeThickness / 2f).dp.toPx() } fun SubtitlePreferences.applyToMpv( @@ -400,3 +429,21 @@ object SubtitleSettings { } inline fun SubtitlePreferences.update(block: SubtitlePreferences.Builder.() -> Unit): SubtitlePreferences = toBuilder().apply(block).build() + +fun InterfacePreferences.shouldEnableSeparateHdrToggle(): Boolean { + // If already separate, leave it + if (subtitlesPreferences.useSeparateHdr) return true + val defaultStyle = + SubtitlePreferences + .newBuilder() + .apply { + resetSubtitles() + }.build() + // If SDR and HDR are the same, don't separate + return if (subtitlesPreferences == hdrSubtitlesPreferences) { + false + } else { + // If different, but HDR has not been changed from original settings, don't separate + hdrSubtitlesPreferences != defaultStyle + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/subtitle/SubtitleStylePage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/subtitle/SubtitleStylePage.kt index 52b3570a..a96ab8fe 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/subtitle/SubtitleStylePage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/subtitle/SubtitleStylePage.kt @@ -51,7 +51,7 @@ import timber.log.Timber @Composable fun SubtitleStylePage( initialPreferences: AppPreferences, - hdrSettings: Boolean, + isPageForHdrSettings: Boolean, modifier: Modifier = Modifier, viewModel: PreferencesViewModel = hiltViewModel(), ) { @@ -67,7 +67,7 @@ fun SubtitleStylePage( if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { DisposableEffect(context) { - if (hdrSettings) { + if (isPageForHdrSettings) { Timber.v("Switching color mode to HDR") context.findActivity()?.window?.colorMode = ActivityInfo.COLOR_MODE_HDR } @@ -77,7 +77,7 @@ fun SubtitleStylePage( } } val prefs = - if (hdrSettings) { + if (isPageForHdrSettings) { preferences.interfacePreferences.hdrSubtitlesPreferences } else { preferences.interfacePreferences.subtitlesPreferences @@ -192,8 +192,8 @@ fun SubtitleStylePage( } val display = LocalView.current.display val prefList = - remember(hdrSettings, display) { - if (!hdrSettings && SubtitleSettings.shouldShowHdr(display)) { + remember(isPageForHdrSettings, display) { + if (!isPageForHdrSettings && SubtitleSettings.shouldShowHdr(display)) { // If not on HDR page and display is HDR capable, then show the HDR button SubtitleSettings.preferences + SubtitleSettings.hdrPreferenceGroup } else { @@ -202,13 +202,13 @@ fun SubtitleStylePage( } SubtitlePreferencesContent( title = - if (hdrSettings) { + if (isPageForHdrSettings) { stringResource(R.string.hdr_subtitle_style) } else { stringResource(R.string.subtitle_style) }, preferences = - if (hdrSettings) { + if (isPageForHdrSettings) { preferences.interfacePreferences.hdrSubtitlesPreferences } else { preferences.interfacePreferences.subtitlesPreferences @@ -217,7 +217,7 @@ fun SubtitleStylePage( onPreferenceChange = { newSubtitlePrefs -> viewModel.preferenceDataStore.updateData { it.updateInterfacePreferences { - if (hdrSettings) { + if (isPageForHdrSettings) { hdrSubtitlesPreferences = newSubtitlePrefs } else { subtitlesPreferences = newSubtitlePrefs diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/user/FilterableLanguagePreference.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/user/FilterableLanguagePreference.kt new file mode 100644 index 00000000..c68239b0 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/user/FilterableLanguagePreference.kt @@ -0,0 +1,145 @@ +package com.github.damontecres.wholphin.ui.preferences.user + +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.HorizontalDivider +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.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.tv.material3.ListItem +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.ui.components.SearchEditTextBox +import com.github.damontecres.wholphin.ui.components.SelectedLeadingContent +import com.github.damontecres.wholphin.ui.isNotNullOrBlank +import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.util.WholphinDispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext +import kotlin.time.Duration.Companion.milliseconds + +/** + * A filterable list of language choices ([PreferredLanguageType]) + */ +@Composable +fun FilterableLanguagePreference( + @StringRes title: Int, + selectedOption: PreferredLanguageType, + options: List<PreferredLanguageType>, + onClickOption: (PreferredLanguageType) -> Unit, + modifier: Modifier = Modifier, +) { + var query by remember { mutableStateOf("") } + + var filteredOptions by remember { mutableStateOf(options) } + LaunchedEffect(query) { + delay(500.milliseconds) + if (query.isNotNullOrBlank()) { + withContext(WholphinDispatchers.Default) { + val q = query.lowercase() + filteredOptions = + options + .filter { + // Only show languages, not "built-in" types + it is PreferredLanguageType.Language && + (it.name.lowercase().contains(q) || it.iso.contains(q)) + }.sortedByDescending { + // User is likely typing the first letters of a language, so show those first + // IE, for q="en", show English before Armenian and Bengali + (it as PreferredLanguageType.Language).name.lowercase().startsWith(q) + } + } + } else { + filteredOptions = options + } + } + + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = + modifier, + ) { + Text( + text = stringResource(title), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + val listState = rememberLazyListState() + val focusRequesters = remember { List(filteredOptions.size) { FocusRequester() } } + LaunchedEffect(Unit) { + focusRequesters.firstOrNull()?.tryRequestFocus() + } + LazyColumn( + state = listState, + modifier = Modifier, + ) { + item { + SearchEditTextBox( + value = query, + onValueChange = { query = it }, + onSearchClick = { focusRequesters.firstOrNull()?.tryRequestFocus() }, + modifier = + Modifier + .padding(bottom = 8.dp) + .fillMaxWidth(), + ) + } + itemsIndexed(filteredOptions) { index, option -> + if (option is PreferredLanguageType.Divider) { + HorizontalDivider() + } else { + ListItem( + selected = false, + onClick = { + onClickOption.invoke(option) + }, + leadingContent = { + SelectedLeadingContent(option == selectedOption) + }, + headlineContent = { + val text = + when (option) { + PreferredLanguageType.AnyLanguage -> option.displayString.getString() + is PreferredLanguageType.Language -> option.displayString.getString() + is PreferredLanguageType.ServerProfile -> stringResource(R.string.use_user_profile) + } + Text(text) + }, + supportingContent = + when (option) { + PreferredLanguageType.AnyLanguage, + is PreferredLanguageType.Language, + -> { + null + } + + is PreferredLanguageType.ServerProfile -> { + option.name?.let { + { + Text(it) + } + } + } + }, + modifier = Modifier.focusRequester(focusRequesters[index]), + ) + } + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/user/PreferredLanguage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/user/PreferredLanguage.kt new file mode 100644 index 00000000..dc8da479 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/user/PreferredLanguage.kt @@ -0,0 +1,55 @@ +package com.github.damontecres.wholphin.ui.preferences.user + +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.ui.isNotNullOrBlank +import com.github.damontecres.wholphin.ui.util.ConcatStringProvider +import com.github.damontecres.wholphin.ui.util.EmptyStringProvider +import com.github.damontecres.wholphin.ui.util.ResStringProvider +import com.github.damontecres.wholphin.ui.util.StringProvider +import com.github.damontecres.wholphin.ui.util.StringStringProvider + +/** + * UI information for displaying a preferred language + */ +sealed interface PreferredLanguageType { + val displayString: StringProvider + + data class ServerProfile( + val name: String?, + ) : PreferredLanguageType { + override val displayString: StringProvider + get() = + ConcatStringProvider( + " - ", + buildList { + add(ResStringProvider(R.string.use_user_profile)) + if (name.isNotNullOrBlank()) { + add(StringStringProvider(name)) + } + }, + ) + } + + data object AnyLanguage : PreferredLanguageType { + override val displayString: StringProvider + get() = ResStringProvider(R.string.any_language) + } + + data class Language( + val iso: String, + val name: String, + ) : PreferredLanguageType { + override val displayString: StringProvider + get() = StringStringProvider(name) + } + + data object Divider : PreferredLanguageType { + override val displayString: StringProvider + get() = EmptyStringProvider + } +} + +data class PreferredLanguage( + val selected: PreferredLanguageType = PreferredLanguageType.ServerProfile(null), + val options: List<PreferredLanguageType> = emptyList(), +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/user/UserPreferencesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/user/UserPreferencesViewModel.kt new file mode 100644 index 00000000..8b4dd64f --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/user/UserPreferencesViewModel.kt @@ -0,0 +1,151 @@ +package com.github.damontecres.wholphin.ui.preferences.user + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.data.JellyfinServerDao +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.JellyfinUserPreferences +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.UserProfileSettings +import com.github.damontecres.wholphin.services.BackdropService +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.ScreensaverService +import com.github.damontecres.wholphin.ui.combineTriple +import com.github.damontecres.wholphin.ui.isNotNullOrBlank +import com.github.damontecres.wholphin.ui.launchIO +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.localizationApi +import org.jellyfin.sdk.model.api.CultureDto +import javax.inject.Inject + +@HiltViewModel +class UserPreferencesViewModel + @Inject + constructor( + @param:ApplicationContext private val context: Context, + private val api: ApiClient, + val preferenceDataStore: DataStore<AppPreferences>, + val navigationManager: NavigationManager, + val backdropService: BackdropService, + val screensaverService: ScreensaverService, + private val serverRepository: ServerRepository, + private val serverDao: JellyfinServerDao, + ) : ViewModel() { + private val mutex = Mutex() + + val currentUser = serverRepository.currentUserFlow + val currentUserDto = + serverRepository.currentUserDtoFlow.stateIn( + viewModelScope, + SharingStarted.Eagerly, + null, + ) + private val serverLanguages = MutableStateFlow<List<CultureDto>>(emptyList()) + + @OptIn(ExperimentalCoroutinesApi::class) + val userAppPreferences: StateFlow<JellyfinUserPreferences> = + currentUser.mapNotNull { it?.appPreferences }.stateIn( + viewModelScope, + SharingStarted.Eagerly, + JellyfinUserPreferences(), + ) + + @OptIn(ExperimentalCoroutinesApi::class) + private fun createLanguageFlow(isAudio: Boolean): StateFlow<PreferredLanguage> = + currentUser + .combineTriple(serverRepository.currentUserDtoFlow, serverLanguages) + .mapLatest { (user, userDto, serverLanguages) -> + if (user == null || userDto == null || serverLanguages.isEmpty() || user.id != userDto.id) { + return@mapLatest PreferredLanguage() + } + val languages = + serverLanguages.filter { it.threeLetterIsoLanguageName.isNotNullOrBlank() } + + // Language user has chosen from local app + val prefLang = user.appPreferences.let { if (isAudio) it.preferredAudioLanguage else it.preferredSubtitleLanguage } + + // Language user has chosen from server user profile + val userDisplayLang = + userDto.configuration + ?.let { if (isAudio) it.audioLanguagePreference else it.subtitleLanguagePreference } + .let { userLang -> + languages.firstOrNull { it.threeLetterIsoLanguageName == userLang }?.displayName + ?: userLang + } + val selected = + when (prefLang) { + UserProfileSettings.USE_USER_PROFILE -> { + PreferredLanguageType.ServerProfile(userDisplayLang) + } + + UserProfileSettings.PREFER_ANY_LANGUAGE -> { + PreferredLanguageType.AnyLanguage + } + + else -> { + languages + .firstOrNull { it.threeLetterIsoLanguageName == prefLang } + ?.let { + PreferredLanguageType.Language( + it.threeLetterIsoLanguageName!!, + it.displayName, + ) + } + } + } ?: PreferredLanguageType.ServerProfile(userDisplayLang) + val options = + buildList { + add(PreferredLanguageType.ServerProfile(userDisplayLang)) + add(PreferredLanguageType.AnyLanguage) + add(PreferredLanguageType.Divider) + languages.forEach { + if (it.threeLetterIsoLanguageName.isNotNullOrBlank()) { + add( + PreferredLanguageType.Language( + it.threeLetterIsoLanguageName!!, + it.displayName, + ), + ) + } + } + } + PreferredLanguage(selected, options) + }.stateIn( + viewModelScope, + SharingStarted.Eagerly, + PreferredLanguage(), + ) + + val audioLanguage: StateFlow<PreferredLanguage> = createLanguageFlow(true) + val subtitleLanguage: StateFlow<PreferredLanguage> = createLanguageFlow(false) + + init { + viewModelScope.launchIO { + serverLanguages.value = api.localizationApi.getCultures().content + } + } + + fun updatePreferences(block: (JellyfinUserPreferences) -> JellyfinUserPreferences) { + viewModelScope.launchIO { + mutex.withLock { + val user = currentUser.first { it != null }!! + val newAppPrefs = block.invoke(user.appPreferences) + serverDao.updateUser(user.copy(appPreferences = newAppPrefs)) + } + } + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/user/UserProfilePreferencesPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/user/UserProfilePreferencesPage.kt new file mode 100644 index 00000000..1308e08e --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/user/UserProfilePreferencesPage.kt @@ -0,0 +1,321 @@ +package com.github.damontecres.wholphin.ui.preferences.user + +import android.widget.Toast +import androidx.annotation.StringRes +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringArrayResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import androidx.tv.material3.surfaceColorAtElevation +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.model.JellyfinUserPreferences +import com.github.damontecres.wholphin.preferences.AppChoicePreference +import com.github.damontecres.wholphin.preferences.AppPreference +import com.github.damontecres.wholphin.preferences.SubtitleModePreference +import com.github.damontecres.wholphin.preferences.UserProfileSettings +import com.github.damontecres.wholphin.ui.components.BasicDialog +import com.github.damontecres.wholphin.ui.ifElse +import com.github.damontecres.wholphin.ui.preferences.ChoicePreference +import com.github.damontecres.wholphin.ui.preferences.ClickPreference +import com.github.damontecres.wholphin.ui.preferences.ComposablePreference +import com.github.damontecres.wholphin.ui.preferences.PreferenceValidation +import com.github.damontecres.wholphin.ui.tryRequestFocus +import org.jellyfin.sdk.model.api.SubtitlePlaybackMode +import timber.log.Timber + +/** + * Page for showing settings derived from the user's profile on the server + * + * @see UserProfileSettings + */ +@Composable +fun UserProfilePreferencesPage(modifier: Modifier = Modifier) { + Box( + modifier = modifier.background(MaterialTheme.colorScheme.background), + ) { + UserProfilePreferencesContent( + Modifier + .fillMaxWidth(.4f) + .fillMaxHeight() + .align(Alignment.TopEnd), + ) + } +} + +@Composable +fun UserProfilePreferencesContent( + modifier: Modifier = Modifier, + viewModel: UserPreferencesViewModel = hiltViewModel(), + onFocus: (Int, Int) -> Unit = { _, _ -> }, +) { + val context = LocalContext.current + val focusRequester = remember { FocusRequester() } + var focusedIndex by rememberSaveable { mutableStateOf(Pair(0, 0)) } + val state = rememberLazyListState() + + val userDto by viewModel.currentUserDto.collectAsState() + val preferences by viewModel.userAppPreferences.collectAsState() + val audioLanguagePref by viewModel.audioLanguage.collectAsState() + val subtitleLanguagePref by viewModel.subtitleLanguage.collectAsState() + var showPreferredLanguageDialog by remember { mutableStateOf<Boolean?>(null) } + + var visible by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { + // Forces the animated to trigger + visible = true + } + + AnimatedVisibility( + visible = visible, + enter = fadeIn() + slideInHorizontally { it / 2 }, + exit = fadeOut() + slideOutHorizontally { it / 2 }, + modifier = modifier, + ) { + LaunchedEffect(Unit) { + focusRequester.tryRequestFocus() + } + Column( + modifier = Modifier.background(MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp)), + ) { + Text( + text = stringResource(R.string.profile_specific_settings), + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + ) + LazyColumn( + state = state, + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.spacedBy(0.dp), + contentPadding = PaddingValues(16.dp), + modifier = Modifier, + ) { + UserProfileSettings.Preferences.forEachIndexed { groupIndex, group -> + item { + Text( + text = stringResource(group.title), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Start, + modifier = + Modifier + .fillMaxWidth() + .padding(top = 8.dp, bottom = 4.dp), + ) + } + val groupPreferences = + group.preferences + + group.conditionalPreferences + .filter { it.condition.invoke(preferences) } + .map { it.preferences } + .flatten() + groupPreferences.forEachIndexed { prefIndex, pref -> + pref as AppPreference<JellyfinUserPreferences, Any> + item { + val interactionSource = remember { MutableInteractionSource() } + val focused = interactionSource.collectIsFocusedAsState().value + + val modifier = + Modifier + .ifElse( + groupIndex == focusedIndex.first && prefIndex == focusedIndex.second, + Modifier.focusRequester(focusRequester), + ).onFocusChanged { + if (it.isFocused) focusedIndex = Pair(groupIndex, prefIndex) + } + + LaunchedEffect(focused) { + if (focused) { + focusedIndex = Pair(groupIndex, prefIndex) + onFocus.invoke(groupIndex, prefIndex) + } + } + when (pref) { + UserProfileSettings.PreferredAudioLang -> { + ClickPreference( + title = stringResource(pref.title), + onClick = { showPreferredLanguageDialog = true }, + summary = audioLanguagePref.selected.displayString.getString(), + interactionSource = interactionSource, + modifier = modifier, + ) + } + + UserProfileSettings.PreferredSubtitleLang -> { + ClickPreference( + title = stringResource(pref.title), + onClick = { showPreferredLanguageDialog = false }, + summary = subtitleLanguagePref.selected.displayString.getString(), + interactionSource = interactionSource, + modifier = modifier, + ) + } + + UserProfileSettings.SubtitleModePref -> { + pref as AppChoicePreference<JellyfinUserPreferences, SubtitleModePreference> + val value = preferences.subtitleMode + val values = stringArrayResource(pref.displayValues).toList() + val summary = + when (value) { + SubtitleModePreference.USE_USER_PROFILE -> { + val userMode = userDto?.configuration?.subtitleMode + if (userMode != null) { + "${values[0]} - ${stringResource(userMode.stringRes)}" + } else { + values[0] + } + } + + else -> { + values[value.ordinal] + } + } + val selectedIndex = + remember(value) { pref.valueToIndex.invoke(value) } + ChoicePreference( + title = stringResource(pref.title), + summary = summary, + possibleValues = values, + selectedIndex = selectedIndex, + onValueChange = { index -> + viewModel.updatePreferences { + it.copy(subtitleMode = SubtitleModePreference.entries[index]) + } + }, + modifier = modifier, + interactionSource = interactionSource, + subtitleDisplay = { index, _ -> + val userMode = userDto?.configuration?.subtitleMode + if (index == 0 && userMode != null) { + { + Text(stringResource(userMode.stringRes)) + } + } else { + null + } + }, + ) + } + + else -> { + val value = pref.getter.invoke(preferences) + ComposablePreference( + preference = pref, + value = value, + onNavigate = viewModel.navigationManager::navigateTo, + onValueChange = { newValue -> + val validation = pref.validate(preferences, newValue) + when (validation) { + is PreferenceValidation.Invalid -> { + // TODO? + Toast + .makeText( + context, + validation.message, + Toast.LENGTH_SHORT, + ).show() + } + + PreferenceValidation.Valid -> { + viewModel.updatePreferences { + pref.setter(preferences, newValue) + } + } + } + }, + interactionSource = interactionSource, + modifier = modifier, + ) + } + } + } + } + } + } + } + } + showPreferredLanguageDialog?.let { isAudio -> + BasicDialog( + onDismissRequest = { showPreferredLanguageDialog = null }, + elevation = 3.dp, + ) { + val lang = if (isAudio) audioLanguagePref else subtitleLanguagePref + FilterableLanguagePreference( + title = if (isAudio) R.string.preferred_audio_language else R.string.preferred_subtitle_language, + selectedOption = lang.selected, + options = lang.options, + onClickOption = { option -> + val value = + when (option) { + PreferredLanguageType.AnyLanguage -> UserProfileSettings.PREFER_ANY_LANGUAGE + is PreferredLanguageType.Language -> option.iso + is PreferredLanguageType.ServerProfile -> UserProfileSettings.USE_USER_PROFILE + PreferredLanguageType.Divider -> throw IllegalStateException("Cannot click on a divider") + } + Timber.v("Updating language pref to %s", option) + viewModel.updatePreferences { + if (isAudio) { + it.copy(preferredAudioLanguage = value) + } else { + it.copy(preferredSubtitleLanguage = value) + } + } + showPreferredLanguageDialog = null + }, + modifier = + Modifier + .padding(16.dp) + .fillMaxSize(), + ) + } + } +} + +val SubtitlePlaybackMode.stringRes: Int + @StringRes get() = + when (this) { + SubtitlePlaybackMode.DEFAULT -> R.string.subtitle_mode_default + SubtitlePlaybackMode.ALWAYS -> R.string.subtitle_mode_always + SubtitlePlaybackMode.ONLY_FORCED -> R.string.subtitle_mode_only_forced + SubtitlePlaybackMode.NONE -> R.string.subtitle_mode_none + SubtitlePlaybackMode.SMART -> R.string.subtitle_mode_smart + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/search/SearchForDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchForDialog.kt similarity index 71% rename from app/src/main/java/com/github/damontecres/wholphin/ui/detail/search/SearchForDialog.kt rename to app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchForDialog.kt index 09df419c..17beee6c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/search/SearchForDialog.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchForDialog.kt @@ -1,4 +1,4 @@ -package com.github.damontecres.wholphin.ui.detail.search +package com.github.damontecres.wholphin.ui.search import androidx.activity.compose.BackHandler import androidx.compose.foundation.focusGroup @@ -9,6 +9,8 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -43,12 +45,12 @@ import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.ui.Cards import com.github.damontecres.wholphin.ui.cards.ItemRow +import com.github.damontecres.wholphin.ui.cards.ItemRowTitle import com.github.damontecres.wholphin.ui.cards.SeasonCard import com.github.damontecres.wholphin.ui.components.BasicDialog import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.SearchEditTextBox import com.github.damontecres.wholphin.ui.components.VoiceSearchButton -import com.github.damontecres.wholphin.ui.main.SearchResult import kotlinx.coroutines.delay import org.jellyfin.sdk.model.api.BaseItemKind @@ -57,7 +59,13 @@ fun SearchForContent( searchType: BaseItemKind, onClick: (BaseItem) -> Unit, modifier: Modifier = Modifier, - viewModel: SearchForViewModel = hiltViewModel(key = searchType.serialName), + viewModel: SearchForViewModel = + hiltViewModel<SearchForViewModel, SearchForViewModel.Factory>( + key = searchType.serialName, + creationCallback = { + it.create(searchType) + }, + ), ) { val focusManager = LocalFocusManager.current val keyboardController = LocalSoftwareKeyboardController.current @@ -92,6 +100,7 @@ fun SearchForContent( } } } + val titleRes = remember { when (searchType) { @@ -178,53 +187,91 @@ fun SearchForContent( } } - when (val st = state.results) { - is SearchResult.Error -> { - ErrorMessage("Error", st.ex) - } - - SearchResult.NoQuery -> { - // no-op - } - - SearchResult.Searching -> { - Text( - text = stringResource(R.string.searching), + LazyColumn( + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + item { + SearchForResultsRow( + title = stringResource(R.string.results), + results = state.results, + onClick = onClick, + modifier = Modifier.focusRequester(focusRequester), ) } - - is SearchResult.SuccessSeerr -> { - Text( - text = "Not supported", - color = MaterialTheme.colorScheme.error, + item { + HorizontalDivider() + } + item { + SearchForResultsRow( + title = stringResource(R.string.suggestions), + results = state.recent, + onClick = onClick, + modifier = Modifier, ) } + } + } +} - is SearchResult.Success -> { - if (st.items.isEmpty()) { - Text( - text = stringResource(R.string.no_results), - ) - } else { - ItemRow( - title = "", - items = st.items, - onClickItem = { _, item -> onClick.invoke(item) }, - onLongClickItem = { _, _ -> }, - modifier = Modifier.focusRequester(focusRequester), - cardContent = { index, item, mod, onClick, onLongClick -> - SeasonCard( - item = item, - onClick = { - onClick.invoke() - }, - onLongClick = onLongClick, - imageHeight = Cards.height2x3, - modifier = mod, - ) - }, - ) - } +@Composable +fun SearchForResultsRow( + title: String, + results: SearchResult, + onClick: (BaseItem) -> Unit, + modifier: Modifier = Modifier, +) { + when (val st = results) { + is SearchResult.Error -> { + ErrorMessage("Error", st.ex, modifier) + } + + SearchResult.NoQuery -> { + ItemRowTitle( + title = stringResource(R.string.no_results), + modifier = modifier, + ) + } + + SearchResult.Searching -> { + ItemRowTitle( + title = stringResource(R.string.searching), + modifier = modifier, + ) + } + + is SearchResult.SuccessSeerr -> { + Text( + text = "Not supported", + color = MaterialTheme.colorScheme.error, + modifier = modifier, + ) + } + + is SearchResult.Success -> { + if (st.items.isEmpty()) { + ItemRowTitle( + title = stringResource(R.string.no_results), + modifier = modifier, + ) + } else { + ItemRow( + title = title, + items = st.items, + onClickItem = { _, item -> onClick.invoke(item) }, + onLongClickItem = { _, _ -> }, + modifier = modifier, + cardContent = { index, item, mod, onClick, onLongClick -> + SeasonCard( + item = item, + onClick = { + onClick.invoke() + }, + onLongClick = onLongClick, + imageHeight = Cards.heightEpisode, + modifier = mod, + ) + }, + ) } } } @@ -242,6 +289,7 @@ fun SearchForDialog( DialogProperties( usePlatformDefaultWidth = false, ), + elevation = 3.dp, ) { SearchForContent( searchType = searchType, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchForViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchForViewModel.kt new file mode 100644 index 00000000..e34697ed --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchForViewModel.kt @@ -0,0 +1,114 @@ +package com.github.damontecres.wholphin.ui.search + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.ui.SlimItemFields +import com.github.damontecres.wholphin.ui.components.VoiceInputManager +import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.ui.toBaseItems +import com.github.damontecres.wholphin.util.ApiRequestPager +import com.github.damontecres.wholphin.util.GetItemsRequestHandler +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.itemsApi +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.ItemSortBy +import org.jellyfin.sdk.model.api.SortOrder +import org.jellyfin.sdk.model.api.request.GetItemsRequest +import timber.log.Timber + +@HiltViewModel(assistedFactory = SearchForViewModel.Factory::class) +class SearchForViewModel + @AssistedInject + constructor( + private val api: ApiClient, + private val serverRepository: ServerRepository, + val navigationManager: NavigationManager, + val voiceInputManager: VoiceInputManager, + @Assisted val searchType: BaseItemKind, + ) : ViewModel() { + @AssistedFactory + interface Factory { + fun create(searchType: BaseItemKind): SearchForViewModel + } + + val state = MutableStateFlow(SearchForState()) + + init { + state.value = SearchForState() + viewModelScope.launchIO { + try { + val request = + GetItemsRequest( + userId = serverRepository.currentUser?.id, + includeItemTypes = listOf(searchType), + recursive = true, + fields = SlimItemFields, + sortBy = listOf(ItemSortBy.DATE_LAST_CONTENT_ADDED), + sortOrder = listOf(SortOrder.DESCENDING), + limit = 25, + ) + val recent = api.itemsApi.getItems(request).toBaseItems(api, false) + state.update { + it.copy(recent = SearchResult.Success(recent)) + } + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Error fetching recent %s", searchType) + state.update { + it.copy(recent = SearchResult.Error(ex)) + } + } + } + } + + fun search( + searchType: BaseItemKind, + query: String, + ) { + viewModelScope.launchIO { + if (state.value.query != query) { + if (query.isBlank()) { + state.update { it.copy(query = query, results = SearchResult.NoQuery) } + return@launchIO + } + state.update { it.copy(query = query, results = SearchResult.NoQuery) } + try { + val request = + GetItemsRequest( + userId = serverRepository.currentUser?.id, + searchTerm = query, + includeItemTypes = listOf(searchType), + recursive = true, + fields = SlimItemFields, + ) + val pager = ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope).init() + state.update { + it.copy( + query = query, + results = SearchResult.Success(pager), + ) + } + } catch (ex: Exception) { + Timber.e(ex) + state.update { it.copy(query = query, results = SearchResult.Error(ex)) } + } + } + } + } + } + +data class SearchForState( + val query: String = "", + val recent: SearchResult = SearchResult.Searching, + val results: SearchResult = SearchResult.NoQuery, +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchPage.kt new file mode 100644 index 00000000..f20222d9 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchPage.kt @@ -0,0 +1,774 @@ +package com.github.damontecres.wholphin.ui.search + +import android.widget.Toast +import androidx.activity.compose.BackHandler +import androidx.annotation.StringRes +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandHorizontally +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkHorizontally +import androidx.compose.animation.shrinkVertically +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.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusRestorer +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.LifecycleResumeEffect +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.DiscoverItem +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard +import com.github.damontecres.wholphin.ui.cards.GridCard +import com.github.damontecres.wholphin.ui.cards.ItemRow +import com.github.damontecres.wholphin.ui.cards.ItemRowTitle +import com.github.damontecres.wholphin.ui.components.ExpandableFaButton +import com.github.damontecres.wholphin.ui.components.SearchEditTextBox +import com.github.damontecres.wholphin.ui.components.TabDetails +import com.github.damontecres.wholphin.ui.components.TabRow +import com.github.damontecres.wholphin.ui.components.VoiceSearchButton +import com.github.damontecres.wholphin.ui.components.rememberContextMenu +import com.github.damontecres.wholphin.ui.data.RowColumn +import com.github.damontecres.wholphin.ui.detail.CardGrid +import com.github.damontecres.wholphin.ui.detail.CardGridItem +import com.github.damontecres.wholphin.ui.detail.GridItemDetails +import com.github.damontecres.wholphin.ui.detail.livetv.ProgramDialog +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.onMain +import com.github.damontecres.wholphin.ui.titleStringRes +import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.util.WholphinDispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext +import org.jellyfin.sdk.model.api.BaseItemKind +import timber.log.Timber +import kotlin.time.Duration.Companion.milliseconds + +internal const val SEARCH_ROW = 0 +internal const val TAB_ROW = SEARCH_ROW + 1 +internal const val SEERR_ROW = TAB_ROW + 1 +internal const val COMBINED_ROW = SEERR_ROW +internal const val RESULTS_START = SEERR_ROW + 1 + +@Composable +fun SearchPage( + initialQuery: String, + userPreferences: UserPreferences, + modifier: Modifier = Modifier, + viewModel: SearchViewModel = hiltViewModel(), +) { + val focusManager = LocalFocusManager.current + val keyboardController = LocalSoftwareKeyboardController.current + val state by viewModel.state.collectAsState() + val programDialogState by viewModel.programDialogState.collectAsState() + + // Start with current preferences, but collect updates when view options change + val prefs = + viewModel.userPreferencesService.flow + .collectAsState(userPreferences) + .value.appPreferences.interfacePreferences.searchPreferences + val combinedMode = prefs.combinedSearchResults + val voiceSearchButtonVisible = prefs.showVoiceSearchButton + +// val query = rememberTextFieldState() + var query by rememberSaveable { mutableStateOf(initialQuery) } + val focusRequesters = + remember(state.includedSearchableTypes.size) { List(RESULTS_START + state.includedSearchableTypes.size) { FocusRequester() } } + + val seerrActive by viewModel.seerrActive.collectAsState() + var selectedTab by rememberSaveable(seerrActive, state.discoverEnabled) { mutableIntStateOf(0) } + var showViewOptions by rememberSaveable { mutableStateOf(false) } + var showFilterTypeDialog by rememberSaveable { mutableStateOf(false) } + var searchClicked by rememberSaveable(query) { mutableStateOf(false) } + var immediateSearchQuery by rememberSaveable { mutableStateOf<String?>(null) } + var showProgramDialog by remember { mutableStateOf(false) } + + val position by viewModel.position.collectAsState() + + fun setPosition(pos: RowColumn) { + Timber.v("pos=%s", pos) + viewModel.position.value = pos + } + + val contextMenu = rememberContextMenu(userPreferences, viewModel) + + LifecycleResumeEffect(Unit) { + onPauseOrDispose { + viewModel.voiceInputManager.stopListening() + } + } + + fun triggerImmediateSearch(searchQuery: String) { + immediateSearchQuery = searchQuery + searchClicked = true + viewModel.search(searchQuery, combinedMode) + } + + LaunchedEffect(query, combinedMode) { + when { + immediateSearchQuery == query -> { + immediateSearchQuery = null + } + + else -> { + delay(750.milliseconds) + viewModel.search(query, combinedMode) + } + } + } + LaunchedEffect(Unit) { + focusRequesters.getOrNull(position.row)?.tryRequestFocus() + } + val onClickItem = { _: Int, item: BaseItem -> + Timber.v("Clicked %s, type=%s", item.id, item.type) + if (item.type == BaseItemKind.TV_PROGRAM || item.type == BaseItemKind.PROGRAM || item.type == BaseItemKind.LIVE_TV_PROGRAM) { + viewModel.fetchProgramForDialog(item.id) + showProgramDialog = true + } else { + viewModel.navigationManager.navigateTo(item.destination()) + } + } + val onLongClickItem = { rowIndex: Int, index: Int, item: BaseItem -> + setPosition(RowColumn(rowIndex, index)) + contextMenu.showContextMenu(index, item) + } + val onPlayItem = { _: Int, item: BaseItem -> + viewModel.navigationManager.navigateTo(Destination.Playback(item)) + } + + val onClickDiscover = { _: Int, item: DiscoverItem -> + val dest = + if (item.jellyfinItemId != null && item.type.baseItemKind != null) { + Destination.MediaItem( + itemId = item.jellyfinItemId, + type = item.type.baseItemKind, + ) + } else { + Destination.DiscoveredItem(item) + } + viewModel.navigationManager.navigateTo(dest) + } + + var showHeader by rememberSaveable { mutableStateOf(true) } + val positionCallback = { columns: Int, index: Int -> + showHeader = index < columns + } + val showTabs = + seerrActive && state.discoverEnabled && query.isNotBlank() && showHeader && combinedMode + val isLibraryTab = selectedTab == 0 + + LaunchedEffect(seerrActive, query) { + if (!seerrActive || query.isBlank()) { + selectedTab = 0 + } + } + + LaunchedEffect( + searchClicked, + state, + combinedMode, + selectedTab, + seerrActive, + ) { + if (!searchClicked || position.row > TAB_ROW) return@LaunchedEffect + + withContext(WholphinDispatchers.IO) { + // Want to focus on the first successful row after all the ones before it are finished searching + val results = + if (isLibraryTab) { + if (combinedMode) { + listOf(state.combinedResults) + } else { + state.includedSearchableTypes.map { state.results[it] } + } + } else { + listOf(state.seerrResults) + } + val firstSuccess = + results.indexOfFirst { it is SearchResult.Success || it is SearchResult.SuccessSeerr } + if (firstSuccess >= 0) { + val anyBeforeSearching = + results.subList(0, firstSuccess).any { it is SearchResult.Searching } + if (!anyBeforeSearching) { + val targetRow = + if (isLibraryTab) { + if (combinedMode) { + COMBINED_ROW + } else { + firstSuccess + } + } else { + SEERR_ROW + } +// setPosition(RowColumn(targetRow, 0)) + onMain { focusRequesters.getOrNull(targetRow)?.tryRequestFocus() } + } + } + } + } + + val tabs = + remember(seerrActive, state.discoverEnabled) { + buildList { + add(TabDetails(R.string.library)) + if (seerrActive && state.discoverEnabled) { + add(TabDetails(R.string.discover)) + } + } + } + + Column( + modifier = modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxWidth(), + ) { + var isSearchActive by remember { mutableStateOf(false) } + var isTextFieldFocused by remember { mutableStateOf(false) } + val textFieldFocusRequester = remember { FocusRequester() } + + BackHandler(isTextFieldFocused) { + when { + isSearchActive -> { + isSearchActive = false + keyboardController?.hide() + } + + else -> { + focusManager.moveFocus(FocusDirection.Next) + } + } + } + + Row( + horizontalArrangement = Arrangement.spacedBy(0.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .padding(start = 16.dp, end = 16.dp, top = 16.dp) + .focusGroup() + .focusRestorer(textFieldFocusRequester) + .focusRequester(focusRequesters[SEARCH_ROW]), + ) { + AnimatedVisibility( + visible = voiceSearchButtonVisible, + enter = fadeIn() + expandHorizontally(expandFrom = Alignment.End), + exit = fadeOut() + shrinkHorizontally(shrinkTowards = Alignment.End), + ) { + VoiceSearchButton( + onSpeechResult = { spokenText -> + query = spokenText + triggerImmediateSearch(spokenText) + }, + voiceInputManager = viewModel.voiceInputManager, + modifier = Modifier.padding(end = 12.dp), + ) + } + + SearchEditTextBox( + value = query, + onValueChange = { + isSearchActive = true + query = it + }, + onSearchClick = { triggerImmediateSearch(query) }, + readOnly = !isSearchActive, + modifier = + Modifier + .focusRequester(textFieldFocusRequester) + .onFocusChanged { state -> + isTextFieldFocused = state.isFocused + if (!state.isFocused) isSearchActive = false + }.onPreviewKeyEvent { event -> + val isActivationKey = + event.key in listOf(Key.DirectionCenter, Key.Enter) + if (event.type == KeyEventType.KeyUp && isActivationKey && !isSearchActive) { + isSearchActive = true + keyboardController?.show() + true + } else { + false + } + }, + ) + + ExpandableFaButton( + title = R.string.view_options, + iconStringRes = R.string.fa_sliders, + onClick = { showViewOptions = true }, + modifier = Modifier.padding(start = 12.dp), + ) + } + } + AnimatedVisibility( + visible = showTabs, + enter = expandVertically(), + exit = shrinkVertically(), + ) { + TabRow( + selectedTabIndex = selectedTab, + tabs = tabs, + onClick = { + selectedTab = it + val row = + when (selectedTab) { + 0 -> COMBINED_ROW + else -> SEERR_ROW + } + setPosition(RowColumn(row, 0)) + }, + modifier = + Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp) + .onFocusChanged { + if (it.hasFocus) setPosition(RowColumn(TAB_ROW, 0)) + }, + ) + } + Box( + modifier = Modifier.fillMaxSize(), + ) { +// SideEffect { +// Timber.v("isLibraryTab=%s, combinedMode=%s", isLibraryTab, combinedMode) +// } + when { + isLibraryTab && combinedMode -> { + SearchCombinedResults( + result = state.combinedResults, + focusRequester = focusRequesters[COMBINED_ROW], + onClickItem = onClickItem, + onLongClickItem = { index, item -> + onLongClickItem(COMBINED_ROW, index, item) + }, + onPlayItem = onPlayItem, + onClickPosition = { setPosition(it) }, + onClickDiscover = onClickDiscover, + positionCallback = positionCallback, + modifier = Modifier.fillMaxSize(), + ) + } + + !isLibraryTab && combinedMode -> { + SearchCombinedResults( + result = state.seerrResults, + focusRequester = focusRequesters[SEERR_ROW], + onClickItem = onClickItem, + onLongClickItem = { _, _ -> }, + onPlayItem = onPlayItem, + onClickPosition = { setPosition(it) }, + onClickDiscover = onClickDiscover, + positionCallback = positionCallback, + modifier = Modifier.fillMaxSize(), + ) + } + + isLibraryTab -> { + LazyColumn( + contentPadding = + PaddingValues( + start = 16.dp, + end = 16.dp, + top = 8.dp, + bottom = 44.dp, + ), + verticalArrangement = Arrangement.spacedBy(0.dp), + modifier = Modifier.focusGroup(), + ) { + itemsIndexed(state.includedSearchableTypes) { index, type -> + val rowIndex = RESULTS_START + index + val result = state.results.getOrDefault(type, SearchResult.Searching) + val focusRequester = + remember(state.includedSearchableTypes.size) { + focusRequesters.getOrNull(rowIndex) ?: FocusRequester() + } + SearchRowResult( + title = type.titleStringRes, + result = result, + rowIndex = rowIndex, + position = position, + focusRequester = focusRequester, + onClickItem = onClickItem, + onLongClickItem = { index, item -> + onLongClickItem(rowIndex, index, item) + }, + onClickPosition = { setPosition(it) }, + modifier = Modifier.fillMaxWidth(), + cardContent = { index, item, mod, onClick, onLongClick -> + SearchPageCard( + item = item, + type = type, + onClick = { + setPosition(RowColumn(rowIndex, index)) + onClick.invoke() + }, + onLongClick = onLongClick, + modifier = mod, + ) + }, + ) + } + + if (seerrActive && state.discoverEnabled) { + item { + SearchRowResult( + title = R.string.discover, + result = state.seerrResults, + rowIndex = SEERR_ROW, + position = position, + focusRequester = focusRequesters[SEERR_ROW], + onClickItem = onClickItem, + onLongClickItem = { _, _ -> }, + onClickDiscover = onClickDiscover, + onClickPosition = { setPosition(it) }, + cardContent = { _, _, _, _, _ -> }, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } + } + } + } + contextMenu.Compose() + } + + if (showViewOptions) { + SearchViewOptionsDialog( + combinedResults = combinedMode, + onCombinedResultsChange = viewModel::setCombinedResults, + voiceSearchButtonVisible = voiceSearchButtonVisible, + onVoiceSearchButtonVisibleChange = viewModel::setVoiceSearchButtonVisible, + onClickFilterTypes = { showFilterTypeDialog = true }, + onDismissRequest = { showViewOptions = false }, + ) + } + + if (showFilterTypeDialog) { + SearchTypeOptionsDialog( + onDismissRequest = { showFilterTypeDialog = false }, + searchableTypes = state.possibleSearchableTypes, + excludedSearchableTypes = state.excludedSearchableTypes, + discoverAvailable = seerrActive, + discoverEnabled = state.discoverEnabled, + onClick = viewModel::onClickExcludeSearchableType, + onClickDiscover = viewModel::onClickExcludeDiscover, + ) + } + + if (showProgramDialog) { + val context = LocalContext.current + val onDismissRequest = { showProgramDialog = false } + ProgramDialog( + state = programDialogState.loading, + canRecord = true, + onDismissRequest = onDismissRequest, + onWatch = { + onDismissRequest.invoke() + val channelId = it.data.channelId + if (channelId != null) { + viewModel.navigationManager.navigateTo( + Destination.Playback( + itemId = channelId, + positionMs = 0L, + ), + ) + } else { + Toast.makeText(context, "Program has no channel ID", Toast.LENGTH_LONG).show() + } + }, + onRecord = { program, series -> + viewModel.record( + programId = program.id, + series = series, + ) + onDismissRequest.invoke() + }, + onCancelRecord = { program, series -> + viewModel.cancelRecording( + series = series, + timerId = if (series) program.data.seriesTimerId else program.data.timerId, + ) + onDismissRequest.invoke() + }, + ) + } +} + +@Composable +fun SearchCombinedResults( + result: SearchResult, + focusRequester: FocusRequester, + onClickItem: (Int, BaseItem) -> Unit, + onLongClickItem: (Int, BaseItem) -> Unit, + onPlayItem: (Int, BaseItem) -> Unit, + onClickPosition: (RowColumn) -> Unit, + onClickDiscover: (Int, DiscoverItem) -> Unit, + positionCallback: (columns: Int, position: Int) -> Unit, + modifier: Modifier = Modifier, +) { + when (result) { + SearchResult.NoQuery -> {} + + SearchResult.Searching -> { + SearchResultPlaceholder( + title = stringResource(R.string.results), + message = stringResource(R.string.searching), + modifier = modifier.padding(16.dp), + ) + } + + is SearchResult.Error -> { + SearchResultPlaceholder( + title = stringResource(R.string.results), + message = result.ex.localizedMessage ?: "Error occurred during search", + messageColor = MaterialTheme.colorScheme.error, + modifier = modifier.padding(16.dp), + ) + } + + is SearchResult.Success -> { + if (result.items.isEmpty()) { + SearchResultPlaceholder( + title = stringResource(R.string.results), + message = stringResource(R.string.no_results), + modifier = modifier.padding(16.dp), + ) + } else { + SearchGrid( + items = result.items, + focusRequester = focusRequester, + onClickItem = onClickItem, + onLongClickItem = onLongClickItem, + onPlayItem = onPlayItem, + onClickPosition = onClickPosition, + positionCallback = positionCallback, + cardContent = { details -> + GridCard( + item = details.item, + onClick = details.onClick, + onLongClick = details.onLongClick, + modifier = details.mod, + fillWidth = details.widthPx, + ) + }, + modifier = modifier, + ) + } + } + + is SearchResult.SuccessSeerr -> { + if (result.items.isEmpty()) { + SearchResultPlaceholder( + title = stringResource(R.string.results), + message = stringResource(R.string.no_results), + modifier = modifier.padding(16.dp), + ) + } else { + SearchGrid( + items = result.items, + focusRequester = focusRequester, + onClickItem = onClickDiscover, + onLongClickItem = { _, _ -> }, + onPlayItem = { _, _ -> }, + onClickPosition = onClickPosition, + positionCallback = positionCallback, + cardContent = { details -> + DiscoverItemCard( + item = details.item, + onClick = details.onClick, + onLongClick = details.onLongClick, + modifier = details.mod, + ) + }, + modifier = modifier, + ) + } + } + } +} + +@Composable +private fun <T : CardGridItem> SearchGrid( + items: List<T?>, + focusRequester: FocusRequester, + onClickItem: (Int, T) -> Unit, + onLongClickItem: (Int, T) -> Unit, + onPlayItem: (Int, T) -> Unit, + onClickPosition: (RowColumn) -> Unit, + cardContent: @Composable (GridItemDetails<T>) -> Unit, + positionCallback: (columns: Int, position: Int) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + verticalArrangement = Arrangement.spacedBy(0.dp), + modifier = modifier, + ) { + ItemRowTitle(stringResource(R.string.results)) + + CardGrid( + pager = items, + onClickItem = { index, item -> + onClickPosition.invoke(RowColumn(COMBINED_ROW, index)) + onClickItem.invoke(index, item) + }, + onLongClickItem = onLongClickItem, + onClickPlay = { index, item -> + onClickPosition.invoke(RowColumn(COMBINED_ROW, index)) + onPlayItem.invoke(index, item) + }, + letterPosition = { -1 }, + gridFocusRequester = focusRequester, + showJumpButtons = false, + showLetterButtons = false, + positionCallback = positionCallback, + modifier = Modifier.fillMaxSize(), + cardContent = cardContent, + ) + } +} + +@Composable +fun SearchRowResult( + @StringRes title: Int, + result: SearchResult, + rowIndex: Int, + position: RowColumn, + focusRequester: FocusRequester, + onClickItem: (Int, BaseItem) -> Unit, + onLongClickItem: (Int, BaseItem) -> Unit, + onClickPosition: (RowColumn) -> Unit, + modifier: Modifier = Modifier, + onClickDiscover: ((Int, DiscoverItem) -> Unit)? = null, + cardContent: @Composable ( + index: Int, + item: BaseItem?, + modifier: Modifier, + onClick: () -> Unit, + onLongClick: () -> Unit, + ) -> Unit, +) { + when (val r = result) { + is SearchResult.Error -> { + SearchResultPlaceholder( + title = stringResource(title), + message = r.ex.localizedMessage ?: "Error occurred during search", + messageColor = MaterialTheme.colorScheme.error, + modifier = Modifier, + ) + } + + SearchResult.NoQuery -> { + // no-op + } + + SearchResult.Searching -> { + SearchResultPlaceholder( + title = stringResource(title), + message = stringResource(R.string.searching), + modifier = modifier, + ) + } + + is SearchResult.Success -> { + if (r.items.isNotEmpty()) { + ItemRow( + title = stringResource(title), + items = r.items, + onClickItem = onClickItem, + onLongClickItem = onLongClickItem, + modifier = modifier.focusRequester(focusRequester), + cardContent = cardContent, + ) + } + } + + is SearchResult.SuccessSeerr -> { + if (r.items.isNotEmpty()) { + ItemRow( + title = stringResource(title), + items = r.items, + onClickItem = { index, item -> + onClickPosition.invoke(RowColumn(rowIndex, index)) + onClickDiscover?.invoke(index, item) + }, + onLongClickItem = { _, _ -> }, + modifier = modifier.focusRequester(focusRequester), + cardContent = { index: Int, item: DiscoverItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit -> + DiscoverItemCard( + item = item, + onClick = onClick, + onLongClick = onLongClick, + showOverlay = true, + modifier = mod, + ) + }, + ) + } + } + } +} + +@Composable +fun SearchResultPlaceholder( + title: String, + message: String, + modifier: Modifier = Modifier, + messageColor: Color = MaterialTheme.colorScheme.onBackground, +) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier.padding(bottom = 32.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onBackground, + ) + Text( + text = message, + style = MaterialTheme.typography.titleMedium, + color = messageColor, + ) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchPageCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchPageCard.kt new file mode 100644 index 00000000..9b82f24c --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchPageCard.kt @@ -0,0 +1,149 @@ +package com.github.damontecres.wholphin.ui.search + +import android.text.format.DateUtils +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.ui.AspectRatios +import com.github.damontecres.wholphin.ui.Cards +import com.github.damontecres.wholphin.ui.LocalImageUrlService +import com.github.damontecres.wholphin.ui.cards.BannerCardWithTitle +import com.github.damontecres.wholphin.ui.cards.PersonCard +import com.github.damontecres.wholphin.ui.cards.SeasonCard +import com.github.damontecres.wholphin.ui.cards.personRowCardWidth +import com.github.damontecres.wholphin.ui.cards.rememberImageUrl +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.ImageType +import java.time.OffsetDateTime + +@Composable +fun SearchPageCard( + item: BaseItem?, + type: BaseItemKind, + onClick: () -> Unit, + onLongClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + when (type) { + BaseItemKind.EPISODE -> { + BannerCardWithTitle( + title = item?.title, + subtitle = item?.subtitle, + item = item, + onClick = onClick, + onLongClick = onLongClick, + modifier = modifier.padding(horizontal = 8.dp), + cardHeight = Cards.heightEpisode, + ) + } + + BaseItemKind.MUSIC_ALBUM, + BaseItemKind.MUSIC_ARTIST, + BaseItemKind.AUDIO, + -> { + SeasonCard( + item = item, + onClick = onClick, + onLongClick = onLongClick, + imageHeight = Cards.heightEpisode, + aspectRatio = AspectRatios.SQUARE, + showImageOverlay = true, + modifier = modifier, + ) + } + + BaseItemKind.PERSON -> { + val imageUrlService = LocalImageUrlService.current + val imageUrl = + remember(item) { + imageUrlService.getItemImageUrl( + item, + ImageType.PRIMARY, + ) + } + PersonCard( + name = item?.name ?: "", + role = null, + imageUrl = imageUrl, + favorite = item?.favorite ?: false, + onClick = onClick, + onLongClick = onLongClick, + modifier = modifier.width(personRowCardWidth), + ) + } + + BaseItemKind.TV_CHANNEL -> { + SeasonCard( + item = item, + onClick = onClick, + onLongClick = onLongClick, + imageHeight = Cards.HEIGHT_LIVE_TV.dp, + showImageOverlay = true, + aspectRatio = AspectRatios.WIDE, + modifier = modifier, + ) + } + + BaseItemKind.PROGRAM, + BaseItemKind.TV_PROGRAM, + BaseItemKind.LIVE_TV_PROGRAM, + -> { + val subtitle = + remember(item) { + val startDate = item?.data?.startDate + val endDate = item?.data?.endDate + if (startDate != null && endDate != null) { + DateUtils.formatDateRange( + context, + startDate + .toInstant(OffsetDateTime.now().offset) + .epochSecond * 1000, + endDate + .toInstant(OffsetDateTime.now().offset) + .epochSecond * 1000, + DateUtils.FORMAT_SHOW_TIME or DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_SHOW_WEEKDAY, + ) + } else { + null + } + } + + SeasonCard( + title = item?.title, + subtitle = subtitle, + name = item?.name, + imageUrl = rememberImageUrl(item, Cards.HEIGHT_LIVE_TV.dp, Dp.Unspecified), + isFavorite = item?.data?.userData?.isFavorite ?: false, + isPlayed = item?.data?.userData?.played ?: false, + unplayedItemCount = item?.data?.userData?.unplayedItemCount ?: 0, + playedPercentage = item?.data?.userData?.playedPercentage ?: 0.0, + numberOfVersions = item?.data?.mediaSourceCount ?: 0, + onClick = onClick, + onLongClick = onLongClick, + modifier = modifier, + imageHeight = Cards.HEIGHT_LIVE_TV.dp, + imageWidth = Dp.Unspecified, + showImageOverlay = true, + aspectRatio = AspectRatios.WIDE, + ) + } + + else -> { + SeasonCard( + item = item, + onClick = onClick, + onLongClick = onLongClick, + imageHeight = Cards.height2x3, + showImageOverlay = true, + modifier = modifier, + ) + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchTypeOptionsDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchTypeOptionsDialog.kt new file mode 100644 index 00000000..f8a6eb2e --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchTypeOptionsDialog.kt @@ -0,0 +1,100 @@ +package com.github.damontecres.wholphin.ui.search + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.tv.material3.ListItem +import androidx.tv.material3.Switch +import androidx.tv.material3.Text +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.ui.components.BasicDialog +import com.github.damontecres.wholphin.ui.main.settings.TitleText +import com.github.damontecres.wholphin.ui.preferences.SwitchColors +import com.github.damontecres.wholphin.ui.titleStringRes +import org.jellyfin.sdk.model.api.BaseItemKind + +@Composable +fun SearchTypeOptionsDialog( + onDismissRequest: () -> Unit, + searchableTypes: List<BaseItemKind>, + excludedSearchableTypes: List<BaseItemKind>, + discoverAvailable: Boolean, + discoverEnabled: Boolean, + onClick: (BaseItemKind) -> Unit, + onClickDiscover: () -> Unit, +) { + BasicDialog( + onDismissRequest = onDismissRequest, + elevation = 3.dp, + ) { + SearchTypeOptionsDialogContent( + searchableTypes = searchableTypes, + excludedSearchableTypes = excludedSearchableTypes, + discoverAvailable = discoverAvailable, + discoverEnabled = discoverEnabled, + onClick = onClick, + onClickDiscover = onClickDiscover, + modifier = Modifier.padding(16.dp), + ) + } +} + +@Composable +fun SearchTypeOptionsDialogContent( + searchableTypes: List<BaseItemKind>, + excludedSearchableTypes: List<BaseItemKind>, + discoverAvailable: Boolean, + discoverEnabled: Boolean, + onClick: (BaseItemKind) -> Unit, + onClickDiscover: () -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier) { + TitleText(stringResource(R.string.include_types)) + + LazyColumn { + items(searchableTypes) { searchableType -> + val checked = searchableType !in excludedSearchableTypes + ListItem( + enabled = true, + selected = false, + onClick = { onClick.invoke(searchableType) }, + headlineContent = { + Text(stringResource(searchableType.titleStringRes)) + }, + trailingContent = { + Switch( + checked = checked, + onCheckedChange = {}, + colors = SwitchColors(), + ) + }, + ) + } + if (discoverAvailable) { + item { + ListItem( + enabled = true, + selected = false, + onClick = onClickDiscover, + headlineContent = { + Text(stringResource(R.string.discover)) + }, + trailingContent = { + Switch( + checked = discoverEnabled, + onCheckedChange = {}, + colors = SwitchColors(), + ) + }, + ) + } + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchViewModel.kt new file mode 100644 index 00000000..044178b2 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchViewModel.kt @@ -0,0 +1,627 @@ +package com.github.damontecres.wholphin.ui.search + +import android.content.Context +import androidx.compose.runtime.snapshots.SnapshotStateMap +import androidx.datastore.core.DataStore +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.DiscoverItem +import com.github.damontecres.wholphin.data.model.SeerrItemType +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.updateSearchPreferences +import com.github.damontecres.wholphin.services.FavoriteWatchManager +import com.github.damontecres.wholphin.services.KeyValueService +import com.github.damontecres.wholphin.services.LiveTvService +import com.github.damontecres.wholphin.services.MediaManagementService +import com.github.damontecres.wholphin.services.NavDrawerService +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.SeerrService +import com.github.damontecres.wholphin.services.ServerReportService +import com.github.damontecres.wholphin.services.UserPreferencesService +import com.github.damontecres.wholphin.services.deleteItem +import com.github.damontecres.wholphin.services.tvAccess +import com.github.damontecres.wholphin.ui.ProgramItemFields +import com.github.damontecres.wholphin.ui.SlimItemFields +import com.github.damontecres.wholphin.ui.components.ContextMenuProvider +import com.github.damontecres.wholphin.ui.components.VoiceInputManager +import com.github.damontecres.wholphin.ui.components.baseItemKinds +import com.github.damontecres.wholphin.ui.data.RowColumn +import com.github.damontecres.wholphin.ui.detail.livetv.ProgramDialogState +import com.github.damontecres.wholphin.ui.isNotNullOrBlank +import com.github.damontecres.wholphin.ui.launchDefault +import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.showToast +import com.github.damontecres.wholphin.ui.toBaseItems +import com.github.damontecres.wholphin.util.DataLoadingState +import com.github.damontecres.wholphin.util.ExceptionHandler +import com.github.damontecres.wholphin.util.SearchRelevance +import com.github.damontecres.wholphin.util.WholphinDispatchers +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.artistsApi +import org.jellyfin.sdk.api.client.extensions.itemsApi +import org.jellyfin.sdk.api.client.extensions.personsApi +import org.jellyfin.sdk.api.client.extensions.userLibraryApi +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.request.GetItemsRequest +import timber.log.Timber +import java.util.UUID +import javax.inject.Inject + +@HiltViewModel +class SearchViewModel + @Inject + constructor( + @param:ApplicationContext private val context: Context, + val api: ApiClient, + val navigationManager: NavigationManager, + val appPreferences: DataStore<AppPreferences>, + private val seerrService: SeerrService, + val voiceInputManager: VoiceInputManager, + val userPreferencesService: UserPreferencesService, + private val serverRepository: ServerRepository, + private val favoriteWatchManager: FavoriteWatchManager, + private val mediaManagementService: MediaManagementService, + private val serverReportService: ServerReportService, + private val liveTvService: LiveTvService, + private val keyValueService: KeyValueService, + private val navDrawerService: NavDrawerService, + ) : ViewModel(), + ContextMenuProvider { + val seerrActive = + seerrService.active.stateIn( + scope = viewModelScope, + started = SharingStarted.Eagerly, + initialValue = false, + ) + + private val _state = MutableStateFlow(SearchState()) + val state: StateFlow<SearchState> = _state + val position = MutableStateFlow(RowColumn(0, 0)) + + private var currentQuery: String? = null + private var combinedMode = false + + private val _programDialogState = MutableStateFlow(ProgramDialogState()) + val programDialogState: StateFlow<ProgramDialogState> = _programDialogState + + private var userLibraryTypes: Set<BaseItemKind> = emptySet() + + init { + init() + } + + private fun init() { + viewModelScope.launchDefault { + userLibraryTypes = + navDrawerService.state.value.allLibraries + .flatMap { it.collectionType.baseItemKinds } + .toSet() + + val excludedSearchableTypes = + serverRepository.currentUser?.id?.let { userId -> + try { + keyValueService + .get<List<BaseItemKind>>( + userId, + EXCLUDED_SEARCHABLE_TYPES_KEY, + emptyList(), + ).firstOrNull() + } catch (ex: Exception) { + Timber.e(ex, "Error occurred fetching excluded search types") + emptyList() + } + } ?: emptyList() + val discoverEnabled = + serverRepository.currentUser?.id?.let { userId -> + try { + keyValueService + .get( + userId, + INCLUDE_DISCOVER_KEY, + true, + ).firstOrNull() + } catch (ex: Exception) { + Timber.e(ex, "Error occurred fetching excluded search types") + true + } + } ?: true + val searchableTypes = determineSearchableTypes(excludedSearchableTypes) + val possibleSearchableTypes = determineSearchableTypes(emptyList()) + _state.update { + it.copy( + discoverEnabled = discoverEnabled, + includedSearchableTypes = searchableTypes, + possibleSearchableTypes = possibleSearchableTypes, + excludedSearchableTypes = excludedSearchableTypes, + results = + SnapshotStateMap<BaseItemKind, SearchResult>().apply { + searchableTypes.forEach { put(it, SearchResult.NoQuery) } + }, + ) + } + } + } + + private fun determineSearchableTypes(excludedSearchableTypes: List<BaseItemKind>): List<BaseItemKind> { + val tvAccess = serverRepository.currentUserDto?.tvAccess == true + val searchableTypes = + allSearchableTypes.filter { + val excluded = excludedSearchableTypes.contains(it) + // Only include if there's a relevant, accessible library + val hasLibrary = userLibraryTypes.contains(it) + when (it) { + // No library type for person + BaseItemKind.PERSON -> !excluded + + // Remove live tv search if user doesn't have access + BaseItemKind.TV_CHANNEL, + BaseItemKind.LIVE_TV_PROGRAM, + BaseItemKind.TV_PROGRAM, + BaseItemKind.PROGRAM, + -> tvAccess && !excluded + + else -> hasLibrary && !excluded + } + } + return searchableTypes + } + + fun search( + query: String?, + combined: Boolean = false, + force: Boolean = false, + ) { + if (currentQuery == query && combinedMode == combined && !force) { + return + } + currentQuery = query + combinedMode = combined + if (query.isNotNullOrBlank()) { + _state.update { + it.copy( + results = + SnapshotStateMap<BaseItemKind, SearchResult>().apply { + it.includedSearchableTypes.forEach { + put(it, SearchResult.Searching) + } + }, + combinedResults = SearchResult.Searching, + ) + } + if (combined) { + searchCombined(query) + } else { + state.value.includedSearchableTypes.forEach { type -> + searchType(query, type) + } + } + searchSeerr(query) + } else { + _state.update { + it.copy( + results = + SnapshotStateMap<BaseItemKind, SearchResult>().apply { + it.includedSearchableTypes.forEach { + put(it, SearchResult.NoQuery) + } + }, + combinedResults = SearchResult.NoQuery, + ) + } + } + } + + private fun searchType( + query: String, + type: BaseItemKind, + ) { + viewModelScope.launchIO { + try { + val items: List<BaseItem> = + when (type) { + BaseItemKind.LIVE_TV_PROGRAM -> { + val request = + GetItemsRequest( + searchTerm = query, + recursive = true, + includeItemTypes = listOf(type), + fields = ProgramItemFields, + limit = SEARCH_LIMIT, + enableTotalRecordCount = false, + ) + api.itemsApi.getItems(request).toBaseItems(api, false) + } + + BaseItemKind.MUSIC_ARTIST -> { + api.artistsApi + .getArtists( + searchTerm = query, + fields = SlimItemFields, + limit = SEARCH_LIMIT, + enableTotalRecordCount = false, + ).toBaseItems(api, false) + } + + BaseItemKind.PERSON -> { + api.personsApi + .getPersons( + searchTerm = query, + fields = SlimItemFields, + limit = SEARCH_LIMIT, + ).toBaseItems(api, false) + } + + else -> { + val request = + GetItemsRequest( + searchTerm = query, + recursive = true, + includeItemTypes = listOf(type), + fields = SlimItemFields, + limit = SEARCH_LIMIT, + enableTotalRecordCount = false, + ) + api.itemsApi.getItems(request).toBaseItems(api, false) + } + } + val sorted = + items.sortedWith( + compareBy<BaseItem> { SearchRelevance.score(it, query) }, + ) + Timber.v("Search finished for %s, %s results", type, sorted.size) + _state.value.results[type] = SearchResult.Success(sorted) + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Exception searching for $type") + _state.value.results[type] = SearchResult.Error(ex) + } + } + } + + private fun searchCombined(query: String) { + viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { + try { + Timber.v("Starting searchCombined") + val request = + GetItemsRequest( + searchTerm = query, + recursive = true, + includeItemTypes = state.value.includedSearchableTypes, + fields = SlimItemFields, + limit = SEARCH_LIMIT, + ) + + val result = api.itemsApi.getItems(request).content + val items = + result.items.map { + BaseItem(it, false) + } + val sorted = + items.sortedWith( + compareBy<BaseItem> { SearchRelevance.score(it, query) }, + ) + Timber.v("searchCombined complete %s results", sorted.size) + _state.update { it.copy(combinedResults = SearchResult.Success(sorted)) } + } catch (ex: Exception) { + Timber.e(ex, "Exception in combined search") + _state.update { it.copy(combinedResults = SearchResult.Error(ex)) } + } + } + } + + fun setCombinedResults(enabled: Boolean) { + viewModelScope.launchIO { + appPreferences.updateData { + it.updateSearchPreferences { + combinedSearchResults = enabled + } + } + } + } + + fun setVoiceSearchButtonVisible(visible: Boolean) { + viewModelScope.launchIO { + appPreferences.updateData { + it.updateSearchPreferences { + showVoiceSearchButton = visible + } + } + } + } + + private fun searchSeerr(query: String) { + viewModelScope.launchIO { + if (seerrActive.first() && state.value.discoverEnabled) { + _state.update { it.copy(seerrResults = SearchResult.Searching) } + val results = + seerrService + .search(query) + .map { seerrService.createDiscoverItem(it) } + .filter { it.type == SeerrItemType.MOVIE || it.type == SeerrItemType.TV } + _state.update { it.copy(seerrResults = SearchResult.SuccessSeerr(results)) } + } + } + } + + init { + addCloseable(voiceInputManager) + } + + override fun navigateTo(destination: Destination) { + navigationManager.navigateTo(destination) + } + + override fun canDelete( + item: BaseItem, + appPreferences: AppPreferences, + ): Boolean = mediaManagementService.canDelete(item, appPreferences) + + override fun deleteItem( + index: Int, + item: BaseItem, + ) { + deleteItem(context, mediaManagementService, item) { + viewModelScope.launchDefault { + refreshItem(item.id) + } + } + } + + private suspend fun refreshItem(itemId: UUID) { + try { + val position = position.value + val searchResult = + if (combinedMode) { + state.value.combinedResults + } else { + state.value.includedSearchableTypes.getOrNull(position.row)?.let { + state.value.results[it] + } + } ?: return + val items = (searchResult as? SearchResult.Success)?.items ?: return + + Timber.v("Item refresh: position=%s", position) + val item = items.getOrNull(position.column) + // Exact item deleted (eg a movie) or deleted item was within the series + if (item != null && item.id == itemId) { + val newItem = + api.userLibraryApi + .getItem(item.id) + .content + .let { BaseItem(it) } + val newList = + SearchResult.Success( + items.toMutableList().apply { + set(position.column, newItem) + }, + ) + if (combinedMode) { + _state.update { + it.copy( + combinedResults = newList, + ) + } + } else { + state.value.includedSearchableTypes.getOrNull(position.row)?.let { type -> + state.value.results[type] = newList + } + } + } + } catch (ex: Exception) { + Timber.e(ex, "Error refreshing item %s", itemId) + showToast(context, "Error refreshing") + } + } + + override fun setWatched( + position: Int, + itemId: UUID, + played: Boolean, + ) { + viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { + favoriteWatchManager.setWatched(itemId, played) + refreshItem(itemId) + } + } + + override fun setFavorite( + position: Int, + itemId: UUID, + favorite: Boolean, + ) { + viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { + favoriteWatchManager.setFavorite(itemId, favorite) + refreshItem(itemId) + } + } + + override fun isAdministrator(): Boolean = serverRepository.currentUserDto?.policy?.isAdministrator == true + + override fun sendReportFor(itemId: UUID) = serverReportService.sendMediaReportFor(itemId) + + fun fetchProgramForDialog(programId: UUID) { + _programDialogState.update { it.copy(loading = DataLoadingState.Loading) } + viewModelScope.launchDefault { + try { + val result = liveTvService.fetchProgramForDialog(programId) + _programDialogState.update { it.copy(loading = DataLoadingState.Success(result)) } + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Error fetching program $programId") + _programDialogState.update { it.copy(loading = DataLoadingState.Error(ex)) } + } + } + } + + fun cancelRecording( + series: Boolean, + timerId: String?, + ) { + viewModelScope.launchIO(ExceptionHandler(autoToast = true)) { + try { + val result = liveTvService.cancelRecording(series, timerId) + // TODO update program card? + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Error canceling timer %s, series=%s", timerId, series) + showToast(context, "Error: ${ex.localizedMessage}") + } + } + } + + fun record( + programId: UUID, + series: Boolean, + ) { + viewModelScope.launchIO { + try { + liveTvService.record(programId, series) + // TODO update program card? + refreshItem(programId) + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Error recording %s, series=%s", programId, series) + showToast(context, "Error: ${ex.localizedMessage}") + } + } + } + + fun onClickExcludeSearchableType(type: BaseItemKind) { + viewModelScope.launchIO { + try { + val state = state.value + val updateSearch: Boolean + val newExcludes = + state.excludedSearchableTypes.toMutableList().apply { + if (type in this) { + updateSearch = true + remove(type) + } else { + // If combined, need to update, otherwise just hide the newly excluded results + updateSearch = combinedMode + add(type) + } + } + Timber.v("newExcludes=%s", newExcludes) + serverRepository.currentUser?.id?.let { userId -> + keyValueService.save(userId, EXCLUDED_SEARCHABLE_TYPES_KEY, newExcludes) + } + val searchableTypes = determineSearchableTypes(newExcludes) + _state.update { + it.copy( + includedSearchableTypes = searchableTypes, + excludedSearchableTypes = newExcludes, + ) + } + if (updateSearch && currentQuery.isNotNullOrBlank()) { + Timber.d("Need to update search") + search(currentQuery, combinedMode, true) + } + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Exception toggling %s", type) + showToast(context, "An error occurred: ${ex.localizedMessage}") + } + } + } + + fun onClickExcludeDiscover() { + viewModelScope.launchIO { + try { + val newIncludeDiscover = state.value.discoverEnabled.not() + Timber.v("newIncludeDiscover=%s", newIncludeDiscover) + serverRepository.currentUser?.id?.let { userId -> + keyValueService.save(userId, INCLUDE_DISCOVER_KEY, newIncludeDiscover) + } + _state.update { + it.copy( + discoverEnabled = newIncludeDiscover, + ) + } + if (newIncludeDiscover) { + currentQuery?.takeIf { it.isNotNullOrBlank() }?.let { query -> + searchSeerr(query) + } + } + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Exception discover") + showToast(context, "An error occurred: ${ex.localizedMessage}") + } + } + } + + companion object { + private const val EXCLUDED_SEARCHABLE_TYPES_KEY = "excludedSearchableTypes" + private const val INCLUDE_DISCOVER_KEY = "searchIncludeDiscover" + } + } + +sealed interface SearchResult { + data object NoQuery : SearchResult + + data object Searching : SearchResult + + data class Error( + val ex: Exception, + ) : SearchResult + + data class Success( + val items: List<BaseItem?>, + ) : SearchResult + + data class SuccessSeerr( + val items: List<DiscoverItem>, + ) : SearchResult +} + +data class SearchState( + val results: SnapshotStateMap<BaseItemKind, SearchResult> = SnapshotStateMap(), + val seerrResults: SearchResult = SearchResult.NoQuery, + val combinedResults: SearchResult = SearchResult.NoQuery, + val possibleSearchableTypes: List<BaseItemKind> = emptyList(), + val includedSearchableTypes: List<BaseItemKind> = emptyList(), + val excludedSearchableTypes: List<BaseItemKind> = emptyList(), + val discoverEnabled: Boolean = true, +) + +private val allSearchableTypes = + listOf( + BaseItemKind.MOVIE, + BaseItemKind.SERIES, + BaseItemKind.EPISODE, + BaseItemKind.BOX_SET, + BaseItemKind.PERSON, + BaseItemKind.TV_CHANNEL, + BaseItemKind.LIVE_TV_PROGRAM, + BaseItemKind.MUSIC_ALBUM, + BaseItemKind.MUSIC_ARTIST, + BaseItemKind.AUDIO, + BaseItemKind.MUSIC_VIDEO, + BaseItemKind.PLAYLIST, + BaseItemKind.VIDEO, + BaseItemKind.PHOTO, + BaseItemKind.PHOTO_ALBUM, + ) + +private const val SEARCH_LIMIT = 50 diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchViewOptionsDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchViewOptionsDialog.kt new file mode 100644 index 00000000..cdbd69e1 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchViewOptionsDialog.kt @@ -0,0 +1,121 @@ +package com.github.damontecres.wholphin.ui.search + +import android.view.Gravity +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.compose.ui.window.DialogWindowProvider +import androidx.tv.material3.ListItem +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Switch +import androidx.tv.material3.Text +import androidx.tv.material3.surfaceColorAtElevation +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.ui.preferences.SwitchColors + +@Composable +fun SearchViewOptionsDialog( + combinedResults: Boolean, + onCombinedResultsChange: (Boolean) -> Unit, + voiceSearchButtonVisible: Boolean, + onVoiceSearchButtonVisibleChange: (Boolean) -> Unit, + onClickFilterTypes: () -> Unit, + onDismissRequest: () -> Unit, +) { + Dialog( + onDismissRequest = onDismissRequest, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + val dialogWindowProvider = LocalView.current.parent as? DialogWindowProvider + dialogWindowProvider?.window?.setGravity(Gravity.CENTER) + + Box( + modifier = + Modifier + .width(400.dp) + .background( + MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp), + RoundedCornerShape(28.dp), + ).padding(24.dp), + ) { + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + Text( + text = stringResource(R.string.view_options), + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + ) + + ListItem( + selected = false, + headlineContent = { + Text(stringResource(R.string.combined_search_results)) + }, + supportingContent = { + Text( + if (combinedResults) { + stringResource(R.string.combined_search_results_on) + } else { + stringResource(R.string.combined_search_results_off) + }, + ) + }, + trailingContent = { + Switch( + checked = combinedResults, + onCheckedChange = onCombinedResultsChange, + colors = SwitchColors(), + ) + }, + onClick = { onCombinedResultsChange(!combinedResults) }, + modifier = Modifier.fillMaxWidth(), + ) + + ListItem( + selected = false, + headlineContent = { + Text(stringResource(R.string.show_voice_search_button)) + }, + supportingContent = { + Text( + if (voiceSearchButtonVisible) { + stringResource(R.string.visible_ui) + } else { + stringResource(R.string.hidden_ui) + }, + ) + }, + trailingContent = { + Switch( + checked = voiceSearchButtonVisible, + onCheckedChange = onVoiceSearchButtonVisibleChange, + colors = SwitchColors(), + ) + }, + onClick = { onVoiceSearchButtonVisibleChange(!voiceSearchButtonVisible) }, + modifier = Modifier.fillMaxWidth(), + ) + + ListItem( + selected = false, + headlineContent = { + Text(stringResource(R.string.include_types)) + }, + onClick = onClickFilterTypes, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/InstallUpdatePage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/InstallUpdatePage.kt index 7e5a6ec5..a3bd52e4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/InstallUpdatePage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/InstallUpdatePage.kt @@ -21,9 +21,8 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.CircularProgressIndicator import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -39,7 +38,6 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.tv.material3.MaterialTheme @@ -51,6 +49,8 @@ import com.github.damontecres.wholphin.services.DownloadCallback import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.Release import com.github.damontecres.wholphin.services.UpdateChecker +import com.github.damontecres.wholphin.services.UserPreferencesService +import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect import com.github.damontecres.wholphin.ui.PreviewTvSpec import com.github.damontecres.wholphin.ui.components.BasicDialog import com.github.damontecres.wholphin.ui.components.ErrorMessage @@ -58,47 +58,56 @@ import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.TextButton import com.github.damontecres.wholphin.ui.dimAndBlur import com.github.damontecres.wholphin.ui.formatBytes -import com.github.damontecres.wholphin.ui.setValueOnMain +import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.theme.WholphinTheme import com.github.damontecres.wholphin.util.ExceptionHandler -import com.github.damontecres.wholphin.util.LoadingExceptionHandler import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.Version import com.mikepenz.markdown.m3.Markdown import com.mikepenz.markdown.m3.markdownTypography import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext +import timber.log.Timber import javax.inject.Inject @HiltViewModel class UpdateViewModel @Inject constructor( + private val userPreferencesService: UserPreferencesService, val updater: UpdateChecker, val navigationManager: NavigationManager, ) : ViewModel(), DownloadCallback { - val loading = MutableLiveData<LoadingState>(LoadingState.Pending) - val release = MutableLiveData<Release?>(null) + private val _state = MutableStateFlow(InstallUpdateState()) + val state: StateFlow<InstallUpdateState> = _state - val downloading = MutableLiveData<Boolean>(false) - val contentLength = MutableLiveData<Long>(-1) - val bytesDownloaded = MutableLiveData<Long>(-1) + private val _bytesDownloaded = MutableStateFlow(0L) + val bytesDownloaded: StateFlow<Long> = _bytesDownloaded val currentVersion = updater.getInstalledVersion() - fun init(updateUrl: String) { - loading.value = LoadingState.Loading - viewModelScope.launch(Dispatchers.IO + LoadingExceptionHandler(loading, "Failed to check for update")) { - val release = updater.getLatestRelease(updateUrl) - withContext(Dispatchers.Main) { - contentLength.value = -1 - bytesDownloaded.value = -1 - this@UpdateViewModel.release.value = release - loading.value = LoadingState.Success + fun init() { + _state.update { it.copy(loading = LoadingState.Loading) } + viewModelScope.launchIO { + val updateUrl = userPreferencesService.getCurrent().appPreferences.updateUrl + try { + val release = updater.getLatestRelease(updateUrl) + _state.update { + it.copy( + loading = LoadingState.Success, + release = release, + contentLength = -1L, + ) + } + } catch (ex: Exception) { + Timber.e(ex, "Error fetching release from %s", updateUrl) + _state.update { it.copy(loading = LoadingState.Error(ex)) } } } } @@ -107,61 +116,59 @@ class UpdateViewModel fun installRelease(release: Release) { downloadJob = - viewModelScope.launch( - Dispatchers.IO + - LoadingExceptionHandler( - loading, - "Failed to install update", - ), - ) { - downloading.setValueOnMain(true) - updater.installRelease(release, this@UpdateViewModel) - downloading.setValueOnMain(false) + viewModelScope.launchIO { + try { + _bytesDownloaded.value = 0L + _state.update { it.copy(downloading = true) } + updater.installRelease(release, this@UpdateViewModel) + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Error downloading") + _state.update { it.copy(loading = LoadingState.Error(ex)) } + } finally { + _state.update { it.copy(downloading = false) } + } } } fun cancelDownload() { - viewModelScope.launch( - Dispatchers.IO + - LoadingExceptionHandler( - loading, - "Error", - ), - ) { + viewModelScope.launchIO { downloadJob?.cancel() - withContext(Dispatchers.Main) { - downloading.value = false - contentLength.value = -1 - bytesDownloaded.value = -1 + _state.update { + it.copy( + downloading = false, + contentLength = -1L, + ) } } } override fun contentLength(contentLength: Long) { - this@UpdateViewModel.contentLength.value = contentLength + _state.update { it.copy(contentLength = contentLength) } } override fun bytesDownloaded(bytes: Long) { - this@UpdateViewModel.bytesDownloaded.value = bytes + _bytesDownloaded.value = bytes } } +data class InstallUpdateState( + val loading: LoadingState = LoadingState.Pending, + val downloading: Boolean = false, + val release: Release? = null, + val contentLength: Long = -1L, +) + @Composable fun InstallUpdatePage( preferences: UserPreferences, modifier: Modifier = Modifier, viewModel: UpdateViewModel = hiltViewModel(), ) { - val loading by viewModel.loading.observeAsState(LoadingState.Pending) - val release by viewModel.release.observeAsState(null) + OneTimeLaunchedEffect { viewModel.init() } - val isDownloading by viewModel.downloading.observeAsState(false) - val contentLength by viewModel.contentLength.observeAsState(-1L) - val bytesDownloaded by viewModel.bytesDownloaded.observeAsState(-1) - - LaunchedEffect(Unit) { - viewModel.init(preferences.appPreferences.updateUrl) - } + val state by viewModel.state.collectAsState() var permissions by remember { mutableStateOf(viewModel.updater.hasPermissions()) } val launcher = rememberLauncherForActivityResult( @@ -173,9 +180,9 @@ fun InstallUpdatePage( // TODO } } - when (val state = loading) { + when (val st = state.loading) { is LoadingState.Error -> { - ErrorMessage(state, modifier) + ErrorMessage(st, modifier) } LoadingState.Loading, @@ -185,26 +192,32 @@ fun InstallUpdatePage( } LoadingState.Success -> { - release?.let { + val release = state.release + if (release != null) { InstallUpdatePageContent( currentVersion = viewModel.currentVersion, - release = it, + release = release, onInstallRelease = { if (!permissions) { launcher.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE) } else { - viewModel.installRelease(it) + viewModel.installRelease(release) } }, onCancel = { viewModel.navigationManager.goBack() }, - modifier = modifier.dimAndBlur(isDownloading), + modifier = modifier.dimAndBlur(state.downloading), + ) + } else { + Text( + text = "No release found! Check the update URL.", ) } - if (isDownloading) { + if (state.downloading) { + val bytesDownloaded by viewModel.bytesDownloaded.collectAsState(0L) DownloadDialog( - contentLength = contentLength, + contentLength = state.contentLength, bytesDownloaded = bytesDownloaded, onDismissRequest = { viewModel.cancelDownload() diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/PinEntry.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/PinEntry.kt index f9f22591..ef97620c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/PinEntry.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/PinEntry.kt @@ -34,7 +34,6 @@ import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.ui.FontAwesome import com.github.damontecres.wholphin.ui.PreviewTvSpec import com.github.damontecres.wholphin.ui.components.BasicDialog -import com.github.damontecres.wholphin.ui.components.Button import com.github.damontecres.wholphin.ui.components.TextButton import com.github.damontecres.wholphin.ui.playback.isEnterKey import com.github.damontecres.wholphin.ui.theme.WholphinTheme diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/ServerList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/ServerList.kt index 84e4cea6..bd14c38c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/ServerList.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/ServerList.kt @@ -32,6 +32,7 @@ import androidx.tv.material3.Surface import androidx.tv.material3.Text import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.JellyfinServer +import com.github.damontecres.wholphin.ui.AppColors import com.github.damontecres.wholphin.ui.Cards import com.github.damontecres.wholphin.ui.components.CircularProgress import org.jellyfin.sdk.model.api.PublicSystemInfo @@ -97,6 +98,7 @@ fun rememberIdColor( @Composable fun ServerIconCard( server: JellyfinServer, + serverVersionSupported: ServerVersionSupported, connectionStatus: ServerConnectionStatus, isCurrentServer: Boolean, onClick: () -> Unit, @@ -166,8 +168,37 @@ fun ServerIconCard( contentAlignment = Alignment.Center, ) { // Show connection status indicator or server name/letter - when (connectionStatus) { - is ServerConnectionStatus.Success -> { + when { + connectionStatus is ServerConnectionStatus.Error || serverVersionSupported != ServerVersionSupported.SUPPORTED -> { + // Show warning icon with server letter below + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Icon( + imageVector = Icons.Default.Warning, + contentDescription = (connectionStatus as? ServerConnectionStatus.Error)?.message, + tint = + if (serverVersionSupported == ServerVersionSupported.NOT_SUPPORTED_IN_FUTURE) { + AppColors.GoldenYellow + } else { + MaterialTheme.colorScheme.errorContainer + }, + modifier = Modifier.size(cardSize * 0.3f), + ) + Text( + text = displayText, + style = + MaterialTheme.typography.titleLarge.copy( + fontWeight = FontWeight.Bold, + ), + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + ) + } + } + + connectionStatus is ServerConnectionStatus.Success -> { // Show server name/letter Text( @@ -181,35 +212,11 @@ fun ServerIconCard( ) } - ServerConnectionStatus.Pending -> { + connectionStatus == ServerConnectionStatus.Pending -> { CircularProgress( modifier = Modifier.size(cardSize * 0.4f), ) } - - is ServerConnectionStatus.Error -> { - // Show warning icon with server letter below - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - Icon( - imageVector = Icons.Default.Warning, - contentDescription = connectionStatus.message, - tint = MaterialTheme.colorScheme.errorContainer, - modifier = Modifier.size(cardSize * 0.3f), - ) - Text( - text = displayText, - style = - MaterialTheme.typography.titleLarge.copy( - fontWeight = FontWeight.Bold, - ), - color = MaterialTheme.colorScheme.onSurface, - textAlign = TextAlign.Center, - ) - } - } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerContent.kt index d975fc29..dd972f9b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerContent.kt @@ -1,7 +1,17 @@ package com.github.damontecres.wholphin.ui.setup +import android.Manifest +import android.content.res.Resources +import android.os.Build +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer @@ -30,12 +40,16 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRestorer +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.window.DialogProperties import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @@ -53,10 +67,13 @@ import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.TextButton import com.github.damontecres.wholphin.ui.dimAndBlur -import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.isNotNullOrBlank +import com.github.damontecres.wholphin.ui.rememberInt +import com.github.damontecres.wholphin.ui.serverSupportedMessage import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.LoadingState +import org.jellyfin.sdk.model.ServerVersion +import timber.log.Timber @Composable fun SwitchServerContent( @@ -85,8 +102,12 @@ private fun SwitchServerContentInternal( viewModel: SwitchServerViewModel, modifier: Modifier = Modifier, ) { + val resources = LocalResources.current + val focusRequesters = + remember(state.servers.size) { List(state.servers.size + 1) { FocusRequester() } } var showAddServer by remember { mutableStateOf(false) } var showDeleteDialog by remember { mutableStateOf<JellyfinServer?>(null) } + var focusedIndex by rememberInt(0) Box( modifier = modifier.dimAndBlur(showAddServer || showDeleteDialog != null), ) { @@ -125,8 +146,16 @@ private fun SwitchServerContentInternal( ) { val focusRequester = remember { FocusRequester() } val firstServerFocus = remember { FocusRequester() } - if (state.servers.isNotEmpty()) { - LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + LaunchedEffect(state.loading) { + val state = state + when (state.loading) { + is LoadingState.Error, + LoadingState.Loading, + LoadingState.Pending, + -> Unit + + LoadingState.Success -> focusRequesters.firstOrNull()?.tryRequestFocus() + } } LazyRow( horizontalArrangement = Arrangement.spacedBy(24.dp), @@ -141,6 +170,7 @@ private fun SwitchServerContentInternal( ServerIconCard( server = server.server, connectionStatus = server.status, + serverVersionSupported = server.versionSupported, isCurrentServer = false, // TODO: Determine current server if needed onClick = { when (server.status) { @@ -162,10 +192,10 @@ private fun SwitchServerContentInternal( }, allowDelete = true, modifier = - Modifier.ifElse( - index == 0, - Modifier.focusRequester(firstServerFocus), - ), + Modifier + .onFocusChanged { + if (it.isFocused) focusedIndex = index + }.focusRequester(focusRequesters[index]), ) } // Add Server card - always rightmost @@ -173,10 +203,10 @@ private fun SwitchServerContentInternal( AddServerCard( onClick = { showAddServer = true }, modifier = - Modifier.ifElse( - state.servers.isEmpty(), - Modifier.focusRequester(firstServerFocus), - ), + Modifier + .onFocusChanged { + if (it.isFocused) focusedIndex = -1 + }.focusRequester(focusRequesters.last()), ) } } @@ -191,6 +221,24 @@ private fun SwitchServerContentInternal( ) } + val errorMessage = + remember(resources, focusedIndex, state.servers) { + val server = state.servers.getOrNull(focusedIndex) + serverErrorMessage(server, resources) + } + AnimatedContent( + targetState = errorMessage, + transitionSpec = { + slideInVertically { it / 2 } togetherWith slideOutVertically { it } + }, + modifier = + Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter), + ) { message -> + SwitchErrorMessage(message ?: "") + } + // Delete server dialog showDeleteDialog?.let { server -> DialogPopup( @@ -223,191 +271,242 @@ private fun SwitchServerContentInternal( } if (showAddServer) { - var showEnterAddress by remember { mutableStateOf(false) } + AddServerDialog( + onDismissRequest = { showAddServer = false }, + viewModel = viewModel, + ) + } + } +} - LaunchedEffect(Unit) { - viewModel.clearAddServerState() - if (!showEnterAddress) { - viewModel.discoverServers() - } +@Composable +private fun AddServerDialog( + onDismissRequest: () -> Unit, + viewModel: SwitchServerViewModel, +) { + val state by viewModel.state.collectAsState() + var showEnterAddress by remember { mutableStateOf(false) } + + val permissionLauncher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission(), + ) { isGranted -> + if (isGranted) { + viewModel.discoverServers() + } else { + showEnterAddress = true } + } - // Filter out duplicates within the discovered servers list (same URL appearing multiple times) - val filteredDiscoveredServers = - remember(state.discoveredServers) { - val seenUrls = mutableSetOf<String>() - state.discoveredServers.filter { server -> - val normalizedUrl = server.url.lowercase().trim() - if (normalizedUrl in seenUrls) { - false // Duplicate, filter it out - } else { - seenUrls.add(normalizedUrl) - true // First occurrence, keep it + LaunchedEffect(Unit) { + viewModel.clearAddServerState() + if (!showEnterAddress) { + if (viewModel.hasPermission) { + viewModel.discoverServers() + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.CINNAMON_BUN) { + permissionLauncher.launch(Manifest.permission.ACCESS_LOCAL_NETWORK) + } else { + Timber.w( + "No ACCESS_LOCAL_NETWORK permission, but API <%s: %s", + Build.VERSION_CODES.CINNAMON_BUN, + Build.VERSION.SDK_INT, + ) + } + } + } + + val buttonFocusRequester = remember { FocusRequester() } + + // Default focus to first discovered server if available + LaunchedEffect(showEnterAddress) { + if (!showEnterAddress) { + buttonFocusRequester.tryRequestFocus() + } + } + + BasicDialog( + onDismissRequest = { + showEnterAddress = false + viewModel.clearAddServerState() + onDismissRequest.invoke() + }, + properties = DialogProperties(usePlatformDefaultWidth = false), + elevation = 10.dp, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = + Modifier + .padding(16.dp) + .fillMaxWidth(.4f), + ) { + if (!showEnterAddress) { + // Show discovered servers first + Text( + text = stringResource(R.string.discovered_servers), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + + if (state.discoveredServers.isEmpty()) { + Text( + text = stringResource(R.string.searching), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } else { + LazyColumn( + modifier = + Modifier + .fillMaxWidth() + .heightIn(max = 300.dp), + ) { + items( + state.discoveredServers.size, + key = { state.discoveredServers[it].url }, + ) { index -> + val server = state.discoveredServers[index] + val focusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { if (index == 0) focusRequester.tryRequestFocus() } + + ListItem( + enabled = true, + selected = false, + headlineContent = { + Text( + text = + server.name?.ifBlank { null } + ?: server.url, + style = MaterialTheme.typography.bodyLarge, + ) + }, + supportingContent = { + Text( + text = server.url, + style = MaterialTheme.typography.bodyMedium, + ) + }, + onClick = { + viewModel.addServer(server.url, showToast = true) + }, + modifier = Modifier.focusRequester(focusRequester), + ) } } } - val firstDiscoveredServerFocusRequester = remember { FocusRequester() } - - // Default focus to first discovered server if available - LaunchedEffect(filteredDiscoveredServers.isNotEmpty(), showEnterAddress) { - if (!showEnterAddress && filteredDiscoveredServers.isNotEmpty()) { - firstDiscoveredServerFocusRequester.tryRequestFocus() + TextButton( + onClick = { + showEnterAddress = true + }, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) { + Text(text = stringResource(R.string.enter_server_address)) } - } + } else { + // Show enter server address form + val addServerState = state.addServerState + var url by remember { mutableStateOf("") } + val submit = { + viewModel.addServer(url, showToast = false) + } + val textBoxFocusRequester = remember { FocusRequester() } - BasicDialog( - onDismissRequest = { - showAddServer = false - showEnterAddress = false + LaunchedEffect(Unit) { + textBoxFocusRequester.tryRequestFocus() + } + LaunchedEffect(url) { viewModel.clearAddServerState() - }, - properties = DialogProperties(usePlatformDefaultWidth = false), - elevation = 10.dp, - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp), + } + + Text( + text = stringResource(R.string.enter_server_url), + ) + EditTextBox( + value = url, + onValueChange = { url = it }, + keyboardOptions = + KeyboardOptions( + capitalization = KeyboardCapitalization.None, + autoCorrectEnabled = false, + keyboardType = KeyboardType.Uri, + imeAction = ImeAction.Go, + ), + keyboardActions = + KeyboardActions( + onGo = { submit.invoke() }, + ), modifier = Modifier - .padding(16.dp) - .fillMaxWidth(.4f), + .testTag("server_url_text") + .focusRequester(textBoxFocusRequester) + .fillMaxWidth(), + ) + when (val st = addServerState) { + is LoadingState.Error -> { + Text( + text = + st.message ?: st.exception?.localizedMessage + ?: "An error occurred", + color = MaterialTheme.colorScheme.error, + ) + } + + else -> {} + } + TextButton( + onClick = { submit.invoke() }, + enabled = url.isNotNullOrBlank() && addServerState == LoadingState.Pending, + modifier = Modifier, ) { - if (!showEnterAddress) { - // Show discovered servers first - Text( - text = stringResource(R.string.discovered_servers), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - ) - - if (filteredDiscoveredServers.isEmpty() && state.discoveredServers.isEmpty()) { - Text( - text = stringResource(R.string.searching), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - ) - } else if (filteredDiscoveredServers.isEmpty()) { - Text( - text = stringResource(R.string.no_servers_found), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - ) - } else { - LazyColumn( - modifier = - Modifier - .fillMaxWidth() - .heightIn(max = 300.dp), - ) { - items( - filteredDiscoveredServers.size, - key = { filteredDiscoveredServers[it].url }, - ) { index -> - val server = filteredDiscoveredServers[index] - val focusRequester = - if (index == 0) { - firstDiscoveredServerFocusRequester - } else { - remember { FocusRequester() } - } - - ListItem( - enabled = true, - selected = false, - headlineContent = { - Text( - text = - server.name?.ifBlank { null } - ?: server.url, - style = MaterialTheme.typography.bodyLarge, - ) - }, - supportingContent = { - Text( - text = server.url, - style = MaterialTheme.typography.bodyMedium, - ) - }, - onClick = { - viewModel.addServer(server.url) - }, - modifier = Modifier.focusRequester(focusRequester), - ) - } - } - } - - TextButton( - onClick = { - showEnterAddress = true - }, - modifier = Modifier.align(Alignment.CenterHorizontally), - ) { - Text(text = stringResource(R.string.enter_server_address)) - } + if (addServerState == LoadingState.Loading) { + CircularProgress(Modifier.size(32.dp)) } else { - // Show enter server address form - val addServerState = state.addServerState - var url by remember { mutableStateOf("") } - val submit = { - viewModel.addServer(url) - } - val textBoxFocusRequester = remember { FocusRequester() } - - LaunchedEffect(Unit) { - textBoxFocusRequester.tryRequestFocus() - } - - Text( - text = stringResource(R.string.enter_server_url), - ) - EditTextBox( - value = url, - onValueChange = { url = it }, - keyboardOptions = - KeyboardOptions( - capitalization = KeyboardCapitalization.None, - autoCorrectEnabled = false, - keyboardType = KeyboardType.Uri, - imeAction = ImeAction.Go, - ), - keyboardActions = - KeyboardActions( - onGo = { submit.invoke() }, - ), - modifier = - Modifier - .testTag("server_url_text") - .focusRequester(textBoxFocusRequester) - .fillMaxWidth(), - ) - when (val st = addServerState) { - is LoadingState.Error -> { - Text( - text = - st.message ?: st.exception?.localizedMessage - ?: "An error occurred", - color = MaterialTheme.colorScheme.error, - ) - } - - else -> {} - } - TextButton( - onClick = { submit.invoke() }, - enabled = url.isNotNullOrBlank() && addServerState == LoadingState.Pending, - modifier = Modifier, - ) { - if (addServerState == LoadingState.Loading) { - CircularProgress(Modifier.size(32.dp)) - } else { - Text(text = stringResource(R.string.submit)) - } - } + Text(text = stringResource(R.string.submit)) } } } } } } + +fun serverErrorMessage( + server: ServerState?, + resources: Resources, +): String? = + if (server == null || server.status == ServerConnectionStatus.Pending) { + null + } else if (server.status is ServerConnectionStatus.Success) { + resources.serverSupportedMessage( + server.versionSupported, + server.status.systemInfo.version + ?.let { ServerVersion.fromString(it) }, + ) + } else if (server.status is ServerConnectionStatus.Error) { + server.status.message + } else { + resources.serverSupportedMessage( + server.versionSupported, + null, + ) + } + +@Composable +fun BoxScope.SwitchErrorMessage(message: String?) { + message?.let { + Text( + text = message, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + modifier = + Modifier + .fillMaxWidth() + .padding(start = 32.dp, end = 32.dp, bottom = 32.dp) + .align(Alignment.BottomCenter), + ) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerViewModel.kt index 9c28aff8..00beccb3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerViewModel.kt @@ -1,10 +1,15 @@ package com.github.damontecres.wholphin.ui.setup +import android.Manifest import android.content.Context +import android.content.pm.PackageManager +import android.os.Build import android.widget.Toast +import androidx.core.content.ContextCompat import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.WholphinApplication import com.github.damontecres.wholphin.data.JellyfinServerDao import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.JellyfinServer @@ -21,9 +26,11 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import org.jellyfin.sdk.Jellyfin import org.jellyfin.sdk.api.client.HttpClientOptions +import org.jellyfin.sdk.api.client.exception.TimeoutException import org.jellyfin.sdk.api.client.extensions.systemApi import org.jellyfin.sdk.discovery.RecommendedServerInfoScore import org.jellyfin.sdk.discovery.RecommendedServerIssue +import org.jellyfin.sdk.model.ServerVersion import org.jellyfin.sdk.model.serializer.toUUID import org.jellyfin.sdk.model.serializer.toUUIDOrNull import timber.log.Timber @@ -43,6 +50,17 @@ class SwitchServerViewModel private val _state = MutableStateFlow(SwitchServerState()) val state: StateFlow<SwitchServerState> = _state + val hasPermission: Boolean + get() = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.CINNAMON_BUN) { + ContextCompat.checkSelfPermission( + context, + Manifest.permission.ACCESS_LOCAL_NETWORK, + ) == PackageManager.PERMISSION_GRANTED + } else { + true + } + fun clearAddServerState() { _state.update { it.copy(addServerState = LoadingState.Pending) } } @@ -73,11 +91,32 @@ class SwitchServerViewModel server: JellyfinServer, result: ServerConnectionStatus, ) { + val supported = + if (result is ServerConnectionStatus.Success) { + val serverVersion = + result.systemInfo.version?.let { ServerVersion.fromString(it) } + if (serverVersion == null || serverVersion < WholphinApplication.minimumServerVersion) { + ServerVersionSupported.NOT_SUPPORTED + } else if (serverVersion < WholphinApplication.newMinimumServerVersion) { + ServerVersionSupported.NOT_SUPPORTED_IN_FUTURE + } else { + ServerVersionSupported.SUPPORTED + } + } else { + ServerVersionSupported.UNKNOWN + } _state.update { val servers = it.servers.toMutableList().apply { val index = indexOfFirst { it.server.id == server.id } - set(index, get(index).copy(server = server, status = result)) + set( + index, + get(index).copy( + server = server, + status = result, + versionSupported = supported, + ), + ) } it.copy(servers = servers) } @@ -113,6 +152,14 @@ class SwitchServerViewModel .getPublicSystemInfo() .content ServerConnectionStatus.Success(systemInfo) + } catch (ex: TimeoutException) { + Timber.w(ex, "timeout checking server ${server.url}") + val cause = ex.cause + if (cause is java.net.ConnectException) { + ServerConnectionStatus.Error("${cause.localizedMessage}") + } else { + ServerConnectionStatus.Error(ex.localizedMessage) + } } catch (ex: Exception) { Timber.w(ex, "Error checking server ${server.url}") ServerConnectionStatus.Error(ex.localizedMessage) @@ -134,12 +181,15 @@ class SwitchServerViewModel serverRepository.addAndChangeServer(updatedServer) navigationManager.navigateTo(SetupDestination.UserList(updatedServer)) } else if (result is ServerConnectionStatus.Error) { - showToast(context, "Error connecting: $${result.message}") + showToast(context, "Error connecting: ${result.message}") } } } - fun addServer(inputUrl: String) { + fun addServer( + inputUrl: String, + showToast: Boolean, + ) { _state.update { it.copy(addServerState = LoadingState.Loading) } viewModelScope.launchIO { try { @@ -165,7 +215,9 @@ class SwitchServerViewModel _state.update { it.copy(addServerState = LoadingState.Success) } navigationManager.navigateTo(SetupDestination.UserList(server)) } else { - _state.update { it.copy(addServerState = LoadingState.Error("Server returned invalid response")) } + val msg = "Server returned invalid response" + _state.update { it.copy(addServerState = LoadingState.Error(msg)) } + if (showToast) showToast(context, msg) } } else { Timber.w("Error connecting with %s: %s", inputUrl, scores) @@ -194,10 +246,12 @@ class SwitchServerViewModel } val message = "Error, tried addresses:\n$errors" _state.update { it.copy(addServerState = LoadingState.Error(message)) } + if (showToast) showToast(context, message) } } catch (ex: Exception) { Timber.w(ex, "Error creating API for $inputUrl") _state.update { it.copy(addServerState = LoadingState.Error(exception = ex)) } + if (showToast) showToast(context, "Error: ${ex.localizedMessage}") } } } @@ -226,6 +280,9 @@ class SwitchServerViewModel .toMutableList() .apply { add(jellyfinServer) + }.distinctBy { + // Filter out duplicates within the discovered servers list (same URL appearing multiple times) + it.url.lowercase().trim() }, ) } @@ -245,4 +302,12 @@ data class ServerState( val server: JellyfinServer, val status: ServerConnectionStatus = ServerConnectionStatus.Pending, val quickConnect: Boolean = false, + val versionSupported: ServerVersionSupported = ServerVersionSupported.UNKNOWN, ) + +enum class ServerVersionSupported { + SUPPORTED, + NOT_SUPPORTED, + UNKNOWN, + NOT_SUPPORTED_IN_FUTURE, +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserContent.kt index 8b2245ce..cfe70890 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserContent.kt @@ -16,7 +16,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -26,6 +25,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardCapitalization @@ -49,6 +49,7 @@ import com.github.damontecres.wholphin.ui.components.TextButton import com.github.damontecres.wholphin.ui.dimAndBlur import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.serverSupportedMessage import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.LoadingState import kotlinx.coroutines.launch @@ -70,15 +71,39 @@ fun SwitchUserContent( val state by viewModel.state.collectAsState() - val currentUser by viewModel.serverRepository.currentUser.observeAsState() + val currentUser by viewModel.serverRepository.currentUserFlow.collectAsState(null) var showAddUser by remember { mutableStateOf(false) } - var username by remember(server) { mutableStateOf("") } + var addUser by remember(server) { mutableStateOf<JellyfinUser?>(null) } + var username by remember(addUser) { mutableStateOf(addUser?.name ?: "") } fun showAddUserDialog(user: JellyfinUser?) { - username = user?.name ?: "" + addUser = user showAddUser = true } + fun hideAddUserDialog() { + addUser = null + showAddUser = false + } + + fun trySwitchUser(user: JellyfinUser) { + val result = viewModel.trySwitchUser(user) + scope.launch { + when (val r = result.await()) { + is SwitchUserResult.Error -> { + Toast.makeText(context, r.errorMessage, Toast.LENGTH_LONG).show() + if (r.showLogin) { + showAddUserDialog(user) + } + } + + SwitchUserResult.Success -> { + // no-op, view model will navigate + } + } + } + } + LaunchedEffect(state.switchUserState) { if (!showAddUser) { when (val s = state.switchUserState) { @@ -136,18 +161,12 @@ fun SwitchUserContent( users = state.users, currentUser = currentUser, onSwitchUser = { user -> - if (user.accessToken == null) { + if (user.accessToken == null || user.requireLogin) { showAddUserDialog(user) } else if (user.hasPin) { switchUserWithPin = user } else { - val result = viewModel.trySwitchUser(user) - scope.launch { - result.await()?.let { - Toast.makeText(context, it, Toast.LENGTH_LONG).show() - showAddUserDialog(user) - } - } + trySwitchUser(user) } }, onAddUser = { @@ -164,6 +183,15 @@ fun SwitchUserContent( modifier = Modifier.fillMaxWidth(), ) } + val resources = LocalResources.current + val message = + remember(resources, state.serverVersionSupported) { + resources.serverSupportedMessage( + state.serverVersionSupported, + state.serverVersion, + ) + } + SwitchErrorMessage(message) } } } @@ -174,13 +202,13 @@ fun SwitchUserContent( viewModel.clearSwitchUserState() viewModel.resetAttempts() if (useQuickConnect) { - viewModel.initiateQuickConnect(server) + viewModel.initiateQuickConnect(server, addUser) } } BasicDialog( onDismissRequest = { viewModel.cancelQuickConnect() - showAddUser = false + hideAddUserDialog() }, properties = DialogProperties( @@ -244,6 +272,7 @@ fun SwitchUserContent( val onSubmit = { viewModel.login( server, + addUser, username, password, ) @@ -357,14 +386,7 @@ fun SwitchUserContent( }, onTextChange = { if (it == user.pin) { - val result = viewModel.trySwitchUser(user) - scope.launch { - result.await()?.let { - Toast.makeText(context, it, Toast.LENGTH_LONG).show() - showAddUserDialog(user) - switchUserWithPin = null - } - } + trySwitchUser(user) } }, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserViewModel.kt index 9a1f06a2..b11b0b19 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserViewModel.kt @@ -1,7 +1,10 @@ package com.github.damontecres.wholphin.ui.setup +import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.WholphinApplication import com.github.damontecres.wholphin.data.JellyfinServerDao import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.JellyfinServer @@ -13,13 +16,14 @@ import com.github.damontecres.wholphin.services.SetupNavigationManager import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.util.LoadingState +import com.github.damontecres.wholphin.util.WholphinDispatchers import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Deferred -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.delay @@ -33,7 +37,9 @@ import org.jellyfin.sdk.api.client.exception.InvalidStatusException import org.jellyfin.sdk.api.client.extensions.authenticateUserByName import org.jellyfin.sdk.api.client.extensions.imageApi import org.jellyfin.sdk.api.client.extensions.quickConnectApi +import org.jellyfin.sdk.api.client.extensions.systemApi import org.jellyfin.sdk.api.client.extensions.userApi +import org.jellyfin.sdk.model.ServerVersion import org.jellyfin.sdk.model.api.QuickConnectDto import org.jellyfin.sdk.model.api.QuickConnectResult import timber.log.Timber @@ -43,6 +49,7 @@ import kotlin.time.Duration.Companion.seconds class SwitchUserViewModel @AssistedInject constructor( + @param:ApplicationContext private val context: Context, val jellyfin: Jellyfin, val serverRepository: ServerRepository, val serverDao: JellyfinServerDao, @@ -78,10 +85,13 @@ class SwitchUserViewModel _state.update { SwitchUserState() } try { val serverUsers = getUsers() + val (serverVersion, supported) = checkServerVersion() _state.update { it.copy( loading = LoadingState.Success, users = serverUsers, + serverVersion = serverVersion, + serverVersionSupported = supported, ) } } catch (ex: Exception) { @@ -118,26 +128,45 @@ class SwitchUserViewModel } } - fun trySwitchUser(user: JellyfinUser): Deferred<String?> = - viewModelScope.async(Dispatchers.IO) { + fun trySwitchUser(user: JellyfinUser): Deferred<SwitchUserResult> = + viewModelScope.async(WholphinDispatchers.IO) { try { val current = serverRepository.changeUser(server, user) + navigationManager.reloadHome() setupNavigationManager.navigateTo(SetupDestination.AppContent(current)) - null + SwitchUserResult.Success } catch (ex: InvalidStatusException) { - if (ex.status == 401) { - "Credentials expired, please login in again" - } else { - ex.localizedMessage + when (ex.status) { + 401 -> { + Timber.w(ex, "Error switching user 401") + SwitchUserResult.Error( + context.getString(R.string.login_credentials_expired), + true, + ) + } + + 403 -> { + Timber.w(ex, "Error switching user 403") + SwitchUserResult.Error( + context.getString(R.string.login_not_authorized), + false, + ) + } + + else -> { + Timber.e(ex, "Error switching user") + SwitchUserResult.Error(ex.localizedMessage, true) + } } } catch (ex: Exception) { Timber.e(ex, "Error switching user") - ex.localizedMessage + SwitchUserResult.Error(ex.localizedMessage, true) } } fun login( server: JellyfinServer, + existingUser: JellyfinUser?, username: String, password: String, ) { @@ -149,7 +178,8 @@ class SwitchUserViewModel username = username, password = password, ) - val current = serverRepository.changeUser(server.url, authenticationResult) + val current = + serverRepository.changeUser(server.url, authenticationResult, existingUser) setupNavigationManager.navigateTo(SetupDestination.AppContent(current)) } catch (ex: Exception) { Timber.e(ex, "Error logging in user") @@ -162,7 +192,10 @@ class SwitchUserViewModel } } - fun initiateQuickConnect(server: JellyfinServer) { + fun initiateQuickConnect( + server: JellyfinServer, + existingUser: JellyfinUser?, + ) { quickConnectJob?.cancel() quickConnectJob = viewModelScope.launchIO { @@ -187,7 +220,12 @@ class SwitchUserViewModel val authenticationResult by api.userApi.authenticateWithQuickConnect( QuickConnectDto(secret = quickConnectStatus.secret), ) - val current = serverRepository.changeUser(server.url, authenticationResult) + val current = + serverRepository.changeUser( + server.url, + authenticationResult, + existingUser, + ) setupNavigationManager.navigateTo(SetupDestination.AppContent(current)) } catch (_: CancellationException) { // no-op, user may have canceled @@ -224,7 +262,7 @@ class SwitchUserViewModel } private suspend fun getUsers(): List<JellyfinUserAndImage> = - withContext(Dispatchers.IO) { + withContext(WholphinDispatchers.IO) { val api = jellyfin.createApi(server.url) val knownUsers = serverDao @@ -262,6 +300,21 @@ class SwitchUserViewModel knownUsers + publicUsers } + private suspend fun checkServerVersion(): Pair<ServerVersion?, ServerVersionSupported> { + val api = jellyfin.createApi(server.url) + val systemInfo by api.systemApi.getPublicSystemInfo() + val serverVersion = systemInfo.version?.let { ServerVersion.fromString(it) } + return if (serverVersion == null) { + serverVersion to ServerVersionSupported.UNKNOWN + } else if (serverVersion < WholphinApplication.minimumServerVersion) { + serverVersion to ServerVersionSupported.NOT_SUPPORTED + } else if (serverVersion < WholphinApplication.newMinimumServerVersion) { + serverVersion to ServerVersionSupported.NOT_SUPPORTED_IN_FUTURE + } else { + serverVersion to ServerVersionSupported.SUPPORTED + } + } + private fun setError( msg: String? = null, ex: Exception? = null, @@ -290,4 +343,18 @@ data class SwitchUserState( // LoadingState for while adding/switching users val switchUserState: LoadingState = LoadingState.Pending, val loginAttempts: Int = 0, + val serverVersion: ServerVersion? = null, + val serverVersionSupported: ServerVersionSupported = ServerVersionSupported.UNKNOWN, ) + +/** + * Result of trying to switch users via [SwitchUserViewModel.trySwitchUser] + */ +sealed interface SwitchUserResult { + data object Success : SwitchUserResult + + data class Error( + val errorMessage: String?, + val showLogin: Boolean, + ) : SwitchUserResult +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/UserList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/UserList.kt index ddc171c9..4d3ff8d3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/UserList.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/UserList.kt @@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add @@ -88,19 +89,24 @@ fun UserList( modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center, ) { - val focusRequester = remember { FocusRequester() } - val firstFocusRequester = remember { FocusRequester() } - if (users.isNotEmpty()) { - LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + val listState = rememberLazyListState() + val initialFocusRequester = remember { FocusRequester() } + val initialIndex = + users.indexOfFirst { it.user.id == currentUser?.id }.takeIf { it >= 0 } ?: 0 + LaunchedEffect(users, currentUser?.id) { + if (users.isNotEmpty()) { + listState.scrollToItem(initialIndex) + initialFocusRequester.tryRequestFocus() + } } LazyRow( + state = listState, horizontalArrangement = Arrangement.spacedBy(24.dp), // Spacing to accommodate 20% scale contentPadding = PaddingValues(horizontal = 48.dp, vertical = 16.dp), // Increased padding to accommodate 20% scale modifier = Modifier .wrapContentWidth() - .focusRestorer(firstFocusRequester) - .focusRequester(focusRequester), + .focusRestorer(initialFocusRequester), ) { itemsIndexed(users) { index, user -> UserIconCard( @@ -108,7 +114,7 @@ fun UserList( isCurrentUser = user.user.id == currentUser?.id, onClick = { onSwitchUser.invoke(user.user) }, onLongClick = { showDeleteDialog = user }, - modifier = if (index == 0) Modifier.focusRequester(firstFocusRequester) else Modifier, + modifier = if (index == initialIndex) Modifier.focusRequester(initialFocusRequester) else Modifier, ) } // Add User card - always rightmost diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServer.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServer.kt index 5fa8c667..5faccc05 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServer.kt @@ -30,6 +30,7 @@ import androidx.compose.ui.unit.dp import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.seerrUrlPrefill import com.github.damontecres.wholphin.ui.PreviewTvSpec import com.github.damontecres.wholphin.ui.components.Button import com.github.damontecres.wholphin.ui.components.EditTextBox @@ -41,6 +42,7 @@ import com.github.damontecres.wholphin.util.LoadingState @Composable fun AddSeerrServerApiKey( + initialUrl: String, onSubmit: (url: String, apiKey: String) -> Unit, status: LoadingState, modifier: Modifier = Modifier, @@ -54,12 +56,18 @@ fun AddSeerrServerApiKey( .padding(16.dp) .wrapContentSize(), ) { - var url by remember { mutableStateOf("") } + var url by remember { mutableStateOf(seerrUrlPrefill(initialUrl)) } var apiKey by remember { mutableStateOf("") } val focusRequester = remember { FocusRequester() } val passwordFocusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + LaunchedEffect(Unit) { + if (url.isBlank()) { + focusRequester.tryRequestFocus() + } else { + passwordFocusRequester.tryRequestFocus() + } + } Text( text = stringResource(R.string.enter_url_api_key), style = MaterialTheme.typography.titleMedium, @@ -152,6 +160,7 @@ fun AddSeerrServerApiKey( @Composable fun AddSeerrServerUsername( + initialUrl: String, onSubmit: (url: String, username: String, password: String) -> Unit, username: String, status: LoadingState, @@ -166,14 +175,22 @@ fun AddSeerrServerUsername( .padding(16.dp) .wrapContentSize(), ) { - var url by remember { mutableStateOf("") } + var url by remember { mutableStateOf(seerrUrlPrefill(initialUrl)) } var username by remember { mutableStateOf(username) } var password by remember { mutableStateOf("") } val focusRequester = remember { FocusRequester() } val usernameFocusRequester = remember { FocusRequester() } val passwordFocusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + LaunchedEffect(Unit) { + val focusRequesterToUse = + when { + url.isNotNullOrBlank() && username.isNotNullOrBlank() -> passwordFocusRequester + url.isNotNullOrBlank() -> usernameFocusRequester + else -> focusRequester + } + focusRequesterToUse.tryRequestFocus() + } Text( text = stringResource(R.string.username_or_password), style = MaterialTheme.typography.titleMedium, @@ -317,6 +334,7 @@ fun AddSeerrServerUsername( private fun AddSeerrServerUsernamePreview() { WholphinTheme { AddSeerrServerUsername( + initialUrl = "", onSubmit = { string: String, string1: String, string2: String -> }, username = "test", status = LoadingState.Pending, 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 d84b98db..5be93b83 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServerDialog.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServerDialog.kt @@ -1,27 +1,161 @@ package com.github.damontecres.wholphin.ui.setup.seerr +import android.widget.Toast +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.window.DialogProperties +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.SeerrAuthMethod +import com.github.damontecres.wholphin.data.model.SeerrServer +import com.github.damontecres.wholphin.data.model.SeerrUser +import com.github.damontecres.wholphin.services.CurrentSeerr +import com.github.damontecres.wholphin.services.SeerrState import com.github.damontecres.wholphin.ui.components.BasicDialog +import com.github.damontecres.wholphin.ui.components.ConfirmDialog import com.github.damontecres.wholphin.ui.components.DialogItem import com.github.damontecres.wholphin.ui.components.DialogParams import com.github.damontecres.wholphin.ui.components.DialogPopup +import com.github.damontecres.wholphin.ui.components.LoadingPage +import com.github.damontecres.wholphin.ui.components.ScrollableDialog +import com.github.damontecres.wholphin.ui.preferences.rememberSeerrDialogBody import com.github.damontecres.wholphin.util.LoadingState +@Composable +fun SeerrDialog( + seerrDialogMode: SeerrDialogMode, + seerrConnection: SeerrState, + onDismissRequest: () -> Unit, + seerrVm: SwitchSeerrViewModel = hiltViewModel(), +) { + val context = LocalContext.current + when (val mode = seerrDialogMode) { + is SeerrDialogMode.Remove -> { + val body = rememberSeerrDialogBody(mode.current.user, mode.current.server, seerrConnection.pluginProvided) + if (seerrConnection.pluginProvided) { + ScrollableDialog( + onDismissRequest = onDismissRequest, + ) { + item { + Text(body) + } + } + } else { + ConfirmDialog( + title = stringResource(R.string.remove_seerr_server), + body = body, + onCancel = onDismissRequest, + onConfirm = { + seerrVm.removeServer() + onDismissRequest.invoke() + }, + elevation = 3.dp, + ) + } + } + + SeerrDialogMode.Add -> { + val currentUser by seerrVm.currentUser.collectAsState(null) + val state by seerrVm.state.collectAsState() + val serverAddedMessage = stringResource(R.string.seerr_server_added) + LaunchedEffect(state.serverConnectionStatus) { + if (state.serverConnectionStatus == LoadingState.Success) { + Toast.makeText(context, serverAddedMessage, Toast.LENGTH_SHORT).show() + onDismissRequest.invoke() + } + } + when (state.loading) { + is LoadingState.Error, + LoadingState.Loading, + LoadingState.Pending, + -> { + BasicDialog( + onDismissRequest = onDismissRequest, + ) { + LoadingPage(Modifier.padding(16.dp)) + } + } + + LoadingState.Success -> { + AddSeerServerDialog( + initialUrl = state.initialUrl, + currentUsername = currentUser?.name, + status = state.serverConnectionStatus, + onSubmit = seerrVm::submitServer, + onResetStatus = seerrVm::resetStatus, + onDismissRequest = onDismissRequest, + ) + } + } + } + + is SeerrDialogMode.Error -> { + val body = rememberSeerrDialogBody(mode.user, mode.server, seerrConnection.pluginProvided, mode.ex) + if (seerrConnection.pluginProvided) { + ConfirmDialog( + title = stringResource(R.string.error), + body = body, + onCancel = onDismissRequest, + onConfirm = { + seerrVm.retryServerFromPlugin() + onDismissRequest.invoke() + }, + bodyColor = MaterialTheme.colorScheme.error, + elevation = 3.dp, + confirmStringRes = R.string.retry, + ) + } else { + ConfirmDialog( + title = stringResource(R.string.remove_seerr_server), + body = body, + onCancel = onDismissRequest, + onConfirm = { + seerrVm.removeServer() + onDismissRequest.invoke() + }, + bodyColor = MaterialTheme.colorScheme.error, + elevation = 3.dp, + ) + } + } + + SeerrDialogMode.None -> {} + } +} + +sealed interface SeerrDialogMode { + data object None : SeerrDialogMode + + data object Add : SeerrDialogMode + + data class Remove( + val current: CurrentSeerr, + ) : SeerrDialogMode + + data class Error( + val server: SeerrServer, + val user: SeerrUser, + val ex: Exception, + ) : SeerrDialogMode +} + @Composable fun AddSeerServerDialog( currentUsername: String?, + initialUrl: String?, status: LoadingState, onSubmit: (url: String, username: String, passwordOrApiKey: String, method: SeerrAuthMethod) -> Unit, onResetStatus: () -> Unit, @@ -42,6 +176,7 @@ fun AddSeerServerDialog( properties = DialogProperties(usePlatformDefaultWidth = false), ) { AddSeerrServerUsername( + initialUrl = initialUrl ?: "", onSubmit = { url, username, password -> onSubmit.invoke(url, username, password, auth) }, @@ -57,6 +192,7 @@ fun AddSeerServerDialog( onDismissRequest = { authMethod = null }, ) { AddSeerrServerApiKey( + initialUrl = initialUrl ?: "", onSubmit = { url, apiKey -> onSubmit.invoke(url, "", apiKey, SeerrAuthMethod.API_KEY) }, @@ -89,10 +225,6 @@ fun ChooseSeerrLoginType( title = stringResource(R.string.seerr_login), items = listOf( - DialogItem( - text = stringResource(R.string.api_key), - onClick = { onChoose.invoke(SeerrAuthMethod.API_KEY) }, - ), DialogItem( text = stringResource(R.string.seerr_jellyfin_user), onClick = { onChoose.invoke(SeerrAuthMethod.JELLYFIN) }, @@ -101,6 +233,10 @@ fun ChooseSeerrLoginType( text = stringResource(R.string.seerr_local_user), onClick = { onChoose.invoke(SeerrAuthMethod.LOCAL) }, ), + DialogItem( + text = stringResource(R.string.api_key), + onClick = { onChoose.invoke(SeerrAuthMethod.API_KEY) }, + ), ), ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/SwitchSeerrViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/SwitchSeerrViewModel.kt index 7c05194a..2fde3eae 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/SwitchSeerrViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/SwitchSeerrViewModel.kt @@ -3,11 +3,13 @@ package com.github.damontecres.wholphin.ui.setup.seerr import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.api.seerr.infrastructure.ClientException import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.SeerrAuthMethod import com.github.damontecres.wholphin.services.SeerrServerRepository import com.github.damontecres.wholphin.services.SeerrService +import com.github.damontecres.wholphin.services.ServerPluginApi import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.util.LoadingState @@ -15,6 +17,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import jakarta.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl @@ -28,11 +31,29 @@ class SwitchSeerrViewModel private val seerrServerRepository: SeerrServerRepository, private val seerrService: SeerrService, private val serverRepository: ServerRepository, + private val serverPluginApi: ServerPluginApi, ) : ViewModel() { - val currentUser = serverRepository.currentUser + val currentUser = serverRepository.currentUserFlow val currentSeerrServer = seerrServerRepository.currentServer - val serverConnectionStatus = MutableStateFlow<LoadingState>(LoadingState.Pending) + private val _state = MutableStateFlow<SwitchSeerrState>(SwitchSeerrState()) + val state: StateFlow<SwitchSeerrState> = _state + + init { + if (serverRepository.serverPluginInstalled.value) { + viewModelScope.launchIO { + try { + serverPluginApi.fetchSeerrSettings()?.serverUrl?.let { serverUrl -> + _state.update { it.copy(initialUrl = serverUrl) } + } + } catch (ex: Exception) { + Timber.e(ex, "Error fetching seerr settings") + } finally { + _state.update { it.copy(loading = LoadingState.Success) } + } + } + } + } fun submitServer( url: String, @@ -41,13 +62,21 @@ class SwitchSeerrViewModel authMethod: SeerrAuthMethod, ) { viewModelScope.launchIO { - serverConnectionStatus.update { LoadingState.Loading } + _state.update { it.copy(serverConnectionStatus = LoadingState.Loading) } val urls = try { createUrls(url) } catch (ex: IllegalArgumentException) { showToast(context, "Invalid URL") - serverConnectionStatus.update { LoadingState.Error("Invalid URL", ex) } + _state.update { + it.copy( + serverConnectionStatus = + LoadingState.Error( + "Invalid URL", + ex, + ), + ) + } return@launchIO } Timber.v("Urls to try: %s", urls) @@ -98,7 +127,7 @@ class SwitchSeerrViewModel ) } } - serverConnectionStatus.update { LoadingState.Success } + _state.update { it.copy(serverConnectionStatus = LoadingState.Success) } } else { val message = results @@ -107,7 +136,7 @@ class SwitchSeerrViewModel "$url - ${s?.localizedMessage}" }.joinToString("\n") showToast(context, "Could not connect") - serverConnectionStatus.update { LoadingState.Error(message) } + _state.update { it.copy(serverConnectionStatus = LoadingState.Error(message)) } } } } @@ -121,11 +150,31 @@ class SwitchSeerrViewModel } } + fun retryServerFromPlugin() { + viewModelScope.launchIO { + val settings = serverPluginApi.fetchSeerrSettings() + if (settings != null) { + val result = seerrServerRepository.usePluginProvidedServer(settings) + if (!result) { + showToast(context, context.getString(R.string.voice_error_server)) + } + } else { + seerrServerRepository.clear() + } + } + } + fun resetStatus() { - serverConnectionStatus.update { LoadingState.Pending } + _state.update { it.copy(serverConnectionStatus = LoadingState.Pending) } } } +data class SwitchSeerrState( + val loading: LoadingState = LoadingState.Pending, + val serverConnectionStatus: LoadingState = LoadingState.Pending, + val initialUrl: String? = null, +) + fun createUrls(url: String): List<HttpUrl> { val urls = mutableListOf<HttpUrl>() if (url.startsWith("http://") || url.startsWith("https://")) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/ImageDetailsHeader.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/ImageDetailsHeader.kt index 99675dc0..9ac3b6b1 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/ImageDetailsHeader.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/ImageDetailsHeader.kt @@ -24,7 +24,6 @@ import com.github.damontecres.wholphin.ui.components.OverviewText import com.github.damontecres.wholphin.ui.components.QuickDetails import com.github.damontecres.wholphin.ui.components.StreamLabel import com.github.damontecres.wholphin.ui.components.VideoStreamDetails -import com.github.damontecres.wholphin.ui.isNotNullOrBlank import org.jellyfin.sdk.model.api.MediaType @OptIn(UnstableApi::class) @@ -64,11 +63,7 @@ fun ImageDetailsHeader( modifier = Modifier.fillMaxWidth(.75f), ) } - if (image.image.ui.quickDetails - .isNotNullOrBlank() - ) { - QuickDetails(image.image.ui.quickDetails, null) - } + QuickDetails(image.image.ui.quickDetails, null) Row( horizontalArrangement = Arrangement.spacedBy(8.dp), ) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowPage.kt index faccb4cc..91dc2543 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowPage.kt @@ -16,7 +16,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -59,7 +58,6 @@ import coil3.request.ImageRequest import coil3.request.transitionFactory import coil3.size.Size import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.data.model.VideoFilter import com.github.damontecres.wholphin.ui.AppColors import com.github.damontecres.wholphin.ui.CrossFadeFactory import com.github.damontecres.wholphin.ui.components.ErrorMessage @@ -93,9 +91,9 @@ fun SlideshowPage( ), ) { val context = LocalContext.current - val loading by viewModel.loading.collectAsState() + val state by viewModel.state.collectAsState() - when (val st = loading) { + when (val st = state.loading) { is LoadingState.Error -> { ErrorMessage(st, modifier) } @@ -107,10 +105,10 @@ fun SlideshowPage( } LoadingState.Success -> { - val loadingState by viewModel.loadingState.observeAsState(ImageLoadingState.Loading) - val imageFilter by viewModel.imageFilter.observeAsState(VideoFilter()) - val position by viewModel.position.observeAsState(0) - val pager by viewModel.pager.observeAsState() + val loadingState = state.imageLoading + val imageFilter by viewModel.imageFilter.collectAsState() + val position = state.position + val pager = state.items // val imageState by viewModel.image.observeAsState() var zoomFactor by rememberSaveable { mutableFloatStateOf(1f) } @@ -150,7 +148,7 @@ fun SlideshowPage( label = "image_panY", ) - val slideshowState by viewModel.slideshow.collectAsState() + val slideshowState by viewModel.state.collectAsState() val slideshowActive by viewModel.slideshowActive.collectAsState(false) val focusRequester = remember { FocusRequester() } @@ -410,8 +408,11 @@ fun SlideshowPage( ImageRequest .Builder(LocalContext.current) .data(imageState.url) - .size(Size.ORIGINAL) - .transitionFactory(CrossFadeFactory(750.milliseconds)) + .apply { + // Fixes images noting being full size when resolutions change + // See: https://github.com/damontecres/Wholphin/pull/1089 + if (isZoomed) size(Size.ORIGINAL) + }.transitionFactory(CrossFadeFactory(750.milliseconds)) .useExistingImageAsPlaceholder(true) .build(), contentDescription = null, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowViewModel.kt index f9abe15d..8172b049 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowViewModel.kt @@ -3,12 +3,12 @@ package com.github.damontecres.wholphin.ui.slideshow import android.content.Context import android.widget.Toast import androidx.compose.runtime.Stable -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel -import androidx.lifecycle.map import androidx.lifecycle.viewModelScope import androidx.media3.common.Player +import coil3.imageLoader +import coil3.request.ImageRequest +import coil3.size.Size import com.github.damontecres.wholphin.data.ChosenStreams import com.github.damontecres.wholphin.data.PlaybackEffectDao import com.github.damontecres.wholphin.data.ServerRepository @@ -20,25 +20,25 @@ import com.github.damontecres.wholphin.preferences.PlayerBackend import com.github.damontecres.wholphin.services.ImageUrlService import com.github.damontecres.wholphin.services.PlayerFactory import com.github.damontecres.wholphin.services.ScreensaverService +import com.github.damontecres.wholphin.services.StreamChoiceReason +import com.github.damontecres.wholphin.services.StreamChoiceResult import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.ui.PhotoItemFields import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.onMain -import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.showToast -import com.github.damontecres.wholphin.ui.util.ThrottledLiveData import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.LoadingState +import com.github.damontecres.wholphin.util.WholphinDispatchers import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow @@ -87,28 +87,18 @@ class SlideshowViewModel /** * Whether slideshow mode is on or off */ - private val _slideshow = MutableStateFlow<SlideshowState>(SlideshowState(false, false)) - val slideshow: StateFlow<SlideshowState> = _slideshow + private val _state = MutableStateFlow(SlideshowState()) + val state: StateFlow<SlideshowState> = _state /** * Whether the slideshow is actively running meaning slideshow mode is ON and is currently NOT paused */ - val slideshowActive = slideshow.map { it.enabled && !it.paused } + val slideshowActive = state.map { it.enabled && !it.paused } var slideshowDelay by Delegates.notNull<Long>() - private val _pager = MutableLiveData<ApiRequestPager<GetItemsRequest>>() - val pager: LiveData<List<BaseItem?>> = _pager.map { it } - val position = MutableLiveData(0) - - private val _image = MutableLiveData<ImageState>() - val image: LiveData<ImageState> = _image - - val loading = MutableStateFlow<LoadingState>(LoadingState.Pending) - - val loadingState = MutableLiveData<ImageLoadingState>(ImageLoadingState.Loading) - private val _imageFilter = MutableLiveData(VideoFilter()) - val imageFilter = ThrottledLiveData(_imageFilter, 500L) + private val _imageFilter = MutableStateFlow(VideoFilter()) + val imageFilter: StateFlow<VideoFilter> = _imageFilter private var albumImageFilter = VideoFilter() @@ -126,7 +116,7 @@ class SlideshowViewModel val playerCreation = playerFactory.createVideoPlayer( backend = PlayerBackend.EXO_PLAYER, - appPreferences.playbackPreferences, + appPreferences, ) player = playerCreation.player player.addListener(this@SlideshowViewModel) @@ -152,14 +142,17 @@ class SlideshowViewModel sortOrder = listOf(slideshowSettings.sortAndDirection.direction), ), ) - serverRepository.currentUser.value?.let { user -> + serverRepository.currentUser?.let { user -> + val filter = - playbackEffectDao - .getPlaybackEffect( - user.rowId, - slideshowSettings.parentId, - BaseItemKind.PHOTO_ALBUM, - )?.videoFilter + slideshowSettings.parentId?.let { + playbackEffectDao + .getPlaybackEffect( + user.rowId, + slideshowSettings.parentId, + BaseItemKind.PHOTO_ALBUM, + )?.videoFilter + } if (filter != null) { Timber.v("Got filter for album %s", slideshowSettings.parentId) albumImageFilter = filter @@ -168,22 +161,26 @@ class SlideshowViewModel val pager = ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope) .init(slideshowSettings.index) - this@SlideshowViewModel._pager.setValueOnMain(pager) - loading.update { LoadingState.Success } + _state.update { + it.copy( + loading = LoadingState.Success, + items = pager, + ) + } updatePosition(slideshowSettings.index)?.join() if (slideshowSettings.startSlideshow) onMain { startSlideshow() } } catch (ex: Exception) { Timber.e(ex, "Error") - loading.update { LoadingState.Error(ex) } + _state.update { it.copy(loading = LoadingState.Error(ex)) } } } } fun nextImage(): Boolean { - val size = pager.value?.size - val newPosition = position.value!! + 1 - return if (size != null && newPosition < size) { - updatePosition(newPosition) + val size = state.value.items.size + val newPosition = state.value.position + 1 + return if (newPosition < size) { + updatePosition(newPosition, true) true } else { false @@ -191,24 +188,33 @@ class SlideshowViewModel } fun previousImage(): Boolean { - val newPosition = position.value!! - 1 + val newPosition = state.value.position - 1 return if (newPosition >= 0) { - updatePosition(newPosition) + updatePosition(newPosition, false) true } else { false } } - fun updatePosition(position: Int): Job? = - _pager.value?.let { pager -> + /** + * Update image state to the specified position + * + * @param position the index to use + * @param isNext whether moving to the next or previous image for prefetching purposes + */ + fun updatePosition( + position: Int, + isNext: Boolean = true, + ): Job? = + (state.value.items as? ApiRequestPager<*>)?.let { pager -> viewModelScope.launchIO { try { val image = if (position in pager.indices) pager.getBlocking(position) else null Timber.v("Got image for $position: ${image != null}") if (image != null) { - this@SlideshowViewModel.position.setValueOnMain(position) + _state.update { it.copy(position = position) } val url = if (image.data.mediaType == MediaType.VIDEO) { @@ -228,12 +234,21 @@ class SlideshowViewModel source.mediaStreams?.firstOrNull { it.type == MediaStreamType.AUDIO } ChosenStreams( itemPlayback = null, - plc = null, + audioStc = emptyList(), + subtitleStc = emptyList(), itemId = image.id, source = source, videoStream = video, - audioStream = audio, - subtitleStream = null, + audioStream = + StreamChoiceResult( + audio, + StreamChoiceReason.Unknown, + ), + subtitleStream = + StreamChoiceResult( + null, + StreamChoiceReason.Unknown, + ), subtitlesDisabled = false, ) } @@ -252,7 +267,7 @@ class SlideshowViewModel updateImageFilter(albumImageFilter) if (saveFilters) { viewModelScope.launchIO { - serverRepository.currentUser.value?.let { user -> + serverRepository.currentUser?.let { user -> val vf = playbackEffectDao .getPlaybackEffect( @@ -264,32 +279,63 @@ class SlideshowViewModel Timber.d( "Loaded VideoFilter for image ${image.id}", ) - withContext(Dispatchers.Main) { - // Pause throttling so that the image loads with the filter applied immediately - imageFilter.stopThrottling(true) - updateImageFilter(vf.videoFilter) - imageFilter.startThrottling() - } + updateImageFilter(vf.videoFilter) } - withContext(Dispatchers.Main) { - _image.value = imageState - loadingState.value = - ImageLoadingState.Success(imageState) + _state.update { + it.copy( + image = imageState, + imageLoading = ImageLoadingState.Success(imageState), + ) } } } } else { - withContext(Dispatchers.Main) { - _image.value = imageState - loadingState.value = ImageLoadingState.Success(imageState) + _state.update { + it.copy( + image = imageState, + imageLoading = ImageLoadingState.Success(imageState), + ) } } } else { - loadingState.setValueOnMain(ImageLoadingState.Error) + _state.update { + it.copy( + imageLoading = ImageLoadingState.Error, + ) + } } } catch (ex: Exception) { Timber.e(ex) - loadingState.setValueOnMain(ImageLoadingState.Error) + _state.update { + it.copy( + imageLoading = ImageLoadingState.Error, + ) + } + } + try { + // If there is a next/previous image, fetch it into the image cache + val nextIndex = position + if (isNext) 1 else -1 + if (nextIndex in pager.indices) { + pager.getBlocking(nextIndex)?.let { nextImage -> + if (nextImage.data.mediaType == MediaType.PHOTO) { + val url = api.libraryApi.getDownloadUrl(nextImage.id) + val request = + ImageRequest + .Builder(context) + .data(url) + .size(Size.ORIGINAL) + .build() + context.imageLoader.enqueue(request) + Timber.v( + "Prefetching index=%s, id=%s", + nextIndex, + nextImage.id, + ) + } + } + } + } catch (ex: Exception) { + Timber.e(ex, "Error preloading image") } } } @@ -298,10 +344,10 @@ class SlideshowViewModel fun startSlideshow() { screensaverService.keepScreenOn(true) - _slideshow.update { - SlideshowState(enabled = true, paused = false) + _state.update { + it.copy(enabled = true, paused = false) } - if (_image.value + if (state.value.image ?.image ?.data ?.mediaType != MediaType.VIDEO @@ -313,14 +359,14 @@ class SlideshowViewModel fun stopSlideshow() { screensaverService.keepScreenOn(false) slideshowJob?.cancel() - _slideshow.update { - SlideshowState(enabled = false, paused = false) + _state.update { + it.copy(enabled = false, paused = false) } } fun pauseSlideshow() { Timber.v("pauseSlideshow") - _slideshow.update { + _state.update { if (it.enabled) { slideshowJob?.cancel() it.copy(paused = true) @@ -332,7 +378,7 @@ class SlideshowViewModel fun unpauseSlideshow() { Timber.v("unpauseSlideshow") - _slideshow.update { + _state.update { if (it.enabled) { it.copy(paused = false) } else { @@ -344,7 +390,7 @@ class SlideshowViewModel fun pulseSlideshow() = pulseSlideshow(slideshowDelay) fun pulseSlideshow(milliseconds: Long) { - Timber.v("pulseSlideshow $milliseconds") +// Timber.v("pulseSlideshow $milliseconds") slideshowJob?.cancel() slideshowJob = viewModelScope @@ -362,16 +408,16 @@ class SlideshowViewModel fun updateImageFilter(newFilter: VideoFilter) { viewModelScope.launchIO { - _imageFilter.setValueOnMain(newFilter) + _imageFilter.update { newFilter } } } fun saveImageFilter() { - image.value?.let { + state.value.image?.let { viewModelScope.launchIO { val vf = _imageFilter.value if (vf != null) { - serverRepository.currentUser.value?.let { user -> + serverRepository.currentUser?.let { user -> playbackEffectDao .insert( PlaybackEffect( @@ -382,7 +428,7 @@ class SlideshowViewModel ), ) Timber.d("Saved VideoFilter for image %s", it.image.id) - withContext(Dispatchers.Main) { + withContext(WholphinDispatchers.Main) { showToast( context, "Saved", @@ -398,9 +444,9 @@ class SlideshowViewModel fun saveGalleryFilter() { viewModelScope.launchIO(ExceptionHandler(autoToast = true)) { val vf = _imageFilter.value - if (vf != null) { + if (slideshowSettings.parentId != null) { albumImageFilter = vf - serverRepository.currentUser.value?.let { user -> + serverRepository.currentUser?.let { user -> playbackEffectDao .insert( PlaybackEffect( @@ -411,7 +457,7 @@ class SlideshowViewModel ), ) Timber.d("Saved VideoFilter for album %s", slideshowSettings.parentId) - withContext(Dispatchers.Main) { + withContext(WholphinDispatchers.Main) { showToast( context, "Saved", @@ -457,6 +503,11 @@ data class ImageState( } data class SlideshowState( - val enabled: Boolean, - val paused: Boolean, + val items: List<BaseItem?> = emptyList(), + val position: Int = 0, + val image: ImageState? = null, + val loading: LoadingState = LoadingState.Pending, + val imageLoading: ImageLoadingState = ImageLoadingState.Loading, + val enabled: Boolean = false, + val paused: Boolean = false, ) 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 53c10bd2..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 @@ -7,10 +7,12 @@ import androidx.tv.material3.MaterialTheme import com.github.damontecres.wholphin.preferences.AppThemeColors import com.github.damontecres.wholphin.ui.theme.colors.BlueThemeColors import com.github.damontecres.wholphin.ui.theme.colors.BoldBlueThemeColors +import com.github.damontecres.wholphin.ui.theme.colors.BrownThemeColors import com.github.damontecres.wholphin.ui.theme.colors.GreenThemeColors 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 val LocalTheme = compositionLocalOf<AppThemeColors> { AppThemeColors.PURPLE } @@ -23,6 +25,8 @@ fun getThemeColors(appThemeColors: AppThemeColors): ThemeColors = AppThemeColors.ORANGE -> OrangeThemeColors AppThemeColors.OLED_BLACK -> OledThemeColors AppThemeColors.BOLD_BLUE -> BoldBlueThemeColors + AppThemeColors.RED -> RedThemeColors + AppThemeColors.BROWN -> BrownThemeColors AppThemeColors.UNRECOGNIZED -> PurpleThemeColors } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/theme/ThemePreview.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/theme/ThemePreview.kt index 70d30254..f5c83ce1 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/theme/ThemePreview.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/theme/ThemePreview.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items @@ -19,6 +20,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.tv.material3.ListItem @@ -42,26 +44,37 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOf @Preview( - device = "spec:width=1200dp,height=2500dp", + device = "spec:width=1200dp,height=3000dp", backgroundColor = 0xFF383535, uiMode = UI_MODE_TYPE_TELEVISION, ) @Composable private fun ThemePreview() { + val themes = AppThemeColors.entries.filterNot { it == AppThemeColors.UNRECOGNIZED } Column { LazyVerticalGrid( - columns = GridCells.Fixed(2), + columns = GridCells.Fixed(3), contentPadding = PaddingValues(16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp), ) { - items(AppThemeColors.entries.filterNot { it == AppThemeColors.UNRECOGNIZED }) { + items(themes) { ThemeExample(it) } } } } +@Composable +private fun SectionTitle(text: String) { + Text( + text = text, + color = Color.White, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(top = 8.dp), + ) +} + @Composable private fun ThemeExample(theme: AppThemeColors) { val source = remember { PreviewInteractionSource() } @@ -71,10 +84,13 @@ private fun ThemeExample(theme: AppThemeColors) { Text( text = theme.toString(), color = Color.White, + modifier = Modifier.padding(8.dp), ) Column( verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(8.dp), ) { + SectionTitle("Cards") Row( horizontalArrangement = Arrangement.SpaceEvenly, verticalAlignment = Alignment.CenterVertically, @@ -134,6 +150,7 @@ private fun ThemeExample(theme: AppThemeColors) { aspectRatio = AspectRatios.SQUARE, ) } + SectionTitle("Controls") Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { SliderPreference( preference = AppPreference.AutoPlayNextDelay, @@ -191,6 +208,7 @@ private fun ThemeExample(theme: AppThemeColors) { interactionSource = source, ) } + SectionTitle("Nav drawer") val navScope = NavScope(true) Row( horizontalArrangement = Arrangement.spacedBy(16.dp), @@ -240,6 +258,7 @@ private fun ThemeExample(theme: AppThemeColors) { ) } } + SectionTitle("Playback controls") Row( horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.background(MaterialTheme.colorScheme.surfaceVariant), @@ -271,6 +290,7 @@ private fun ThemeExample(theme: AppThemeColors) { interactionSource = source, ) } + SectionTitle("List items") Column( modifier = Modifier.background(MaterialTheme.colorScheme.surface), ) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/theme/colors/BrownThemeColors.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/theme/colors/BrownThemeColors.kt new file mode 100644 index 00000000..04c050fd --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/theme/colors/BrownThemeColors.kt @@ -0,0 +1,204 @@ +package com.github.damontecres.wholphin.ui.theme.colors + +import androidx.compose.material3.ColorScheme +import androidx.compose.ui.graphics.Color +import androidx.tv.material3.darkColorScheme +import androidx.tv.material3.lightColorScheme +import com.github.damontecres.wholphin.ui.theme.ThemeColors + +val BrownThemeColors = + object : ThemeColors { + val primaryLight = Color(0xFF8A5022) + val onPrimaryLight = Color(0xFFFFFFFF) + val primaryContainerLight = Color(0xFFFFDCC6) + val onPrimaryContainerLight = Color(0xFF6D390C) + val secondaryLight = Color(0xFF755845) + val onSecondaryLight = Color(0xFFFFFFFF) + val secondaryContainerLight = Color(0xFFFFDCC6) + val onSecondaryContainerLight = Color(0xFF5B412F) + val tertiaryLight = Color(0xFF5F6135) + val onTertiaryLight = Color(0xFFFFFFFF) + val tertiaryContainerLight = Color(0xFFE5E6AE) + val onTertiaryContainerLight = Color(0xFF47491F) + val errorLight = Color(0xFFBA1A1A) + val onErrorLight = Color(0xFFFFFFFF) + val errorContainerLight = Color(0xFFFFDAD6) + val onErrorContainerLight = Color(0xFF93000A) + val backgroundLight = Color(0xFFFFF8F5) + val onBackgroundLight = Color(0xFF221A15) + val surfaceLight = Color(0xFFFFF8F5) + val onSurfaceLight = Color(0xFF221A15) + val surfaceVariantLight = Color(0xFFF3DED3) + val onSurfaceVariantLight = Color(0xFF52443B) + val outlineLight = Color(0xFF84746A) + val outlineVariantLight = Color(0xFFD7C3B7) + val scrimLight = Color(0xFF000000) + val inverseSurfaceLight = Color(0xFF382F29) + val inverseOnSurfaceLight = Color(0xFFFEEDE4) + val inversePrimaryLight = Color(0xFFFFB784) + val surfaceDimLight = Color(0xFFE7D7CE) + val surfaceBrightLight = Color(0xFFFFF8F5) + val surfaceContainerLowestLight = Color(0xFFFFFFFF) + val surfaceContainerLowLight = Color(0xFFFFF1EA) + val surfaceContainerLight = Color(0xFFFBEBE2) + val surfaceContainerHighLight = Color(0xFFF6E5DC) + val surfaceContainerHighestLight = Color(0xFFF0DFD6) + + val primaryDark = Color(0xFFFFB784) + val onPrimaryDark = Color(0xFF4F2500) + val primaryContainerDark = Color(0xFF6D390C) + val onPrimaryContainerDark = Color(0xFFFFDCC6) + val secondaryDark = Color(0xFFE4BFA8) + val onSecondaryDark = Color(0xFF422B1B) + val secondaryContainerDark = Color(0xFF5B412F) + val onSecondaryContainerDark = Color(0xFFFFDCC6) + val tertiaryDark = Color(0xFFC8CA94) + val onTertiaryDark = Color(0xFF30330B) + val tertiaryContainerDark = Color(0xFF47491F) + val onTertiaryContainerDark = Color(0xFFE5E6AE) + val errorDark = Color(0xFFFFB4AB) + val onErrorDark = Color(0xFF690005) + val errorContainerDark = Color(0xFF93000A) + val onErrorContainerDark = Color(0xFFFFDAD6) + val backgroundDark = Color(0xFF19120D) + val onBackgroundDark = Color(0xFFF0DFD6) + val surfaceDark = Color(0xFF19120D) + val onSurfaceDark = Color(0xFFF0DFD6) + val surfaceVariantDark = Color(0xFF52443B) + val onSurfaceVariantDark = Color(0xFFD7C3B7) + val outlineDark = Color(0xFF9F8D83) + val outlineVariantDark = Color(0xFF52443B) + val scrimDark = Color(0xFF000000) + val inverseSurfaceDark = Color(0xFFF0DFD6) + val inverseOnSurfaceDark = Color(0xFF382F29) + val inversePrimaryDark = Color(0xFF6D3B07) + val surfaceDimDark = Color(0xFF19120D) + val surfaceBrightDark = Color(0xFF413731) + val surfaceContainerLowestDark = Color(0xFF140D08) + val surfaceContainerLowDark = Color(0xFF221A15) + val surfaceContainerDark = Color(0xFF261E18) + val surfaceContainerHighDark = Color(0xFF312822) + val surfaceContainerHighestDark = Color(0xFF3C332D) + + override val lightSchemeMaterial: ColorScheme = + androidx.compose.material3.lightColorScheme( + primary = primaryLight, + onPrimary = onPrimaryLight, + primaryContainer = primaryContainerLight, + onPrimaryContainer = onPrimaryContainerLight, + secondary = secondaryLight, + onSecondary = onSecondaryLight, + secondaryContainer = secondaryContainerLight, + onSecondaryContainer = onSecondaryContainerLight, + tertiary = tertiaryLight, + onTertiary = onTertiaryLight, + tertiaryContainer = tertiaryContainerLight, + onTertiaryContainer = onTertiaryContainerLight, + error = errorLight, + onError = onErrorLight, + errorContainer = errorContainerLight, + onErrorContainer = onErrorContainerLight, + background = backgroundLight, + onBackground = onBackgroundLight, + surface = surfaceLight, + onSurface = onSurfaceLight, + surfaceVariant = surfaceVariantLight, + onSurfaceVariant = onSurfaceVariantLight, + scrim = scrimLight, + inverseSurface = inverseSurfaceLight, + inverseOnSurface = inverseOnSurfaceLight, + inversePrimary = inversePrimaryLight, + ) + + override val lightScheme = + lightColorScheme( + primary = primaryLight, + onPrimary = onPrimaryLight, + primaryContainer = primaryContainerLight, + onPrimaryContainer = onPrimaryContainerLight, + secondary = secondaryLight, + onSecondary = onSecondaryLight, + secondaryContainer = secondaryContainerLight, + onSecondaryContainer = onSecondaryContainerLight, + tertiary = tertiaryLight, + onTertiary = onTertiaryLight, + tertiaryContainer = tertiaryContainerLight, + onTertiaryContainer = onTertiaryContainerLight, + error = errorLight, + onError = onErrorLight, + errorContainer = errorContainerLight, + onErrorContainer = onErrorContainerLight, + background = backgroundLight, + onBackground = onBackgroundLight, + surface = surfaceLight, + onSurface = onSurfaceLight, + surfaceVariant = surfaceVariantLight, + onSurfaceVariant = onSurfaceVariantLight, + scrim = scrimLight, + inverseSurface = inverseSurfaceLight, + inverseOnSurface = inverseOnSurfaceLight, + inversePrimary = inversePrimaryLight, + border = inversePrimaryLight, + ) + + override val darkSchemeMaterial = + androidx.compose.material3.darkColorScheme( + primary = primaryDark, + onPrimary = onPrimaryDark, + primaryContainer = primaryContainerDark, + onPrimaryContainer = onPrimaryContainerDark, + secondary = secondaryDark, + onSecondary = onSecondaryDark, + secondaryContainer = secondaryContainerDark, + onSecondaryContainer = onSecondaryContainerDark, + tertiary = tertiaryDark, + onTertiary = onTertiaryDark, + tertiaryContainer = tertiaryContainerDark, + onTertiaryContainer = onTertiaryContainerDark, + error = errorDark, + onError = onErrorDark, + errorContainer = errorContainerDark, + onErrorContainer = onErrorContainerDark, + background = backgroundDark, + onBackground = onBackgroundDark, + surface = surfaceDark, + onSurface = onSurfaceDark, + surfaceVariant = surfaceVariantDark, + onSurfaceVariant = onSurfaceVariantDark, + scrim = scrimDark, + inverseSurface = inverseSurfaceDark, + inverseOnSurface = inverseOnSurfaceDark, + inversePrimary = inversePrimaryDark, + ) + + override val darkScheme = + darkColorScheme( + primary = primaryDark, + onPrimary = onPrimaryDark, + primaryContainer = primaryContainerDark, + onPrimaryContainer = onPrimaryContainerDark, + secondary = secondaryDark, + onSecondary = onSecondaryDark, + secondaryContainer = secondaryContainerDark, + onSecondaryContainer = onSecondaryContainerDark, + tertiary = tertiaryDark, + onTertiary = onTertiaryDark, + tertiaryContainer = tertiaryContainerDark, + onTertiaryContainer = onTertiaryContainerDark, + error = errorDark, + onError = onErrorDark, + errorContainer = errorContainerDark, + onErrorContainer = onErrorContainerDark, + background = backgroundDark, + onBackground = onBackgroundDark, + surface = surfaceDark, + onSurface = onSurfaceDark, + surfaceVariant = surfaceVariantDark, + onSurfaceVariant = onSurfaceVariantDark, + scrim = scrimDark, + inverseSurface = inverseSurfaceDark, + inverseOnSurface = inverseOnSurfaceDark, + inversePrimary = inversePrimaryDark, + border = inversePrimaryDark, + ) + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/theme/colors/RedThemeColors.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/theme/colors/RedThemeColors.kt new file mode 100644 index 00000000..ecdcbdf4 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/theme/colors/RedThemeColors.kt @@ -0,0 +1,204 @@ +package com.github.damontecres.wholphin.ui.theme.colors + +import androidx.compose.material3.ColorScheme +import androidx.compose.ui.graphics.Color +import androidx.tv.material3.darkColorScheme +import androidx.tv.material3.lightColorScheme +import com.github.damontecres.wholphin.ui.theme.ThemeColors + +val RedThemeColors = + object : ThemeColors { + val primaryLight = Color(0xFF904A42) + val onPrimaryLight = Color(0xFFFFFFFF) + val primaryContainerLight = Color(0xFFFFDAD5) + val onPrimaryContainerLight = Color(0xFF73342C) + val secondaryLight = Color(0xFF775652) + val onSecondaryLight = Color(0xFFFFFFFF) + val secondaryContainerLight = Color(0xFFFFDAD5) + val onSecondaryContainerLight = Color(0xFF5D3F3B) + val tertiaryLight = Color(0xFF715C2E) + val onTertiaryLight = Color(0xFFFFFFFF) + val tertiaryContainerLight = Color(0xFFFDDFA6) + val onTertiaryContainerLight = Color(0xFF574419) + val errorLight = Color(0xFFBA1A1A) + val onErrorLight = Color(0xFFFFFFFF) + val errorContainerLight = Color(0xFFFFDAD6) + val onErrorContainerLight = Color(0xFF93000A) + val backgroundLight = Color(0xFFFFF8F7) + val onBackgroundLight = Color(0xFF231918) + val surfaceLight = Color(0xFFFFF8F7) + val onSurfaceLight = Color(0xFF231918) + val surfaceVariantLight = Color(0xFFF5DDDA) + val onSurfaceVariantLight = Color(0xFF534341) + val outlineLight = Color(0xFF857370) + val outlineVariantLight = Color(0xFFD8C2BE) + val scrimLight = Color(0xFF000000) + val inverseSurfaceLight = Color(0xFF392E2C) + val inverseOnSurfaceLight = Color(0xFFFFEDEA) + val inversePrimaryLight = Color(0xFFFFB4AA) + val surfaceDimLight = Color(0xFFE8D6D4) + val surfaceBrightLight = Color(0xFFFFF8F7) + val surfaceContainerLowestLight = Color(0xFFFFFFFF) + val surfaceContainerLowLight = Color(0xFFFFF0EE) + val surfaceContainerLight = Color(0xFFFCEAE7) + val surfaceContainerHighLight = Color(0xFFF7E4E2) + val surfaceContainerHighestLight = Color(0xFFF1DEDC) + + val primaryDark = Color(0xFFFFB4AA) + val onPrimaryDark = Color(0xFF561E18) + val primaryContainerDark = Color(0xFF73342C) + val onPrimaryContainerDark = Color(0xFFFFDAD5) + val secondaryDark = Color(0xFFE7BDB7) + val onSecondaryDark = Color(0xFF442926) + val secondaryContainerDark = Color(0xFF5D3F3B) + val onSecondaryContainerDark = Color(0xFFFFDAD5) + val tertiaryDark = Color(0xFFDFC38C) + val onTertiaryDark = Color(0xFF3F2E04) + val tertiaryContainerDark = Color(0xFF574419) + val onTertiaryContainerDark = Color(0xFFFDDFA6) + val errorDark = Color(0xFFFFB4AB) + val onErrorDark = Color(0xFF690005) + val errorContainerDark = Color(0xFF93000A) + val onErrorContainerDark = Color(0xFFFFDAD6) + val backgroundDark = Color(0xFF1A1110) + val onBackgroundDark = Color(0xFFF1DEDC) + val surfaceDark = Color(0xFF1A1110) + val onSurfaceDark = Color(0xFFF1DEDC) + val surfaceVariantDark = Color(0xFF534341) + val onSurfaceVariantDark = Color(0xFFD8C2BE) + val outlineDark = Color(0xFFA08C8A) + val outlineVariantDark = Color(0xFF534341) + val scrimDark = Color(0xFF000000) + val inverseSurfaceDark = Color(0xFFF1DEDC) + val inverseOnSurfaceDark = Color(0xFF392E2C) + val inversePrimaryDark = Color(0xFFC0000C) + val surfaceDimDark = Color(0xFF1A1110) + val surfaceBrightDark = Color(0xFF423735) + val surfaceContainerLowestDark = Color(0xFF140C0B) + val surfaceContainerLowDark = Color(0xFF231918) + val surfaceContainerDark = Color(0xFF271D1C) + val surfaceContainerHighDark = Color(0xFF322826) + val surfaceContainerHighestDark = Color(0xFF3D3231) + + override val lightSchemeMaterial: ColorScheme = + androidx.compose.material3.lightColorScheme( + primary = primaryLight, + onPrimary = onPrimaryLight, + primaryContainer = primaryContainerLight, + onPrimaryContainer = onPrimaryContainerLight, + secondary = secondaryLight, + onSecondary = onSecondaryLight, + secondaryContainer = secondaryContainerLight, + onSecondaryContainer = onSecondaryContainerLight, + tertiary = tertiaryLight, + onTertiary = onTertiaryLight, + tertiaryContainer = tertiaryContainerLight, + onTertiaryContainer = onTertiaryContainerLight, + error = errorLight, + onError = onErrorLight, + errorContainer = errorContainerLight, + onErrorContainer = onErrorContainerLight, + background = backgroundLight, + onBackground = onBackgroundLight, + surface = surfaceLight, + onSurface = onSurfaceLight, + surfaceVariant = surfaceVariantLight, + onSurfaceVariant = onSurfaceVariantLight, + scrim = scrimLight, + inverseSurface = inverseSurfaceLight, + inverseOnSurface = inverseOnSurfaceLight, + inversePrimary = inversePrimaryLight, + ) + + override val lightScheme = + lightColorScheme( + primary = primaryLight, + onPrimary = onPrimaryLight, + primaryContainer = primaryContainerLight, + onPrimaryContainer = onPrimaryContainerLight, + secondary = secondaryLight, + onSecondary = onSecondaryLight, + secondaryContainer = secondaryContainerLight, + onSecondaryContainer = onSecondaryContainerLight, + tertiary = tertiaryLight, + onTertiary = onTertiaryLight, + tertiaryContainer = tertiaryContainerLight, + onTertiaryContainer = onTertiaryContainerLight, + error = errorLight, + onError = onErrorLight, + errorContainer = errorContainerLight, + onErrorContainer = onErrorContainerLight, + background = backgroundLight, + onBackground = onBackgroundLight, + surface = surfaceLight, + onSurface = onSurfaceLight, + surfaceVariant = surfaceVariantLight, + onSurfaceVariant = onSurfaceVariantLight, + scrim = scrimLight, + inverseSurface = inverseSurfaceLight, + inverseOnSurface = inverseOnSurfaceLight, + inversePrimary = inversePrimaryLight, + border = inversePrimaryLight, + ) + + override val darkSchemeMaterial = + androidx.compose.material3.darkColorScheme( + primary = primaryDark, + onPrimary = onPrimaryDark, + primaryContainer = primaryContainerDark, + onPrimaryContainer = onPrimaryContainerDark, + secondary = secondaryDark, + onSecondary = onSecondaryDark, + secondaryContainer = secondaryContainerDark, + onSecondaryContainer = onSecondaryContainerDark, + tertiary = tertiaryDark, + onTertiary = onTertiaryDark, + tertiaryContainer = tertiaryContainerDark, + onTertiaryContainer = onTertiaryContainerDark, + error = errorDark, + onError = onErrorDark, + errorContainer = errorContainerDark, + onErrorContainer = onErrorContainerDark, + background = backgroundDark, + onBackground = onBackgroundDark, + surface = surfaceDark, + onSurface = onSurfaceDark, + surfaceVariant = surfaceVariantDark, + onSurfaceVariant = onSurfaceVariantDark, + scrim = scrimDark, + inverseSurface = inverseSurfaceDark, + inverseOnSurface = inverseOnSurfaceDark, + inversePrimary = inversePrimaryDark, + ) + + override val darkScheme = + darkColorScheme( + primary = primaryDark, + onPrimary = onPrimaryDark, + primaryContainer = primaryContainerDark, + onPrimaryContainer = onPrimaryContainerDark, + secondary = secondaryDark, + onSecondary = onSecondaryDark, + secondaryContainer = secondaryContainerDark, + onSecondaryContainer = onSecondaryContainerDark, + tertiary = tertiaryDark, + onTertiary = onTertiaryDark, + tertiaryContainer = tertiaryContainerDark, + onTertiaryContainer = onTertiaryContainerDark, + error = errorDark, + onError = onErrorDark, + errorContainer = errorContainerDark, + onErrorContainer = onErrorContainerDark, + background = backgroundDark, + onBackground = onBackgroundDark, + surface = surfaceDark, + onSurface = onSurfaceDark, + surfaceVariant = surfaceVariantDark, + onSurfaceVariant = onSurfaceVariantDark, + scrim = scrimDark, + inverseSurface = inverseSurfaceDark, + inverseOnSurface = inverseOnSurfaceDark, + inversePrimary = inversePrimaryDark, + border = inversePrimaryDark, + ) + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/util/FilterUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/util/FilterUtils.kt deleted file mode 100644 index a39cae25..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/util/FilterUtils.kt +++ /dev/null @@ -1,126 +0,0 @@ -package com.github.damontecres.wholphin.ui.util - -import com.github.damontecres.wholphin.data.filter.CommunityRatingFilter -import com.github.damontecres.wholphin.data.filter.DecadeFilter -import com.github.damontecres.wholphin.data.filter.FavoriteFilter -import com.github.damontecres.wholphin.data.filter.FilterValueOption -import com.github.damontecres.wholphin.data.filter.FilterVideoType -import com.github.damontecres.wholphin.data.filter.GenreFilter -import com.github.damontecres.wholphin.data.filter.ItemFilterBy -import com.github.damontecres.wholphin.data.filter.OfficialRatingFilter -import com.github.damontecres.wholphin.data.filter.PlayedFilter -import com.github.damontecres.wholphin.data.filter.StudioFilter -import com.github.damontecres.wholphin.data.filter.VideoTypeFilter -import com.github.damontecres.wholphin.data.filter.YearFilter -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.api.client.extensions.genresApi -import org.jellyfin.sdk.api.client.extensions.localizationApi -import org.jellyfin.sdk.api.client.extensions.studiosApi -import org.jellyfin.sdk.api.client.extensions.yearsApi -import org.jellyfin.sdk.model.api.BaseItemKind -import org.jellyfin.sdk.model.api.ItemSortBy -import org.jellyfin.sdk.model.api.SortOrder -import timber.log.Timber -import java.util.TreeSet -import java.util.UUID - -object FilterUtils { - /** - * Gets the possible values for a filter - * - * For example, the possible genres in the parent ID - */ - suspend fun getFilterOptionValues( - api: ApiClient, - userId: UUID?, - parentId: UUID?, - filterOption: ItemFilterBy<*>, - ): List<FilterValueOption> = - withContext(Dispatchers.IO) { - try { - when (filterOption) { - GenreFilter -> { - api.genresApi - .getGenres( - parentId = parentId, - userId = userId, - ).content.items - .map { FilterValueOption(it.name ?: "", it.id) } - } - - StudioFilter -> { - api.studiosApi - .getStudios( - parentId = parentId, - userId = userId, - includeItemTypes = listOf(BaseItemKind.SERIES), - ).content.items - .map { FilterValueOption(it.name ?: "", it.id) } - } - - FavoriteFilter, - PlayedFilter, - -> { - listOf( - FilterValueOption("True", null), - FilterValueOption("False", null), - ) - } - - OfficialRatingFilter -> { - api.localizationApi.getParentalRatings().content.map { - FilterValueOption(it.name ?: "", it.value) - } - } - - VideoTypeFilter -> { - FilterVideoType.entries.map { - FilterValueOption(it.readable, it) - } - } - - YearFilter -> { - api.yearsApi - .getYears( - parentId = parentId, - userId = userId, - sortBy = listOf(ItemSortBy.SORT_NAME), - sortOrder = listOf(SortOrder.ASCENDING), - ).content.items - .mapNotNull { - it.name?.toIntOrNull()?.let { FilterValueOption(it.toString(), it) } - } - } - - DecadeFilter -> { - val items = TreeSet<Int>() - api.yearsApi - .getYears( - parentId = parentId, - userId = userId, - sortBy = listOf(ItemSortBy.SORT_NAME), - sortOrder = listOf(SortOrder.ASCENDING), - ).content.items - .mapNotNullTo(items) { - it.name - ?.toIntOrNull() - ?.div(10) - ?.times(10) - } - items.toList().sorted().map { FilterValueOption("$it's", it) } - } - - CommunityRatingFilter -> { - (1..10).map { - FilterValueOption("$it", it) - } - } - } - } catch (ex: Exception) { - Timber.e(ex, "Exception get filter value options for $filterOption") - listOf() - } - } -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/util/InterfaceCustomization.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/util/InterfaceCustomization.kt new file mode 100644 index 00000000..30bcac68 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/util/InterfaceCustomization.kt @@ -0,0 +1,28 @@ +package com.github.damontecres.wholphin.ui.util + +import androidx.compose.runtime.staticCompositionLocalOf +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.DisplayToggle +import java.util.EnumSet + +/** + * Represents various UI preferences made available via [LocalInterfaceCustomization] + */ +data class InterfaceCustomization( + val enabledDisplayToggles: EnumSet<DisplayToggle>, +) { + constructor(prefs: AppPreferences) : this( + prefs.interfacePreferences.displayTogglesList.let { + if (it.isEmpty()) { + EnumSet.noneOf(DisplayToggle::class.java) + } else { + EnumSet.copyOf(it) + } + }, + ) +} + +val LocalInterfaceCustomization = + staticCompositionLocalOf<InterfaceCustomization> { + InterfaceCustomization(EnumSet.allOf(DisplayToggle::class.java)) + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/util/LocalClock.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/util/LocalClock.kt index 213dc725..2639a919 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/util/LocalClock.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/util/LocalClock.kt @@ -7,8 +7,9 @@ import androidx.compose.runtime.MutableState import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import com.github.damontecres.wholphin.ui.getTimeFormatter -import kotlinx.coroutines.Dispatchers +import androidx.compose.ui.platform.LocalContext +import com.github.damontecres.wholphin.ui.formatTime +import com.github.damontecres.wholphin.util.WholphinDispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.withContext @@ -25,19 +26,20 @@ data class Clock( */ val now: MutableState<LocalDateTime> = mutableStateOf(LocalDateTime.now()), /** - * The current time formatted as a string with [getTimeFormatter] + * The current time formatted as a string; populated by [ProvideLocalClock] via [formatTime]. */ - val timeString: MutableState<String> = mutableStateOf(getTimeFormatter().format(now.value)), + val timeString: MutableState<String> = mutableStateOf(""), ) @Composable fun ProvideLocalClock(content: @Composable () -> Unit) { + val context = LocalContext.current val clock = remember { Clock() } - LaunchedEffect(Unit) { - withContext(Dispatchers.Default) { + LaunchedEffect(context) { + withContext(WholphinDispatchers.Default) { while (isActive) { val now = LocalDateTime.now() - val time = getTimeFormatter().format(now) + val time = formatTime(context, now) clock.now.value = now clock.timeString.value = time delay(2_000) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/util/RandomButtonUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/util/RandomButtonUtils.kt new file mode 100644 index 00000000..31f4f6fa --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/util/RandomButtonUtils.kt @@ -0,0 +1,48 @@ +package com.github.damontecres.wholphin.ui.util + +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.CollectionFolderFilter +import com.github.damontecres.wholphin.data.model.GetItemsFilter +import com.github.damontecres.wholphin.ui.data.SortAndDirection +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.util.BlockingList +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.serializer.toUUIDOrNull +import timber.log.Timber +import kotlin.random.Random + +/** + * Navigate to a random item in the given list + */ +suspend fun goToRandom( + navigateTo: (Destination) -> Unit, + parentId: String, + filter: GetItemsFilter, + sortAndDirection: SortAndDirection, + recursive: Boolean, + list: BlockingList<BaseItem?>, +) { + val randomIndex = Random.nextInt(list.size) + val random = list.getBlocking(randomIndex) + + Timber.d("Got random item #%s: %s", randomIndex, random?.id) + if (random != null) { + val destination = + if (random.type == BaseItemKind.PHOTO) { + Destination.Slideshow( + parentId = parentId.toUUIDOrNull(), + index = randomIndex, + filter = + CollectionFolderFilter( + filter = filter, + ), + sortAndDirection = sortAndDirection, + recursive = recursive, + startSlideshow = false, + ) + } else { + random.destination() + } + navigateTo(destination) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/util/StreamFormatting.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/util/StreamFormatting.kt index 15d35b3b..4f1a592d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/util/StreamFormatting.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/util/StreamFormatting.kt @@ -1,6 +1,6 @@ package com.github.damontecres.wholphin.ui.util -import android.content.Context +import android.content.res.Resources import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.util.languageName @@ -44,7 +44,7 @@ object StreamFormatting { } fun formatVideoRange( - context: Context, + resources: Resources, videoRange: VideoRange?, type: VideoRangeType?, doviTitle: String?, @@ -58,7 +58,7 @@ object StreamFormatting { VideoRange.HDR -> { if (doviTitle.isNotNullOrBlank()) { - context.getString(R.string.dolby_vision) + resources.getString(R.string.dolby_vision) } else { when (type) { VideoRangeType.UNKNOWN, @@ -76,40 +76,40 @@ object StreamFormatting { VideoRangeType.DOVI_WITH_HDR10, VideoRangeType.DOVI_WITH_HLG, VideoRangeType.DOVI_WITH_SDR, - -> context.getString(R.string.dolby_vision) + -> resources.getString(R.string.dolby_vision) } } } } fun formatAudioCodec( - context: Context, + resources: Resources, codec: String?, profile: String?, ): String? = when { profile?.contains("Dolby Atmos", true) == true -> { - context.getString(R.string.dolby_atmos) + resources.getString(R.string.dolby_atmos) } profile?.contains("DTS", true) == true -> { when { - profile.contains("X", true) -> context.getString(R.string.dts_x) - profile.contains("MA", true) -> context.getString(R.string.dts_hd_ma) - profile.contains("HD", true) -> context.getString(R.string.dts_hd) - else -> context.getString(R.string.dts) + profile.contains("X", true) -> resources.getString(R.string.dts_x) + profile.contains("MA", true) -> resources.getString(R.string.dts_hd_ma) + profile.contains("HD", true) -> resources.getString(R.string.dts_hd) + else -> resources.getString(R.string.dts) } } else -> { when (codec?.lowercase()) { - Codec.Audio.TRUEHD -> context.getString(R.string.truehd) + Codec.Audio.TRUEHD -> resources.getString(R.string.truehd) - Codec.Audio.AC3 -> context.getString(R.string.dolby_digital) + Codec.Audio.AC3 -> resources.getString(R.string.dolby_digital) - Codec.Audio.EAC3 -> context.getString(R.string.dolby_digital_plus) + Codec.Audio.EAC3 -> resources.getString(R.string.dolby_digital_plus) - Codec.Audio.DCA -> context.getString(R.string.dts) + Codec.Audio.DCA -> resources.getString(R.string.dts) Codec.Audio.OGG, Codec.Audio.OPUS, @@ -141,7 +141,7 @@ object StreamFormatting { } fun mediaStreamDisplayTitle( - context: Context, + resources: Resources, stream: MediaStream, includeFlags: Boolean, ): String { @@ -149,7 +149,7 @@ object StreamFormatting { buildList { add(languageName(stream.language)) if (stream.type == MediaStreamType.AUDIO) { - add(formatAudioCodec(context, stream.codec, stream.profile)) + add(formatAudioCodec(resources, stream.codec, stream.profile)) add(stream.channelLayout) } else if (stream.type == MediaStreamType.SUBTITLE) { "SDH".takeIf { stream.isHearingImpaired }?.let(::add) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/util/StringProvider.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/util/StringProvider.kt new file mode 100644 index 00000000..c63c4067 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/util/StringProvider.kt @@ -0,0 +1,105 @@ +package com.github.damontecres.wholphin.ui.util + +import androidx.annotation.StringRes +import androidx.compose.runtime.Composable +import androidx.compose.runtime.NonRestartableComposable +import androidx.compose.ui.res.stringResource +import kotlinx.serialization.Serializable + +/** + * Flexible way to provide a string for the UI + */ +@Serializable +sealed interface StringProvider { + @Composable + @NonRestartableComposable + fun getString(): String +} + +/** + * Provides a string literal + */ +@Serializable +data class StringStringProvider( + val str: String, +) : StringProvider { + @Composable + @NonRestartableComposable + override fun getString(): String = str +} + +/** + * Provide an empty string literal + */ +@Serializable +data object EmptyStringProvider : StringProvider { + @Composable + @NonRestartableComposable + override fun getString(): String = "" +} + +/** + * Provides a string resource using [stringResource] + */ +@Serializable +data class ResStringProvider( + @param:StringRes val stringResId: Int, +) : StringProvider { + @Composable + @NonRestartableComposable + override fun getString() = stringResource(stringResId) +} + +/** + * Provides a string resource with format arguments using [stringResource] + */ +@Serializable +data class ResArgStringProvider( + @param:StringRes val stringResId: Int, + val args: Array<String>, +) : StringProvider { + constructor(stringResId: Int, arg: String) : this(stringResId, arrayOf(arg)) + + @Composable + @NonRestartableComposable + override fun getString() = stringResource(stringResId, *args) + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is ResArgStringProvider) return false + + if (stringResId != other.stringResId) return false + if (!args.contentEquals(other.args)) return false + + return true + } + + override fun hashCode(): Int { + var result = stringResId + result = 31 * result + args.contentHashCode() + return result + } +} + +/** + * Provides a string resource with another [StringProvider] format argument using [stringResource] + */ +@Serializable +data class ResProviderStringProvider( + @param:StringRes val stringResId: Int, + val argProvider: StringProvider, +) : StringProvider { + @Composable + @NonRestartableComposable + override fun getString(): String = stringResource(stringResId, argProvider.getString()) +} + +@Serializable +data class ConcatStringProvider( + val separator: String, + val providers: List<StringProvider>, +) : StringProvider { + @Composable + @NonRestartableComposable + override fun getString(): String = providers.map { it.getString() }.joinToString(separator) +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/util/ThrottledLiveData.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/util/ThrottledLiveData.kt deleted file mode 100644 index 5a27f818..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/util/ThrottledLiveData.kt +++ /dev/null @@ -1,82 +0,0 @@ -package com.github.damontecres.wholphin.ui.util - -import android.os.Handler -import android.os.Looper -import androidx.lifecycle.LiveData -import androidx.lifecycle.MediatorLiveData - -/** - * LiveData throttling value emissions so they don't happen more often than [delayMs]. - * - * From https://stackoverflow.com/a/62467521 - */ -class ThrottledLiveData<T>( - source: LiveData<T>, - delayMs: Long, -) : MediatorLiveData<T>() { - val handler = Handler(Looper.getMainLooper()) - var delayMs = delayMs - private set - - private var isValueDelayed = false - private var delayedValue: T? = null - private var delayRunnable: Runnable? = null - set(value) { - field?.let { handler.removeCallbacks(it) } - value?.let { handler.postDelayed(it, delayMs) } - field = value - } - private val objDelayRunnable = Runnable { if (consumeDelayedValue()) startDelay() } - - init { - addSource(source) { newValue -> - if (delayRunnable == null) { - value = newValue - startDelay() - } else { - isValueDelayed = true - delayedValue = newValue - } - } - } - - /** Start throttling or modify the delay. If [newDelay] is `0` (default) reuse previous delay value. */ - fun startThrottling(newDelay: Long = 0L) { - require(newDelay >= 0L) - when { - newDelay > 0 -> delayMs = newDelay - delayMs < 0 -> delayMs *= -1 - delayMs > 0 -> return - else -> throw kotlin.IllegalArgumentException("newDelay cannot be zero if old delayMs is zero") - } - } - - /** Stop throttling, if [immediate] emit any pending value now. */ - fun stopThrottling(immediate: Boolean = false) { - if (delayMs <= 0) return - delayMs *= -1 - if (immediate) consumeDelayedValue() - } - - override fun onInactive() { - super.onInactive() - consumeDelayedValue() - } - - // start counting the delay or clear it if conditions are not met - private fun startDelay() { - delayRunnable = if (delayMs > 0 && hasActiveObservers()) objDelayRunnable else null - } - - private fun consumeDelayedValue(): Boolean { - delayRunnable = null - return if (isValueDelayed) { - value = delayedValue - delayedValue = null - isValueDelayed = false - true - } else { - false - } - } -} 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 a64acb70..2f98fccc 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt @@ -5,8 +5,12 @@ import com.github.damontecres.wholphin.ui.DEFAULT_PAGE_SIZE import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.sync.withLock import kotlinx.serialization.Serializable +import kotlinx.serialization.modules.SerializersModule +import kotlinx.serialization.modules.polymorphic +import kotlinx.serialization.modules.subclass import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.Response +import org.jellyfin.sdk.api.client.extensions.artistsApi import org.jellyfin.sdk.api.client.extensions.genresApi import org.jellyfin.sdk.api.client.extensions.itemsApi import org.jellyfin.sdk.api.client.extensions.liveTvApi @@ -18,12 +22,16 @@ import org.jellyfin.sdk.api.client.extensions.tvShowsApi import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult import org.jellyfin.sdk.model.api.GetProgramsDto +import org.jellyfin.sdk.model.api.request.GetAlbumArtistsRequest +import org.jellyfin.sdk.model.api.request.GetArtistsRequest import org.jellyfin.sdk.model.api.request.GetEpisodesRequest import org.jellyfin.sdk.model.api.request.GetGenresRequest import org.jellyfin.sdk.model.api.request.GetItemsRequest +import org.jellyfin.sdk.model.api.request.GetLiveTvChannelsRequest import org.jellyfin.sdk.model.api.request.GetNextUpRequest import org.jellyfin.sdk.model.api.request.GetPersonsRequest import org.jellyfin.sdk.model.api.request.GetPlaylistItemsRequest +import org.jellyfin.sdk.model.api.request.GetRecordingsRequest import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest import org.jellyfin.sdk.model.api.request.GetStudiosRequest import org.jellyfin.sdk.model.api.request.GetSuggestionsRequest @@ -105,7 +113,8 @@ class ApiRequestPager<T>( /** * Specifies how a [RequestPager] should prepare and execute API calls */ -interface RequestHandler<T> { +@Serializable +sealed interface RequestHandler<T> { /** * Prepare the given request with the specified parameters (eg which page to fetch) */ @@ -123,208 +132,311 @@ interface RequestHandler<T> { api: ApiClient, request: T, ): Response<BaseItemDtoQueryResult> + + /** + * Count the total number of possible results for the request. + * + * [request] should be efficient and use `limit = 0` and `enableTotalRecordCount = true` + */ + suspend fun countMatching( + api: ApiClient, + request: T, + ): Int { + val result by execute(api, request) + return result.totalRecordCount + } } @Serializable -val GetItemsRequestHandler = - object : RequestHandler<GetItemsRequest> { - override fun prepare( - request: GetItemsRequest, - startIndex: Int, - limit: Int, - enableTotalRecordCount: Boolean, - ): GetItemsRequest = - request.copy( - startIndex = startIndex, - limit = limit, - enableTotalRecordCount = enableTotalRecordCount, - ) +object GetItemsRequestHandler : RequestHandler<GetItemsRequest> { + override fun prepare( + request: GetItemsRequest, + startIndex: Int, + limit: Int, + enableTotalRecordCount: Boolean, + ): GetItemsRequest = + request.copy( + startIndex = startIndex, + limit = limit, + enableTotalRecordCount = enableTotalRecordCount, + ) - override suspend fun execute( - api: ApiClient, - request: GetItemsRequest, - ): Response<BaseItemDtoQueryResult> = api.itemsApi.getItems(request) - } + override suspend fun execute( + api: ApiClient, + request: GetItemsRequest, + ): Response<BaseItemDtoQueryResult> = api.itemsApi.getItems(request) +} @Serializable -val GetEpisodesRequestHandler = - object : RequestHandler<GetEpisodesRequest> { - override fun prepare( - request: GetEpisodesRequest, - startIndex: Int, - limit: Int, - enableTotalRecordCount: Boolean, - ): GetEpisodesRequest = - request.copy( - startIndex = startIndex, - limit = limit, - ) +object GetEpisodesRequestHandler : RequestHandler<GetEpisodesRequest> { + override fun prepare( + request: GetEpisodesRequest, + startIndex: Int, + limit: Int, + enableTotalRecordCount: Boolean, + ): GetEpisodesRequest = + request.copy( + startIndex = startIndex, + limit = limit, + ) - override suspend fun execute( - api: ApiClient, - request: GetEpisodesRequest, - ): Response<BaseItemDtoQueryResult> = api.tvShowsApi.getEpisodes(request) - } + override suspend fun execute( + api: ApiClient, + request: GetEpisodesRequest, + ): Response<BaseItemDtoQueryResult> = api.tvShowsApi.getEpisodes(request) +} @Serializable -val GetResumeItemsRequestHandler = - object : RequestHandler<GetResumeItemsRequest> { - override fun prepare( - request: GetResumeItemsRequest, - startIndex: Int, - limit: Int, - enableTotalRecordCount: Boolean, - ): GetResumeItemsRequest = - request.copy( - startIndex = startIndex, - limit = limit, - enableTotalRecordCount = enableTotalRecordCount, - ) +object GetResumeItemsRequestHandler : RequestHandler<GetResumeItemsRequest> { + override fun prepare( + request: GetResumeItemsRequest, + startIndex: Int, + limit: Int, + enableTotalRecordCount: Boolean, + ): GetResumeItemsRequest = + request.copy( + startIndex = startIndex, + limit = limit, + enableTotalRecordCount = enableTotalRecordCount, + ) - override suspend fun execute( - api: ApiClient, - request: GetResumeItemsRequest, - ): Response<BaseItemDtoQueryResult> = api.itemsApi.getResumeItems(request) - } + override suspend fun execute( + api: ApiClient, + request: GetResumeItemsRequest, + ): Response<BaseItemDtoQueryResult> = api.itemsApi.getResumeItems(request) +} @Serializable -val GetNextUpRequestHandler = - object : RequestHandler<GetNextUpRequest> { - override fun prepare( - request: GetNextUpRequest, - startIndex: Int, - limit: Int, - enableTotalRecordCount: Boolean, - ): GetNextUpRequest = - request.copy( - startIndex = startIndex, - limit = limit, - enableTotalRecordCount = enableTotalRecordCount, - ) +object GetNextUpRequestHandler : RequestHandler<GetNextUpRequest> { + override fun prepare( + request: GetNextUpRequest, + startIndex: Int, + limit: Int, + enableTotalRecordCount: Boolean, + ): GetNextUpRequest = + request.copy( + startIndex = startIndex, + limit = limit, + enableTotalRecordCount = enableTotalRecordCount, + ) - override suspend fun execute( - api: ApiClient, - request: GetNextUpRequest, - ): Response<BaseItemDtoQueryResult> = api.tvShowsApi.getNextUp(request) - } + override suspend fun execute( + api: ApiClient, + request: GetNextUpRequest, + ): Response<BaseItemDtoQueryResult> = api.tvShowsApi.getNextUp(request) +} @Serializable -val GetSuggestionsRequestHandler = - object : RequestHandler<GetSuggestionsRequest> { - override fun prepare( - request: GetSuggestionsRequest, - startIndex: Int, - limit: Int, - enableTotalRecordCount: Boolean, - ): GetSuggestionsRequest = - request.copy( - startIndex = startIndex, - limit = limit, - enableTotalRecordCount = enableTotalRecordCount, - ) +object GetSuggestionsRequestHandler : RequestHandler<GetSuggestionsRequest> { + override fun prepare( + request: GetSuggestionsRequest, + startIndex: Int, + limit: Int, + enableTotalRecordCount: Boolean, + ): GetSuggestionsRequest = + request.copy( + startIndex = startIndex, + limit = limit, + enableTotalRecordCount = enableTotalRecordCount, + ) - override suspend fun execute( - api: ApiClient, - request: GetSuggestionsRequest, - ): Response<BaseItemDtoQueryResult> = api.suggestionsApi.getSuggestions(request) - } + override suspend fun execute( + api: ApiClient, + request: GetSuggestionsRequest, + ): Response<BaseItemDtoQueryResult> = api.suggestionsApi.getSuggestions(request) +} @Serializable -val GetPlaylistItemsRequestHandler = - object : RequestHandler<GetPlaylistItemsRequest> { - override fun prepare( - request: GetPlaylistItemsRequest, - startIndex: Int, - limit: Int, - enableTotalRecordCount: Boolean, - ): GetPlaylistItemsRequest = - request.copy( - startIndex = startIndex, - limit = limit, - ) +object GetPlaylistItemsRequestHandler : RequestHandler<GetPlaylistItemsRequest> { + override fun prepare( + request: GetPlaylistItemsRequest, + startIndex: Int, + limit: Int, + enableTotalRecordCount: Boolean, + ): GetPlaylistItemsRequest = + request.copy( + startIndex = startIndex, + limit = limit, + ) - override suspend fun execute( - api: ApiClient, - request: GetPlaylistItemsRequest, - ): Response<BaseItemDtoQueryResult> = api.playlistsApi.getPlaylistItems(request) - } + override suspend fun execute( + api: ApiClient, + request: GetPlaylistItemsRequest, + ): Response<BaseItemDtoQueryResult> = api.playlistsApi.getPlaylistItems(request) +} @Serializable -val GetGenresRequestHandler = - object : RequestHandler<GetGenresRequest> { - override fun prepare( - request: GetGenresRequest, - startIndex: Int, - limit: Int, - enableTotalRecordCount: Boolean, - ): GetGenresRequest = - request.copy( - startIndex = startIndex, - limit = limit, - enableTotalRecordCount = enableTotalRecordCount, - ) +object GetGenresRequestHandler : RequestHandler<GetGenresRequest> { + override fun prepare( + request: GetGenresRequest, + startIndex: Int, + limit: Int, + enableTotalRecordCount: Boolean, + ): GetGenresRequest = + request.copy( + startIndex = startIndex, + limit = limit, + enableTotalRecordCount = enableTotalRecordCount, + ) - override suspend fun execute( - api: ApiClient, - request: GetGenresRequest, - ): Response<BaseItemDtoQueryResult> = api.genresApi.getGenres(request) - } - -val GetProgramsDtoHandler = - object : RequestHandler<GetProgramsDto> { - override fun prepare( - request: GetProgramsDto, - startIndex: Int, - limit: Int, - enableTotalRecordCount: Boolean, - ): GetProgramsDto = - request.copy( - startIndex = startIndex, - limit = limit, - ) - - override suspend fun execute( - api: ApiClient, - request: GetProgramsDto, - ): Response<BaseItemDtoQueryResult> = api.liveTvApi.getPrograms(request) - } + override suspend fun execute( + api: ApiClient, + request: GetGenresRequest, + ): Response<BaseItemDtoQueryResult> = api.genresApi.getGenres(request) +} @Serializable -val GetPersonsHandler = - object : RequestHandler<GetPersonsRequest> { - override fun prepare( - request: GetPersonsRequest, - startIndex: Int, - limit: Int, - enableTotalRecordCount: Boolean, - ): GetPersonsRequest = - request.copy( +object GetProgramsDtoHandler : RequestHandler<GetProgramsDto> { + override fun prepare( + request: GetProgramsDto, + startIndex: Int, + limit: Int, + enableTotalRecordCount: Boolean, + ): GetProgramsDto = + request.copy( + startIndex = startIndex, + limit = limit, + ) + + override suspend fun execute( + api: ApiClient, + request: GetProgramsDto, + ): Response<BaseItemDtoQueryResult> = api.liveTvApi.getPrograms(request) +} + +@Serializable +object GetPersonsHandler : RequestHandler<GetPersonsRequest> { + override fun prepare( + request: GetPersonsRequest, + startIndex: Int, + limit: Int, + enableTotalRecordCount: Boolean, + ): GetPersonsRequest = + request.copy( // startIndex = startIndex, - limit = limit, - ) + limit = limit, + ) - override suspend fun execute( - api: ApiClient, - request: GetPersonsRequest, - ): Response<BaseItemDtoQueryResult> = api.personsApi.getPersons((request)) - } - -val GetStudiosRequestHandler = - object : RequestHandler<GetStudiosRequest> { - override fun prepare( - request: GetStudiosRequest, - startIndex: Int, - limit: Int, - enableTotalRecordCount: Boolean, - ): GetStudiosRequest = - request.copy( - startIndex = startIndex, - limit = limit, - enableTotalRecordCount = enableTotalRecordCount, - ) - - override suspend fun execute( - api: ApiClient, - request: GetStudiosRequest, - ): Response<BaseItemDtoQueryResult> = api.studiosApi.getStudios(request) + override suspend fun execute( + api: ApiClient, + request: GetPersonsRequest, + ): Response<BaseItemDtoQueryResult> = api.personsApi.getPersons((request)) +} + +@Serializable +object GetStudiosRequestHandler : RequestHandler<GetStudiosRequest> { + override fun prepare( + request: GetStudiosRequest, + startIndex: Int, + limit: Int, + enableTotalRecordCount: Boolean, + ): GetStudiosRequest = + request.copy( + startIndex = startIndex, + limit = limit, + enableTotalRecordCount = enableTotalRecordCount, + ) + + override suspend fun execute( + api: ApiClient, + request: GetStudiosRequest, + ): Response<BaseItemDtoQueryResult> = api.studiosApi.getStudios(request) +} + +@Serializable +object GetRecordingsRequestHandler : RequestHandler<GetRecordingsRequest> { + override fun prepare( + request: GetRecordingsRequest, + startIndex: Int, + limit: Int, + enableTotalRecordCount: Boolean, + ): GetRecordingsRequest = + request.copy( + startIndex = startIndex, + limit = limit, + enableTotalRecordCount = enableTotalRecordCount, + ) + + override suspend fun execute( + api: ApiClient, + request: GetRecordingsRequest, + ): Response<BaseItemDtoQueryResult> = api.liveTvApi.getRecordings(request) +} + +@Serializable +object GetLiveTvChannelsRequestHandler : RequestHandler<GetLiveTvChannelsRequest> { + override fun prepare( + request: GetLiveTvChannelsRequest, + startIndex: Int, + limit: Int, + enableTotalRecordCount: Boolean, + ): GetLiveTvChannelsRequest = + request.copy( + startIndex = startIndex, + limit = limit, +// enableTotalRecordCount = enableTotalRecordCount, + ) + + override suspend fun execute( + api: ApiClient, + request: GetLiveTvChannelsRequest, + ): Response<BaseItemDtoQueryResult> = api.liveTvApi.getLiveTvChannels(request) +} + +@Serializable +object GetArtistsHandler : RequestHandler<GetArtistsRequest> { + override fun prepare( + request: GetArtistsRequest, + startIndex: Int, + limit: Int, + enableTotalRecordCount: Boolean, + ): GetArtistsRequest = + request.copy( + startIndex = startIndex, + limit = limit, + ) + + override suspend fun execute( + api: ApiClient, + request: GetArtistsRequest, + ): Response<BaseItemDtoQueryResult> = api.artistsApi.getArtists((request)) +} + +@Serializable +object GetAlbumArtistsHandler : RequestHandler<GetAlbumArtistsRequest> { + override fun prepare( + request: GetAlbumArtistsRequest, + startIndex: Int, + limit: Int, + enableTotalRecordCount: Boolean, + ): GetAlbumArtistsRequest = + request.copy( + startIndex = startIndex, + limit = limit, + ) + + override suspend fun execute( + api: ApiClient, + request: GetAlbumArtistsRequest, + ): Response<BaseItemDtoQueryResult> = api.artistsApi.getAlbumArtists(request) +} + +val requestSerializersModule = + SerializersModule { + polymorphic(Any::class) { + subclass(GetItemsRequest::class) + subclass(GetEpisodesRequest::class) + subclass(GetResumeItemsRequest::class) + subclass(GetNextUpRequest::class) + subclass(GetSuggestionsRequest::class) + subclass(GetPlaylistItemsRequest::class) + subclass(GetGenresRequest::class) + subclass(GetProgramsDto::class) + subclass(GetPersonsRequest::class) + subclass(GetStudiosRequest::class) + subclass(GetRecordingsRequest::class) + subclass(GetLiveTvChannelsRequest::class) + subclass(GetArtistsRequest::class) + subclass(GetAlbumArtistsRequest::class) + } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/BitstreamFilteringCodecAdapterFactory.kt b/app/src/main/java/com/github/damontecres/wholphin/util/BitstreamFilteringCodecAdapterFactory.kt new file mode 100644 index 00000000..cb1486f2 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/util/BitstreamFilteringCodecAdapterFactory.kt @@ -0,0 +1,128 @@ +@file:OptIn(markerClass = [UnstableApi::class]) + +package com.github.damontecres.wholphin.util + +import android.media.MediaCodec +import androidx.annotation.OptIn +import androidx.media3.common.MimeTypes +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.mediacodec.ForwardingMediaCodecAdapter +import androidx.media3.exoplayer.mediacodec.MediaCodecAdapter +import timber.log.Timber +import java.nio.ByteBuffer + +/** + * Rewrites the bitstream of an access unit after the extractor and before it reaches the decoder. + * + * Implementations edit the buffer in place. Overwriting bytes leaves the access unit the size it + * was, removing bytes does not: a filter which drops part of an access unit has to move the bytes + * it keeps so that they stay contiguous from `offset`, and report the shorter length. The buffer's + * position and limit are not to be modified. + * + * The buffer is the decoder's input buffer, so it is direct: `hasArray()` is false and `array()` + * throws. Read and write it with the absolute `get(int)` and `put(int, Byte)`, or through a + * [ByteBuffer.duplicate] for bulk moves, never through a backing array. The absolute bulk + * `put(int, ByteBuffer, int, int)` is API 34 and `minSdk` is 23, so moving bytes means absolute + * puts or a duplicate. + */ +fun interface BitstreamFilter { + /** + * Filters the access unit in `[offset, offset + size)`. + * + * @return the new size of the access unit, which is never larger than [size]. Filters which + * only overwrite bytes return [size] unchanged. + */ + fun filter( + data: ByteBuffer, + offset: Int, + size: Int, + ): Int +} + +/** + * A [MediaCodecAdapter.Factory] which runs [filters] over the samples of Dolby Vision streams + * before they are queued to the decoder. + * + * The wrapper is only applied when a Dolby Vision decoder was actually selected, so playback which + * falls back to a regular HEVC/AVC decoder sees the bitstream as it came out of the extractor. + */ +class BitstreamFilteringCodecAdapterFactory( + private val delegate: MediaCodecAdapter.Factory, + private val filters: List<BitstreamFilter>, +) : MediaCodecAdapter.Factory { + override fun createAdapter(configuration: MediaCodecAdapter.Configuration): MediaCodecAdapter { + val adapter = delegate.createAdapter(configuration) + return if (filters.isNotEmpty() && configuration.codecInfo.mimeType == MimeTypes.VIDEO_DOLBY_VISION) { + Timber.i( + "Filtering the bitstream of Dolby Vision playback on %s with %s", + configuration.codecInfo.name, + filters.joinToString { it.javaClass.simpleName }, + ) + BitstreamFilteringCodecAdapter(adapter, filters) + } else { + adapter + } + } +} + +/** + * Runs each filter over the access unit in `[offset, offset + size)`, handing the next filter the + * length the previous one reported, and returns the length that is left to queue. A filter which + * reports a length it cannot have produced is ignored rather than trusted, since queueing the + * wrong length would feed the decoder a truncated or over long access unit. + */ +internal fun applyBitstreamFilters( + filters: List<BitstreamFilter>, + data: ByteBuffer, + offset: Int, + size: Int, +): Int { + var filteredSize = size + for (filter in filters) { + if (filteredSize <= 0) { + break + } + val newSize = filter.filter(data, offset, filteredSize) + if (newSize in 0..filteredSize) { + filteredSize = newSize + } else { + Timber.w( + "%s returned %d bytes for a %d byte access unit, keeping the access unit as it is", + filter.javaClass.simpleName, + newSize, + filteredSize, + ) + } + } + return filteredSize +} + +/** + * Filters each input buffer before it is queued. Encrypted samples go through + * [MediaCodecAdapter.queueSecureInputBuffer] and are forwarded untouched. + */ +internal class BitstreamFilteringCodecAdapter( + delegate: MediaCodecAdapter, + private val filters: List<BitstreamFilter>, +) : ForwardingMediaCodecAdapter(delegate) { + override fun queueInputBuffer( + index: Int, + offset: Int, + size: Int, + presentationTimeUs: Long, + flags: Int, + ) { + var filteredSize = size + if (size > 0 && (flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG) == 0) { + getInputBuffer(index)?.let { buffer -> + filteredSize = applyBitstreamFilters(filters, buffer, offset, size) + } + } + super.queueInputBuffer(index, offset, filteredSize, presentationTimeUs, flags) + } + + override fun release() { + Timber.d("Releasing bitstream filtering adapter, %s", filters.joinToString()) + super.release() + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/BlockingList.kt b/app/src/main/java/com/github/damontecres/wholphin/util/BlockingList.kt index 08207e53..80272719 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/BlockingList.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/BlockingList.kt @@ -2,6 +2,7 @@ package com.github.damontecres.wholphin.util import java.util.function.IntFunction import java.util.function.Predicate +import kotlin.random.Random /** * A [List] which has function that will wait for a result @@ -17,6 +18,11 @@ interface BlockingList<T> : List<T> { */ suspend fun indexOfBlocking(predicate: Predicate<T>): Int + suspend fun randomBlocking(): T { + val index = Random.nextInt(size) + return getBlocking(index) + } + companion object { /** * Create a [BlockingList] over a regular [List] diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/Constants.kt b/app/src/main/java/com/github/damontecres/wholphin/util/Constants.kt index 0c577571..f2078766 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/Constants.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/Constants.kt @@ -33,14 +33,7 @@ val supportedCollectionTypes = CollectionType.MUSICVIDEOS, CollectionType.FOLDERS, CollectionType.MUSIC, - null, // Mixed - ) - -val supportedHomeCollectionTypes = - setOf( - CollectionType.MOVIES, - CollectionType.TVSHOWS, - CollectionType.HOMEVIDEOS, + CollectionType.PHOTOS, null, // Mixed ) @@ -53,4 +46,16 @@ val supportedPlayableTypes = BaseItemKind.TV_CHANNEL, BaseItemKind.TV_PROGRAM, BaseItemKind.RECORDING, + BaseItemKind.SEASON, + BaseItemKind.SERIES, + BaseItemKind.BOX_SET, + BaseItemKind.PLAYLIST, + ) + +val supportedShufflableTypes = + setOf( + BaseItemKind.SEASON, + BaseItemKind.SERIES, + BaseItemKind.BOX_SET, + BaseItemKind.PLAYLIST, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/CoroutineContextApiClient.kt b/app/src/main/java/com/github/damontecres/wholphin/util/CoroutineContextApiClient.kt index 3ea6bdf9..412130d7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/CoroutineContextApiClient.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/CoroutineContextApiClient.kt @@ -1,7 +1,6 @@ package com.github.damontecres.wholphin.util import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClientFactory @@ -21,7 +20,7 @@ import kotlin.coroutines.CoroutineContext */ class CoroutineContextApiClient( private val client: ApiClient, - private val coroutineContext: CoroutineContext = Dispatchers.IO, + private val coroutineContext: CoroutineContext = WholphinDispatchers.IO, ) : ApiClient() { override val baseUrl: String? get() = client.baseUrl @@ -65,7 +64,7 @@ class CoroutineContextApiClient( class CoroutineContextApiClientFactory( private val factory: OkHttpFactory, - private val coroutineContext: CoroutineContext = Dispatchers.IO, + private val coroutineContext: CoroutineContext = WholphinDispatchers.IO, ) : ApiClientFactory, SocketConnectionFactory { override fun create( diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/CrashReportSenderFactory.kt b/app/src/main/java/com/github/damontecres/wholphin/util/CrashReportSenderFactory.kt index c334967f..d83ae269 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/CrashReportSenderFactory.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/CrashReportSenderFactory.kt @@ -1,7 +1,7 @@ package com.github.damontecres.wholphin.util import android.content.Context -import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.MostRecentServer import com.github.damontecres.wholphin.services.hilt.AppModule import com.github.damontecres.wholphin.services.hilt.DeviceModule import com.google.auto.service.AutoService @@ -13,6 +13,7 @@ import org.acra.sender.ReportSender import org.acra.sender.ReportSenderException import org.acra.sender.ReportSenderFactory import org.jellyfin.sdk.Jellyfin +import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.clientLogApi import org.jellyfin.sdk.api.okhttp.OkHttpFactory import org.jellyfin.sdk.createJellyfin @@ -39,26 +40,10 @@ class CrashReportSender : ReportSender { errorContent: CrashReportData, ) { Timber.v("Attempting to send crash report") - val prefs = ServerRepository.getServerSharedPreferences(context) - val serverUrl = prefs.getString(ServerRepository.SERVER_URL_KEY, null) - val accessToken = prefs.getString(ServerRepository.ACCESS_TOKEN_KEY, null) - if (serverUrl != null && accessToken != null) { - try { - val okHttpClient = - OkHttpClient - .Builder() - .build() - val okHttpFactory = OkHttpFactory(okHttpClient) - val api = - createJellyfin { - this.context = context - clientInfo = AppModule.clientInfo(context) - deviceInfo = DeviceModule.deviceInfo(context) - apiClientFactory = okHttpFactory - socketConnectionFactory = okHttpFactory - minimumServerVersion = Jellyfin.minimumVersion - }.createApi(baseUrl = serverUrl, accessToken = accessToken) - + try { + val recentServer = AppModule.mostRecentServerProvider(context).get() + val api = recentServer.createApi(context) + if (api != null) { val obj = JSONObject() for ((key, value) in errorContent.toMap()) { obj.put(key, value) @@ -75,13 +60,32 @@ class CrashReportSender : ReportSender { """.trimIndent() + jsonStr, ).content.fileName - Timber.i("Sent report to $serverUrl, filename=$filename") + Timber.i("Sent report to %s, filename=%s", api.baseUrl, filename) } - } catch (ex: Exception) { - throw ReportSenderException("Exception while sending crash report", ex) + } else { + throw ReportSenderException("Could not find valid server and/or credentials to use") } - } else { - throw ReportSenderException("Could not find valid server and/or credentials to use") + } catch (ex: Exception) { + throw ReportSenderException("Exception while sending crash report", ex) } } } + +private fun MostRecentServer.createApi(context: Context): ApiClient? = + (this as? MostRecentServer.ServerAndUser)?.let { + val okHttpClient = + OkHttpClient + .Builder() + .build() + val okHttpFactory = OkHttpFactory(okHttpClient) + val api = + createJellyfin { + this.context = context + clientInfo = AppModule.clientInfo(context) + deviceInfo = DeviceModule.deviceInfo(context) + apiClientFactory = okHttpFactory + socketConnectionFactory = okHttpFactory + minimumServerVersion = Jellyfin.minimumVersion + }.createApi(baseUrl = serverUrl, accessToken = accessToken) + api + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/DiscoverRequestPager.kt b/app/src/main/java/com/github/damontecres/wholphin/util/DiscoverRequestPager.kt index cee890ce..ceb4f160 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/DiscoverRequestPager.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/DiscoverRequestPager.kt @@ -4,10 +4,16 @@ import androidx.annotation.StringRes import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.api.seerr.model.MovieResult import com.github.damontecres.wholphin.api.seerr.model.TvResult +import com.github.damontecres.wholphin.data.filter.DiscoverFilter import com.github.damontecres.wholphin.data.model.DiscoverItem +import com.github.damontecres.wholphin.data.model.SeerrItemType import com.github.damontecres.wholphin.services.SeerrApi import com.github.damontecres.wholphin.services.SeerrSearchResult +import com.github.damontecres.wholphin.ui.discover.discoverGenreTitle +import com.github.damontecres.wholphin.ui.util.ResStringProvider +import com.github.damontecres.wholphin.ui.util.StringProvider import kotlinx.coroutines.CoroutineScope +import kotlinx.serialization.Serializable /** * A [RequestPager] for Seerr server queries @@ -45,6 +51,27 @@ enum class DiscoverRequestType( UNKNOWN(R.string.unknown), } +@Serializable +sealed interface DiscoverPagerType { + val title: StringProvider + + @Serializable + data class RequestType( + val type: DiscoverRequestType, + ) : DiscoverPagerType { + override val title: StringProvider = ResStringProvider(type.stringRes) + } + + @Serializable + data class Genre( + val genreId: Int, + val name: String, + val type: SeerrItemType, + ) : DiscoverPagerType { + override val title: StringProvider = discoverGenreTitle(name, type) + } +} + /** * Specifies how a [RequestPager] should prepare and execute API calls */ @@ -55,27 +82,29 @@ interface DiscoverRequestHandler<T> { ): QueryResult<T> } -val DiscoverTvRequestHandler = - object : DiscoverRequestHandler<TvResult> { - override suspend fun execute( - api: SeerrApi, - pageNumber: Int, - ): QueryResult<TvResult> = - api.api.searchApi.discoverTvGet(page = pageNumber).let { - QueryResult(it.results.orEmpty(), it.totalResults ?: 0) - } - } +class DiscoverTvRequestHandler( + val filter: DiscoverFilter = DiscoverFilter(), +) : DiscoverRequestHandler<TvResult> { + override suspend fun execute( + api: SeerrApi, + pageNumber: Int, + ): QueryResult<TvResult> = + filter.discoverTv(api.api.searchApi, pageNumber).let { + QueryResult(it.results.orEmpty(), it.totalResults ?: 0) + } +} -val DiscoverMovieRequestHandler = - object : DiscoverRequestHandler<MovieResult> { - override suspend fun execute( - api: SeerrApi, - pageNumber: Int, - ): QueryResult<MovieResult> = - api.api.searchApi.discoverMoviesGet(page = pageNumber).let { - QueryResult(it.results.orEmpty(), it.totalResults ?: 0) - } - } +class DiscoverMovieRequestHandler( + val filter: DiscoverFilter = DiscoverFilter(), +) : DiscoverRequestHandler<MovieResult> { + override suspend fun execute( + api: SeerrApi, + pageNumber: Int, + ): QueryResult<MovieResult> = + filter.discoverMovies(api.api.searchApi, pageNumber).let { + QueryResult(it.results.orEmpty(), it.totalResults ?: 0) + } +} val TrendingRequestHandler = object : DiscoverRequestHandler<SeerrSearchResult> { diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7Conversion.kt b/app/src/main/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7Conversion.kt new file mode 100644 index 00000000..b4f8e15f --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7Conversion.kt @@ -0,0 +1,71 @@ +@file:OptIn(UnstableApi::class) + +package com.github.damontecres.wholphin.util + +import androidx.annotation.OptIn +import androidx.media3.common.Format +import androidx.media3.common.MimeTypes +import androidx.media3.common.util.UnstableApi +import com.github.damontecres.wholphin.preferences.ExperimentalPreferences +import com.github.damontecres.wholphin.preferences.enabled +import com.github.damontecres.wholphin.util.profile.MediaCodecCapabilitiesTest + +/** + * The one place which decides whether Dolby Vision profile 7 is converted to profile 8.1 on this + * device, and how a converted track is described. + * + * Profile 7 is HDR10 compatible HEVC plus two extra NAL unit types: 62 carries the RPU metadata and + * 63 the enhancement layer. A decoder which only lists profile 8 takes such a stream but does not + * switch the display into Dolby Vision for it, so the picture comes out as the HDR10 base layer. + * Rewriting the RPU into its profile 8.1 form and dropping the enhancement layer produces the + * single layer stream that decoder does switch for. Nothing is re-encoded. + * + * The conversion has two halves which have to agree: + * - [DolbyVisionProfile7Filter] rewrites the access units on their way into the decoder; + * - [DolbyVisionProfile7ExtractorsFactory] rewrites the codec string of the track, because + * media3 picks the decoder by `Format.codecs` and a decoder which does not list profile 7 would + * otherwise never be chosen for it. + * + * Both are switched on by [isWanted], as is direct play of profile 7 in the device profile. + */ +object DolbyVisionProfile7Conversion { + /** + * True when both the user has enabled the setting and this device is one it helps: its Dolby + * Vision decoder lists profile 8 but not profile 7. A device which decodes profile 7 itself + * is left alone, since it can use the enhancement layer the conversion would throw away, and + * a device without a profile 8 decoder has nothing to handle the converted stream + */ + fun isWanted( + experimental: ExperimentalPreferences, + mediaTest: MediaCodecCapabilitiesTest, + ): Boolean = + experimental.enabled { convertDolbyVisionProfile7 } && + !mediaTest.supportsHevcDolbyVisionEL() && + mediaTest.supportsHevcDolbyVisionProfile8() + + /** Whether [format] is a Dolby Vision profile 7 video track, in either sample entry form. */ + fun isProfile7(format: Format): Boolean { + val codecs = format.codecs ?: return false + return MimeTypes.VIDEO_DOLBY_VISION == format.sampleMimeType && + (codecs.startsWith(CODECS_PREFIX_DVHE_07) || codecs.startsWith(CODECS_PREFIX_DVH1_07)) + } + + /** + * The [format] as the converted stream should be advertised: `dvhe.07.06` becomes + * `dvhe.08.06`, the level and the sample entry prefix stay. Encrypted tracks are left as they + * are: their samples reach the decoder through the secure path the filter does not touch, so + * advertising them as converted would be a lie. + */ + fun advertiseAsProfile8(format: Format): Format = + if (isProfile7(format) && format.drmInitData == null) { + format.buildUpon().setCodecs(toProfile8(format.codecs!!)).build() + } else { + format + } + + /** `dvhe.07.06` → `dvhe.08.06`. The caller checks [isProfile7] first. */ + fun toProfile8(codecs: String): String = codecs.substring(0, 5) + "08" + codecs.substring(7) + + private const val CODECS_PREFIX_DVHE_07 = "dvhe.07" + private const val CODECS_PREFIX_DVH1_07 = "dvh1.07" +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7ExtractorsFactory.kt b/app/src/main/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7ExtractorsFactory.kt new file mode 100644 index 00000000..632f46c3 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7ExtractorsFactory.kt @@ -0,0 +1,79 @@ +@file:OptIn(markerClass = [UnstableApi::class]) + +package com.github.damontecres.wholphin.util + +import android.net.Uri +import androidx.annotation.OptIn +import androidx.media3.common.C +import androidx.media3.common.Format +import androidx.media3.common.util.UnstableApi +import androidx.media3.extractor.Extractor +import androidx.media3.extractor.ExtractorOutput +import androidx.media3.extractor.ExtractorsFactory +import androidx.media3.extractor.ForwardingExtractor +import androidx.media3.extractor.ForwardingExtractorOutput +import androidx.media3.extractor.ForwardingExtractorsFactory +import androidx.media3.extractor.ForwardingTrackOutput +import androidx.media3.extractor.TrackOutput +import timber.log.Timber + +/** + * Advertises Dolby Vision profile 7 video tracks as profile 8.1, which is what + * [DolbyVisionProfile7Filter] turns their samples into before the decoder sees them. + * + * media3 chooses the decoder by `Format.codecs`, and a decoder which lists profile 8 but not + * profile 7 answers "not supported" to `dvhe.07`; with decoder fallback on, playback then lands on + * whichever decoder comes first in the list, with a warning that the format exceeds its + * capabilities. Rewriting the codec string at the extractor routes the track to the profile 8 + * decoder, and it also sets `MediaFormat.KEY_PROFILE` right, which media3 derives from the same + * string. + * + * Only the codec string changes. Sample data is not touched here: the two convenience overloads of + * [TrackOutput.sampleData] go straight to the delegate, which is exactly what is wanted. + */ +class DolbyVisionProfile7ExtractorsFactory( + delegate: ExtractorsFactory, +) : ForwardingExtractorsFactory(delegate) { + override fun createExtractors(): Array<Extractor> = wrap(super.createExtractors()) + + override fun createExtractors( + uri: Uri, + responseHeaders: Map<String, List<String>>, + ): Array<Extractor> = wrap(super.createExtractors(uri, responseHeaders)) + + private fun wrap(extractors: Array<Extractor>): Array<Extractor> = + Array(extractors.size) { Profile8AdvertisingExtractor(extractors[it]) } +} + +private class Profile8AdvertisingExtractor( + delegate: Extractor, +) : ForwardingExtractor(delegate) { + override fun init(output: ExtractorOutput) { + super.init(Profile8AdvertisingExtractorOutput(output)) + } +} + +private class Profile8AdvertisingExtractorOutput( + output: ExtractorOutput, +) : ForwardingExtractorOutput(output) { + override fun track( + id: Int, + type: Int, + ): TrackOutput { + val trackOutput = super.track(id, type) + return if (type == C.TRACK_TYPE_VIDEO) Profile8AdvertisingTrackOutput(trackOutput) else trackOutput + } +} + +/** Rewrites the codec string of every profile 7 format it is handed; everything else passes unchanged. */ +internal class Profile8AdvertisingTrackOutput( + delegate: TrackOutput, +) : ForwardingTrackOutput(delegate) { + override fun format(format: Format) { + val advertised = DolbyVisionProfile7Conversion.advertiseAsProfile8(format) + if (advertised !== format) { + Timber.i("Advertising Dolby Vision track %s as %s for conversion", format.codecs, advertised.codecs) + } + super.format(advertised) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7Filter.kt b/app/src/main/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7Filter.kt new file mode 100644 index 00000000..7cf50622 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7Filter.kt @@ -0,0 +1,313 @@ +@file:OptIn(markerClass = [UnstableApi::class]) + +package com.github.damontecres.wholphin.util + +import androidx.annotation.OptIn +import androidx.media3.common.util.UnstableApi +import com.suyashbelekar.exoplayerhdrutils.libdovi.LibDovi +import timber.log.Timber +import java.nio.Buffer +import java.nio.ByteBuffer + +/** + * Rewrites the Dolby Vision RPU of one access unit, in place. The production implementation is + * libdovi, as packaged by ExoplayerHdrUtils; tests substitute their own. + * + * Both methods take a direct buffer whose access unit starts at index 0 and is [size] bytes long. + * That is the shape the native code reads: it takes the buffer's address and capacity, not its + * position and limit. + */ +interface RpuRewriter { + /** + * The Dolby Vision profile of the RPU (NAL unit type 62) in the access unit, or 0 when there is + * none it can parse. + */ + fun profileOf( + frame: ByteBuffer, + size: Int, + ): Int + + /** + * Rewrites the profile 7 RPU in the access unit into its profile 8.1 form and returns the new + * size of the access unit. The bytes after the RPU move to stay contiguous, so the buffer's + * capacity must leave room past [size] for an RPU which comes out longer than it went in. + */ + fun convertToProfile8( + frame: ByteBuffer, + size: Int, + ): Int +} + +/** [RpuRewriter] on libdovi. Constructing it loads the native library, which throws a [LinkageError] on an ABI it does not ship for. */ +class LibDoviRpuRewriter : RpuRewriter { + private val libDovi = LibDovi() + + override fun profileOf( + frame: ByteBuffer, + size: Int, + ): Int = libDovi.getFrameInfo(frame, size)?.doviProfile ?: 0 + + override fun convertToProfile8( + frame: ByteBuffer, + size: Int, + ): Int = libDovi.processHevcFrame(frame, size, DOVI_TRANSFORM_CONVERT_TO_PROFILE_8, false) + + private companion object { + /** `doviTransform` value of `LibDovi.processHevcFrame` for the profile 7 → 8.1 rewrite. */ + const val DOVI_TRANSFORM_CONVERT_TO_PROFILE_8 = 1 + } +} + +/** + * Converts Dolby Vision profile 7 access units to profile 8.1 on their way into the decoder. + * + * Profile 7 carries its RPU in NAL unit type 62 and its enhancement layer in type 63, after the + * picture's own NAL units. The conversion drops every type 63 unit and has libdovi rewrite the RPU + * into the profile 8.1 form, which is what a profile 8 decoder switches the display into Dolby + * Vision for. The enhancement layer goes because profile 8.1 is single layer by definition: a + * decoder handed a dual layer stream under that name keeps it out of the Dolby Vision pipeline and + * shows the HDR10 base layer, which is the very symptom this filter exists to fix. + * + * Every access unit is judged on its own. One without an RPU, or whose RPU is not profile 7, is + * left exactly as it came, and so is one libdovi refuses. The rewrite happens in a scratch buffer + * and is copied back only once it has succeeded and fits, so a refused access unit reaches the + * decoder untouched rather than half rewritten. + * + * The codec string of the track has to say profile 8 for the decoder to be chosen at all; that is + * [DolbyVisionProfile7ExtractorsFactory]'s job. An instance belongs to one video renderer and is + * called from its playback thread only. + */ +class DolbyVisionProfile7Filter( + private val newRewriter: () -> RpuRewriter = { LibDoviRpuRewriter() }, +) : BitstreamFilter { + private var rewriter: RpuRewriter? = null + private var rewriterUnavailable = false + + /** The access unit as it arrived, for scanning: a heap array is much faster to walk than absolute gets on a direct buffer. */ + private var staging = ByteArray(0) + + /** The access unit without its enhancement layer, where libdovi rewrites the RPU. Direct, because the native code needs its address. */ + private var scratch: ByteBuffer? = null + + private var convertedCount = 0 + private var passedThroughCount = 0 + private var refusedCount = 0 + + override fun filter( + data: ByteBuffer, + offset: Int, + size: Int, + ): Int { + if (!startsWithStartCode(data, offset, size)) { + return size + } + val rewriter = rewriter() ?: return size + + val staging = staging(size) + window(data, offset, size).get(staging, 0, size) + if (!carriesRpu(staging, size)) { + passedThroughCount++ + return size + } + + val scratch = scratch(size + RPU_GROWTH_HEADROOM_BYTES) + val kept = copyWithoutEnhancementLayer(staging, size, scratch) + val converted = + try { + if (rewriter.profileOf(scratch, kept) != DOLBY_VISION_PROFILE_7) { + passedThroughCount++ + return size + } + rewriter.convertToProfile8(scratch, kept) + } catch (e: RuntimeException) { + refuse("the RPU rewrite threw", e) + return size + } + if (converted <= 0 || converted > size) { + refuse("the RPU rewrite returned $converted bytes for a $size byte access unit", null) + return size + } + + window(data, offset, converted).put(window(scratch, 0, converted)) + convertedCount++ + return converted + } + + private fun rewriter(): RpuRewriter? { + if (rewriterUnavailable) { + return null + } + return rewriter ?: try { + newRewriter().also { rewriter = it } + } catch (e: LinkageError) { + // No libdovi for this ABI. Playback goes on exactly as it did without the filter. + Timber.w(e, "libdovi is not available on this device, Dolby Vision profile 7 is left as it is") + rewriterUnavailable = true + null + } + } + + private fun refuse( + reason: String, + e: Throwable?, + ) { + if (refusedCount == 0) { + Timber.w(e, "Leaving a Dolby Vision profile 7 access unit as it is: %s", reason) + } + refusedCount++ + } + + private fun staging(size: Int): ByteArray { + if (staging.size < size) { + staging = ByteArray(maxOf(size, INITIAL_CAPACITY_BYTES)) + } + return staging + } + + private fun scratch(capacity: Int): ByteBuffer { + val current = scratch + val buffer = + if (current == null || current.capacity() < capacity) { + ByteBuffer.allocateDirect(maxOf(capacity, INITIAL_CAPACITY_BYTES)).also { scratch = it } + } else { + current + } + (buffer as Buffer).clear() + return buffer + } + + override fun toString(): String = + "Dolby Vision profile 7 → 8.1 ($convertedCount access units converted, " + + "$passedThroughCount passed through, $refusedCount refused)" + + private companion object { + const val DOLBY_VISION_PROFILE_7 = 7 + + /** HEVC NAL unit type carrying the Dolby Vision RPU. */ + const val NAL_UNIT_TYPE_RPU = 62 + + /** HEVC NAL unit type carrying the Dolby Vision enhancement layer picture. */ + const val NAL_UNIT_TYPE_ENHANCEMENT_LAYER = 63 + + /** Big enough for a UHD access unit without regrowing on every early frame. */ + const val INITIAL_CAPACITY_BYTES = 512 * 1024 + + /** + * Room past the access unit for an RPU which comes out of the rewrite longer than it went + * in. Dropping the enhancement layer usually frees far more, but a profile 7 stream whose + * enhancement layer the extractor never delivered has none to free. + */ + const val RPU_GROWTH_HEADROOM_BYTES = 4 * 1024 + + private fun startsWithStartCode( + data: ByteBuffer, + offset: Int, + size: Int, + ): Boolean { + if (size < 4) { + return false + } + return data.get(offset) == 0.toByte() && + data.get(offset + 1) == 0.toByte() && + ( + data.get(offset + 2) == 1.toByte() || + (data.get(offset + 2) == 0.toByte() && data.get(offset + 3) == 1.toByte()) + ) + } + + /** Whether the Annex B access unit in `[0, length)` of [source] carries an RPU. */ + private fun carriesRpu( + source: ByteArray, + length: Int, + ): Boolean { + var unit = 0 + while (unit < length) { + val payload = unit + startCodeLength(source, unit) + if (payload >= length) { + return false + } + if (nalUnitType(source[payload]) == NAL_UNIT_TYPE_RPU) { + return true + } + unit = nextStartCode(source, payload, length) + } + return false + } + + /** + * Copies the Annex B access unit in `[0, length)` of [source] into [target] from index 0, + * leaving out every enhancement layer NAL unit, and returns the number of bytes copied. + */ + fun copyWithoutEnhancementLayer( + source: ByteArray, + length: Int, + target: ByteBuffer, + ): Int { + var unit = 0 + var written = 0 + while (unit < length) { + val payload = unit + startCodeLength(source, unit) + if (payload >= length) { + // A start code with nothing behind it. Not something to interpret; kept as it came. + target.put(source, unit, length - unit) + written += length - unit + break + } + val next = nextStartCode(source, payload, length) + if (nalUnitType(source[payload]) != NAL_UNIT_TYPE_ENHANCEMENT_LAYER) { + target.put(source, unit, next - unit) + written += next - unit + } + unit = next + } + return written + } + + /** + * A view of `[offset, offset + length)` of [data] with a position and limit of its own, so + * that bulk copies leave the buffer's untouched. The position and limit calls go through + * [Buffer]: the [ByteBuffer] overrides which return a ByteBuffer are API 34, and `minSdk` is 23. + */ + private fun window( + data: ByteBuffer, + offset: Int, + length: Int, + ): ByteBuffer { + val view = data.duplicate() + val buffer: Buffer = view + buffer.clear() + buffer.position(offset) + buffer.limit(offset + length) + return view + } + + /** nal_unit_type is the 6 bits after forbidden_zero_bit, H.265 section 7.3.1.2. */ + private fun nalUnitType(header: Byte): Int = (header.toInt() shr 1) and 0x3F + + /** Length of the start code at [at], which the caller knows is one: 3 or 4 bytes. */ + private fun startCodeLength( + data: ByteArray, + at: Int, + ): Int = if (data[at + 2] == 1.toByte()) 3 else 4 + + /** + * Index of the next Annex B start code at or after [from], or [limit] if there is none. A + * fourth leading zero byte counts as part of the start code, which is the form media3's + * extractors emit; three byte codes are walked all the same. + */ + private fun nextStartCode( + data: ByteArray, + from: Int, + limit: Int, + ): Int { + var i = from + while (i + 2 < limit) { + if (data[i] == 0.toByte() && data[i + 1] == 0.toByte() && data[i + 2] == 1.toByte()) { + return if (i > from && data[i - 1] == 0.toByte()) i - 1 else i + } + i++ + } + return limit + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/EqualityMutableLiveData.kt b/app/src/main/java/com/github/damontecres/wholphin/util/EqualityMutableLiveData.kt deleted file mode 100644 index 7ee4d0f8..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/util/EqualityMutableLiveData.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.github.damontecres.wholphin.util - -import androidx.lifecycle.MutableLiveData - -/** - * A [MutableLiveData] that only notifies observers if the value does not equal the previous value - */ -class EqualityMutableLiveData<T> : MutableLiveData<T> { - constructor() : super() - constructor(value: T) : super(value) - - override fun setValue(value: T?) { - if (value != getValue()) { - super.setValue(value) - } - } - - fun setValueNoCheck(value: T?) { - super.setValue(value) - } - - override fun postValue(value: T?) { - if (value != getValue()) { - super.postValue(value) - } - } -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/Hdr10PlusSeiFilter.kt b/app/src/main/java/com/github/damontecres/wholphin/util/Hdr10PlusSeiFilter.kt new file mode 100644 index 00000000..7b5d73e0 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/util/Hdr10PlusSeiFilter.kt @@ -0,0 +1,266 @@ +@file:OptIn(markerClass = [UnstableApi::class]) + +package com.github.damontecres.wholphin.util + +import androidx.annotation.OptIn +import androidx.media3.common.util.UnstableApi +import androidx.media3.container.NalUnitUtil +import java.nio.ByteBuffer + +/** + * Masks HDR10+ (SMPTE ST 2094-40) SEI messages in HEVC access units so that decoders ignore them. + * + * Some decoder pipelines (notably on MediaTek based TVs) latch on to HDR10+ when a stream carries + * both Dolby Vision RPUs and HDR10+ dynamic metadata, even when a Dolby Vision decoder is selected. + * Overwriting the SEI payload type of the HDR10+ messages with an undefined value makes decoders + * skip them without changing the size of the access unit, so no NAL units need to be removed or + * rewritten. + */ +object Hdr10PlusSeiFilter { + /** + * Highest VCL NAL unit type in H.265 Table 7-1. Prefix SEI NAL units always precede the VCL + * NAL units of an access unit, so the scan can stop at the first one. + */ + private const val MAX_VCL_NAL_UNIT_TYPE = 31 + + /** `user_data_registered_itu_t_t35`, the SEI payload type used by HDR10+ (H.265 D.2.1). */ + private const val PAYLOAD_TYPE_USER_DATA_REGISTERED_ITU_T_T35 = 4 + + /** + * An SEI payload type H.265 does not define. H.265 D.3.1 requires a decoder to ignore SEI + * messages whose payload type it does not recognise, so writing this over the payload type + * makes the decoder skip the message without anything being removed or resized. + */ + private const val PAYLOAD_TYPE_MASKED = 254 + + /** + * ITU-T T.35 header identifying an HDR10+ message: `itu_t_t35_country_code` 0xB5 (United + * States), `itu_t_t35_terminal_provider_code` 0x003C (Samsung, which defined HDR10+) and + * `itu_t_t35_terminal_provider_oriented_code` 0x0001. FFmpeg declares the first two as + * `ITU_T_T35_COUNTRY_CODE_US` and `ITU_T_T35_PROVIDER_CODE_SAMSUNG` in `libavcodec/itut35.h`. + */ + private val HDR10_PLUS_T35_HEADER = intArrayOf(0xB5, 0x00, 0x3C, 0x00, 0x01) + + /** + * Masks every HDR10+ SEI message found in the HEVC access unit in `[offset, offset + size)` by + * overwriting its SEI payload type in place. The access unit must contain Annex B start code + * delimited NAL units (which is what media3's extractors emit); buffers with any other layout + * are left untouched. The buffer's position and limit are not modified. + * + * @return the number of masked SEI messages + */ + fun maskHdr10PlusSeiMessages( + data: ByteBuffer, + offset: Int, + size: Int, + ): Int { + val end = offset + size + if (!startsWithStartCode(data, offset, end)) { + return 0 + } + var masked = 0 + var searchFrom = offset + while (searchFrom < end) { + val nalStart = findNalUnitStart(data, searchFrom, end) + if (nalStart < 0 || nalStart >= end) { + break + } + val nalEnd = findNalUnitEnd(data, nalStart, end) + // nal_unit_type is the 6 bits after forbidden_zero_bit, H.265 section 7.3.1.2 + val nalUnitType = (data.get(nalStart).toInt() shr 1) and 0x3F + if (nalUnitType <= MAX_VCL_NAL_UNIT_TYPE) { + // HDR10+ SEI messages precede the VCL NAL units, so stop scanning here + break + } + if (nalUnitType == NalUnitUtil.H265_NAL_UNIT_TYPE_PREFIX_SEI) { + masked += maskInSeiNalUnit(data, nalStart + 2, nalEnd) + } + searchFrom = nalEnd + } + return masked + } + + private fun startsWithStartCode( + data: ByteBuffer, + offset: Int, + end: Int, + ): Boolean { + if (end - offset < 4) { + return false + } + return data.get(offset) == 0.toByte() && + data.get(offset + 1) == 0.toByte() && + ( + data.get(offset + 2) == 1.toByte() || + (data.get(offset + 2) == 0.toByte() && data.get(offset + 3) == 1.toByte()) + ) + } + + /** + * Returns the index of the first NAL unit byte after the next start code, or -1 if none. + * Start codes are the Annex B byte stream format. + */ + private fun findNalUnitStart( + data: ByteBuffer, + from: Int, + end: Int, + ): Int { + for (i in from..end - 4) { + if (data.get(i) == 0.toByte() && + data.get(i + 1) == 0.toByte() && + data.get(i + 2) == 1.toByte() + ) { + return i + 3 + } + } + return -1 + } + + /** Returns the index just past the NAL unit starting at [from], i.e. the next start code or [end]. */ + private fun findNalUnitEnd( + data: ByteBuffer, + from: Int, + end: Int, + ): Int { + for (i in from..end - 3) { + if (data.get(i) == 0.toByte() && + data.get(i + 1) == 0.toByte() && + data.get(i + 2) == 1.toByte() + ) { + return i + } + } + return end + } + + /** + * Parses the SEI messages in the NAL unit payload in `[payloadStart, nalEnd)` per H.265 + * section D.2.1 and masks the payload type of each HDR10+ message. Parsing is conservative: + * on any malformed data it stops without touching the remainder. + */ + private fun maskInSeiNalUnit( + data: ByteBuffer, + payloadStart: Int, + nalEnd: Int, + ): Int { + // Trim trailing_zero_8bits so the last byte is the RBSP trailing bits + var end = nalEnd + while (end > payloadStart && data.get(end - 1) == 0.toByte()) { + end-- + } + val reader = RbspReader(data, payloadStart, end) + var masked = 0 + while (reader.bytesRemaining > 1) { + var payloadType = 0 + var b = reader.readByte() + while (b == 0xFF) { + payloadType += 255 + b = reader.readByte() + } + if (b < 0) { + return masked + } + val payloadTypePosition = reader.lastBytePosition + payloadType += b + + var payloadSize = 0 + b = reader.readByte() + while (b == 0xFF) { + payloadSize += 255 + b = reader.readByte() + } + if (b < 0) { + return masked + } + payloadSize += b + if (payloadSize > reader.bytesRemaining) { + return masked + } + + var remainingPayload = payloadSize + var isHdr10Plus = + payloadType == PAYLOAD_TYPE_USER_DATA_REGISTERED_ITU_T_T35 && + payloadSize >= HDR10_PLUS_T35_HEADER.size + if (isHdr10Plus) { + for (expected in HDR10_PLUS_T35_HEADER) { + remainingPayload-- + if (reader.readByte() != expected) { + isHdr10Plus = false + break + } + } + } + repeat(remainingPayload) { + if (reader.readByte() < 0) { + return masked + } + } + if (isHdr10Plus) { + data.put(payloadTypePosition, PAYLOAD_TYPE_MASKED.toByte()) + masked++ + } + } + return masked + } + + /** + * Reads RBSP bytes from `[position, end)`, skipping the `emulation_prevention_three_byte` + * that H.265 section 7.4.2 inserts after two consecutive zero bytes. + */ + private class RbspReader( + private val data: ByteBuffer, + private var position: Int, + private val end: Int, + ) { + private var zeroRun = 0 + + /** Raw buffer index of the byte returned by the last [readByte] call. */ + var lastBytePosition = -1 + private set + + /** Upper bound of the remaining RBSP bytes. */ + val bytesRemaining: Int + get() = end - position + + /** Returns the next RBSP byte, or -1 if the end was reached. */ + fun readByte(): Int { + if (position >= end) { + return -1 + } + var b = data.get(position).toInt() and 0xFF + if (zeroRun >= 2 && b == 0x03) { + // Skip the emulation_prevention_three_byte + zeroRun = 0 + position++ + if (position >= end) { + return -1 + } + b = data.get(position).toInt() and 0xFF + } + zeroRun = if (b == 0) zeroRun + 1 else 0 + lastBytePosition = position + position++ + return b + } + } +} + +/** + * The [BitstreamFilter] form of [Hdr10PlusSeiFilter]. Masking overwrites one byte per message, so + * the access unit keeps the size it was handed. Instances count what they masked, which is what + * the adapter logs when it is released. + */ +class Hdr10PlusMaskingFilter : BitstreamFilter { + private var maskedCount = 0 + + override fun filter( + data: ByteBuffer, + offset: Int, + size: Int, + ): Int { + maskedCount += Hdr10PlusSeiFilter.maskHdr10PlusSeiMessages(data, offset, size) + return size + } + + override fun toString(): String = "HDR10+ SEI masking ($maskedCount messages masked)" +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/LoadingExceptionHandler.kt b/app/src/main/java/com/github/damontecres/wholphin/util/LoadingExceptionHandler.kt deleted file mode 100644 index 3e9c8ea9..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/util/LoadingExceptionHandler.kt +++ /dev/null @@ -1,52 +0,0 @@ -package com.github.damontecres.wholphin.util - -import android.widget.Toast -import androidx.lifecycle.MutableLiveData -import com.github.damontecres.wholphin.WholphinApplication -import com.github.damontecres.wholphin.ui.showToast -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineExceptionHandler -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withContext -import timber.log.Timber -import kotlin.coroutines.CoroutineContext - -/** - * A [CoroutineExceptionHandler] that is aware of a [LoadingState] and will set it to error if an exception occurs in the coroutine. - */ -class LoadingExceptionHandler( - private val loadingState: MutableLiveData<LoadingState>, - private val errorMessage: String?, - private val autoToast: Boolean = false, -) : CoroutineExceptionHandler { - override val key: CoroutineContext.Key<*> - get() = CoroutineExceptionHandler - - override fun handleException( - context: CoroutineContext, - exception: Throwable, - ) { - if (exception is CancellationException) { - // Don't log/toast cancellations - return - } - Timber.e(exception, "Exception in coroutine") - runBlocking { - withContext(Dispatchers.Main) { - loadingState.value = - LoadingState.Error( - message = errorMessage, - exception = exception, - ) - } - if (autoToast) { - showToast( - WholphinApplication.instance, - "Error: ${exception.message}", - Toast.LENGTH_LONG, - ) - } - } - } -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/LoadingState.kt b/app/src/main/java/com/github/damontecres/wholphin/util/LoadingState.kt index 97308113..7c618290 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/LoadingState.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/LoadingState.kt @@ -3,6 +3,7 @@ package com.github.damontecres.wholphin.util 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.ui.util.StringProvider /** * Generic state for loading something from the API @@ -48,28 +49,29 @@ sealed interface RowLoadingState { } sealed interface HomeRowLoadingState { - val title: String + val title: StringProvider val completed: Boolean get() = this is Success || this is Error data class Pending( - override val title: String, + override val title: StringProvider, ) : HomeRowLoadingState data class Loading( - override val title: String, + override val title: StringProvider, ) : HomeRowLoadingState data class Success( - override val title: String, + override val title: StringProvider, val items: List<BaseItem?>, val viewOptions: HomeRowViewOptions = HomeRowViewOptions(), val rowType: HomeRowConfig? = null, + val showViewMore: Boolean = true, ) : HomeRowLoadingState data class Error( - override val title: String, + override val title: StringProvider, val message: String? = null, val exception: Throwable? = null, ) : HomeRowLoadingState { @@ -102,3 +104,5 @@ sealed interface DataLoadingState<out T> { listOfNotNull(message, exception?.localizedMessage).joinToString(" - ") } } + +val <T> DataLoadingState<T>.successValue: T? get() = (this as? DataLoadingState.Success<T>)?.data diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/Media3SubtitleOverride.kt b/app/src/main/java/com/github/damontecres/wholphin/util/Media3SubtitleOverride.kt index c618f6e5..e02a525e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/Media3SubtitleOverride.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/Media3SubtitleOverride.kt @@ -1,5 +1,7 @@ package com.github.damontecres.wholphin.util +import androidx.annotation.OptIn +import androidx.media3.common.util.UnstableApi import androidx.media3.ui.SubtitleView import timber.log.Timber import java.lang.reflect.Field @@ -7,6 +9,7 @@ import java.lang.reflect.Field /** * Utility class to override private fields in media3's [SubtitleView] */ +@OptIn(UnstableApi::class) class Media3SubtitleOverride( val outlineThicknessPx: Float, ) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/PlaybackWatchState.kt b/app/src/main/java/com/github/damontecres/wholphin/util/PlaybackWatchState.kt new file mode 100644 index 00000000..3306ca18 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/util/PlaybackWatchState.kt @@ -0,0 +1,27 @@ +package com.github.damontecres.wholphin.util + +import android.content.Context +import org.justinvisser.wolfskin.conductarr.FileCacheStore +import java.io.File +import java.io.IOException +import timber.log.Timber +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +/** Only an acknowledged stop invalidates the captured server/user's watch projections. */ +object PlaybackWatchState { + private val mutableRevisions = MutableStateFlow<Map<String, Long>>(emptyMap()) + val revisions = mutableRevisions.asStateFlow() + + /** Remove persisted watch grids before publishing the captured user's acknowledgement. */ + fun stopped(context: Context, scope: String) { + try { + FileCacheStore(File(context.filesDir, "wolfskin-library-cache")).clear(scope) + } catch (exception: IOException) { + Timber.w(exception, "Could not invalidate playback watch snapshots") + } + val time = System.currentTimeMillis() + mutableRevisions.update { previous -> previous + (scope to maxOf(time, (previous[scope] ?: 0L) + 1L)) } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/RememberTabManager.kt b/app/src/main/java/com/github/damontecres/wholphin/util/RememberTabManager.kt deleted file mode 100644 index 46ec3606..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/util/RememberTabManager.kt +++ /dev/null @@ -1,45 +0,0 @@ -package com.github.damontecres.wholphin.util - -import com.github.damontecres.wholphin.preferences.UserPreferences -import java.util.UUID - -/** - * Functions to remember tabs choices for a library - */ -interface RememberTabManager { - /** - * If enabled, get the remembered tab index for the given item - */ - fun getRememberedTab( - preferences: UserPreferences, - itemId: UUID, - defaultTab: Int, - ): Int = getRememberedTab(preferences, itemId.toString(), defaultTab) - - /** - * If enabled, get the remembered tab index for the given item - */ - fun getRememberedTab( - preferences: UserPreferences, - itemId: String, - defaultTab: Int, - ): Int - - /** - * If enabled, save the remembered tab index for the given item - */ - fun saveRememberedTab( - preferences: UserPreferences, - itemId: UUID, - tabIndex: Int, - ) = saveRememberedTab(preferences, itemId.toString(), tabIndex) - - /** - * If enabled, save the remembered tab index for the given item - */ - fun saveRememberedTab( - preferences: UserPreferences, - itemId: String, - tabIndex: Int, - ) -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/RequestPager.kt b/app/src/main/java/com/github/damontecres/wholphin/util/RequestPager.kt index b478338b..10f0f64f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/RequestPager.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/RequestPager.kt @@ -7,7 +7,6 @@ import androidx.compose.runtime.setValue import com.github.damontecres.wholphin.ui.DEFAULT_PAGE_SIZE import com.google.common.cache.CacheBuilder import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex @@ -25,7 +24,7 @@ import java.util.function.Predicate */ abstract class RequestPager<T>( protected val scope: CoroutineScope, - protected val pageSize: Int = DEFAULT_PAGE_SIZE, + val pageSize: Int = DEFAULT_PAGE_SIZE, cacheSize: Long = 8, ) : AbstractList<T?>(), BlockingList<T?> { @@ -85,7 +84,7 @@ abstract class RequestPager<T>( get() = totalCount private fun fetchPage(position: Int): Job = - scope.launch(ExceptionHandler() + Dispatchers.IO) { + scope.launch(ExceptionHandler() + WholphinDispatchers.IO) { fetchPageBlocking(position, false) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/SearchRelevance.kt b/app/src/main/java/com/github/damontecres/wholphin/util/SearchRelevance.kt new file mode 100644 index 00000000..dd0d0b38 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/util/SearchRelevance.kt @@ -0,0 +1,95 @@ +package com.github.damontecres.wholphin.util + +import com.github.damontecres.wholphin.data.model.BaseItem +import org.jellyfin.sdk.model.api.BaseItemKind +import java.util.Locale + +object SearchRelevance { + private val REGEX_PATTERN = Regex("^/(.+)/(i)?$") + + fun score( + item: BaseItem, + query: String, + ): Int = score(item.name, item.type, query) + + fun score( + name: String?, + type: BaseItemKind, + query: String, + ): Int { + val name = name?.lowercase(Locale.getDefault()) ?: return Int.MAX_VALUE + val q = query.lowercase(Locale.getDefault()) + + REGEX_PATTERN.find(query)?.let { match -> + val pattern = match.groupValues[1] + val caseInsensitive = match.groupValues[2] == "i" + return regexScore(name, pattern, caseInsensitive) + typeBonus(type) + } + + return when { + name == q -> 0 + name.startsWith(q) -> 100 + name.contains(" $q") -> 200 + name.contains(q) -> 300 + else -> fuzzyScore(name, q) + } + typeBonus(type) + } + + private fun regexScore( + name: String, + pattern: String, + caseInsensitive: Boolean, + ): Int = + try { + val options = if (caseInsensitive) setOf(RegexOption.IGNORE_CASE) else emptySet() + if (Regex(pattern, options).containsMatchIn(name)) 250 else 400 + } catch (e: Exception) { + 400 + } + + private fun fuzzyScore( + name: String, + query: String, + ): Int { + val distance = levenshteinDistance(name, query) + val maxLen = maxOf(name.length, query.length) + val similarity = 1.0 - (distance.toDouble() / maxLen) + + return when { + similarity >= 0.8 -> 350 + similarity >= 0.6 -> 375 + else -> 400 + } + } + + private fun levenshteinDistance( + s1: String, + s2: String, + ): Int { + val m = s1.length + val n = s2.length + val dp = Array(m + 1) { IntArray(n + 1) } + for (i in 0..m) dp[i][0] = i + for (j in 0..n) dp[0][j] = j + for (i in 1..m) { + for (j in 1..n) { + dp[i][j] = + if (s1[i - 1] == s2[j - 1]) { + dp[i - 1][j - 1] + } else { + 1 + minOf(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]) + } + } + } + return dp[m][n] + } + + private fun typeBonus(type: BaseItemKind): Int = + when (type) { + BaseItemKind.SERIES -> 0 + BaseItemKind.MOVIE -> 1 + BaseItemKind.BOX_SET -> 2 + BaseItemKind.EPISODE -> 10 + else -> 5 + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/TrackActivityPlaybackListener.kt b/app/src/main/java/com/github/damontecres/wholphin/util/TrackActivityPlaybackListener.kt index 86ba1425..859a04aa 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/TrackActivityPlaybackListener.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/TrackActivityPlaybackListener.kt @@ -3,12 +3,12 @@ package com.github.damontecres.wholphin.util import androidx.annotation.OptIn import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi -import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.playback.CurrentPlayback import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.playStateApi import org.jellyfin.sdk.model.api.PlayMethod @@ -22,6 +22,7 @@ import timber.log.Timber import java.util.Timer import java.util.TimerTask import java.util.UUID +import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds @@ -32,9 +33,12 @@ import kotlin.time.Duration.Companion.seconds class TrackActivityPlaybackListener( private val api: ApiClient, private val player: Player, + private val onStopped: () -> Unit = {}, private val getState: () -> PlaybackItemState?, ) : Player.Listener { - private val coroutineScope = CoroutineScope(Dispatchers.Main) + private val coroutineScope = CoroutineScope(WholphinDispatchers.Main) + private val reports = Mutex() + @Volatile private var released = false private val task: TimerTask = object : TimerTask() { override fun run() { @@ -57,7 +61,7 @@ class TrackActivityPlaybackListener( PlaybackStartInfo( canSeek = true, itemId = state.itemId, - isPaused = withContext(Dispatchers.Main) { !player.isPlaying }, + isPaused = withContext(WholphinDispatchers.Main) { !player.isPlaying }, playMethod = state.playMethod, repeatMode = RepeatMode.REPEAT_NONE, playbackOrder = PlaybackOrder.DEFAULT, @@ -71,29 +75,37 @@ class TrackActivityPlaybackListener( val delay = 5.seconds.inWholeMilliseconds // Every x seconds, check if the video is playing - TIMER.schedule(task, delay, delay) - initialized = true + if (!released) { + TIMER.schedule(task, delay, delay) + initialized = true + } } } } fun release() { -// player.removeListener(this) + if (released) return + released = true task.cancel() TIMER.purge() val position = player.currentPosition.milliseconds + val stoppedState = getState.invoke() launch("reportPlaybackStopped") { - getState.invoke()?.let { state -> + stoppedState?.let { state -> Timber.v("reportPlaybackStopped for ${state.itemId} at $position") + if (position < Duration.ZERO) { + Timber.w("Negative position when reporting playback stopped: %s", position) + } api.playStateApi.reportPlaybackStopped( PlaybackStopInfo( itemId = state.itemId, - positionTicks = position.inWholeTicks, + positionTicks = position.inWholeTicks.takeIf { it >= 0 }, failed = false, playSessionId = state.playSessionId, liveStreamId = state.liveStreamId, ), ) + onStopped() } } } @@ -115,13 +127,14 @@ class TrackActivityPlaybackListener( private fun saveActivity(position: Long) { launch("saveActivity") { + if (released) return@launch getState.invoke()?.let { state -> val calcPosition = - withContext(Dispatchers.Main) { + withContext(WholphinDispatchers.Main) { (if (position >= 0) position else player.currentPosition) } if (calcPosition > 0) { - val isPaused = withContext(Dispatchers.Main) { !player.isPlaying } + val isPaused = withContext(WholphinDispatchers.Main) { !player.isPlaying } Timber.v("saveActivity: itemId=${state.itemId}, pos=$calcPosition") api.playStateApi.reportPlaybackProgress( PlaybackProgressInfo( @@ -150,7 +163,7 @@ class TrackActivityPlaybackListener( ) { coroutineScope.launchIO { try { - block.invoke(this) + reports.withLock { block.invoke(this) } } catch (ex: Exception) { Timber.w(ex, "Exception during %s", name) } @@ -174,12 +187,11 @@ data class PlaybackItemState( ) { constructor( playback: CurrentPlayback, - itemPlayback: ItemPlayback, ) : this( - itemId = itemPlayback.itemId, + itemId = playback.item.id, playMethod = playback.playMethod, - audioStreamIndex = itemPlayback.audioIndex.takeIf { itemPlayback.audioIndexEnabled }, - subtitleStreamIndex = itemPlayback.subtitleIndex.takeIf { itemPlayback.subtitleIndexEnabled }, + audioStreamIndex = playback.audioIndex.takeIf { it >= 0 }, + subtitleStreamIndex = playback.subtitleIndex.takeIf { it >= 0 }, playSessionId = playback.playSessionId, liveStreamId = playback.liveStreamId, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/TrackSupport.kt b/app/src/main/java/com/github/damontecres/wholphin/util/TrackSupport.kt index 86b0f47e..f3ac966d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/TrackSupport.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/TrackSupport.kt @@ -7,7 +7,6 @@ import androidx.media3.common.Format import androidx.media3.common.MimeTypes import androidx.media3.common.Tracks import androidx.media3.common.util.UnstableApi -import com.github.damontecres.wholphin.ui.playback.idAsInt import timber.log.Timber import java.util.Locale @@ -159,7 +158,7 @@ fun checkForSupport(tracks: Tracks): List<TrackSupport> = val reason = TrackSupportReason.fromInt(it.getTrackSupport(i)) add( TrackSupport( - format.id + " (${format.idAsInt})", + format.id, type, reason, it.isSelected, diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/Version.kt b/app/src/main/java/com/github/damontecres/wholphin/util/Version.kt index b670326b..d3248935 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/Version.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/Version.kt @@ -70,7 +70,7 @@ data class Version( private fun compareNumCommits(version: Version): Int = (this.numCommits ?: 0) - (version.numCommits ?: 0) override fun toString(): String = - if (numCommits != null && hash != null) { + if (numCommits != null && numCommits > 0 && hash != null) { "v$major.$minor.$patch-$numCommits-g$hash" } else { "v$major.$minor.$patch" diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/WholphinDispatchers.kt b/app/src/main/java/com/github/damontecres/wholphin/util/WholphinDispatchers.kt new file mode 100644 index 00000000..2a466742 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/util/WholphinDispatchers.kt @@ -0,0 +1,20 @@ +package com.github.damontecres.wholphin.util + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.MainCoroutineDispatcher + +/** + * Analogous to [kotlinx.coroutines.Dispatchers], but easier to swap for testing + */ +object WholphinDispatchers { + @Suppress("ktlint:standard:property-naming") + val Main: MainCoroutineDispatcher get() = kotlinx.coroutines.Dispatchers.Main + + @Suppress("ktlint:standard:property-naming") + var IO: CoroutineDispatcher = kotlinx.coroutines.Dispatchers.IO + internal set + + @Suppress("ktlint:standard:property-naming") + var Default: CoroutineDispatcher = kotlinx.coroutines.Dispatchers.Default + internal set +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MPVLib.kt b/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MPVLib.kt deleted file mode 100644 index fad62f2b..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MPVLib.kt +++ /dev/null @@ -1,311 +0,0 @@ -package com.github.damontecres.wholphin.util.mpv - -/* -This file is copied from https://github.com/mpv-android/mpv-android/blob/master/app/src/main/java/is/xyz/mpv/MPVLib.kt - -Copyright (c) 2016 Ilya Zhuravlev -Copyright (c) 2016 sfan5 <sfan5@live.de> - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and -associated documentation files (the "Software"), to deal in the Software without restriction, -including without limitation the rights to use, copy, modify, merge, publish, distribute, -sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or -substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT -NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, -DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -import android.content.Context -import android.graphics.Bitmap -import android.view.Surface -import java.util.concurrent.CopyOnWriteArrayList - -// Wrapper for native library - -@Suppress("unused") -object MPVLib { - init { - val libs = arrayOf("mpv", "player") - for (lib in libs) { - System.loadLibrary(lib) - } - } - - external fun create(appctx: Context) - - external fun init() - - external fun destroy() - - external fun attachSurface(surface: Surface) - - external fun detachSurface() - - external fun command(cmd: Array<out String>) - - external fun setOptionString( - name: String, - value: String, - ): Int - - external fun grabThumbnail(dimension: Int): Bitmap? - - external fun getPropertyInt(property: String): Int? - - external fun setPropertyInt( - property: String, - value: Int, - ) - - external fun getPropertyDouble(property: String): Double? - - external fun setPropertyDouble( - property: String, - value: Double, - ) - - external fun getPropertyBoolean(property: String): Boolean? - - external fun setPropertyBoolean( - property: String, - value: Boolean, - ) - - external fun getPropertyString(property: String): String? - - external fun setPropertyString( - property: String, - value: String, - ) - - external fun observeProperty( - property: String, - format: Int, - ) - - private val observers = CopyOnWriteArrayList<EventObserver>() - - @JvmStatic - fun addObserver(o: EventObserver) { - observers.add(o) - } - - @JvmStatic - fun removeObserver(o: EventObserver) { - observers.remove(o) - } - - @JvmStatic - fun eventProperty( - property: String, - value: Long, - ) { - for (o in observers) { - o.eventProperty(property, value) - } - } - - @JvmStatic - fun eventProperty( - property: String, - value: Boolean, - ) { - for (o in observers) { - o.eventProperty(property, value) - } - } - - @JvmStatic - fun eventProperty( - property: String, - value: Double, - ) { - for (o in observers) { - o.eventProperty(property, value) - } - } - - @JvmStatic - fun eventProperty( - property: String, - value: String, - ) { - for (o in observers) { - o.eventProperty(property, value) - } - } - - @JvmStatic - fun eventProperty(property: String) { - for (o in observers) { - o.eventProperty(property) - } - } - - @JvmStatic - fun event(eventId: Int) { - for (o in observers) { - o.event(eventId) - } - } - - @JvmStatic - fun eventEndFile( - reason: Int, - error: Int, - ) { - for (o in observers) { - o.eventEndFile(reason, error) - } - } - - private val log_observers = CopyOnWriteArrayList<LogObserver>() - - @JvmStatic - fun addLogObserver(o: LogObserver) { - log_observers.add(o) - } - - @JvmStatic - fun removeLogObserver(o: LogObserver) { - log_observers.remove(o) - } - - @JvmStatic - fun logMessage( - prefix: String, - level: Int, - text: String, - ) { - for (o in log_observers) { - o.logMessage(prefix, level, text) - } - } - - interface EventObserver { - fun eventProperty(property: String) - - fun eventProperty( - property: String, - value: Long, - ) - - fun eventProperty( - property: String, - value: Boolean, - ) - - fun eventProperty( - property: String, - value: String, - ) - - fun eventProperty( - property: String, - value: Double, - ) - - fun event(eventId: Int) - - fun eventEndFile( - reason: Int, - error: Int, - ) - } - - interface LogObserver { - fun logMessage( - prefix: String, - level: Int, - text: String, - ) - } - - object MpvFormat { - const val MPV_FORMAT_NONE: Int = 0 - const val MPV_FORMAT_STRING: Int = 1 - const val MPV_FORMAT_OSD_STRING: Int = 2 - const val MPV_FORMAT_FLAG: Int = 3 - const val MPV_FORMAT_INT64: Int = 4 - const val MPV_FORMAT_DOUBLE: Int = 5 - const val MPV_FORMAT_NODE: Int = 6 - const val MPV_FORMAT_NODE_ARRAY: Int = 7 - const val MPV_FORMAT_NODE_MAP: Int = 8 - const val MPV_FORMAT_BYTE_ARRAY: Int = 9 - } - - object MpvEvent { - const val MPV_EVENT_NONE: Int = 0 - const val MPV_EVENT_SHUTDOWN: Int = 1 - const val MPV_EVENT_LOG_MESSAGE: Int = 2 - const val MPV_EVENT_GET_PROPERTY_REPLY: Int = 3 - const val MPV_EVENT_SET_PROPERTY_REPLY: Int = 4 - const val MPV_EVENT_COMMAND_REPLY: Int = 5 - const val MPV_EVENT_START_FILE: Int = 6 - const val MPV_EVENT_END_FILE: Int = 7 - const val MPV_EVENT_FILE_LOADED: Int = 8 - - @Deprecated("") - const val MPV_EVENT_IDLE: Int = 11 - - @Deprecated("") - const val MPV_EVENT_TICK: Int = 14 - const val MPV_EVENT_CLIENT_MESSAGE: Int = 16 - const val MPV_EVENT_VIDEO_RECONFIG: Int = 17 - const val MPV_EVENT_AUDIO_RECONFIG: Int = 18 - const val MPV_EVENT_SEEK: Int = 20 - const val MPV_EVENT_PLAYBACK_RESTART: Int = 21 - const val MPV_EVENT_PROPERTY_CHANGE: Int = 22 - const val MPV_EVENT_QUEUE_OVERFLOW: Int = 24 - const val MPV_EVENT_HOOK: Int = 25 - } - - object MpvLogLevel { - const val MPV_LOG_LEVEL_NONE: Int = 0 - const val MPV_LOG_LEVEL_FATAL: Int = 10 - const val MPV_LOG_LEVEL_ERROR: Int = 20 - const val MPV_LOG_LEVEL_WARN: Int = 30 - const val MPV_LOG_LEVEL_INFO: Int = 40 - const val MPV_LOG_LEVEL_V: Int = 50 - const val MPV_LOG_LEVEL_DEBUG: Int = 60 - const val MPV_LOG_LEVEL_TRACE: Int = 70 - } - - object MpvEndFileReason { - const val MPV_END_FILE_REASON_EOF: Int = 0 - const val MPV_END_FILE_REASON_STOP: Int = 2 - const val MPV_END_FILE_REASON_QUIT: Int = 3 - const val MPV_END_FILE_REASON_ERROR: Int = 4 - const val MPV_END_FILE_REASON_REDIRECT: Int = 5 - } - - object MpvError { - const val MPV_ERROR_SUCCESS = 0 - const val MPV_ERROR_EVENT_QUEUE_FULL = -1 - const val MPV_ERROR_NOMEM = -2 - const val MPV_ERROR_UNINITIALIZED = -3 - const val MPV_ERROR_INVALID_PARAMETER = -4 - const val MPV_ERROR_OPTION_NOT_FOUND = -5 - const val MPV_ERROR_OPTION_FORMAT = -6 - const val MPV_ERROR_OPTION_ERROR = -7 - const val MPV_ERROR_PROPERTY_NOT_FOUND = -8 - const val MPV_ERROR_PROPERTY_FORMAT = -9 - const val MPV_ERROR_PROPERTY_UNAVAILABLE = -10 - const val MPV_ERROR_PROPERTY_ERROR = -11 - const val MPV_ERROR_COMMAND = -12 - const val MPV_ERROR_LOADING_FAILED = -13 - const val MPV_ERROR_AO_INIT_FAILED = -14 - const val MPV_ERROR_VO_INIT_FAILED = -15 - const val MPV_ERROR_NOTHING_TO_PLAY = -16 - const val MPV_ERROR_UNKNOWN_FORMAT = -17 - const val MPV_ERROR_UNSUPPORTED = -18 - const val MPV_ERROR_NOT_IMPLEMENTED = -19 - const val MPV_ERROR_GENERIC = -20 - } -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MPVProperty.kt b/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MPVProperty.kt deleted file mode 100644 index f9a0669a..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MPVProperty.kt +++ /dev/null @@ -1,46 +0,0 @@ -package com.github.damontecres.wholphin.util.mpv - -import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvFormat.MPV_FORMAT_DOUBLE -import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvFormat.MPV_FORMAT_FLAG -import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvFormat.MPV_FORMAT_INT64 -import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvFormat.MPV_FORMAT_NONE -import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvFormat.MPV_FORMAT_STRING - -object MPVProperty { - const val POSITION = "time-pos" - const val POSITION_FULL = "time-pos/full" - const val DURATION = "duration/full" - const val PAUSED = "pause" - const val PAUSED_FOR_CACHE = "paused-for-cache" - const val SPEED = "speed" - const val TRACK_LIST = "track-list" - const val ASPECT = "video-params/aspect" - const val ROTATE = "video-params/rotate" - const val TRACK_VIDEO = "current-tracks/video/image" - const val METADATA = "metadata" - const val HWDEC = "hwdec-current" - const val MUTE = "mute" - const val TRACK_AUDIO = "current-tracks/audio/selected" - const val SEEKABLE = "seekable" - const val SUBTITLE_TEXT = "sub-text" - - val observedProperties = - mapOf( - POSITION to MPV_FORMAT_INT64, -// POSITION_FULL to MPV_FORMAT_DOUBLE, - DURATION to MPV_FORMAT_DOUBLE, - PAUSED to MPV_FORMAT_FLAG, - PAUSED_FOR_CACHE to MPV_FORMAT_FLAG, - SPEED to MPV_FORMAT_STRING, - TRACK_LIST to MPV_FORMAT_NONE, - ASPECT to MPV_FORMAT_DOUBLE, - ROTATE to MPV_FORMAT_DOUBLE, - TRACK_VIDEO to MPV_FORMAT_NONE, - METADATA to MPV_FORMAT_NONE, - HWDEC to MPV_FORMAT_NONE, - MUTE to MPV_FORMAT_FLAG, - TRACK_AUDIO to MPV_FORMAT_NONE, - SEEKABLE to MPV_FORMAT_FLAG, - SUBTITLE_TEXT to MPV_FORMAT_STRING, - ) -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MpvLogger.kt b/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MpvLogger.kt deleted file mode 100644 index 300d36f0..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MpvLogger.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.github.damontecres.wholphin.util.mpv - -import timber.log.Timber - -class MpvLogger : MPVLib.LogObserver { - override fun logMessage( - prefix: String, - level: Int, - text: String, - ) { - // https://github.com/mpv-player/mpv/blob/122abdfec3124bfc92a2918a70ca8150eee68338/include/mpv/client.h#L1423 - when { - level <= 10 -> Timber.wtf("%s, %s", prefix, text) - level <= 20 -> Timber.e("%s, %s", prefix, text) - level <= 30 -> Timber.w("%s, %s", prefix, text) - level <= 40 -> Timber.i("%s, %s", prefix, text) - level <= 50 -> Timber.d("%s, %s", prefix, text) - else -> Timber.v("%s, %s", prefix, text) - } - } -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MpvPlayer.kt b/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MpvPlayer.kt deleted file mode 100644 index 77613be3..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MpvPlayer.kt +++ /dev/null @@ -1,1239 +0,0 @@ -package com.github.damontecres.wholphin.util.mpv - -import android.content.Context -import android.os.Build -import android.os.Handler -import android.os.HandlerThread -import android.os.Looper -import android.os.Message -import android.os.Process -import android.view.Surface -import android.view.SurfaceHolder -import android.view.SurfaceView -import android.view.TextureView -import androidx.annotation.OptIn -import androidx.compose.ui.graphics.Color -import androidx.media3.common.AudioAttributes -import androidx.media3.common.BasePlayer -import androidx.media3.common.C -import androidx.media3.common.DeviceInfo -import androidx.media3.common.Format -import androidx.media3.common.MediaItem -import androidx.media3.common.MediaMetadata -import androidx.media3.common.MimeTypes -import androidx.media3.common.PlaybackException -import androidx.media3.common.PlaybackParameters -import androidx.media3.common.Player -import androidx.media3.common.Timeline -import androidx.media3.common.TrackGroup -import androidx.media3.common.TrackSelectionParameters -import androidx.media3.common.Tracks -import androidx.media3.common.VideoSize -import androidx.media3.common.text.CueGroup -import androidx.media3.common.util.Clock -import androidx.media3.common.util.ListenerSet -import androidx.media3.common.util.Size -import androidx.media3.common.util.UnstableApi -import androidx.media3.common.util.Util -import androidx.media3.exoplayer.trackselection.DefaultTrackSelector -import androidx.media3.exoplayer.trackselection.TrackSelector -import androidx.media3.exoplayer.upstream.DefaultBandwidthMeter -import com.github.damontecres.wholphin.ui.isNotNullOrBlank -import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvEndFileReason.MPV_END_FILE_REASON_EOF -import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvEndFileReason.MPV_END_FILE_REASON_ERROR -import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvEndFileReason.MPV_END_FILE_REASON_STOP -import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvEvent.MPV_EVENT_AUDIO_RECONFIG -import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvEvent.MPV_EVENT_END_FILE -import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvEvent.MPV_EVENT_FILE_LOADED -import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvEvent.MPV_EVENT_PLAYBACK_RESTART -import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvEvent.MPV_EVENT_START_FILE -import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvEvent.MPV_EVENT_VIDEO_RECONFIG -import timber.log.Timber -import kotlin.concurrent.atomics.AtomicReference -import kotlin.concurrent.atomics.ExperimentalAtomicApi -import kotlin.concurrent.atomics.update -import kotlin.time.Duration -import kotlin.time.Duration.Companion.milliseconds -import kotlin.time.Duration.Companion.seconds - -/** - * This is barebones implementation of a [Player] which plays content using libmpv - * - * It doesn't support every feature or emit every event - */ -@kotlin.OptIn(ExperimentalAtomicApi::class) -@OptIn(UnstableApi::class) -class MpvPlayer( - private val context: Context, - private val enableHardwareDecoding: Boolean, - private val useGpuNext: Boolean, -) : BasePlayer(), - MPVLib.EventObserver, - TrackSelector.InvalidationListener, - Handler.Callback, - SurfaceHolder.Callback { - companion object { - private const val DEBUG = false - - private val initLock = Any() - } - - private var surface: Surface? = null - private val playbackState = AtomicReference<PlaybackState>(PlaybackState.EMPTY) - private val mpvLogger = MpvLogger() - - // This looper will sent events to the main thread - private val looper = Util.getCurrentOrMainLooper() - private val mainHandler = Handler(looper) - private val listeners = - ListenerSet<Player.Listener>( - looper, - Clock.DEFAULT, - ) { listener, eventFlags -> - listener.onEvents(this@MpvPlayer, Player.Events(eventFlags)) - } - private var availableCommands: Player.Commands = Player.Commands.Builder().build() - private val trackSelector = DefaultTrackSelector(context) - - // This thread/looper will receive commands from the main thread to execute - private val thread: HandlerThread = - HandlerThread("MpvPlayer:Playback", Process.THREAD_PRIORITY_AUDIO) - .also { it.start() } - private val internalHandler: Handler = Handler(thread.looper, this) - - @Volatile - var isReleased = false - private set - - init { - sendCommand(MpvCommand.INITIALIZE, null) - } - - private fun init() { - Timber.v("config-dir=${context.filesDir.path}") - MPVLib.addLogObserver(mpvLogger) - - Timber.v("Creating MPVLib") - MPVLib.create(context) - MPVLib.setOptionString("config", "yes") - MPVLib.setOptionString("config-dir", context.filesDir.path) - - if (enableHardwareDecoding) { - MPVLib.setOptionString("hwdec", "mediacodec,mediacodec-copy") - MPVLib.setOptionString("vo", if (useGpuNext) "gpu-next" else "gpu") - } else { - MPVLib.setOptionString("hwdec", "no") - } - MPVLib.setOptionString("gpu-context", "android") - - MPVLib.setOptionString("opengl-es", "yes") - MPVLib.setOptionString("hwdec-codecs", "h264,hevc,mpeg4,mpeg2video,vp8,vp9,av1") - val cacheMegs = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) 64 else 32 - MPVLib.setOptionString("demuxer-max-bytes", "${cacheMegs * 1024 * 1024}") - MPVLib.setOptionString("demuxer-max-back-bytes", "${cacheMegs * 1024 * 1024}") - - Timber.v("Initializing MPVLib") - MPVLib.init() - - MPVLib.setOptionString("force-window", "no") - MPVLib.setOptionString("idle", "yes") -// MPVLib.setOptionString("sub-fonts-dir", File(context.filesDir, "fonts").absolutePath) - - MPVLib.addObserver(this@MpvPlayer) - MPVProperty.observedProperties.forEach(MPVLib::observeProperty) - - availableCommands = - Player.Commands - .Builder() - .addAll( - COMMAND_PLAY_PAUSE, - COMMAND_PREPARE, - COMMAND_STOP, - COMMAND_SET_SPEED_AND_PITCH, - COMMAND_SET_SHUFFLE_MODE, - COMMAND_SET_REPEAT_MODE, - COMMAND_GET_CURRENT_MEDIA_ITEM, - COMMAND_GET_TIMELINE, - COMMAND_GET_METADATA, -// COMMAND_SET_PLAYLIST_METADATA, - COMMAND_SET_MEDIA_ITEM, -// COMMAND_CHANGE_MEDIA_ITEMS, - COMMAND_GET_TRACKS, -// COMMAND_GET_AUDIO_ATTRIBUTES, -// COMMAND_SET_AUDIO_ATTRIBUTES, -// COMMAND_GET_VOLUME, -// COMMAND_SET_VOLUME, - COMMAND_SET_VIDEO_SURFACE, -// COMMAND_GET_TEXT, - COMMAND_RELEASE, - ).build() - trackSelector.init(this, DefaultBandwidthMeter.getSingletonInstance(context)) - } - - override fun getApplicationLooper(): Looper = looper - - override fun addListener(listener: Player.Listener) { - if (DEBUG) Timber.v("addListener") - listeners.add(listener) - } - - override fun removeListener(listener: Player.Listener) { - if (DEBUG) Timber.v("removeListener") - listeners.remove(listener) - } - - override fun setMediaItems( - mediaItems: List<MediaItem>, - resetPosition: Boolean, - ) { - if (DEBUG) Timber.v("setMediaItems") - throwIfReleased() - mediaItems.firstOrNull()?.let { - if (surface != null) { - sendCommand(MpvCommand.LOAD_FILE, MediaAndPosition(it, C.TIME_UNSET)) - } - } - } - - override fun setMediaItems( - mediaItems: List<MediaItem>, - startIndex: Int, - startPositionMs: Long, - ) { - if (DEBUG) Timber.v("setMediaItems") - throwIfReleased() - sendCommand( - MpvCommand.LOAD_FILE, - MediaAndPosition(mediaItems[startIndex], startPositionMs), - ) - } - - override fun addMediaItems( - index: Int, - mediaItems: List<MediaItem>, - ): Unit = throw UnsupportedOperationException() - - override fun moveMediaItems( - fromIndex: Int, - toIndex: Int, - newIndex: Int, - ): Unit = throw UnsupportedOperationException() - - override fun replaceMediaItems( - fromIndex: Int, - toIndex: Int, - mediaItems: List<MediaItem>, - ): Unit = throw UnsupportedOperationException() - - override fun removeMediaItems( - fromIndex: Int, - toIndex: Int, - ): Unit = throw UnsupportedOperationException() - - override fun getAvailableCommands(): Player.Commands = availableCommands - - override fun prepare() { - if (DEBUG) Timber.v("prepare") - } - - override fun getPlaybackState(): Int { - if (DEBUG) Timber.v("getPlaybackState") - return playbackState.load().state - } - - override fun getPlaybackSuppressionReason(): Int = PLAYBACK_SUPPRESSION_REASON_NONE - - override fun getPlayerError(): PlaybackException? { - // TODO - return null - } - - override fun setPlayWhenReady(playWhenReady: Boolean) { - if (isReleased) return - if (DEBUG) Timber.v("setPlayWhenReady: $playWhenReady") - sendCommand(MpvCommand.PLAY_PAUSE, playWhenReady) - } - - override fun getPlayWhenReady(): Boolean { - if (DEBUG) Timber.v("getPlayWhenReady") - if (isReleased) return false - return !playbackState.load().isPaused - } - - override fun setRepeatMode(repeatMode: Int) { - if (DEBUG) Timber.v("setRepeatMode") - } - - override fun getRepeatMode(): Int = REPEAT_MODE_OFF - - override fun setShuffleModeEnabled(shuffleModeEnabled: Boolean) { - if (DEBUG) Timber.v("setShuffleModeEnabled") - } - - override fun getShuffleModeEnabled(): Boolean = false - - override fun isLoading(): Boolean = playbackState.load().isLoadingFile - - override fun getSeekBackIncrement(): Long = 10_000 - - override fun getSeekForwardIncrement(): Long = 30_000 - - override fun getMaxSeekToPreviousPosition(): Long = 10_000 - - override fun setPlaybackParameters(playbackParameters: PlaybackParameters) { - if (DEBUG) Timber.v("setPlaybackParameters") - sendCommand(MpvCommand.SET_SPEED, playbackParameters.speed) - } - - override fun getPlaybackParameters(): PlaybackParameters { - if (DEBUG) Timber.v("getPlaybackParameters") - return PlaybackParameters(playbackState.load().speed) - } - - override fun stop() { - if (DEBUG) Timber.v("stop") - if (isReleased) return - pause() - internalHandler.removeCallbacks(updatePlaybackState) - playbackState.update { - PlaybackState.EMPTY - } - notifyListeners(EVENT_IS_PLAYING_CHANGED) { onIsPlayingChanged(false) } - notifyListeners(EVENT_PLAYBACK_STATE_CHANGED) { onPlaybackStateChanged(STATE_IDLE) } - } - - override fun release() { - Timber.i("release") - playbackState.update { - PlaybackState.EMPTY - } - if (!isReleased) { - internalHandler.removeCallbacks(updatePlaybackState) - MPVLib.removeObserver(this@MpvPlayer) - sendCommand(MpvCommand.DESTROY, null) - thread.quitSafely() - } - isReleased = true - } - - override fun getCurrentTracks(): Tracks { - if (DEBUG) Timber.v("getCurrentTracks") - if (isReleased) return Tracks.EMPTY - return playbackState.load().tracks - } - - override fun getTrackSelectionParameters(): TrackSelectionParameters { - if (DEBUG) Timber.v("getTrackSelectionParameters") - - return TrackSelectionParameters - .Builder() - .build() - } - - override fun setTrackSelectionParameters(parameters: TrackSelectionParameters) { - Timber.v("TrackSelection: setTrackSelectionParameters %s", parameters) - if (isReleased) return - val tracks = playbackState.load().tracks - if (C.TRACK_TYPE_TEXT in parameters.disabledTrackTypes) { - // Subtitles disabled - Timber.v("TrackSelection: disabling subtitles") - sendCommand(MpvCommand.SET_TRACK_SELECTION, TrackSelection("sid", "no")) - } - if (C.TRACK_TYPE_AUDIO in parameters.disabledTrackTypes) { - // Audio disabled - Timber.v("TrackSelection: disabling audio") - sendCommand(MpvCommand.SET_TRACK_SELECTION, TrackSelection("aid", "no")) - } - Timber.v("TrackSelection: Got ${parameters.overrides.size} overrides") - parameters.overrides.forEach { (trackGroup, trackSelectionOverride) -> - val result = - tracks.groups.firstOrNull { it.mediaTrackGroup == trackGroup }?.let { - val id = it.mediaTrackGroup.getFormat(0).id - val splits = id?.split(":") - val trackId = splits?.getOrNull(1) - val propertyName = - when (it.mediaTrackGroup.type) { - C.TRACK_TYPE_AUDIO -> "aid" - C.TRACK_TYPE_VIDEO -> "vid" - C.TRACK_TYPE_TEXT -> "sid" - else -> null - } - Timber.v("TrackSelection: activating %s %s '%s'", propertyName, trackId, id) - if (trackId != null && propertyName != null) { - sendCommand( - MpvCommand.SET_TRACK_SELECTION, - TrackSelection(propertyName, trackId), - ) - true - } else { - false - } - } - if (result != true) { - Timber.w( - "Did not find track to select for type=%s, id=%s", - trackGroup.type, - trackGroup.getFormat(0).id, - ) - } - } - } - - override fun getMediaMetadata(): MediaMetadata { - if (DEBUG) Timber.v("getMediaMetadata") - return MediaMetadata.EMPTY - } - - override fun getPlaylistMetadata(): MediaMetadata { - if (DEBUG) Timber.v("getPlaylistMetadata") - return MediaMetadata.EMPTY - } - - override fun setPlaylistMetadata(mediaMetadata: MediaMetadata): Unit = throw UnsupportedOperationException() - - override fun getCurrentTimeline(): Timeline { - if (DEBUG) Timber.v("getCurrentTimeline") - return playbackState.load().timeline - } - - override fun getCurrentPeriodIndex(): Int { - if (DEBUG) Timber.v("getCurrentPeriodIndex") - // TODO - return 0 - } - - override fun getCurrentMediaItemIndex(): Int { - if (DEBUG) Timber.v("getCurrentMediaItemIndex") - return 0 - } - - override fun getDuration(): Long { - if (DEBUG) Timber.v("getDuration") - return playbackState.load().durationMs - } - - override fun getCurrentPosition(): Long { - if (DEBUG) Timber.v("getCurrentPosition") - val state = playbackState.load() - return state.positionMs - } - - override fun getBufferedPosition(): Long { - if (DEBUG) Timber.v("getBufferedPosition") - return currentPosition + totalBufferedDuration - } - - override fun getTotalBufferedDuration(): Long { - if (DEBUG) Timber.v("getTotalBufferedDuration") - if (isReleased) return 0 - return playbackState.load().bufferMs - } - - override fun isPlayingAd(): Boolean { - if (DEBUG) Timber.v("isPlayingAd") - return false - } - - override fun getCurrentAdGroupIndex(): Int = C.INDEX_UNSET - - override fun getCurrentAdIndexInAdGroup(): Int = C.INDEX_UNSET - - override fun getContentPosition(): Long = currentPosition - - override fun getContentBufferedPosition(): Long = bufferedPosition - - override fun getAudioAttributes(): AudioAttributes = throw UnsupportedOperationException() - - override fun setVolume(volume: Float): Unit = throw UnsupportedOperationException() - - override fun getVolume(): Float = 1f - - override fun mute() { - volume = 0f - } - - override fun unmute() { - volume = 1f - } - - override fun clearVideoSurface(): Unit = throw UnsupportedOperationException() - - override fun clearVideoSurface(surface: Surface?): Unit = throw UnsupportedOperationException() - - override fun setVideoSurface(surface: Surface?): Unit = throw UnsupportedOperationException() - - override fun setVideoSurfaceHolder(surfaceHolder: SurfaceHolder?): Unit = throw UnsupportedOperationException() - - override fun clearVideoSurfaceHolder(surfaceHolder: SurfaceHolder?): Unit = throw UnsupportedOperationException() - - private var surfaceHolder: SurfaceHolder? = null - - override fun setVideoSurfaceView(surfaceView: SurfaceView?) { - if (DEBUG) Timber.v("setVideoSurfaceView") - if (surfaceView != null) { - this.surfaceHolder?.removeCallback(this) - this.surfaceHolder = surfaceView.holder - if (surfaceView.holder != null) { - val surface = surfaceView.holder?.surface - surfaceView.holder.addCallback(this) - Timber.v("Got surface holder: isValid=${surface?.isValid}") - if (surface != null && surface.isValid) { - Timber.v("Queued attach") - sendCommand(MpvCommand.ATTACH_SURFACE, surface) - return - } - } - } - clearVideoSurfaceView(null) - } - - override fun clearVideoSurfaceView(surfaceView: SurfaceView?) { - if (surface != null && surface == surfaceView?.holder?.surface) { - Timber.d("clearVideoSurfaceView") - sendCommand(MpvCommand.ATTACH_SURFACE, null) - } else { - Timber.w("clearVideoSurfaceView called with different surface: %s", surfaceView) - } - } - - override fun surfaceChanged( - holder: SurfaceHolder, - format: Int, - width: Int, - height: Int, - ) { - Timber.v("surfaceChanged: format=$format, width=$width, height=$height") - } - - override fun surfaceCreated(holder: SurfaceHolder) { - Timber.v("surfaceCreated") - sendCommand(MpvCommand.ATTACH_SURFACE, holder.surface) - } - - override fun surfaceDestroyed(holder: SurfaceHolder) { - Timber.v("surfaceDestroyed") - sendCommand(MpvCommand.ATTACH_SURFACE, null) - } - - override fun setVideoTextureView(textureView: TextureView?): Unit = throw UnsupportedOperationException() - - override fun clearVideoTextureView(textureView: TextureView?): Unit = throw UnsupportedOperationException() - - override fun getVideoSize(): VideoSize { - if (DEBUG) Timber.v("getVideoSize") - if (isReleased) return VideoSize.UNKNOWN - return playbackState.load().videoSize - } - - override fun getSurfaceSize(): Size = surfaceHolder?.surfaceFrame?.let { Size(it.width(), it.height()) } ?: Size.UNKNOWN - - override fun getCurrentCues(): CueGroup = CueGroup.EMPTY_TIME_ZERO - - override fun getDeviceInfo(): DeviceInfo { - if (DEBUG) Timber.v("getDeviceInfo") - return DeviceInfo.Builder(DeviceInfo.PLAYBACK_TYPE_REMOTE).build() - } - - override fun getDeviceVolume(): Int = throw UnsupportedOperationException() - - override fun isDeviceMuted(): Boolean = throw UnsupportedOperationException() - - @Deprecated("Deprecated in Java") - override fun setDeviceVolume(volume: Int): Unit = throw UnsupportedOperationException() - - override fun setDeviceVolume( - volume: Int, - flags: Int, - ): Unit = throw UnsupportedOperationException() - - @Deprecated("Deprecated in Java") - override fun increaseDeviceVolume(): Unit = throw UnsupportedOperationException() - - override fun increaseDeviceVolume(flags: Int): Unit = throw UnsupportedOperationException() - - @Deprecated("Deprecated in Java") - override fun decreaseDeviceVolume(): Unit = throw UnsupportedOperationException() - - override fun decreaseDeviceVolume(flags: Int): Unit = throw UnsupportedOperationException() - - @Deprecated("Deprecated in Java") - override fun setDeviceMuted(muted: Boolean): Unit = throw UnsupportedOperationException() - - override fun setDeviceMuted( - muted: Boolean, - flags: Int, - ): Unit = throw UnsupportedOperationException() - - override fun setAudioAttributes( - audioAttributes: AudioAttributes, - handleAudioFocus: Boolean, - ): Unit = throw UnsupportedOperationException() - - override fun seekTo( - mediaItemIndex: Int, - positionMs: Long, - seekCommand: Int, - isRepeatingCurrentItem: Boolean, - ) { - if (DEBUG) Timber.v("seekTo") - if (isReleased) { - Timber.w("seekTo called after release") - return - } - if (mediaItemIndex == C.INDEX_UNSET) { - return - } - sendCommand(MpvCommand.SEEK, positionMs) - } - - override fun onTrackSelectionsInvalidated() { - // no-op - } - - override fun eventProperty(property: String) { - if (DEBUG) Timber.v("eventProperty: $property") - } - - override fun eventProperty( - property: String, - value: Long, - ) { - if (DEBUG) Timber.v("eventPropertyLong: $property=$value") - when (property) { - MPVProperty.POSITION -> { - playbackState.update { - it.copy(positionMs = value.seconds.inWholeMilliseconds) - } - } - } - } - - override fun eventProperty( - property: String, - value: Boolean, - ) { - if (DEBUG) Timber.v("eventPropertyBoolean: $property=$value") - when (property) { - MPVProperty.PAUSED -> { - playbackState.update { - it.copy(isPaused = value) - } - notifyListeners(EVENT_IS_PLAYING_CHANGED) { onIsPlayingChanged(!value) } - } - } - } - - override fun eventProperty( - property: String, - value: String, - ) { - if (DEBUG) Timber.v("eventPropertyString: $property=$value") - } - - override fun eventProperty( - property: String, - value: Double, - ) { - Timber.v("eventPropertyDouble: $property=$value") - when (property) { - MPVProperty.DURATION -> { - playbackState.update { - it.copy(durationMs = value.seconds.inWholeMilliseconds) - } - } - } - } - - override fun event(eventId: Int) { - Timber.v("event: eventId=%s", eventId) - when (eventId) { - MPV_EVENT_START_FILE -> { - internalHandler.post(updatePlaybackState) - } - - MPV_EVENT_FILE_LOADED -> { - Timber.d("event: MPV_EVENT_FILE_LOADED") - playbackState.update { - it.copy( - isLoadingFile = false, - ) - } - updatePlaybackState.run() - notifyListeners(EVENT_IS_LOADING_CHANGED) { onIsLoadingChanged(false) } - - playbackState.load().media?.mediaItem?.let { media -> - media.localConfiguration?.subtitleConfigurations?.forEach { - val url = it.uri.toString() - val title = it.label ?: "External Subtitles" - Timber.v("Adding external subtitle track '$title'") - if (it.language.isNotNullOrBlank()) { - MPVLib.command(arrayOf("sub-add", url, "select", title, it.language!!)) - } else { - MPVLib.command(arrayOf("sub-add", url, "select", title)) - } - } - } - notifyListeners(EVENT_RENDERED_FIRST_FRAME) { onRenderedFirstFrame() } - notifyListeners(EVENT_IS_PLAYING_CHANGED) { onIsPlayingChanged(true) } - updateTracksAndNotify() - } - - MPV_EVENT_PLAYBACK_RESTART -> { - Timber.d("event: MPV_EVENT_PLAYBACK_RESTART") - updateTracksAndNotify() - } - - MPV_EVENT_AUDIO_RECONFIG -> { - Timber.d("event: MPV_EVENT_AUDIO_RECONFIG") - updateTracksAndNotify() - } - - MPV_EVENT_VIDEO_RECONFIG -> { - Timber.d("event: MPV_EVENT_VIDEO_RECONFIG") - updateTracksAndNotify() - updateVideoSizeAndNotify() - } - - MPV_EVENT_END_FILE -> { - Timber.d("event: MPV_EVENT_END_FILE") - // Handled by eventEndFile - } - - else -> { - Timber.v("event: $eventId") - } - } - } - - override fun eventEndFile( - reason: Int, - error: Int, - ) { - Timber.d("MPV_EVENT_END_FILE: %s %s", reason, error) - notifyListeners(EVENT_IS_PLAYING_CHANGED) { onIsPlayingChanged(false) } - when (reason) { - MPV_END_FILE_REASON_EOF -> { - playbackState.update { - it.copy(state = Player.STATE_ENDED) - } - notifyListeners(EVENT_PLAYBACK_STATE_CHANGED) { - onPlaybackStateChanged(STATE_ENDED) - } - } - - MPV_END_FILE_REASON_STOP -> { - // User initiated (eg stop, play next, etc) - } - - MPV_END_FILE_REASON_ERROR -> { - Timber.e("libmpv error, error=%s", error) - notifyListeners(EVENT_PLAYER_ERROR) { - onPlayerError( - PlaybackException( - "libmpv error", - null, - error, - ), - ) - } - } - - else -> { - // no-op - } - } - } - - private fun updateTracksAndNotify() { - val tracks = createTracks() - playbackState.update { - it.copy(tracks = tracks) - } - notifyListeners(EVENT_TRACKS_CHANGED) { onTracksChanged(tracks) } - } - - private fun updateVideoSizeAndNotify() { - val width = MPVLib.getPropertyInt("width") - val height = MPVLib.getPropertyInt("height") - val videoSize = - if (width != null && height != null) { - VideoSize(width, height) - } else { - VideoSize.UNKNOWN - } - playbackState.update { it.copy(videoSize = videoSize) } - notifyListeners(EVENT_VIDEO_SIZE_CHANGED) { onVideoSizeChanged(videoSize) } - } - - private fun loadFile(media: MediaAndPosition) { - Timber.v("loadFile: media=$media") - val timeline = - object : Timeline() { - override fun getWindowCount(): Int = 1 - - override fun getWindow( - windowIndex: Int, - window: Window, - defaultPositionProjectionUs: Long, - ): Window = - window.set( - media.mediaItem.mediaId, - media.mediaItem, - null, - C.TIME_UNSET, - C.TIME_UNSET, - C.TIME_UNSET, - true, - true, - media.mediaItem.liveConfiguration, - 0L, - C.TIME_UNSET, - 0, - 0, - 0, - ) - - override fun getPeriodCount(): Int = 1 - - override fun getPeriod( - periodIndex: Int, - period: Period, - setIds: Boolean, - ): Period = - period.set( - media.mediaItem.mediaId, - media.mediaItem.mediaId, - 0, - C.TIME_UNSET, - 0, - ) - - override fun getIndexOfPeriod(uid: Any): Int = 0 - - override fun getUidOfPeriod(periodIndex: Int) = media.mediaItem.mediaId - } - playbackState.update { - it.copy( - isLoadingFile = true, - state = STATE_READY, - media = media, - timeline = timeline, - ) - } - notifyListeners(EVENT_TIMELINE_CHANGED) { - onTimelineChanged( - timeline, - TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, - ) - } - notifyListeners(EVENT_IS_LOADING_CHANGED) { onIsLoadingChanged(true) } - val url = - media.mediaItem.localConfiguration - ?.uri - .toString() - if (media.startPositionMs > 0) { - MPVLib.command( - arrayOf( - "loadfile", - url, - "replace", - "-1", - "start=${media.startPositionMs / 1000.0}", - ), - ) - } else { - MPVLib.command(arrayOf("loadfile", url, "replace", "-1")) - } - - if (enableHardwareDecoding) { - MPVLib.setOptionString("vo", if (useGpuNext) "gpu-next" else "gpu") - } - Timber.d("Called loadfile") - } - - private fun throwIfReleased() { - if (isReleased) { - throw IllegalStateException("Cannot access MpvPlayer after it is released") - } - } - - private fun notifyListeners( - eventId: Int, - block: Player.Listener.() -> Unit, - ) { - mainHandler.post { - listeners.queueEvent(eventId) { - block.invoke(it) - } - listeners.flushEvents() - } - } - - var subtitleDelaySeconds: Double - get() { - if (isReleased) return 0.0 - return playbackState.load().subtitleDelay - } - set(value) { - if (isReleased) return - sendCommand(MpvCommand.SET_SUBTITLE_DELAY, value) - } - - var subtitleDelay: Duration - get() { - if (isReleased) return Duration.ZERO - return subtitleDelaySeconds.seconds - } - set(value) { - if (isReleased) return - subtitleDelaySeconds = value.inWholeMilliseconds / 1000.0 - } - - private val updatePlaybackState: Runnable = - Runnable { - val state = playbackState.load() - if (state.media == null) { - return@Runnable - } - val positionMs = - MPVLib.getPropertyDouble("time-pos/full")?.seconds?.inWholeMilliseconds - ?: C.TIME_UNSET - val bufferMs = - MPVLib.getPropertyDouble("demuxer-cache-duration")?.seconds?.inWholeMilliseconds - ?: C.TIME_UNSET - val durationMs = - MPVLib.getPropertyDouble("duration/full")?.seconds?.inWholeMilliseconds - ?: C.TIME_UNSET - val speed = MPVLib.getPropertyDouble("speed")?.toFloat() ?: 1f - val paused = MPVLib.getPropertyBoolean("pause") ?: false - val width = MPVLib.getPropertyInt("width") - val height = MPVLib.getPropertyInt("height") - val videoSize = - if (width != null && height != null) { - VideoSize(width, height) - } else { - VideoSize.UNKNOWN - } - - val mediaItem = state.media.mediaItem - val timeline = - object : Timeline() { - override fun getWindowCount(): Int = 1 - - override fun getWindow( - windowIndex: Int, - window: Window, - defaultPositionProjectionUs: Long, - ): Window = - window.set( - mediaItem.mediaId, - mediaItem, - null, - C.TIME_UNSET, - C.TIME_UNSET, - C.TIME_UNSET, - true, - false, - mediaItem.liveConfiguration, - 0L, - if (durationMs != C.TIME_UNSET) durationMs.milliseconds.inWholeMicroseconds else C.TIME_UNSET, - 0, - 0, - 0, - ) - - override fun getPeriodCount(): Int = 1 - - override fun getPeriod( - periodIndex: Int, - period: Period, - setIds: Boolean, - ): Period = - period.set( - mediaItem.mediaId, - mediaItem.mediaId, - 0, - state.durationMs.milliseconds.inWholeMicroseconds, - 0, - ) - - override fun getIndexOfPeriod(uid: Any): Int = 0 - - override fun getUidOfPeriod(periodIndex: Int) = mediaItem.mediaId - } - - playbackState.update { - it.copy( - timestamp = System.currentTimeMillis(), - positionMs = positionMs, - bufferMs = bufferMs, - durationMs = durationMs, - speed = speed, - isPaused = paused, - videoSize = videoSize, - timeline = timeline, - ) - } - notifyListeners(EVENT_TIMELINE_CHANGED) { - onTimelineChanged( - timeline, - TIMELINE_CHANGE_REASON_SOURCE_UPDATE, - ) - } - } - - private fun sendCommand( - cmd: MpvCommand, - obj: Any?, - ) { - internalHandler.obtainMessage(cmd.ordinal, obj).sendToTarget() - } - - private val queuedCommands = mutableListOf<Pair<MpvCommand, Any?>>() - - override fun handleMessage(msg: Message): Boolean { - val cmd = MpvCommand.entries[msg.what] - if (isReleased && cmd != MpvCommand.DESTROY) { - Timber.w("Player is released, ignoring command %s", cmd) - return true - } - if (surface == null && !cmd.isLifecycle) { - // If libmpv isn't ready, ueue the messages - // Note: this means nothing will play until it is attached to a surface, - // so MpvPlayer can't be used for background audio/music playback - Timber.v("MPV is not initialized/attached yet, queue cmd %s", cmd) - queuedCommands.add(Pair(cmd, msg.obj)) - } else { - handleCommand(cmd, msg.obj) - } - return true - } - - private fun handleCommand( - cmd: MpvCommand, - obj: Any?, - ) { - Timber.d("handleCommand: cmd=$cmd") - when (cmd) { - MpvCommand.PLAY_PAUSE -> { - val playWhenReady = obj as Boolean - MPVLib.setPropertyBoolean("pause", !playWhenReady) - playbackState.update { - it.copy(isPaused = !playWhenReady) - } - notifyListeners(EVENT_PLAY_WHEN_READY_CHANGED) { - onPlayWhenReadyChanged( - playWhenReady, - PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST, - ) - } - } - - MpvCommand.SET_TRACK_SELECTION -> { - val (propertyName, trackId) = obj as TrackSelection - MPVLib.setPropertyString(propertyName, trackId) - updateTracksAndNotify() - } - - MpvCommand.SEEK -> { - val positionMs = obj as Long - MPVLib.setPropertyDouble("time-pos", positionMs / 1000.0) - playbackState.update { - it.copy(positionMs = positionMs) - } - } - - MpvCommand.SET_SPEED -> { - val value = obj as Float - MPVLib.setPropertyDouble("speed", value.toDouble()) - playbackState.update { - it.copy(speed = value) - } - } - - MpvCommand.SET_SUBTITLE_DELAY -> { - val value = obj as Double - MPVLib.setPropertyDouble("sub-delay", value) - playbackState.update { - it.copy(subtitleDelay = value) - } - } - - MpvCommand.LOAD_FILE -> { - loadFile(obj as MediaAndPosition) - } - - MpvCommand.ATTACH_SURFACE -> { - val surface = obj as Surface? - if (surface == null || (this.surface != null && this.surface != surface)) { - // If clearing or changing the surface - MPVLib.detachSurface() - MPVLib.setPropertyString("vo", "null") - MPVLib.setPropertyString("force-window", "no") - Timber.d("Detached surface") - } - if (surface != null) { - MPVLib.attachSurface(surface) - this.surface = surface - MPVLib.setOptionString("force-window", "yes") - Timber.d("Attached surface") - if (queuedCommands.isNotEmpty()) { - Timber.d("Processing queued commands") - while (queuedCommands.isNotEmpty()) { - val msg = queuedCommands.removeAt(0) - handleCommand(msg.first, msg.second) - } - } - } - } - - MpvCommand.INITIALIZE -> { - synchronized(initLock) { - init() - } - } - - MpvCommand.DESTROY -> { - synchronized(initLock) { - MPVLib.setPropertyBoolean("pause", true) - MPVLib.removeLogObserver(mpvLogger) - MPVLib.destroy() - Timber.d("MPVLib destroyed") - } - } - } - } -} - -fun MPVLib.setPropertyColor( - property: String, - color: Color, -) = setPropertyString(property, color.mpvFormat) - -private val Color.mpvFormat: String get() = "$red/$green/$blue/$alpha" - -@OptIn(UnstableApi::class) -private fun createTracks(): Tracks { - val trackCount = MPVLib.getPropertyInt("track-list/count") ?: return Tracks.EMPTY - val groups = - (0..<trackCount).mapNotNull { idx -> - val type = MPVLib.getPropertyString("track-list/$idx/type") - val id = MPVLib.getPropertyInt("track-list/$idx/id") - val lang = MPVLib.getPropertyString("track-list/$idx/lang") - val codec = MPVLib.getPropertyString("track-list/$idx/codec") - val codecDescription = MPVLib.getPropertyString("track-list/$idx/codec-desc") - val isDefault = MPVLib.getPropertyBoolean("track-list/$idx/default") ?: false - val isForced = MPVLib.getPropertyBoolean("track-list/$idx/forced") ?: false - val isExternal = MPVLib.getPropertyBoolean("track-list/$idx/external") ?: false - val isSelected = MPVLib.getPropertyBoolean("track-list/$idx/selected") ?: false - val channelCount = MPVLib.getPropertyInt("track-list/$idx/demux-channel-count") - val title = MPVLib.getPropertyString("track-list/$idx/title") - - if (type != null && id != null) { - // TODO do we need the real mimetypes? - val mimeType = - when (type) { - "video" -> MimeTypes.BASE_TYPE_VIDEO + "/todo" - "audio" -> MimeTypes.BASE_TYPE_AUDIO + "/todo" - "sub" -> MimeTypes.BASE_TYPE_TEXT + "/todo" - else -> "unknown/todo" - } - var flags = 0 - if (isDefault) flags = flags or C.SELECTION_FLAG_DEFAULT - if (isForced) flags = flags or C.SELECTION_FLAG_FORCED - val builder = - Format - .Builder() - .apply { - if (isExternal) { - setId("$idx:e:$id") - } else { - setId("$idx:$id") - } - }.setCodecs(codec) - .setSampleMimeType(mimeType) - .setLanguage(lang) - .setLabel(listOfNotNull(title, codecDescription).joinToString(",")) - .setSelectionFlags(flags) - if (type == "video" && isSelected) { - builder.setWidth(MPVLib.getPropertyInt("width") ?: -1) - builder.setHeight(MPVLib.getPropertyInt("height") ?: -1) - } - channelCount?.let(builder::setChannelCount) - val format = builder.build() -// Timber.v("$idx=$format") - - val trackGroup = TrackGroup(format) - val group = - Tracks.Group( - trackGroup, - false, - intArrayOf(C.FORMAT_HANDLED), - booleanArrayOf(isSelected), - ) - group - } else { - null - } - } - return Tracks(groups) -} - -private data class PlaybackState( - val timestamp: Long, - val isLoadingFile: Boolean, - val media: MediaAndPosition?, - val positionMs: Long, - val bufferMs: Long, - val durationMs: Long, - val isPaused: Boolean, - val speed: Float, - val subtitleDelay: Double, - val videoSize: VideoSize, - @param:Player.State val state: Int, - val tracks: Tracks, - val timeline: Timeline, -) { - companion object { - val EMPTY = - PlaybackState( - timestamp = C.TIME_UNSET, - isLoadingFile = false, - media = null, - positionMs = C.TIME_UNSET, - durationMs = C.TIME_UNSET, - tracks = Tracks.EMPTY, - bufferMs = C.TIME_UNSET, - isPaused = false, - speed = 1f, - videoSize = VideoSize.UNKNOWN, - state = Player.STATE_IDLE, - subtitleDelay = 0.0, - timeline = Timeline.EMPTY, - ) - } -} - -private data class TrackSelection( - val property: String, - val trackId: String, -) - -private data class MediaAndPosition( - val mediaItem: MediaItem, - val startPositionMs: Long, -) - -enum class MpvCommand( - val isLifecycle: Boolean, -) { - PLAY_PAUSE(false), - SEEK(false), - SET_TRACK_SELECTION(false), - SET_SPEED(false), - SET_SUBTITLE_DELAY(false), - LOAD_FILE(false), - ATTACH_SURFACE(true), - INITIALIZE(true), - DESTROY(true), -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/mpv/Utils.kt b/app/src/main/java/com/github/damontecres/wholphin/util/mpv/Utils.kt new file mode 100644 index 00000000..18e67f63 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/util/mpv/Utils.kt @@ -0,0 +1,11 @@ +package com.github.damontecres.wholphin.util.mpv + +import androidx.compose.ui.graphics.Color +import com.github.damontecres.wholphin.mpv.MPVLib + +fun MPVLib.setPropertyColor( + property: String, + color: Color, +) = setPropertyString(property, color.mpvFormat) + +private val Color.mpvFormat: String get() = "$red/$green/$blue/$alpha" diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/profile/DeviceProfileUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/util/profile/DeviceProfileUtils.kt index 2de5310e..f1384a27 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/profile/DeviceProfileUtils.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/profile/DeviceProfileUtils.kt @@ -3,6 +3,7 @@ package com.github.damontecres.wholphin.util.profile // Adapted from https://github.com/jellyfin/jellyfin-androidtv/blob/v0.19.4/app/src/main/java/org/jellyfin/androidtv/util/profile/deviceProfile.kt import android.media.MediaCodecInfo +import android.util.Size import androidx.media3.common.MimeTypes import com.github.damontecres.wholphin.util.profile.KnownDefects.supportsHi10P52 import org.jellyfin.sdk.model.api.CodecType @@ -11,7 +12,6 @@ import org.jellyfin.sdk.model.api.EncodingContext import org.jellyfin.sdk.model.api.MediaStreamProtocol import org.jellyfin.sdk.model.api.ProfileConditionValue import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod -import org.jellyfin.sdk.model.api.VideoRangeType import org.jellyfin.sdk.model.deviceprofile.DeviceProfileBuilder import org.jellyfin.sdk.model.deviceprofile.buildDeviceProfile @@ -61,6 +61,8 @@ val supportedAudioCodecs = // ) // } +private fun Size.min(min: Int) = if (min > 0 && height > min) Size(width, min) else this + fun createDeviceProfile( mediaTest: MediaCodecCapabilitiesTest, maxBitrate: Int, @@ -68,9 +70,12 @@ fun createDeviceProfile( downMixAudio: Boolean, assDirectPlay: Boolean, pgsDirectPlay: Boolean, - dolbyVisionELDirectPlay: Boolean, decodeAv1: Boolean, jellyfinTenEleven: Boolean, + preferAc3ForSurround: Boolean, + forceEnabledHdr: Set<String>, + forceDisabledHdr: Set<String>, + maxResolution: Int, ) = buildDeviceProfile { val allowedAudioCodecs = when { @@ -105,10 +110,10 @@ fun createDeviceProfile( val supportsAV1 = mediaTest.supportsAV1() val supportsAV1Main10 = mediaTest.supportsAV1Main10() val supportsVC1 = mediaTest.supportsVc1() - val maxResolutionAVC = mediaTest.getMaxResolution(MimeTypes.VIDEO_H264) - val maxResolutionHevc = mediaTest.getMaxResolution(MimeTypes.VIDEO_H265) - val maxResolutionAV1 = mediaTest.getMaxResolution(MimeTypes.VIDEO_AV1) - val maxResolutionVC1 = mediaTest.getMaxResolution(MimeTypes.VIDEO_VC1) + val maxResolutionAVC = mediaTest.getMaxResolution(MimeTypes.VIDEO_H264).min(maxResolution) + val maxResolutionHevc = mediaTest.getMaxResolution(MimeTypes.VIDEO_H265).min(maxResolution) + val maxResolutionAV1 = mediaTest.getMaxResolution(MimeTypes.VIDEO_AV1).min(maxResolution) + val maxResolutionVC1 = mediaTest.getMaxResolution(MimeTypes.VIDEO_VC1).min(maxResolution) // / HDR capabilities @@ -142,10 +147,20 @@ fun createDeviceProfile( if (supportsHevc) videoCodec(Codec.Video.HEVC) videoCodec(Codec.Video.H264) - audioCodec(*allowedAudioCodecs) + if (preferAc3ForSurround) { + audioCodec(Codec.Audio.AC3) + } else { + audioCodec(*allowedAudioCodecs) + } copyTimestamps = false enableSubtitlesInManifest = true + + if (maxResolution > 0) { + conditions { + ProfileConditionValue.HEIGHT lowerThanOrEquals maxResolution + } + } } // Audio @@ -156,7 +171,11 @@ fun createDeviceProfile( container = Codec.Container.TS protocol = MediaStreamProtocol.HLS - audioCodec(Codec.Audio.AAC) + if (preferAc3ForSurround) { + audioCodec(Codec.Audio.AC3) + } else { + audioCodec(Codec.Audio.AAC) + } } // / Direct play profiles @@ -203,6 +222,19 @@ fun createDeviceProfile( } // / Codec profiles + // Prevent 8-channel EAC3 passthrough in TS on certain devices + if (KnownDefects.eac3HlsPassthroughBug && Codec.Audio.EAC3 in allowedAudioCodecs) { + codecProfile { + type = CodecType.VIDEO_AUDIO + container = Codec.Container.TS + codec = Codec.Audio.EAC3 + + conditions { + ProfileConditionValue.AUDIO_CHANNELS lowerThanOrEquals 7 + } + } + } + // H264 profile codecProfile { type = CodecType.VIDEO @@ -415,62 +447,76 @@ fun createDeviceProfile( } } - // / HDR exclude list + if (maxResolution > 0) { + listOf( + Codec.Video.MPEG, + Codec.Video.MPEG2VIDEO, + Codec.Video.VP8, + Codec.Video.VP9, + ).forEach { codecName -> + codecProfile { + type = CodecType.VIDEO + codec = codecName - // TODO Use VideoRangeType enum with Jellyfin 10.11 based SDK - val unsupportedRangeTypesAv1 = - buildSet { - if (jellyfinTenEleven) add("DOVIInvalid") - - if (!decodeAv1) { - if (!supportsAV1DolbyVision) { - add(VideoRangeType.DOVI.serialName) - if (!supportsAV1HDR10) add(VideoRangeType.DOVI_WITH_HDR10.serialName) - if (jellyfinTenEleven && !supportsAV1HDR10Plus) add("DOVIWithHDR10Plus") - } - - if (!supportsAV1HDR10Plus) { - add(VideoRangeType.HDR10_PLUS.serialName) - - if (!mediaTest.supportsAV1HDR10()) add(VideoRangeType.HDR10.serialName) + conditions { + ProfileConditionValue.HEIGHT lowerThanOrEquals maxResolution } } } + } + + // / HDR exclude list + + val unsupportedRangeTypesAv1 = + buildSet { + if (jellyfinTenEleven) add(WholphinVideoRangeType.DOVI_INVALID.serialName) + + if (!decodeAv1) { + if (!supportsAV1DolbyVision) { + add(WholphinVideoRangeType.DOVI.serialName) + if (!supportsAV1HDR10) add(WholphinVideoRangeType.DOVI_WITH_HDR10.serialName) + if (jellyfinTenEleven && !supportsAV1HDR10Plus) add(WholphinVideoRangeType.DOVI_WITH_HDR10_PLUS.serialName) + } + + if (!supportsAV1HDR10Plus) { + add(WholphinVideoRangeType.HDR10_PLUS.serialName) + + if (!mediaTest.supportsAV1HDR10()) add(WholphinVideoRangeType.HDR10.serialName) + } + } + } - forceEnabledHdr + forceDisabledHdr - // TODO Use VideoRangeType enum with Jellyfin 10.11 based SDK val unsupportedRangeTypesHevc = buildSet { - if (jellyfinTenEleven) add("DOVIInvalid") + if (jellyfinTenEleven) add(WholphinVideoRangeType.DOVI_INVALID.serialName) if (!supportsHevcDolbyVisionEL) { - if (!dolbyVisionELDirectPlay) { - if (jellyfinTenEleven) { - add("DOVIWithEL") - if (!supportsHevcHDR10Plus && !KnownDefects.hevcDoviHdr10PlusBug) add("DOVIWithELHDR10Plus") + if (jellyfinTenEleven) { + add(WholphinVideoRangeType.DOVI_WITH_EL.serialName) + if (!supportsHevcHDR10Plus && !KnownDefects.hevcDoviHdr10PlusBug) { + add(WholphinVideoRangeType.DOVI_WITH_EL_HDR10_PLUS.serialName) } } if (!supportsHevcDolbyVision) { - add(VideoRangeType.DOVI.serialName) - if (!supportsHevcHDR10) add(VideoRangeType.DOVI_WITH_HDR10.serialName) + add(WholphinVideoRangeType.DOVI.serialName) + if (!supportsHevcHDR10) add(WholphinVideoRangeType.DOVI_WITH_HDR10.serialName) if (jellyfinTenEleven && !supportsHevcHDR10Plus && !KnownDefects.hevcDoviHdr10PlusBug) { - add( - "DOVIWithHDR10Plus", - ) + add(WholphinVideoRangeType.DOVI_WITH_HDR10_PLUS.serialName) } } } if (!supportsHevcHDR10Plus) { - add(VideoRangeType.HDR10_PLUS.serialName) - if (!supportsHevcHDR10) add(VideoRangeType.HDR10.serialName) + add(WholphinVideoRangeType.HDR10_PLUS.serialName) + if (!supportsHevcHDR10) add(WholphinVideoRangeType.HDR10.serialName) } if (jellyfinTenEleven && KnownDefects.hevcDoviHdr10PlusBug) { - add("DOVIWithHDR10Plus") - add("DOVIWithELHDR10Plus") + add(WholphinVideoRangeType.DOVI_WITH_HDR10_PLUS.serialName) + add(WholphinVideoRangeType.DOVI_WITH_EL_HDR10_PLUS.serialName) } - } + } - forceEnabledHdr + forceDisabledHdr // Display // Note: The codec profiles use a workaround to create correct behavior @@ -517,6 +563,27 @@ fun createDeviceProfile( } } + if (preferAc3ForSurround) { + supportedAudioCodecs + .filterNot { it == Codec.Audio.AC3 } + .forEach { audioCodec -> + codecProfile { + type = CodecType.VIDEO_AUDIO + codec = audioCodec + conditions { + ProfileConditionValue.AUDIO_CHANNELS lowerThanOrEquals 2 + } + } + codecProfile { + type = CodecType.AUDIO + codec = audioCodec + conditions { + ProfileConditionValue.AUDIO_CHANNELS lowerThanOrEquals 2 + } + } + } + } + // Audio channel profile codecProfile { type = CodecType.VIDEO_AUDIO diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/profile/KnownDefects.kt b/app/src/main/java/com/github/damontecres/wholphin/util/profile/KnownDefects.kt index a4cd60c4..d2a3c042 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/profile/KnownDefects.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/profile/KnownDefects.kt @@ -14,6 +14,14 @@ private val modelsWithDoViHdr10PlusBug = "AFTKM", // Amazon Fire TV 4K (2nd Gen) ) +/** + * List of device models with known playback issues when 8-channel EAC3 is copied into MPEG-TS. + */ +private val modelsWithEac3TsPassthroughBug = + listOf( + "AFTKRT", // Amazon Fire TV 4K Max (2nd Gen) + ) + /** * List of device models that support H264 Hi10P 5.2, but don't advertise it * @@ -30,5 +38,6 @@ private val modelsWithHi10P52Support = object KnownDefects { val hevcDoviHdr10PlusBug = Build.MODEL in modelsWithDoViHdr10PlusBug + val eac3HlsPassthroughBug = Build.MODEL in modelsWithEac3TsPassthroughBug val supportsHi10P52 = Build.MODEL in modelsWithHi10P52Support } diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/profile/MediaCodecCapabilitiesTest.kt b/app/src/main/java/com/github/damontecres/wholphin/util/profile/MediaCodecCapabilitiesTest.kt index ab4de03e..a769c400 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/profile/MediaCodecCapabilitiesTest.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/profile/MediaCodecCapabilitiesTest.kt @@ -209,6 +209,15 @@ class MediaCodecCapabilitiesTest( ) && supportsMultiInstance(MediaFormat.MIMETYPE_VIDEO_HEVC) + // Checks for single-layer Dolby Vision Profile 8, which the Profile 7 conversion produces + fun supportsHevcDolbyVisionProfile8(): Boolean = + Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1 && + hasDecoder( + MediaFormat.MIMETYPE_VIDEO_DOLBY_VISION, + DolbyVisionProfiles.Profile8, + CodecProfileLevel.DolbyVisionLevelHd24, + ) + fun supportsHevcHDR10(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && hasDecoder( diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/profile/WholphinVideoRangeType.kt b/app/src/main/java/com/github/damontecres/wholphin/util/profile/WholphinVideoRangeType.kt new file mode 100644 index 00000000..bd49a6c4 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/util/profile/WholphinVideoRangeType.kt @@ -0,0 +1,27 @@ +package com.github.damontecres.wholphin.util.profile + +import org.jellyfin.sdk.model.api.VideoRangeType + +/** + * Complete list of video range types, including ones not yet present in the pinned Jellyfin SDK's + * [VideoRangeType] enum (added server-side in Jellyfin 10.11). + * + * TODO Remove this and use [VideoRangeType] directly once the SDK is upgraded to a version built against Jellyfin 10.11+ + */ +enum class WholphinVideoRangeType( + val serialName: String, +) { + UNKNOWN(VideoRangeType.UNKNOWN.serialName), + SDR(VideoRangeType.SDR.serialName), + HDR10(VideoRangeType.HDR10.serialName), + HLG(VideoRangeType.HLG.serialName), + DOVI(VideoRangeType.DOVI.serialName), + DOVI_WITH_HDR10(VideoRangeType.DOVI_WITH_HDR10.serialName), + DOVI_WITH_HLG(VideoRangeType.DOVI_WITH_HLG.serialName), + DOVI_WITH_SDR(VideoRangeType.DOVI_WITH_SDR.serialName), + HDR10_PLUS(VideoRangeType.HDR10_PLUS.serialName), + DOVI_INVALID("DOVIInvalid"), + DOVI_WITH_EL("DOVIWithEL"), + DOVI_WITH_EL_HDR10_PLUS("DOVIWithELHDR10Plus"), + DOVI_WITH_HDR10_PLUS("DOVIWithHDR10Plus"), +} diff --git a/app/src/main/proto/WholphinDataStore.proto b/app/src/main/proto/WholphinDataStore.proto index 69df8689..910853e9 100644 --- a/app/src/main/proto/WholphinDataStore.proto +++ b/app/src/main/proto/WholphinDataStore.proto @@ -54,9 +54,15 @@ message PlaybackOverrides{ bool direct_play_ass = 3 [deprecated = true]; bool direct_play_pgs = 4; MediaExtensionStatus media_extensions_enabled = 5; - bool direct_play_dolby_vision_e_l = 6; + bool direct_play_dolby_vision_e_l = 6 [deprecated = true]; bool decode_av1 = 7; AssPlaybackMode ass_playback_mode = 8; + int32 max_resolution = 9; + HdrOverrideMode dovi_profile_5_override = 10; + HdrOverrideMode dovi_profile_7_override = 11; + HdrOverrideMode dovi_profile_8_override = 12; + HdrOverrideMode hdr10_override = 13; + HdrOverrideMode hdr10_plus_override = 14; } message PlaybackPreferences { @@ -87,13 +93,21 @@ message PlaybackPreferences { bool resolution_switching = 23; string external_player = 24; bool cinema_mode = 25; + DpadSeekMode dpad_seek_mode = 26; +} + +enum DpadSeekMode { + SEEKBAR_MINIMAL = 0; + SEEKBAR_TRICKPLAY = 1; + SEEKBAR_NONE = 2; } message HomePagePreferences{ int32 max_items_per_row = 1; bool enable_rewatching_next_up = 2; - bool combine_continue_next = 3; + bool combine_continue_next = 3 [deprecated = true]; int32 max_days_next_up = 4; + bool click_to_play = 5; } enum ThemeSongVolume { @@ -112,6 +126,9 @@ enum AppThemeColors { ORANGE = 3; BOLD_BLUE = 4; OLED_BLACK = 5; + RED = 6; + BROWN = 7; + reserved 8; } enum EdgeStyle{ @@ -140,6 +157,7 @@ message SubtitlePreferences{ int32 margin = 11; int32 edge_thickness = 12; int32 image_subtitle_opacity = 13; + bool use_separate_hdr = 14; } message LiveTvPreferences { @@ -163,12 +181,25 @@ message ScreensaverPreferences{ bool enabled = 5; repeated string item_types = 6; bool show_clock = 7; + bool dim_enabled = 8; + int32 dim_percent = 9; +} + +message SearchPreferences { + bool combined_search_results = 1; + bool show_voice_search_button = 2; +} + +enum DisplayToggle{ + OFFICIAL_RATING = 0; + CRITIC_RATING = 1; + COMMUNITY_RATING = 2; } message InterfacePreferences { ThemeSongVolume play_theme_songs = 1; bool remember_selected_tab = 2; - map<string, int32> remembered_tabs = 3; + map<string, int32> remembered_tabs = 3 [deprecated = true]; AppThemeColors app_theme_colors = 4; bool nav_drawer_switch_on_focus = 5; bool show_clock = 6; @@ -179,6 +210,8 @@ message InterfacePreferences { ScreensaverPreferences screensaver_preference = 11; bool enable_media_management = 12; bool show_logos = 13; + SearchPreferences search_preferences = 14; + repeated DisplayToggle display_toggles = 15; } message AdvancedPreferences { @@ -197,10 +230,24 @@ message MusicPreferences { bool show_backdrop = 4; } +enum HdrOverrideMode{ + HDR_OVERRIDE_AUTO = 0; + HDR_OVERRIDE_ENABLE = 1; + HDR_OVERRIDE_DISABLE = 2; +} + +message ExperimentalPreferences { + bool enabled = 1; + bool video_tunneling_enabled = 2; + bool prefer_ac3_surround = 3; + bool disable_audio_offload = 4; + bool prefer_dolby_vision_over_hdr10_plus = 5; + bool convert_dolby_vision_profile_7 = 6; +} + message AppPreferences { - // The currently signed in server and user IDs, mostly for restoring a session - string current_server_id = 1; - string current_user_id = 2; + string current_server_id = 1 [deprecated = true]; + string current_user_id = 2 [deprecated = true]; PlaybackPreferences playback_preferences = 3; HomePagePreferences home_page_preferences = 4; @@ -214,4 +261,5 @@ message AppPreferences { bool sign_in_automatically = 11; PhotoPreferences photo_preferences = 12; MusicPreferences music_preferences = 13; + ExperimentalPreferences experimental_preferences = 14; } diff --git a/app/src/main/res/drawable/ic_rating_imdb.xml b/app/src/main/res/drawable/ic_rating_imdb.xml new file mode 100644 index 00000000..6725d42f --- /dev/null +++ b/app/src/main/res/drawable/ic_rating_imdb.xml @@ -0,0 +1,22 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:aapt="http://schemas.android.com/aapt" + android:width="575dp" + android:height="289.83dp" + android:viewportWidth="575" + android:viewportHeight="289.83"> + <path + android:pathData="M575 24.91C573.44 12.15 563.97 1.98 551.91 0C499.05 0 76.18 0 23.32 0C10.11 2.17 0 14.16 0 28.61C0 51.84 0 237.64 0 260.86C0 276.86 12.37 289.83 27.64 289.83C79.63 289.83 495.6 289.83 547.59 289.83C561.65 289.83 573.26 278.82 575 264.57C575 216.64 575 48.87 575 24.91Z" + android:fillColor="#f6c700"/> + <path + android:pathData="M69.35 58.24L114.98 58.24L114.98 233.89L69.35 233.89L69.35 58.24Z" + android:fillColor="#000000"/> + <path + android:pathData="M201.2 139.15C197.28 112.38 195.1 97.5 194.67 94.53C192.76 80.2 190.94 67.73 189.2 57.09C185.25 57.09 165.54 57.09 130.04 57.09L130.04 232.74L170.01 232.74L170.15 116.76L186.97 232.74L215.44 232.74L231.39 114.18L231.54 232.74L271.38 232.74L271.38 57.09L211.77 57.09L201.2 139.15Z" + android:fillColor="#000000"/> + <path + android:pathData="M346.71 93.63C347.21 95.87 347.47 100.95 347.47 108.89C347.47 115.7 347.47 170.18 347.47 176.99C347.47 188.68 346.71 195.84 345.2 198.48C343.68 201.12 339.64 202.43 333.09 202.43C333.09 190.9 333.09 98.66 333.09 87.13C338.06 87.13 341.45 87.66 343.25 88.7C345.05 89.75 346.21 91.39 346.71 93.63ZM367.32 230.95C372.75 229.76 377.31 227.66 381.01 224.67C384.7 221.67 387.29 217.52 388.77 212.21C390.26 206.91 391.14 196.38 391.14 180.63C391.14 174.47 391.14 125.12 391.14 118.95C391.14 102.33 390.49 91.19 389.48 85.53C388.46 79.86 385.93 74.71 381.88 70.09C377.82 65.47 371.9 62.15 364.12 60.13C356.33 58.11 343.63 57.09 321.54 57.09C319.27 57.09 307.93 57.09 287.5 57.09L287.5 232.74L342.78 232.74C355.52 232.34 363.7 231.75 367.32 230.95Z" + android:fillColor="#000000"/> + <path + android:pathData="M464.76 204.7C463.92 206.93 460.24 208.06 457.46 208.06C454.74 208.06 452.93 206.98 452.01 204.81C451.09 202.65 450.64 197.72 450.64 190C450.64 185.36 450.64 148.22 450.64 143.58C450.64 135.58 451.04 130.59 451.85 128.6C452.65 126.63 454.41 125.63 457.13 125.63C459.91 125.63 463.64 126.76 464.6 129.03C465.55 131.3 466.03 136.15 466.03 143.58C466.03 146.58 466.03 161.58 466.03 188.59C465.74 197.84 465.32 203.21 464.76 204.7ZM406.68 231.21L447.76 231.21C449.47 224.5 450.41 220.77 450.6 220.02C454.32 224.52 458.41 227.9 462.9 230.14C467.37 232.39 474.06 233.51 479.24 233.51C486.45 233.51 492.67 231.62 497.92 227.83C503.16 224.05 506.5 219.57 507.92 214.42C509.34 209.26 510.05 201.42 510.05 190.88C510.05 185.95 510.05 146.53 510.05 141.6C510.05 131 509.81 124.08 509.34 120.83C508.87 117.58 507.47 114.27 505.14 110.88C502.81 107.49 499.42 104.86 494.98 102.98C490.54 101.1 485.3 100.16 479.26 100.16C474.01 100.16 467.29 101.21 462.81 103.28C458.34 105.35 454.28 108.49 450.64 112.7C450.64 108.89 450.64 89.85 450.64 55.56L406.68 55.56L406.68 231.21Z" + android:fillColor="#000000"/> +</vector> diff --git a/app/src/main/res/drawable/ic_rating_tmdb.xml b/app/src/main/res/drawable/ic_rating_tmdb.xml new file mode 100644 index 00000000..31c0be7d --- /dev/null +++ b/app/src/main/res/drawable/ic_rating_tmdb.xml @@ -0,0 +1,16 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:aapt="http://schemas.android.com/aapt" + android:width="185.04dp" + android:height="133.4dp" + android:viewportWidth="185.04" + android:viewportHeight="133.4"> + <path android:pathData="M51.06,66.7h0A17.67,17.67,0,0,1,68.73,49h-.1A17.67,17.67,0,0,1,86.3,66.7h0A17.67,17.67,0,0,1,68.63,84.37h.1A17.67,17.67,0,0,1,51.06,66.7Zm82.67-31.33h32.9A17.67,17.67,0,0,0,184.3,17.7h0A17.67,17.67,0,0,0,166.63,0h-32.9A17.67,17.67,0,0,0,116.06,17.7h0A17.67,17.67,0,0,0,133.73,35.37Zm-113,98h63.9A17.67,17.67,0,0,0,102.3,115.7h0A17.67,17.67,0,0,0,84.63,98H20.73A17.67,17.67,0,0,0,3.06,115.7h0A17.67,17.67,0,0,0,20.73,133.37Zm83.92-49h6.25L125.5,49h-8.35l-8.9,23.2h-.1L99.4,49H90.5Zm32.45,0h7.8V49h-7.8Zm22.2,0h24.95V77.2H167.1V70h15.35V62.8H167.1V56.2h16.25V49h-24ZM10.1,35.4h7.8V6.9H28V0H0V6.9H10.1ZM39,35.4h7.8V20.1H61.9V35.4h7.8V0H61.9V13.2H46.75V0H39Zm41.25,0h25V28.2H88V21h15.35V13.8H88V7.2h16.25V0h-24Zm-79,49H9V57.25h.1l9,27.15H24l9.3-27.15h.1V84.4h7.8V49H29.45l-8.2,23.1h-.1L13,49H1.2Zm112.09,49H126a24.59,24.59,0,0,0,7.56-1.15,19.52,19.52,0,0,0,6.35-3.37,16.37,16.37,0,0,0,4.37-5.5A16.91,16.91,0,0,0,146,115.8a18.5,18.5,0,0,0-1.68-8.25,15.1,15.1,0,0,0-4.52-5.53A18.55,18.55,0,0,0,133.07,99,33.54,33.54,0,0,0,125,98H113.29Zm7.81-28.2h4.6a17.43,17.43,0,0,1,4.67.62,11.68,11.68,0,0,1,3.88,1.88,9,9,0,0,1,2.62,3.18,9.87,9.87,0,0,1,1,4.52,11.92,11.92,0,0,1-1,5.08,8.69,8.69,0,0,1-2.67,3.34,10.87,10.87,0,0,1-4,1.83,21.57,21.57,0,0,1-5,.55H121.1Zm36.14,28.2h14.5a23.11,23.11,0,0,0,4.73-.5,13.38,13.38,0,0,0,4.27-1.65,9.42,9.42,0,0,0,3.1-3,8.52,8.52,0,0,0,1.2-4.68,9.16,9.16,0,0,0-.55-3.2,7.79,7.79,0,0,0-1.57-2.62,8.38,8.38,0,0,0-2.45-1.85,10,10,0,0,0-3.18-1v-.1a9.28,9.28,0,0,0,4.43-2.82,7.42,7.42,0,0,0,1.67-5,8.34,8.34,0,0,0-1.15-4.65,7.88,7.88,0,0,0-3-2.73,12.9,12.9,0,0,0-4.17-1.3,34.42,34.42,0,0,0-4.63-.32h-13.2Zm7.8-28.8h5.3a10.79,10.79,0,0,1,1.85.17,5.77,5.77,0,0,1,1.7.58,3.33,3.33,0,0,1,1.23,1.13,3.22,3.22,0,0,1,.47,1.82,3.63,3.63,0,0,1-.42,1.8,3.34,3.34,0,0,1-1.13,1.2,4.78,4.78,0,0,1-1.57.65,8.16,8.16,0,0,1-1.78.2H165Zm0,14.15h5.9a15.12,15.12,0,0,1,2.05.15,7.83,7.83,0,0,1,2,.55,4,4,0,0,1,1.58,1.17,3.13,3.13,0,0,1,.62,2,3.71,3.71,0,0,1-.47,1.95,4,4,0,0,1-1.23,1.3,4.78,4.78,0,0,1-1.67.7,8.91,8.91,0,0,1-1.83.2h-7Z"> + <aapt:attr name="android:fillColor"> + <gradient android:type="linear" android:startX="0" android:startY="66.7" android:endX="185.04" android:endY="66.7"> + <item android:offset="0" android:color="#90cea1"/> + <item android:offset="0.56" android:color="#3cbec9"/> + <item android:offset="1" android:color="#00b3e5"/> + </gradient> + </aapt:attr> + </path> +</vector> diff --git a/app/src/main/res/drawable/ic_rt_spilled.xml b/app/src/main/res/drawable/ic_rt_spilled.xml new file mode 100644 index 00000000..fea81f97 --- /dev/null +++ b/app/src/main/res/drawable/ic_rt_spilled.xml @@ -0,0 +1,73 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:aapt="http://schemas.android.com/aapt" + android:width="143.75dp" + android:height="108.75dp" + android:viewportWidth="143.75" + android:viewportHeight="108.75"> + <path + android:pathData="m96.641 2.9657c28.149 1.101 27.459 97.814 0.825 97.194l-74.45-9.973c-25.51-7.211-25.922-69.313-0.534-76.178z" + android:fillColor="#07a23b"/> + <path + android:pathData="m85.419 8.8789-63.171 8.9751c-4.2681 3.3648-6.9679 8.2192-7.5687 9.3296l65.017-6.963c1.3226-3.3762 4.3015-9.5395 5.7202-11.342z" + android:fillColor="#fff"/> + <path + android:pathData="m78.042 28.008-65.329 5.5498c-2.494 4.757-3.169 10.65-3.3147 15.17l66.739-1.5147c0.0074-3.2891 0.55003-13.318 1.9052-19.206z" + android:fillColor="#fff"/> + <path + android:pathData="m85.595 94.456-63.251-8.403c-4.2975-3.326-7.0398-8.1557-7.6503-9.2607l65.082 6.3737c1.3522 3.3644 4.3852 9.4999 5.8189 11.29z" + android:fillColor="#fff"/> + <path + android:pathData="m78.051 75.394-65.375-4.957c-2.536-4.734-3.2627-10.621-3.4481-15.14l66.749 0.9101c0.03629 3.2889 0.66694 13.312 2.0737 19.188z" + android:fillColor="#fff"/> + <path + android:pathData="m100.36 10.836c-13.099 0.685-19.878 48.223-11.732 71.195l21.342-4.561c8.39-24.044 1.28-66.986-9.61-66.634z" + android:fillColor="#03621e"/> + <path + android:pathData="m99.087 78.942a6.1255 6.1255 0 0 1 -6.1238 6.1255 6.1255 6.1255 0 0 1 -6.1273 -6.122 6.1255 6.1255 0 0 1 6.1202 -6.1291 6.1255 6.1255 0 0 1 6.1309 6.1184" + android:fillColor="#fdd600"/> + <path + android:pathData="m112.96 75.406a5.5952 5.5952 0 0 1 -5.5936 5.5952 5.5952 5.5952 0 0 1 -5.5968 -5.592 5.5952 5.5952 0 0 1 5.5903 -5.5985 5.5952 5.5952 0 0 1 5.6001 5.5887" + android:fillColor="#fdd600"/> + <path + android:pathData="m120.39 74.743a4.9323 4.9323 0 0 1 -4.9309 4.9323 4.9323 4.9323 0 0 1 -4.9337 -4.9294 4.9323 4.9323 0 0 1 4.928 -4.9352 4.9323 4.9323 0 0 1 4.9366 4.9266" + android:fillColor="#fdd600"/> + <path + android:pathData="m124.1 78.942a3.7391 3.7391 0 0 1 -3.738 3.7391 3.7391 3.7391 0 0 1 -3.7402 -3.7369 3.7391 3.7391 0 0 1 3.7358 -3.7412 3.7391 3.7391 0 0 1 3.7423 3.7347" + android:fillColor="#fdd600"/> + <path + android:pathData="m131.08 83.14a5.0207 5.0207 0 0 1 -5.0192 5.0207 5.0207 5.0207 0 0 1 -5.0222 -5.0178 5.0207 5.0207 0 0 1 5.0163 -5.0236 5.0207 5.0207 0 0 1 5.0251 5.0148" + android:fillColor="#fdd600"/> + <path + android:pathData="m135.86 91.67a5.5952 5.5952 0 0 1 -5.5936 5.5952 5.5952 5.5952 0 0 1 -5.5968 -5.592 5.5952 5.5952 0 0 1 5.5903 -5.5985 5.5952 5.5952 0 0 1 5.6001 5.5887" + android:fillColor="#fdd600"/> + <path + android:pathData="m140.36 97.327a3.9158 3.9158 0 0 1 -3.9147 3.9158 3.9158 3.9158 0 0 1 -3.917 -3.9136 3.9158 3.9158 0 0 1 3.9124 -3.9181 3.9158 3.9158 0 0 1 3.9192 3.9113" + android:fillColor="#fdd600"/> + <path + android:pathData="m140.45 99.625a4.181 4.181 0 0 1 -4.1798 4.181 4.181 4.181 0 0 1 -4.1822 -4.1786 4.181 4.181 0 0 1 4.1773 -4.1834 4.181 4.181 0 0 1 4.1846 4.1761" + android:fillColor="#fdd600"/> + <path + android:pathData="m134.44 100.55a4.8439 4.8439 0 0 1 -4.8425 4.8439 4.8439 4.8439 0 0 1 -4.8453 -4.8411 4.8439 4.8439 0 0 1 4.8397 -4.8467 4.8439 4.8439 0 0 1 4.8481 4.8383" + android:fillColor="#fdd600"/> + <path + android:pathData="m126.84 100.24a4.0042 4.0042 0 0 1 -4.0031 4.0042 4.0042 4.0042 0 0 1 -4.0054 -4.0019 4.0042 4.0042 0 0 1 4.0007 -4.0065 4.0042 4.0042 0 0 1 4.0077 3.9996" + android:fillColor="#fdd600"/> + <path + android:pathData="m125.43 97.636a5.1091 5.1091 0 0 1 -5.1076 5.1091 5.1091 5.1091 0 0 1 -5.1106 -5.1061 5.1091 5.1091 0 0 1 5.1046 -5.112 5.1091 5.1091 0 0 1 5.1135 5.1031" + android:fillColor="#fdd600"/> + <path + android:pathData="m117.12 98.078a5.1091 5.1091 0 0 1 -5.1076 5.1091 5.1091 5.1091 0 0 1 -5.1106 -5.1061 5.1091 5.1091 0 0 1 5.1046 -5.112 5.1091 5.1091 0 0 1 5.1135 5.1031" + android:fillColor="#fdd600"/> + <path + android:pathData="m110.49 97.459a3.6065 3.6065 0 0 1 -3.6054 3.6065 3.6065 3.6065 0 0 1 -3.6075 -3.6044 3.6065 3.6065 0 0 1 3.6033 -3.6086 3.6065 3.6065 0 0 1 3.6096 3.6023" + android:fillColor="#fdd600"/> + <path + android:pathData="m105.72 96.929a4.0484 4.0484 0 0 1 -4.0472 4.0484 4.0484 4.0484 0 0 1 -4.0496 -4.0461 4.0484 4.0484 0 0 1 4.0449 -4.0508 4.0484 4.0484 0 0 1 4.052 4.0437" + android:fillColor="#fdd600"/> + <path + android:pathData="m94.71 80.271c2.1568-1.7217 5.4319-2.8842 9.5881-3.6062l11.579 0.61872 15.203 13.612-2.0329 9.6343-27.047-2.5633-4.7288 1.4584-11.183-17.899z" + android:fillColor="#fdd600"/> + <path + android:pathData="m85.913 71.627c3.2472 12.036 7.0507 22.57 12.64 28.284l-9.9879-1.591s-5.5685-25.456-4.8614-25.544c0.70711-0.08839 2.2097-1.149 2.2097-1.149z" + android:fillColor="#09a339"/> +</vector> diff --git a/app/src/main/res/drawable/ic_rt_upright.xml b/app/src/main/res/drawable/ic_rt_upright.xml new file mode 100644 index 00000000..e7166b39 --- /dev/null +++ b/app/src/main/res/drawable/ic_rt_upright.xml @@ -0,0 +1,52 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:aapt="http://schemas.android.com/aapt" + android:width="106.25dp" + android:height="140dp" + android:viewportWidth="106.25" + android:viewportHeight="140"> + <path + android:pathData="M2.727 39.537c-.471-21.981 100.88-25.089 100.88-.42L92.91 117.56c-7.605 26.86-72.064 27.007-79.07.21z" + android:fillColor="#fa3106"/> + <path + android:pathData="M8.809 51.911l9.018 66.639c3.472 4.515 8.498 7.384 9.648 8.022l-6.921-68.576c-3.498-1.41-9.881-4.579-11.745-6.083zM28.629 59.776l5.453 68.898c4.926 2.652 11.04 3.391 15.73 3.566l-1.258-70.366c-3.414-.024-13.82-.642-19.925-2.098zM97.632 52.121l-9.019 66.643c-3.472 4.515-8.498 7.384-9.647 8.022l6.92-68.583c3.5-1.41 9.882-4.579 11.746-6.082zM77.812 59.986l-5.453 68.898c-4.926 2.652-11.04 3.391-15.73 3.566l1.258-70.366c3.414-.024 13.82-.642 19.925-2.098z" + android:fillColor="#fff"/> + <path + android:pathData="M6.397,31.252a6.816,6.816 0 1,0 13.632,0a6.816,6.816 0 1,0 -13.632,0z" + android:fillColor="#ffd600"/> + <path + android:pathData="M15.415,27.687a6.607,6.607 0 1,0 13.214,0a6.607,6.607 0 1,0 -13.214,0z" + android:fillColor="#ffd600"/> + <path + android:pathData="M24.434,19.769a5.925,5.925 0 1,0 11.85,0a5.925,5.925 0 1,0 -11.85,0z" + android:fillColor="#ffd600"/> + <path + android:pathData="M28.943,15.155a6.03,6.03 0 1,0 12.06,0a6.03,6.03 0 1,0 -12.06,0z" + android:fillColor="#ffd600"/> + <path + android:pathData="M40.164,17.095a4.929,4.929 0 1,0 9.858,0a4.929,4.929 0 1,0 -9.858,0z" + android:fillColor="#ffd600"/> + <path + android:pathData="M44.883,9.597a6.24,6.24 0 1,0 12.48,0a6.24,6.24 0 1,0 -12.48,0z" + android:fillColor="#ffd600"/> + <path + android:pathData="M54.636,9.387a6.554,6.554 0 1,0 13.108,0a6.554,6.554 0 1,0 -13.108,0z" + android:fillColor="#ffd600"/> + <path + android:pathData="M63.025,13.635a4.929,4.929 0 1,0 9.858,0a4.929,4.929 0 1,0 -9.858,0z" + android:fillColor="#ffd600"/> + <path + android:pathData="M70.156,17.672a5.925,5.925 0 1,0 11.85,0a5.925,5.925 0 1,0 -11.85,0z" + android:fillColor="#ffd600"/> + <path + android:pathData="M74.561,22.706a4.352,4.352 0 1,0 8.704,0a4.352,4.352 0 1,0 -8.704,0z" + android:fillColor="#ffd600"/> + <path + android:pathData="M78.232,26.324a5.243,5.243 0 1,0 10.486,0a5.243,5.243 0 1,0 -10.486,0z" + android:fillColor="#ffd600"/> + <path + android:pathData="M82.426,34.398a5.768,5.768 0 1,0 11.536,0a5.768,5.768 0 1,0 -11.536,0z" + android:fillColor="#ffd600"/> + <path + android:pathData="M87.355 35.447c5.79 2.799 1.352-2.213 10.696 2.097-9.574 15.338-74.774 16.892-90.291.525l-.21-3.985L38.59 16.99l22.863-6.606 15.52 9.962z" + android:fillColor="#ffd600"/> +</vector> diff --git a/app/src/main/res/raw/keep.xml b/app/src/main/res/raw/keep.xml new file mode 100644 index 00000000..5714801d --- /dev/null +++ b/app/src/main/res/raw/keep.xml @@ -0,0 +1,3 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:tools="http://schemas.android.com/tools" + tools:keep="@raw/aboutlibraries" /> diff --git a/app/src/main/res/resources.properties b/app/src/main/res/resources.properties new file mode 100644 index 00000000..467b3efe --- /dev/null +++ b/app/src/main/res/resources.properties @@ -0,0 +1 @@ +unqualifiedResLocale=en-US diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 0bfa6343..6db20221 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -31,7 +31,7 @@ <string name="director">المخرج</string> <string name="disabled">مُعطّل</string> <string name="discovered_servers">الخوادم المكتشفة</string> - <string name="download_and_update"><![CDATA[تنزيل وتحديث]]></string> + <string name="download_and_update">تنزيل وتحديث</string> <string name="downloading">تنزيل…</string> <string name="enabled">مُفعّل</string> <string name="ends_at">ينتهي عند %1$s</string> @@ -66,11 +66,7 @@ <string name="more">المزيد</string> <plurals name="movies"> <item quantity="zero">الأفلام</item> - <item quantity="one"/> - <item quantity="two"/> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="other">الأفلام</item> </plurals> <string name="name">الاسم</string> <string name="next_up">التالي</string> @@ -85,11 +81,7 @@ <string name="path">المسار</string> <plurals name="people"> <item quantity="zero">الأشخاص</item> - <item quantity="one"/> - <item quantity="two"/> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="other">الأشخاص</item> </plurals> <string name="play_count">عدد مرات التشغيل</string> <string name="play_from_here">تشغيل من هنا</string> @@ -114,7 +106,7 @@ <string name="restart">إعادة تشغيل</string> <string name="resume">استئناف</string> <string name="save">حفظ</string> - <string name="search_and_download"><![CDATA[بحث وتنزيل]]></string> + <string name="search_and_download">بحث وتنزيل</string> <string name="search">بحث</string> <string name="searching">جارٍ البحث…</string> <string name="voice_search">البحث الصوتي</string> @@ -161,32 +153,41 @@ <string name="show_clock">إظهار الساعة</string> <plurals name="behind_the_scenes"> <item quantity="zero">الكواليس</item> + <item quantity="other">الكواليس</item> </plurals> <string name="official_rating">التصنيف العمري</string> <string name="runtime_sort">مدة العرض</string> - <string name="extras">إضافات</string> + <string name="extras_title">إضافات</string> <plurals name="other_extras"> <item quantity="zero">أخرى</item> + <item quantity="other">أخرى</item> </plurals> <plurals name="theme_songs"> <item quantity="zero">الأغاني الرئيسية</item> + <item quantity="other">الأغاني الرئيسية</item> </plurals> <plurals name="clips"> <item quantity="zero">مقاطع</item> + <item quantity="other">مقاطع</item> </plurals> <plurals name="deleted_scenes"> <item quantity="zero">مشاهد محذوفة</item> + <item quantity="other">مشاهد محذوفة</item> </plurals> <plurals name="interviews"> <item quantity="zero">مقابلات</item> + <item quantity="other">مقابلات</item> </plurals> <plurals name="scenes"> <item quantity="zero">مشاهد</item> + <item quantity="other">مشاهد</item> </plurals> <plurals name="featurettes"> <item quantity="zero">مقاطع خاصة</item> + <item quantity="other">مقاطع خاصة</item> </plurals> <plurals name="shorts"> <item quantity="zero">مقاطع قصيرة</item> + <item quantity="other">مقاطع قصيرة</item> </plurals> </resources> diff --git a/app/src/main/res/values-be/strings.xml b/app/src/main/res/values-be/strings.xml new file mode 100644 index 00000000..d7509408 --- /dev/null +++ b/app/src/main/res/values-be/strings.xml @@ -0,0 +1,6 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <string name="about">Аб праграме</string> + <string name="add_server">Дадаць сервер</string> + <string name="add_user">Двдаць карыстальніка</string> +</resources> diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index e41bd2b5..d71123be 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -63,9 +63,7 @@ <string name="more">Více</string> <plurals name="movies"> <item quantity="one">Filmy</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="other">Filmy</item> </plurals> <string name="name">Jméno</string> <string name="next_up">Další v pořadí</string> @@ -80,9 +78,7 @@ <string name="path">Cesta</string> <plurals name="people"> <item quantity="one">Lidé</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="other">Lidé</item> </plurals> <string name="play_count">Počet přehrání</string> <string name="play_from_here">Přehrát odtud</string> @@ -149,12 +145,9 @@ <string name="switch_servers">Přepnout servery</string> <string name="switch_user">Přepnout</string> <string name="top_unwatched">Nejlépe hodnocené Nezhlédnuté</string> - <string name="trailer">Upoutávka</string> <plurals name="trailers"> <item quantity="one">Upoutávky</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="other">Upoutávky</item> </plurals> <string name="tv_dvr_schedule">Harmonogram nahrávání</string> <string name="tv_guide">Televizní program</string> @@ -162,9 +155,7 @@ <string name="tv_seasons">Sezóny</string> <plurals name="tv_shows"> <item quantity="one">Seriály</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="other">Seriály</item> </plurals> <string name="ui_interface">Prostředí</string> <string name="unknown">Neznámý</string> @@ -174,7 +165,9 @@ <string name="video">Video</string> <string name="videos">Videa</string> <string name="watch_live">Sledujte živě</string> - <string name="years_old">%1$d let starý</string> + <plurals name="years_old"> + <item quantity="other">%1$d let starý</item> + </plurals> <string name="play_with_transcoding">Přehraj s překódováním</string> <string name="media_information">Informace o médiu</string> <string name="show_clock">Zobrazit hodiny</string> @@ -188,60 +181,42 @@ <string name="background">Pozadí</string> <string name="success">Úspěch</string> <string name="official_rating">Rodičovské hodnocení</string> - <string name="extras">Doplňky</string> + <string name="extras_title">Doplňky</string> <plurals name="other_extras"> <item quantity="one">Jiný</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="other">Jiný</item> </plurals> <plurals name="behind_the_scenes"> <item quantity="one">V zákulisí</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="other">V zákulisí</item> </plurals> <plurals name="theme_songs"> <item quantity="one">Znělky</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="other">Znělky</item> </plurals> <plurals name="theme_videos"> <item quantity="one">Tématická videa</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="other">Tématická videa</item> </plurals> <plurals name="clips"> <item quantity="one">Klipy</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="other">Klipy</item> </plurals> <plurals name="deleted_scenes"> <item quantity="one">Vymazané scény</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="other">Vymazané scény</item> </plurals> <plurals name="interviews"> <item quantity="one">Rozhovory</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="other">Rozhovory</item> </plurals> <plurals name="scenes"> <item quantity="one">Scény</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="other">Scény</item> </plurals> <plurals name="featurettes"> <item quantity="one">Krátkometrážní filmy</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="other">Krátkometrážní filmy</item> </plurals> <plurals name="hours"> <item quantity="one">%d hodina</item> @@ -423,21 +398,17 @@ <string name="request_4k">Žádost ve 4K</string> <string name="software_decoding_av1">Softwarové dekódování AV1</string> <string name="commercial">Komerční</string> - <string name="download_and_update"><![CDATA[Download & Update]]></string> + <string name="download_and_update">Download & Update</string> <string name="immediate">Bezprostředně</string> - <string name="search_and_download"><![CDATA[Search & Download]]></string> + <string name="search_and_download"><![CDATA[Najít & Stáhnout]]></string> <string name="runtime_sort">Trvání</string> <plurals name="samples"> <item quantity="one">Vzorky</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="other">Vzorky</item> </plurals> <plurals name="shorts"> <item quantity="one">Krátká videa</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="other">Krátká videa</item> </plurals> <plurals name="downloads"> <item quantity="one">%s Stažen</item> @@ -445,11 +416,11 @@ <item quantity="many">%s Stažení</item> <item quantity="other">%s Stažení</item> </plurals> - <string name="combine_continue_next"><![CDATA[Combine Continue Watching & Next Up]]></string> - <string name="direct_play_ass">Direct play ASS titulky</string> + <string name="combine_continue_next">Combine Continue Watching & Next Up</string> + <string name="direct_play_ass">Použít libass pro ASS titulky</string> <string name="direct_play_pgs">Direct play PGS titulky</string> <string name="player_backend">Přehrávání Backendu</string> - <string name="cast_and_crew"><![CDATA[Cast & Crew]]></string> + <string name="cast_and_crew">Cast & Crew</string> <string name="anamorphic">Anamorfní</string> <string name="interlaced">Prokládané</string> <string name="color_code_programs">Color code programs</string> @@ -512,14 +483,14 @@ <string name="display_presets">Zobrazit předvolby</string> <string name="display_presets_description">Vestavěné předvolby pro rychlou úpravu všech řádků</string> <string name="customize_home_summary">Vyberte řádky a obrázky na domovské stránce</string> - <string name="favorite_items">Oblíbené %s</string> + <string name="favorite_items_title">Oblíbené %s</string> <string name="content_scale_fit">Přizpůsobit</string> <string name="content_scale_crop">Oříznout</string> <string name="content_scale_fill">Vyplnit</string> <string name="content_scale_fill_width">Vyplnit šířku</string> <string name="content_scale_fill_height">Vyplnit výšku</string> - <string name="display_preset_default">Výchozí předvolba Wholphin</string> - <string name="display_preset_compact">Wholphin kompaktní</string> + <string name="display_preset_default">Výchozí předvolba Wolfskin</string> + <string name="display_preset_compact">Wolfskin kompaktní</string> <string name="display_preset_series_thumb">Obrázky náhledů seriálů</string> <string name="display_preset_episode_thumbnails">Obrázky náhledů epizod</string> <string name="volume_lowest">Nejnižší</string> @@ -559,12 +530,57 @@ <string name="image_type_thumb">Náhled</string> <string name="backdrop_style_dynamic">Obrázek s dynamickou barvou</string> <string name="backdrop_style_image">Pouze obrázek</string> - <string name="enter_url_api_key"><![CDATA[Zadejte adresu a klíč API]]></string> + <string name="enter_url_api_key">Zadejte adresu a klíč API</string> <string name="api_key">Klíč API</string> <string name="seerr_login">Přihlásit se k serveru Seerr</string> <string name="seerr_jellyfin_user">Uživatel Jellyfin</string> <string name="seerr_local_user">Lokální uživatel</string> - <string name="search_and_download_subtitles"><![CDATA[Hledat a stáhnout titulky]]></string> + <string name="search_and_download_subtitles">Hledat a stáhnout titulky</string> <string name="no_subtitles_found">Nenalezeny žádné vzdálené titulky</string> <string name="series_continueing">Současnost</string> + <string name="duration_hours_minutes_seconds">%1$dh %2$dm %3$ds</string> + <string name="duration_hours_minutes">%1$dh %2$dm</string> + <string name="duration_minutes_seconds">%1$dm %2$ds</string> + <string name="duration_hours">%1$dh</string> + <string name="duration_minutes">%1$dm</string> + <string name="duration_seconds">%1$ds</string> + <string name="movies_title">Filmy</string> + <string name="people_title">Osoby</string> + <string name="profile_specific_settings_from_server">Nastavení uživatelského profilu ze serveru</string> + <string name="time_left">%1$s zbývá</string> + <string name="trailers_title">Ukázky</string> + <string name="tv_shows_title">TV Seriály</string> + <string name="other_extras_title">Jiné</string> + <string name="behind_the_scenes_title">Ze zákulisí</string> + <string name="clips_title">Klipy</string> + <string name="deleted_scenes_title">Vystřižené scény</string> + <string name="interviews_title">Rozhovory</string> + <string name="scenes_title">Scény</string> + <string name="samples_title">Ukázky</string> + <string name="featurettes_title">Film o filmu</string> + <string name="shorts_title">Krátké filmy</string> + <plurals name="minutes"> + <item quantity="one">%s minuta</item> + <item quantity="few">%s minut</item> + <item quantity="many">%s minut</item> + <item quantity="other">%s minut</item> + </plurals> + <string name="require_login">Požaduje přihlášení pro profil</string> + <string name="pin_digits_hint">[0–9]</string> + <string name="disable_audio_offload">Vypnout hardwarové zpracování zvuku</string> + <string name="disable_audio_offload_summary">Vypnutí hardwarového dekódování zvuku může u některých zařízení vyřešit zamrzání přehrávání</string> + <string name="combined_search_results">Společné výsledky vyhledávání</string> + <string name="combined_search_results_on">Zobraz všechny výsledky v jednom seznamu</string> + <string name="combined_search_results_off">Zobraz výsledky podle kategorie</string> + <string name="show_voice_search_button">Zobraz tlačítko pro hlasové vyhledávání</string> + <string name="visible_ui">Viditelné</string> + <string name="hidden_ui">Skryté</string> + <string name="results">Výsledky</string> + <string name="albums">Alba</string> + <string name="artists">Tvůrci</string> + <string name="songs">Skladby</string> + <string name="go_to_artist">Přejdi na tvůrce</string> + <string name="now_playing">Právě hraje</string> + <string name="go_to_album">Jdi do alba</string> + <string name="add_to_queue">Přidat do fronty</string> </resources> diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index 7fdb5d82..a6efeb91 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -30,7 +30,7 @@ <string name="director">Instruktør</string> <string name="disabled">Deaktiveret</string> <string name="discovered_servers">Opdagede servere</string> - <string name="download_and_update"><![CDATA[Download og opdater]]></string> + <string name="download_and_update">Download og opdater</string> <string name="downloading">Downloader…</string> <string name="enabled">Aktiveret</string> <string name="ends_at">Slutter %1$s</string> @@ -45,19 +45,19 @@ <string name="genres">Genrer</string> <string name="go_to_series">Gå til serien</string> <string name="go_to">Gå til</string> - <string name="hide_controller_timeout">Skjul afspilningsknapper</string> + <string name="hide_controller_timeout">Skjul afspilningskontroller</string> <string name="hide_debug_info">Skjul fejlfindingsoplysninger</string> <string name="hide">Skjul</string> <string name="home">Hjem</string> - <string name="immediate">Umiddelbar</string> + <string name="immediate">Med det samme</string> <string name="intro">Intro</string> <string name="jump_letters">#ABCDEFGHIJKLMNOPQRSTUVWXYZÆØÅ</string> <string name="library">Bibliotek</string> <string name="license_info">Licensoplysninger</string> <string name="live_tv">Live-tv</string> <string name="loading">Indlæser…</string> - <string name="mark_entire_series_as_played">Markér hele serien som afspillet?</string> - <string name="mark_entire_series_as_unplayed">Markér hele serien som ikke afspillet?</string> + <string name="mark_entire_series_as_played">Markér hele serien som set?</string> + <string name="mark_entire_series_as_unplayed">Markér hele serien som ikke set?</string> <string name="mark_unwatched">Markér som uset</string> <string name="mark_watched">Markér som set</string> <string name="max_bitrate">Maks. bitrate</string> @@ -65,7 +65,7 @@ <string name="more">Mere</string> <plurals name="movies"> <item quantity="one">Film</item> - <item quantity="other"/> + <item quantity="other">Film</item> </plurals> <string name="name">Navn</string> <string name="next_up">Næste</string> @@ -76,20 +76,20 @@ <string name="no_update_available">Ingen opdatering tilgængelig</string> <string name="none">Ingen</string> <string name="only_forced_subtitles">Kun tvungne undertekster</string> - <string name="outro">Eftertekster</string> + <string name="outro">Outro</string> <string name="path">Filsti</string> <plurals name="people"> - <item quantity="one">Personer</item> - <item quantity="other"/> + <item quantity="one">Person</item> + <item quantity="other">Personer</item> </plurals> <string name="play_count">Antal afspilninger</string> <string name="play_from_here">Afspil herfra</string> <string name="play">Afspil</string> - <string name="playback_debug_info">Vis debugoplysninger for afspilning</string> + <string name="playback_debug_info">Vis fejlfindingsoplysninger for afspilning</string> <string name="playback_speed">Afspilningshastighed</string> <string name="playback">Afspilning</string> - <string name="playlist">Playlist</string> - <string name="playlists">Playlists</string> + <string name="playlist">Playliste</string> + <string name="playlists">Playlister</string> <string name="preview">Forhåndsvisning</string> <string name="profile_specific_settings">Profilindstillinger</string> <string name="queue">Kø</string> @@ -97,7 +97,7 @@ <string name="recently_added_in">Nyligt tilføjet i %1$s</string> <string name="recently_added">Nyligt tilføjet</string> <string name="recently_recorded">Nyligt optaget</string> - <string name="recently_released">Nyligt udkommet</string> + <string name="recently_released">Nyligt udgivet</string> <string name="recommended">Anbefalet</string> <string name="record_program">Optag program</string> <string name="record_series">Optag serie</string> @@ -105,13 +105,13 @@ <string name="restart">Genstart</string> <string name="resume">Fortsæt</string> <string name="save">Gem</string> - <string name="search_and_download"><![CDATA[Søg og download]]></string> + <string name="search_and_download">Søg og download</string> <string name="search">Søg</string> <string name="searching">Søger…</string> <string name="voice_search">Stemmesøgning</string> <string name="voice_starting">Starter</string> <string name="voice_search_prompt">Tal for at søge</string> - <string name="processing">Arbejder</string> + <string name="processing">Behandler</string> <string name="press_back_to_cancel">Tryk tilbage for at afbryde</string> <string name="voice_error_audio">Fejl ved lydoptagelse</string> <string name="voice_error_client">Fejl ved stemmegenkendelse</string> @@ -119,7 +119,7 @@ <string name="voice_error_network">Netværksfejl</string> <string name="voice_error_network_timeout">Netværkstimeout</string> <string name="voice_error_no_match">Ingen tale genkendt</string> - <string name="voice_error_busy">Stemmegenkendelse optaget</string> + <string name="voice_error_busy">Stemmegenkendelsen er allerede i brug</string> <string name="voice_error_server">Serverfejl</string> <string name="voice_error_speech_timeout">Ingen tale registreret</string> <string name="voice_error_unknown">Ukendt fejl</string> @@ -129,12 +129,12 @@ <string name="select_server">Vælg server</string> <string name="select_user">Vælg bruger</string> <string name="show_debug_info">Vis fejlfindingsoplysninger</string> - <string name="show_next_up_when">Vis næste</string> + <string name="show_next_up_when">Vis Næste</string> <string name="show">Vis</string> <string name="shuffle">Bland</string> <string name="skip">Spring over</string> <string name="sort_by_date_added">Dato tilføjet</string> - <string name="sort_by_date_episode_added">Dato episode tilføjet</string> + <string name="sort_by_date_episode_added">Dato episoden blev tilføjet</string> <string name="sort_by_date_played">Dato afspillet</string> <string name="sort_by_date_released">Udgivelsesdato</string> <string name="sort_by_name">Navn</string> @@ -145,38 +145,40 @@ <string name="subtitle">Undertekst</string> <string name="subtitles">Undertekster</string> <string name="suggestions">Forslag</string> - <string name="switch_servers">Skift servere</string> + <string name="switch_servers">Skift server</string> <string name="switch_user">Skift</string> <string name="top_unwatched">Bedst bedømte usete</string> - <string name="trailer">Trailer</string> <plurals name="trailers"> - <item quantity="one">Trailere</item> - <item quantity="other"/> + <item quantity="one">Trailer</item> + <item quantity="other">Trailere</item> </plurals> <string name="tv_dvr_schedule">DVR-plan</string> <string name="tv_guide">TV-guide</string> <string name="tv_season">Sæson</string> <string name="tv_seasons">Sæsoner</string> <plurals name="tv_shows"> - <item quantity="one">TV-serier</item> - <item quantity="other"/> + <item quantity="one">TV-serie</item> + <item quantity="other">TV-serier</item> </plurals> - <string name="ui_interface">Grænseflade</string> + <string name="ui_interface">Brugerflade</string> <string name="unknown">Ukendt</string> <string name="updates">Opdateringer</string> <string name="version">Version</string> <string name="video_scale">Videoskalering</string> <string name="video">Video</string> <string name="videos">Videoer</string> - <string name="watch_live">Se direkte</string> - <string name="years_old">%1$d år gammel</string> + <string name="watch_live">Se live</string> + <plurals name="years_old"> + <item quantity="one">%1$d år gammel</item> + <item quantity="other">%1$d år gammel</item> + </plurals> <string name="play_with_transcoding">Afspil med transkodning</string> <string name="media_information">Medieinformation</string> <string name="show_clock">Vis ur</string> <string name="aired_episode_order">Udsendelsesrækkefølge</string> <string name="create_playlist">Opret ny playliste</string> <string name="add_to_playlist">Føj til playliste</string> - <string name="one_click_pause">Pause med ét klik</string> + <string name="one_click_pause">Sæt på pause med ét klik</string> <string name="one_click_pause_summary_on">Tryk på midten af D-Pad\'en for at pause/afspille</string> <string name="italic_font">Kursiv skrift</string> <string name="font">Skrifttype</string> @@ -184,50 +186,50 @@ <string name="success">Succes</string> <string name="official_rating">Aldersgrænse</string> <string name="runtime_sort">Spilletid</string> - <string name="extras">Ekstra</string> + <string name="extras_title">Ekstra</string> <plurals name="other_extras"> <item quantity="one">Andet</item> - <item quantity="other"/> + <item quantity="other">Andet</item> </plurals> <plurals name="behind_the_scenes"> - <item quantity="one">Bag kulisserne</item> - <item quantity="other"/> + <item quantity="one">Bag scenen</item> + <item quantity="other">Bag scenerne</item> </plurals> <plurals name="theme_songs"> - <item quantity="one">Temasange</item> - <item quantity="other"/> + <item quantity="one">Temasang</item> + <item quantity="other">Temasange</item> </plurals> <plurals name="theme_videos"> - <item quantity="one">Temavideoer</item> - <item quantity="other"/> + <item quantity="one">Temavideo</item> + <item quantity="other">Temavideoer</item> </plurals> <plurals name="clips"> <item quantity="one">Klip</item> - <item quantity="other"/> + <item quantity="other">Klip</item> </plurals> <plurals name="deleted_scenes"> - <item quantity="one">Slettede scener</item> - <item quantity="other"/> + <item quantity="one">Slettet scene</item> + <item quantity="other">Slettede scener</item> </plurals> <plurals name="interviews"> <item quantity="one">Interview</item> - <item quantity="other"/> + <item quantity="other">Interviews</item> </plurals> <plurals name="scenes"> - <item quantity="one">Scener</item> - <item quantity="other"/> + <item quantity="one">Scene</item> + <item quantity="other">Scener</item> </plurals> <plurals name="samples"> - <item quantity="one">Prøver</item> - <item quantity="other"/> + <item quantity="one">Smagsprøve</item> + <item quantity="other">Smagsprøver</item> </plurals> <plurals name="featurettes"> - <item quantity="one">Specialindslag</item> - <item quantity="other"/> + <item quantity="one">Featurette</item> + <item quantity="other">Featurettes</item> </plurals> <plurals name="shorts"> - <item quantity="one">Kortfilm</item> - <item quantity="other"/> + <item quantity="one">Kort</item> + <item quantity="other">Kort</item> </plurals> <plurals name="downloads"> <item quantity="one">%s download</item> @@ -251,70 +253,70 @@ </plurals> <string name="ac3_supported">Enheden understøtter AC3/Dolby Digital</string> <string name="advanced_settings">Avancerede indstillinger</string> - <string name="advanced_ui">Avanceret brugergrænseflade</string> + <string name="advanced_ui">Avanceret brugerflade</string> <string name="app_theme">Tema</string> <string name="auto_check_for_updates">Søg automatisk efter opdateringer</string> - <string name="auto_play_next_delay">Forsinkelse før næste afspilning</string> - <string name="auto_play_next">Afspil næste automatisk</string> + <string name="auto_play_next_delay">Forsinkelse før Næste afspilles</string> + <string name="auto_play_next">Afspil Næste automatisk</string> <string name="check_for_updates">Søg efter opdateringer</string> <string name="combine_continue_next_summary">Gælder kun for tv-serier</string> - <string name="combine_continue_next"><![CDATA[Kombiner Fortsæt med at se & Næste]]></string> - <string name="direct_play_ass">Direkte afspilning af ASS-undertekster</string> - <string name="direct_play_pgs">Direkte afspilning af PGS-undertekster</string> + <string name="combine_continue_next"><![CDATA[Kombiner Fortsæt med at se og Næste]]></string> + <string name="direct_play_ass">Brug libass til ASS-undertekster</string> + <string name="direct_play_pgs">Afspil PGS-undertekster direkte</string> <string name="downmix_stereo">Mix altid ned til stereo</string> <string name="ffmpeg_extension_pref">Brug FFmpeg-dekodermodulet</string> <string name="global_content_scale">Standard skalering</string> <string name="install_update">Installer opdatering</string> <string name="installed_version">Installeret version</string> - <string name="max_homepage_items">Maks antal elementer per række på startsiden</string> - <string name="nav_drawer_pins_summary">Vælg de standardelementer, der skal vises, andre vil blive skjult</string> + <string name="max_homepage_items">Maks. antal elementer pr. række på startsiden</string> + <string name="nav_drawer_pins_summary">Vælg de elementer, der skal vises som standard. Andre skjules</string> <string name="nav_drawer_pins">Tilpas elementer i navigationsmenuen</string> - <string name="nav_drawer_switch_on_focus_summary_off">Klik for at skifte side</string> + <string name="nav_drawer_switch_on_focus_summary_off">Tryk for at skifte side</string> <string name="nav_drawer_switch_on_focus">Skift sider i navigationsmenuen ved fokus</string> <string name="pass_out_protection">Inaktivitetsbeskyttelse</string> <string name="play_theme_music">Afspil temamusik</string> - <string name="playback_overrides">Tilsidesættelser af afspilning</string> + <string name="playback_overrides">Overstyring af afspilning</string> <string name="remember_selected_tab">Husk valgte faner</string> - <string name="rewatch_next_up">Tillad gensening i \"Næste\"</string> - <string name="seek_bar_steps">Trinlængde for søgelinje</string> + <string name="rewatch_next_up">Tillad gensening i Næste</string> + <string name="seek_bar_steps">Trin på afspilningslinjen</string> <string name="send_app_logs_summary">Nyttig til fejlfinding</string> <string name="send_app_logs">Send app-logfiler til den aktuelle server</string> - <string name="send_crash_reports_summary">Vil forsøge at sende til den sidst forbundne server</string> + <string name="send_crash_reports_summary">Vil forsøge at sende til den sidst tilsluttede server</string> <string name="send_crash_reports">Send nedbrudsrapporter</string> <string name="settings">Indstillinger</string> <string name="skip_back_on_resume_preference">Spring tilbage, når afspilningen genoptages</string> <string name="skip_back_preference">Spring tilbage</string> - <string name="skip_commercials_behavior">Adfærd ved at springe reklamer over</string> + <string name="skip_commercials_behavior">Hvordan reklamer springes over</string> <string name="skip_forward_preference">Spring fremad</string> - <string name="skip_intro_behavior">Adfærd ved at springe intro over</string> - <string name="skip_outro_behavior">Adfærd ved at springe eftertekster over</string> - <string name="skip_previews_behavior">Adfærd ved at springe forhåndsvisninger over</string> - <string name="skip_recap_behavior">Adfærd ved at springe opsummeringer over</string> + <string name="skip_intro_behavior">Hvordan introer springes over</string> + <string name="skip_outro_behavior">Hvordan outroer springes over</string> + <string name="skip_previews_behavior">Hvordan forhåndsvisninger springes over</string> + <string name="skip_recap_behavior">Hvordan opsummeringer springes over</string> <string name="update_available">Opdatering tilgængelig</string> - <string name="update_url_summary">URL brugt til at søge efter appopdateringer</string> + <string name="update_url_summary">URL, der bruges til at søge efter appopdateringer</string> <string name="update_url">URL til opdatering</string> - <string name="font_size">Tekststørrelse</string> - <string name="font_color">Tekstfarve</string> - <string name="font_opacity">Tekstgennemsigtighed</string> + <string name="font_size">Skriftstørrelse</string> + <string name="font_color">Skriftfarve</string> + <string name="font_opacity">Skriftgennemsigtighed</string> <string name="bold_font">Fed skrift</string> <string name="edge_style">Kantstil</string> <string name="edge_color">Kantfarve</string> - <string name="background_opacity">Baggrundsopacitet</string> + <string name="background_opacity">Baggrundsgennemsigtighed</string> <string name="background_style">Baggrundsstil</string> <string name="subtitle_style">Undertekststil</string> <string name="background_color">Baggrundsfarve</string> <string name="reset">Nulstil</string> - <string name="player_backend">Afspilning Backend</string> + <string name="player_backend">Afspilningsmotor</string> <string name="mpv_hardware_decoding">MPV: Brug hardwareafkodning</string> - <string name="disable_if_crash">Deaktiver, hvis du oplever fejl</string> - <string name="mpv_options">MPV indstillinger</string> - <string name="exoplayer_options">ExoPlayer indstillinger</string> - <string name="skip_segment_unknown">Spring over segmentet</string> - <string name="skip_segment_commercial">Spring over reklamer</string> - <string name="skip_segment_preview">Spring over forhåndsvisning</string> - <string name="skip_segment_recap">Spring over opsummering</string> - <string name="skip_segment_outro">Spring over eftertekster</string> - <string name="skip_segment_intro">Spring over intro</string> + <string name="disable_if_crash">Deaktiver, hvis appen går ned</string> + <string name="mpv_options">MPV-indstillinger</string> + <string name="exoplayer_options">ExoPlayer-indstillinger</string> + <string name="skip_segment_unknown">Spring segment over</string> + <string name="skip_segment_commercial">Spring reklamer over</string> + <string name="skip_segment_preview">Spring forhåndsvisning over</string> + <string name="skip_segment_recap">Spring opsummering over</string> + <string name="skip_segment_outro">Spring outro over</string> + <string name="skip_segment_intro">Spring intro over</string> <string name="played">Afspillet</string> <string name="filter">Filter</string> <string name="year">År</string> @@ -325,30 +327,30 @@ <string name="mpv_use_gpu_next">MPV: Brug gpu-next</string> <string name="mpv_conf">Rediger mpv.conf</string> <string name="discard_change">Kassér ændringer?</string> - <string name="subtitle_margin">Margin</string> + <string name="subtitle_margin">Margen</string> <string name="verbose_logging">Detaljeret logning</string> - <string name="enter_pin">Indtast PIN</string> + <string name="enter_pin">Indtast PIN-kode</string> <string name="sign_in_auto">Log ind automatisk</string> - <string name="use_server_credentials">Log ind via server</string> + <string name="use_server_credentials">Log ind via serveren</string> <string name="press_enter_to_confirm">Tryk på midten for at bekræfte</string> <string name="require_pin_code">Kræv PIN-kode til profil</string> - <string name="confirm_pin">Bekræft PIN</string> - <string name="incorrect">Fejl</string> - <string name="pin_too_short">PIN-koden skal være 4 cifre eller længere</string> - <string name="will_remove_pin">Vil fjerne PIN</string> - <string name="image_cache_size">Størrelse på billeddiskcache (MB)</string> - <string name="view_options">Vis muligheder</string> + <string name="confirm_pin">Bekræft PIN-kode</string> + <string name="incorrect">Forkert</string> + <string name="pin_too_short">PIN-koden skal være på mindst 4 cifre</string> + <string name="will_remove_pin">PIN-koden fjernes</string> + <string name="image_cache_size">Billedcache på disk (MB)</string> + <string name="view_options">Visningsindstillinger</string> <string name="columns">Kolonner</string> - <string name="spacing">Mellemrum</string> - <string name="aspect_ratio">Aspektforhold</string> + <string name="spacing">Afstand</string> + <string name="aspect_ratio">Billedformat</string> <string name="show_details">Vis detaljer</string> <string name="image_type">Billedtype</string> - <string name="cast_and_crew"><![CDATA[Skuespillere og filmhold]]></string> - <string name="guest_stars">Gæsteroller</string> + <string name="cast_and_crew"><![CDATA[Medvirkende og filmhold]]></string> + <string name="guest_stars">Gæsteskuespillere</string> <string name="edge_size">Kantstørrelse</string> <string name="refresh_rate_switching">Skift af billedopdateringsfrekvens</string> <string name="automatic">Automatisk</string> - <string name="username_or_password">Brug brugernavn/adgangskode</string> + <string name="username_or_password">Brug brugernavn og adgangskode</string> <string name="login">Log ind</string> <string name="general">Generelt</string> <string name="container">Container</string> @@ -358,7 +360,7 @@ <string name="level">Niveau</string> <string name="resolution">Opløsning</string> <string name="anamorphic">Anamorfisk</string> - <string name="interlaced">Interlaced</string> + <string name="interlaced">Sammenflettet</string> <string name="framerate">Billedfrekvens</string> <string name="bit_depth">Bitdybde</string> <string name="video_range">Videoområde</string> @@ -386,15 +388,15 @@ <string name="show_titles">Vis titler</string> <string name="live_tv_repeat">Gentag</string> <string name="favorite_channels_at_beginning">Vis favoritkanaler først</string> - <string name="sort_channels_recently_watched">Sorter kanaler efter nyligt set</string> + <string name="sort_channels_recently_watched">Sorter kanaler efter senest sete</string> <string name="color_code_programs">Farvekod programmer</string> <string name="subtitle_delay">Undertekstforsinkelse</string> - <string name="backdrop_display">Stil for baggrundsbillede</string> + <string name="backdrop_display">Baggrundsbilledstil</string> <string name="resolution_switching">Skift af opløsning</string> <string name="local">Lokal</string> <string name="play_trailer">Afspil trailer</string> <string name="no_trailers">Ingen trailere</string> - <string name="clear_track_choices">Ryd valg af spor</string> + <string name="clear_track_choices">Ryd sporvalg</string> <string name="dts_x">DTS:X</string> <string name="dts_hd">DTS:HD</string> <string name="dts_hd_ma">DTS:HD MA</string> @@ -402,28 +404,28 @@ <string name="dolby_digital">DD</string> <string name="dolby_digital_plus">DD+</string> <string name="dts">DTS</string> - <string name="force_dovi_profile_7">Direkte afspilning af Dolby Vision Profile 7</string> + <string name="force_dovi_profile_7">Afspil Dolby Vision-profil 7 direkte</string> <string name="force_dovi_profile_7_summary">Ignorerer kontrol af enhedskompatibilitet</string> - <string name="discover">Opdag</string> - <string name="request">Anmod</string> + <string name="discover">Udforsk</string> + <string name="request">Ønsk</string> <string name="pending">Afventer</string> - <string name="seerr_integration">Seerr integration</string> - <string name="remove_seerr_server">Fjern Seerr server</string> - <string name="seerr_server_added">Seerr server tilføjet</string> + <string name="seerr_integration">Seerr-integration</string> + <string name="remove_seerr_server">Fjern Seerr-server</string> + <string name="seerr_server_added">Seerr-server tilføjet</string> <string name="quick_connect">Quick Connect</string> <string name="quick_connect_summary">Godkend en anden enhed til at logge ind på din konto</string> <string name="quick_connect_code">Indtast Quick Connect-kode</string> <string name="quick_connect_code_error">Koden skal være på 6 cifre</string> - <string name="quick_connect_success">Enheden er godkendt</string> + <string name="quick_connect_success">Enheden blev godkendt</string> <string name="password">Adgangskode</string> <string name="username">Brugernavn</string> <string name="url">URL</string> <string name="trending">Populært lige nu</string> <string name="upcoming_movies">Kommende film</string> <string name="upcoming_tv">Kommende tv-serier</string> - <string name="request_4k">Anmod i 4K</string> - <string name="software_decoding_av1">AV1 software afkodning</string> - <string name="upgrade_mpv_toast">MPV er nu standardafspilleren undtagen for HDR.\nDu kan ændre dette i indstillingerne.</string> + <string name="request_4k">Ønsk i 4K</string> + <string name="software_decoding_av1">AV1-softwareafkodning</string> + <string name="upgrade_mpv_toast">MPV er nu standardafspilleren, undtagen ved HDR.\nDu kan ændre dette i indstillingerne.</string> <string name="hdr_subtitle_style">HDR-undertekststil</string> <string name="image_subtitle_opacity">Billedunderteksters gennemsigtighed</string> <string name="brightness">Lysstyrke</string> @@ -434,7 +436,7 @@ <string name="green">Grøn</string> <string name="blue">Blå</string> <string name="blur">Sløring</string> - <string name="save_for_album">Gem i album</string> + <string name="save_for_album">Gem for album</string> <string name="play_slideshow">Afspil diasshow</string> <string name="stop_slideshow">Stop diasshow</string> <string name="slideshow_at_beginning">Fra begyndelsen</string> @@ -450,13 +452,13 @@ <string name="max_days_next_up">Maks. antal dage i Næste</string> <string name="add_row">Tilføj række</string> <string name="genres_in">Genrer i %1$s</string> - <string name="recently_released_in">Nyligt udkommet i %1$s</string> + <string name="recently_released_in">Nyligt udgivet i %1$s</string> <string name="height">Højde</string> <string name="apply_all_rows">Anvend på alle rækker</string> <string name="customize_home">Tilpas startsiden</string> <string name="home_rows">Rækker på startsiden</string> - <string name="load_from_server">Indlæs fra brugerprofilen på serveren</string> - <string name="save_to_server">Gem i brugerprofilen på serveren</string> + <string name="load_from_server">Indlæs fra serverens brugerprofil</string> + <string name="save_to_server">Gem i serverens brugerprofil</string> <string name="load_from_web_client">Indlæs fra webklienten</string> <string name="use_series">Brug seriens billede</string> <string name="add_row_for">Tilføj række for %1$s</string> @@ -466,10 +468,245 @@ <string name="suggestions_for">Forslag til %1$s</string> <string name="increase_all_cards_size">Øg størrelsen på alle kort</string> <string name="decrease_all_cards_size">Reducer størrelsen på alle kort</string> - <string name="use_thumb_images">Brug thumb-billeder</string> + <string name="use_thumb_images">Brug miniaturebilleder</string> <string name="settings_saved">Indstillinger gemt</string> - <string name="display_presets">Skærmforudindstillinger</string> - <string name="display_presets_description">Indbyggede forudindstillinger til hurtigt at style alle rækker</string> + <string name="display_presets">Layoutforudindstillinger</string> + <string name="display_presets_description">Indbyggede forudindstillinger til hurtigt at tilpasse alle rækker</string> <string name="customize_home_summary">Vælg rækker og billeder på startsiden</string> <string name="commercial">Reklame</string> + <string name="duration_hours_minutes_seconds">%1$dt %2$dm %3$ds</string> + <string name="duration_hours_minutes">%1$dt %2$dm</string> + <string name="duration_minutes_seconds">%1$dm %2$ds</string> + <string name="duration_hours">%1$dt</string> + <string name="duration_minutes">%1$dm</string> + <string name="duration_seconds">%1$ds</string> + <string name="movies_title">Film</string> + <string name="profile_specific_settings_from_server">Profilindstillinger fra serveren</string> + <string name="time_left">%1$s tilbage</string> + <string name="trailers_title">Trailere</string> + <string name="tv_shows_title">TV-serier</string> + <string name="other_extras_title">Andet</string> + <string name="behind_the_scenes_title">Bag scenerne</string> + <string name="theme_songs_title">Temasange</string> + <string name="theme_videos_title">Temavideoer</string> + <string name="clips_title">Klip</string> + <string name="deleted_scenes_title">Slettede scener</string> + <string name="interviews_title">Interviews</string> + <string name="scenes_title">Scener</string> + <string name="featurettes_title">Featurettes</string> + <string name="shorts_title">Kort</string> + <string name="favorite_items_title">Favorit %s</string> + <plurals name="minutes"> + <item quantity="one">%s minut</item> + <item quantity="other">%s minutter</item> + </plurals> + <string name="require_login">Kræv login for profil</string> + <string name="pin_digits_hint">[0–9]</string> + <string name="disable_audio_offload">Deaktiver hardware-offload af lyd</string> + <string name="disable_audio_offload_summary">Deaktivering af hardware-offload for lyddekodning kan afhjælpe frysninger under afspilning på nogle enheder</string> + <string name="combined_search_results">Kombinerede søgeresultater</string> + <string name="combined_search_results_on">Vis alle resultater i en enkelt liste</string> + <string name="combined_search_results_off">Vis resultater efter kategori</string> + <string name="show_voice_search_button">Vis knappen til stemmesøgning</string> + <string name="visible_ui">Synlig</string> + <string name="hidden_ui">Skjult</string> + <string name="results">Resultater</string> + <string name="albums">Albummer</string> + <string name="artists">Kunstnere</string> + <string name="songs">Sange</string> + <string name="go_to_artist">Gå til kunstneren</string> + <string name="now_playing">Afspiller nu</string> + <string name="go_to_album">Gå til albummet</string> + <string name="add_to_queue">Føj til kø</string> + <string name="prefer_ac3_for_surround_summary"><![CDATA[Transkod ikke-stereolyd til AC3/Dolby Digital]]></string> + <string name="prefer_ac3_for_surround">Brug AC3 til surroundlyd</string> + <string name="start_after">Start efter</string> + <string name="animate">Animér</string> + <string name="max_age_rating">Maks. aldersgrænse</string> + <string name="no_max">Ingen maks.</string> + <string name="for_all_ages">For alle aldre</string> + <string name="up_to_age">Op til %s år</string> + <string name="screensaver">Pauseskærm</string> + <string name="screensaver_settings">Pauseskærmsindstillinger</string> + <string name="start_screensaver">Start pauseskærm</string> + <string name="duration">Varighed</string> + <string name="photos">Billeder</string> + <string name="include_types">Inkluder typer</string> + <string name="include_types_summary">Hvilke typer elementer der skal vises billeder for</string> + <string name="content_scale_fit">Tilpas</string> + <string name="content_scale_crop">Beskær</string> + <string name="content_scale_fill">Fyld</string> + <string name="content_scale_fill_width">Fyld bredde</string> + <string name="content_scale_fill_height">Fyld højde</string> + <string name="display_preset_default">Wolfskin-standard</string> + <string name="display_preset_compact">Wolfskin kompakt</string> + <string name="display_preset_series_thumb">Seriens miniaturebilleder</string> + <string name="display_preset_episode_thumbnails">Episode-miniaturebilleder</string> + <string name="volume_lowest">Laveste</string> + <string name="volume_low">Lav</string> + <string name="volume_medium">Mellem</string> + <string name="volume_high">Høj</string> + <string name="purple">Lilla</string> + <string name="orange">Orange</string> + <string name="black">Sort</string> + <string name="skip_automatically">Spring over automatisk</string> + <string name="skip_ask">Spørg om at springe over</string> + <string name="ffmpeg_fallback">Anvend kun FFmpeg, hvis der ikke findes en indbygget dekoder</string> + <string name="ffmpeg_prefer">Foretræk FFmpeg frem for indbyggede dekodere</string> + <string name="ffmpeg_never">Brug aldrig FFmpeg-dekodere</string> + <string name="next_up_playback_end">Ved afslutningen af afspilningen</string> + <string name="next_up_outro">Under rulletekster/outro</string> + <string name="white">Hvid</string> + <string name="light_gray">Lysegrå</string> + <string name="dark_gray">Mørkegrå</string> + <string name="yellow">Gul</string> + <string name="cyan">Cyanblå</string> + <string name="magenta">Magenta</string> + <string name="subtitle_edge_outline">Omrids</string> + <string name="subtitle_edge_shadow">Skygge</string> + <string name="background_style_wrap">Ombrydning</string> + <string name="background_style_boxed">Indrammet</string> + <string name="prefer_mpv">Foretræk MPV</string> + <string name="player_backend_options_subtitles_prefer_mpv">Brug ExoPlayer til HDR-afspilning</string> + <string name="aspect_ratios_poster">Plakat (2:3)</string> + <string name="aspect_ratios_16_9">16:9</string> + <string name="aspect_ratios_4_3">4:3</string> + <string name="aspect_ratios_square">Kvadrat (1:1)</string> + <string name="image_type_primary">Primær</string> + <string name="bold_blue">Kraftig blå</string> + <string name="brown">Brun</string> + <string name="skip_ignore">Ignorer</string> + <string name="next_up_never">Aldrig</string> + <string name="external_player">Ekstern afspiller</string> + <string name="image_type_thumb">Miniature</string> + <string name="view_options_type_grid">Gitter</string> + <string name="view_options_type_list">Liste</string> + <string name="view_options_type_list_compact">Kompakt liste</string> + <string name="enter_url_api_key"><![CDATA[Indtast URL og API-nøgle]]></string> + <string name="api_key">API-nøgle</string> + <string name="search_and_download_subtitles"><![CDATA[Søg og download undertekster]]></string> + <string name="no_subtitles_found">Fandt ingen eksterne undertekster</string> + <string name="series_continueing">Nu</string> + <string name="delete_item">Er du sikker på, at du vil slette dette element?</string> + <string name="show_media_management">Vis muligheder for mediehåndtering</string> + <string name="search_for">Søg %s</string> + <string name="actor">Skuespiller</string> + <string name="composer">Komponist</string> + <string name="writer">Manuskriptforfatter</string> + <string name="guest_star">Gæsteskuespiller</string> + <string name="producer">Producer</string> + <string name="conductor">Dirigent</string> + <string name="lyricist">Tekstforfatter</string> + <string name="arranger">Arrangør</string> + <string name="engineer">Lydtekniker</string> + <string name="mixer">Mixtekniker</string> + <string name="creator">Skaber</string> + <string name="artist">Kunstner</string> + <string name="album">Album</string> + <string name="album_artist">Albumkunstner</string> + <string name="any_language">Ethvert sprog</string> + <string name="popular_songs">Populære sange</string> + <string name="play_next">Afspil næste</string> + <string name="play_as_type">Afspil som?</string> + <string name="visualizer_rationale">Visualizeren kræver tilladelse til at optage den lyd, der afspilles.</string> + <string name="last_played">Sidst afspillet</string> + <string name="ass_subtitle_mode_libass">Direkte afspilning med libass</string> + <string name="ass_subtitle_mode_exoplayer">Direkte afspilning med ExoPlayers indbyggede understøttelse</string> + <string name="ass_subtitle_playback">Afspilning af SSA/ASS-undertekster</string> + <string name="play_with">Afspil med</string> + <string name="backdrop_style_dynamic">Billede med dynamisk farve</string> + <string name="backdrop_style_image">Kun billede</string> + <string name="seerr_login">Log ind på Seerr-serveren</string> + <string name="seerr_jellyfin_user">Jellyfin-bruger</string> + <string name="seerr_local_user">Lokal bruger</string> + <string name="people_title">Personer</string> + <string name="samples_title">Smagsprøver</string> + <string name="instant_mix">Hurtigt mix</string> + <string name="volume_full">Maks.</string> + <string name="in_app_screensaver">Brug appens egen pauseskærm</string> + <string name="music_videos">Musikvideoer</string> + <string name="lyrics">Sangtekst</string> + <string name="hide_lyrics">Skjul sangtekst</string> + <string name="show_lyrics">Vis sangtekst</string> + <string name="song_has_lyrics">Sangen har sangtekst</string> + <string name="remove_from_queue">Fjern fra kø</string> + <string name="show_album_cover">Vis albumcover</string> + <string name="show_visualizer">Vis visualizer</string> + <string name="show_backdrop">Vis baggrundsbillede</string> + <string name="continue_string">Fortsæt</string> + <string name="select_all">Vælg alle</string> + <string name="separate_types">Adskil typer</string> + <string name="prefer_logos">Foretræk logoer som titler</string> + <string name="updated_toast">Wolfskin er opdateret til %s</string> + <string name="remove_continue_watching">Fjern fra Fortsæt med at se</string> + <string name="remove_next_up">Fjern serien fra Næste</string> + <string name="view_removed_next_up">Vis serier fjernet fra Næste</string> + <string name="view_more">Vis mere</string> + <string name="discover_tv">Udforsk TV-serier</string> + <string name="discover_movies">Udforsk film</string> + <string name="studios_in">Studier i %1$s</string> + <string name="system_default">Systemstandard</string> + <string name="ass_subtitle_mode_transcode">Brænd ind/transkod på serveren</string> + <string name="cinema_mode">Biograftilstand</string> + <string name="show_more">Vis %1$s mere</string> + <string name="skip_behavior">Indstillinger for overspringning</string> + <string name="skip_behavior_summary">For introer, rulletekster osv.</string> + <string name="transcoding">Transkodning</string> + <string name="user_interface_language">Sprog for brugerflade</string> + <string name="display_toggles_title">Visning af bedømmelser</string> + <string name="display_toggles_summary">Vælg, hvilke der skal vises</string> + <string name="profile_protection">Profilbeskyttelse</string> + <string name="continue_watching_click_behavior">Handling ved tryk på Fortsæt med at se/Næste</string> + <string name="continue_watching_click_summary_on">Afspil ved tryk</string> + <string name="continue_watching_click_summary_off">Vis detaljer ved tryk</string> + <string name="stars">Stjerner</string> + <string name="tomatoes">Tomater</string> + <string name="sort">Sortér</string> + <string name="split_into_separate_rows">Opdel i separate rækker</string> + <string name="genre_movies">%1$s-film</string> + <string name="genre_tv_shows">%1$s-TV-serier</string> + <string name="quality_profile">Kvalitetsprofil</string> + <string name="is_default">Standard</string> + <string name="root_folder">Rodmappe</string> + <string name="popularity">Popularitet</string> + <string name="production_status">Produktionsstatus</string> + <string name="different_for_hdr">Brug en anden stil til HDR</string> + <string name="experimental_settings">Eksperimentelle indstillinger</string> + <string name="confirm_enable_experimental_title">Aktivér eksperimentelle indstillinger?</string> + <string name="confirm_enable_experimental_body">Eksperimentelle indstillinger kan være ustabile!\n\nDe kan blive fjernet eller ændret ved enhver udgivelse.</string> + <string name="video_tunneling">Videotunneling</string> + <string name="d_pad_seek_mode_title">D-Pad-tilstand for afspilningslinje</string> + <string name="dpad_seek_mode_minimal">Minimal</string> + <string name="dpad_seek_mode_trickplay">Trickplay</string> + <string name="dpad_seek_mode_minimal_summary">Lille afspilningslinje nederst på skærmen (standard)</string> + <string name="dpad_seek_mode_trickplay_summary">Afspilningslinje med Trickplay-forhåndsvisningsbilleder</string> + <string name="dpad_seek_mode_none_summary">Ingen afspilningslinje</string> + <string name="blocklisted">På blokeringslisten</string> + <string name="unavailable">Ikke tilgængelig</string> + <string name="subtitle_mode">Underteksttilstand</string> + <string name="use_user_profile">Brug din profilindstilling</string> + <string name="subtitle_mode_default">Standard</string> + <string name="subtitle_mode_smart">Smart</string> + <string name="subtitle_mode_only_forced">Kun tvungne</string> + <string name="subtitle_mode_always">Altid</string> + <string name="subtitle_mode_none">Ingen</string> + <string name="preferred_subtitle_language">Foretrukket undertekstsprog</string> + <string name="preferred_audio_language">Foretrukket lydsprog</string> + <string name="override_user_profile_settings">Flere profilindstillinger</string> + <string name="override_user_profile_settings_summary">Tilsidesæt dine profilindstillinger fra serveren</string> + <string name="hash_matches">Hash-match</string> + <string name="subtitles_hearing_impaired">SDH</string> + <string name="programs">TV-programmer</string> + <string name="recordings">Optagelser</string> + <string name="folders_title">Mapper</string> + <string name="user_views">Brugervisninger</string> + <string name="books">Bøger</string> + <string name="audio_books">Lydbøger</string> + <string name="server_version_not_supported">Serverversionen understøttes ikke</string> + <string name="appears_on">Medvirker på</string> + <string name="dim_after_delay">Dæmp skærmen efter ventetid</string> + <string name="dim_percentage">Dæmpning (%)</string> + <string name="dim_screen">Dæmp skærmen</string> + <string name="photo_albums">Fotoalbummer</string> + <string name="no_favorites_found">Ingen favoritter fundet!</string> </resources> diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 10a94022..7170a274 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -10,7 +10,7 @@ <string name="bitrate">Bitrate</string> <string name="born">Geboren</string> <string name="cancel_recording">Aufnahme abbrechen</string> - <string name="cancel_series_recording">Serien Aufnahme abbrechen</string> + <string name="cancel_series_recording">Serienaufnahme abbrechen</string> <string name="cancel">Abbrechen</string> <string name="chapters">Kapitel</string> <string name="collection">Sammlung</string> @@ -25,14 +25,14 @@ <string name="died">Gestorben</string> <string name="director">Regisseur</string> <string name="more">Mehr</string> - <string name="movies">Filme</string> + <string name="movies_title">Filme</string> <string name="name">Name</string> <string name="no_data">Keine Daten</string> <string name="no_results">Keine Ergebnisse</string> <string name="no_scheduled_recordings">Keine geplanten Aufnahmen</string> <string name="no_update_available">Kein Update verfügbar</string> <string name="path">Pfad</string> - <string name="people">Darsteller</string> + <string name="people_title">Personen</string> <string name="recently_added_in">Zuletzt hinzugefügt in %1$s</string> <string name="recently_added">Zuletzt hinzugefügt</string> <string name="recently_recorded">Zuletzt aufgenommen</string> @@ -46,24 +46,26 @@ <string name="select_user">Benutzer auswählen</string> <string name="subtitles">Untertitel</string> <string name="switch_servers">Server wechseln</string> - <string name="trailer">Trailer</string> <plurals name="trailers"> <item quantity="one">Trailer</item> - <item quantity="other"/> + <item quantity="other">Trailer</item> </plurals> <string name="tv_season">Staffel</string> <string name="tv_seasons">Staffeln</string> - <string name="tv_shows">Serien</string> + <string name="tv_shows_title">Serien</string> <string name="ui_interface">Oberfläche</string> <string name="unknown">Unbekannt</string> <string name="updates">Aktualisierungen</string> <string name="version">Version</string> <string name="watch_live">Live ansehen</string> - <string name="years_old">%1$d Jahre alt</string> - <string name="extras">Extras</string> + <plurals name="years_old"> + <item quantity="one">%1$d Jahr alt</item> + <item quantity="other">%1$d Jahre alt</item> + </plurals> + <string name="extras_title">Extras</string> <plurals name="scenes"> - <item quantity="one">Szenen</item> - <item quantity="other"/> + <item quantity="one">Szene</item> + <item quantity="other">Szenen</item> </plurals> <plurals name="downloads"> <item quantity="one">%s Download</item> @@ -81,16 +83,16 @@ <string name="auto_check_for_updates">Automatisch auf Aktualisierungen prüfen</string> <string name="check_for_updates">Auf Aktualisierungen prüfen</string> <string name="settings">Einstellungen</string> - <string name="choose_stream">%1$s Wählen</string> - <string name="clear_image_cache">Bilder Cache leeren</string> - <string name="community_rating">Community Bewertung</string> + <string name="choose_stream">%1$s wählen</string> + <string name="clear_image_cache">Bilder-Cache leeren</string> + <string name="community_rating">Community-Bewertung</string> <string name="compose_error_message_title">Ein Fehler ist aufgetreten! Drücke die Taste um Logs an deinen Server zu senden.</string> <string name="directed_by">Regie von %1$s</string> <string name="disabled">Deaktiviert</string> <string name="discovered_servers">Gefundene Server</string> <string name="downloading">Herunterladen…</string> <string name="enabled">Aktiviert</string> - <string name="enter_server_url">Serveradresse oder URL inklusive Port eingeben</string> + <string name="enter_server_url">Serveradresse oder URL eingeben</string> <string name="episodes">Episoden</string> <string name="error_loading_collection">Fehler beim Laden der Sammlung %1$s</string> <string name="external_track">Extern</string> @@ -101,13 +103,13 @@ <string name="go_to_series">Gehe zur Serie</string> <string name="go_to">Gehe zu</string> <string name="hide_controller_timeout">Wiedergabesteuerung ausblenden</string> - <string name="hide_debug_info">Debug Infos ausblenden</string> + <string name="hide_debug_info">Debug-Infos ausblenden</string> <string name="hide">Ausblenden</string> <string name="home">Home</string> <string name="immediate">Sofort</string> <string name="intro">Intro</string> <string name="library">Bibliothek</string> - <string name="license_info">Lizenz Informationen</string> + <string name="license_info">Lizenzinformationen</string> <string name="loading">Lädt…</string> <string name="mark_entire_series_as_played">Gesamte Serie als gesehen markieren?</string> <string name="mark_entire_series_as_unplayed">Gesamte Serie als ungesehen markieren?</string> @@ -122,7 +124,7 @@ <string name="playback_speed">Wiedergabegeschwindigkeit</string> <string name="playback">Wiedergabe</string> <string name="preview">Vorschau</string> - <string name="profile_specific_settings">Benutzerprofil Einstellungen</string> + <string name="profile_specific_settings">Benutzerprofil-Einstellungen</string> <string name="queue">Warteschlange</string> <string name="recap">Rückblick</string> <string name="recently_released">Zuletzt veröffentlicht</string> @@ -130,7 +132,7 @@ <string name="record_series">Serie aufnehmen</string> <string name="remove_favorite">Nicht favorisieren</string> <string name="search_and_download"><![CDATA[Suchen & Herunterladen]]></string> - <string name="show_debug_info">Debug Infos anzeigen</string> + <string name="show_debug_info">Debug-Infos anzeigen</string> <string name="download_and_update"><![CDATA[Herunterladen & Aktualisieren]]></string> <string name="shuffle">Zufällig</string> <string name="skip">Überspringen</string> @@ -157,15 +159,15 @@ <string name="official_rating">Altersfreigabe</string> <plurals name="other_extras"> <item quantity="one">Sonstiges</item> - <item quantity="other"/> + <item quantity="other">Sonstige</item> </plurals> <plurals name="behind_the_scenes"> <item quantity="one">Hinter den Kulissen</item> - <item quantity="other"/> + <item quantity="other">Hinter den Kulissen</item> </plurals> <plurals name="deleted_scenes"> - <item quantity="one">Gelöschte Szenen</item> - <item quantity="other"/> + <item quantity="one">Nicht verwendete Szene</item> + <item quantity="other">Nicht verwendete Szenen</item> </plurals> <plurals name="items"> <item quantity="one">%d Eintrag</item> @@ -175,15 +177,15 @@ <string name="advanced_ui">Erweiterte Oberfläche</string> <string name="app_theme">Design</string> <string name="auto_play_next_delay">Verzögerung vor dem nächsten Titel</string> - <string name="auto_play_next">Automatisch als nächstes wiedergeben</string> + <string name="auto_play_next">Automatisch als Nächstes wiedergeben</string> <string name="install_update">Aktualisierung installieren</string> <string name="installed_version">Installierte Version</string> - <string name="max_homepage_items">Max. Anzahl Objekte auf der Startseite</string> + <string name="max_homepage_items">Max. Anzahl Objekte je Startseiten-Reihe</string> <string name="jump_letters">#ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÜ</string> - <string name="live_tv">Live TV</string> - <string name="outro">Outro</string> - <string name="play_count">Wiedergabe Anzahl</string> - <string name="playback_debug_info">Wiedergabe Debug Infos anzeigen</string> + <string name="live_tv">Live-TV</string> + <string name="outro">Abspann</string> + <string name="play_count">Wiedergabeanzahl</string> + <string name="playback_debug_info">Wiedergabe Debug-Infos anzeigen</string> <string name="playlist">Wiedergabeliste</string> <string name="playlists">Wiedergabelisten</string> <string name="show_next_up_when">Zeige als nächstes</string> @@ -191,11 +193,11 @@ <string name="sort_by_name">Name</string> <string name="studios">Studios</string> <string name="tv_dvr_schedule">Aufnahmeplan</string> - <string name="video_scale">Video Skalierung</string> + <string name="video_scale">Videoskalierung</string> <string name="video">Video</string> <string name="videos">Videos</string> <string name="aired_episode_order">Nach Sendedatum sortiert</string> - <string name="one_click_pause_summary_on">Drücke D-Pad Mitte zum Pausieren/Fortsetzen</string> + <string name="one_click_pause_summary_on">Drücke Steuerkreuz Mitte zum Pausieren/Fortsetzen</string> <string name="italic_font">Kursivschrift</string> <string name="runtime_sort">Laufzeit</string> <plurals name="theme_songs"> @@ -215,12 +217,12 @@ <item quantity="other">Interviews</item> </plurals> <plurals name="samples"> - <item quantity="one">Beispiele</item> - <item quantity="other"/> + <item quantity="one">Ausschnitt</item> + <item quantity="other">Ausschnitte</item> </plurals> <plurals name="featurettes"> - <item quantity="one">Begleitfilme</item> - <item quantity="other"/> + <item quantity="one">Begleitfilm</item> + <item quantity="other">Begleitfilme</item> </plurals> <plurals name="shorts"> <item quantity="one">Short</item> @@ -228,23 +230,23 @@ </plurals> <string name="combine_continue_next_summary">Trifft nur auf Serien zu</string> <string name="direct_play_ass">Libass für ASS Untertitel verwenden</string> - <string name="direct_play_pgs">Direktes abspielen von PGS Untertitel</string> + <string name="direct_play_pgs">Direktes Abspielen von PGS-Untertiteln</string> <string name="downmix_stereo">Immer zu Stereo heruntermischen</string> - <string name="ffmpeg_extension_pref">FFmpeg decoder Modul verwenden</string> - <string name="global_content_scale">Standard Skalierung</string> + <string name="ffmpeg_extension_pref">FFmpeg-Dekoder-Modul verwenden</string> + <string name="global_content_scale">Standard-Skalierung</string> <string name="nav_drawer_switch_on_focus_summary_off">Klicken um die Seite zu wechseln</string> <string name="pass_out_protection">Einschlafschutz</string> - <string name="play_theme_music">Themenmusik wiedergeben</string> + <string name="play_theme_music">Titelmusik wiedergeben</string> <string name="remember_selected_tab">Ausgewählte Tabs merken</string> <string name="send_app_logs_summary">Nützlich zum Debuggen</string> <string name="send_crash_reports">Sende Absturzmeldung</string> - <string name="skip_back_on_resume_preference">Zurückspringen wenn wiedergabe fortgesetzt wird</string> + <string name="skip_back_on_resume_preference">Zurückspringen, wenn Wiedergabe fortgesetzt wird</string> <string name="skip_commercials_behavior">Werbung überspringen</string> <string name="skip_forward_preference">Vorspringen</string> - <string name="skip_intro_behavior">Überspringe Intro verhalten</string> - <string name="skip_outro_behavior">Überspringe Outro verhalten</string> - <string name="skip_previews_behavior">Überspringe Vorschau verhalten</string> - <string name="skip_recap_behavior">Überspringe Rückblick verhalten</string> + <string name="skip_intro_behavior">Überspringe Intro</string> + <string name="skip_outro_behavior">Überspringe Abspann</string> + <string name="skip_previews_behavior">Überspringe Vorschau</string> + <string name="skip_recap_behavior">Überspringe Rückblick</string> <string name="update_available">Aktualisierung verfügbar</string> <string name="update_url_summary">Verwendete URL um auf Aktualisierungen zu prüfen</string> <string name="update_url">Aktualisierungs-URL</string> @@ -254,32 +256,32 @@ <string name="edge_style">Stil der Umrandung</string> <string name="edge_color">Farbe der Umrandung</string> <string name="background_opacity">Deckkraft des Hintergrundes</string> - <string name="background_style">Hintergrund Stil</string> - <string name="subtitle_style">Untertitel Stil</string> + <string name="background_style">Hintergrund-Stil</string> + <string name="subtitle_style">Untertitelstil</string> <string name="background_color">Hintergrundfarbe</string> <string name="reset">Zurücksetzen</string> <string name="bold_font">Fettschrift</string> - <string name="mpv_hardware_decoding">MPV: Hardware dekodierung verwenden</string> - <string name="disable_if_crash">Deaktivieren, wenn abstürze auftreten</string> + <string name="mpv_hardware_decoding">MPV: Hardware-Dekodierung verwenden</string> + <string name="disable_if_crash">Deaktivieren, wenn Abstürze auftreten</string> <string name="mpv_options">MPV Optionen</string> <string name="exoplayer_options">ExoPlayer Optionen</string> - <string name="combine_continue_next"><![CDATA[Kombiniere Weiterschauen & Als nächstes]]></string> + <string name="combine_continue_next"><![CDATA[Kombiniere Weiterschauen & Als Nächstes]]></string> <string name="nav_drawer_pins_summary">Wähle die Standardobjekte zur Anzeige aus, andere werden ausgeblendet</string> - <string name="nav_drawer_pins">Navigationsmenü Objekte anpassen</string> - <string name="nav_drawer_switch_on_focus">Navigationsmenüseiten beim Fokus wechseln</string> + <string name="nav_drawer_pins">Navigationsmenü anpassen</string> + <string name="nav_drawer_switch_on_focus">Navigationsmenü-Seiten beim Fokus wechseln</string> <string name="playback_overrides">Wiedergabe überschreiben</string> - <string name="seek_bar_steps">Zeitleiste Sprünge</string> + <string name="seek_bar_steps">Zeitleistensprünge</string> <string name="send_app_logs">Sende Logs zum aktuellen Server</string> <string name="send_crash_reports_summary">Versucht zum letzten verbundenen Server zu senden</string> <string name="skip_back_preference">Zurückspringen</string> - <string name="player_backend">Wiedergabe backend</string> - <string name="rewatch_next_up">Aktiviere erneut wiedergeben in als nächstes</string> - <string name="skip_segment_unknown">Segment Überspringen</string> - <string name="skip_segment_commercial">Werbung Überspringen</string> - <string name="skip_segment_preview">Vorschau Überspringen</string> - <string name="skip_segment_recap">Rückblick Überspringen</string> - <string name="skip_segment_outro">Outro Überspringen</string> - <string name="skip_segment_intro">Intro Überspringen</string> + <string name="player_backend">Wiedergabe-Backend</string> + <string name="rewatch_next_up">Aktiviere erneut wiedergeben in als Nächstes</string> + <string name="skip_segment_unknown">Segment überspringen</string> + <string name="skip_segment_commercial">Werbung überspringen</string> + <string name="skip_segment_preview">Vorschau überspringen</string> + <string name="skip_segment_recap">Rückblick überspringen</string> + <string name="skip_segment_outro">Abspann überspringen</string> + <string name="skip_segment_intro">Intro überspringen</string> <string name="filter">Filter</string> <string name="year">Jahr</string> <string name="decade">Jahrzehnt</string> @@ -287,7 +289,7 @@ <string name="dolby_vision">Dolby Vision</string> <string name="dolby_atmos">Dolby Atmos</string> <string name="ends_at">Endet um %1$s</string> - <string name="enter_server_address">Server Adresse eingeben</string> + <string name="enter_server_address">Serveradresse eingeben</string> <string name="no_servers_found">Keine Server gefunden</string> <string name="media_information">Medieninformation</string> <string name="played">Abgespielt</string> @@ -307,7 +309,7 @@ <string name="spacing">Abstände</string> <string name="aspect_ratio">Seitenverhältnis</string> <string name="show_details">Details anzeigen</string> - <string name="image_type">Bild Typ</string> + <string name="image_type">Bildertyp</string> <string name="automatic">Automatisch</string> <string name="username_or_password">Verwende Benutzername/Passwort</string> <string name="login">Anmelden</string> @@ -325,7 +327,7 @@ <string name="no_trailers">Keine Trailer</string> <string name="play_trailer">Trailer abspielen</string> <string name="local">Lokal</string> - <string name="verbose_logging">detaillierte Protokollierung</string> + <string name="verbose_logging">Detailliertes Protokoll</string> <string name="use_server_credentials">Login via Server</string> <string name="press_enter_to_confirm">Drücke die mittlere Taste zur Bestätigung</string> <string name="image_cache_size">Größe des Festplatten-Caches für Bilder (MB)</string> @@ -344,21 +346,21 @@ <string name="sample_rate_unit">Hz</string> <string name="only_forced_subtitles">Nur erzwungene Untertitel</string> <string name="voice_starting">Beginne</string> - <string name="processing">verarbeite</string> + <string name="processing">Verarbeite</string> <string name="voice_error_permissions">Mikrofonzugriff erforderlich</string> <string name="voice_error_network">Netzwerkfehler</string> - <string name="voice_error_network_timeout">Netzwerk Zeitüberschreitung</string> + <string name="voice_error_network_timeout">Netzwerk-Zeitüberschreitung</string> <string name="voice_error_server">Serverfehler</string> - <string name="voice_error_unknown">unbekannter Fehler</string> - <string name="retry">wiederholen</string> - <string name="layout">🇩🇪 Layout</string> + <string name="voice_error_unknown">Unbekannter Fehler</string> + <string name="retry">Wiederholen</string> + <string name="layout">Layout</string> <string name="sample_rate">Abtastrate</string> <string name="dts">DTS</string> <string name="discover">Entdecke</string> <string name="request">Anfrage</string> <string name="pending">Ausstehend</string> - <string name="seerr_integration">Seerr Integration</string> - <string name="seerr_server_added">Seerr server hinzugefügt</string> + <string name="seerr_integration">Seerr-Integration</string> + <string name="seerr_server_added">Seerr-Server hinzugefügt</string> <string name="password">Passwort</string> <string name="username">Benutzername</string> <string name="url">URL</string> @@ -372,31 +374,31 @@ <string name="trending">Im Trend</string> <string name="voice_error_start_failed">Starten der Spracherkennung fehlgeschlagen</string> <string name="press_back_to_cancel">Zurück wählen um abzubrechen</string> - <string name="upgrade_mpv_toast">MPV ist jetzt der Standard-Player, außer für HDR.\nDu kannst das in den Einstellungen ändern.</string> + <string name="upgrade_mpv_toast">MPV ist jetzt der Standard-Player, außer für HDR.\nDu kannst dies in den Einstellungen ändern.</string> <string name="voice_search">Sprachsuche</string> <string name="voice_search_prompt">Sprechen, um zu suchen</string> - <string name="force_dovi_profile_7_summary">Ignoriert geräte kompatibilität</string> - <string name="upcoming_movies">kommende Filme</string> - <string name="upcoming_tv">kommende Serien</string> - <string name="force_dovi_profile_7">Direktes abspielen von Dolby Vision Profil 7</string> + <string name="force_dovi_profile_7_summary">Ignoriert Gerätekompatibilitätsprüfungen</string> + <string name="upcoming_movies">Kommende Filme</string> + <string name="upcoming_tv">Kommende Serien</string> + <string name="force_dovi_profile_7">Direktes Abspielen von Dolby Vision Profil 7</string> <string name="subtitle_delay">Untertitelverzögerung</string> <string name="sort_channels_recently_watched">Sortiere Kanäle nach zuletzt geschaut</string> - <string name="favorite_channels_at_beginning">Favoriten als erstes anzeigen</string> + <string name="favorite_channels_at_beginning">Favoriten als Erstes anzeigen</string> <string name="backdrop_display">Hintergrundstil</string> <string name="refresh_rate_switching">Bildwiederholraten-Anpassung</string> <string name="voice_error_speech_timeout">Keine Spracheingabe erkannt</string> <string name="voice_error_client">Spracherkennungsfehler</string> - <string name="voice_error_audio">Aufnahme-Fehler</string> - <string name="voice_error_no_match">Keine Spracherkennung</string> + <string name="voice_error_audio">Audio-Aufnahme-Fehler</string> + <string name="voice_error_no_match">Keine Spracheingabe erkannt</string> <string name="voice_error_busy">Spracherkennung beschäftigt</string> <string name="voice_error_timeout">Spracherkennung ist abgelaufen</string> <string name="video_range">Videobereich</string> - <string name="video_range_type">Video-Bereichstyp</string> + <string name="video_range_type">Videobereich-Typ</string> <string name="color_code_programs">Farbkorrektur-Software</string> <string name="clear_track_choices">Spur-Auswahl löschen</string> <string name="remove_seerr_server">Seerr-Server entfernen</string> <string name="software_decoding_av1">AV1 Software-Dekodierung</string> - <string name="anamorphic">anamorph</string> + <string name="anamorphic">Anamorph</string> <string name="color_space">Farbraum</string> <string name="color_transfer">Farbübertragung</string> <string name="color_primaries">Primärfarben</string> @@ -404,11 +406,11 @@ <string name="ref_frames">Referenzbilder</string> <string name="edge_size">Randgröße</string> <string name="resolution_switching">Auflösungswechsel</string> - <string name="guest_stars">Gaststars</string> + <string name="guest_stars">Gastdarsteller</string> <string name="slideshow_at_beginning">Ab Anfang</string> - <string name="no_more_images">Keine Fotos</string> - <string name="rotate_left">Links Rotieren</string> - <string name="rotate_right">Rechts Rotieren</string> + <string name="no_more_images">Keine weiteren Fotos</string> + <string name="rotate_left">Links rotieren</string> + <string name="rotate_right">Rechts rotieren</string> <string name="red">Rot</string> <string name="green">Grün</string> <string name="blue">Blau</string> @@ -431,7 +433,7 @@ <string name="hdr_subtitle_style">HDR-Untertitelstil</string> <string name="slideshow_duration">Dauer der Diashow</string> <string name="play_videos_during_slideshow">Videos während der Diashow abspielen</string> - <string name="max_days_next_up">Maximale Tage in \"Als nächstes\"</string> + <string name="max_days_next_up">Max. Tage in \"Als Nächstes\"</string> <string name="quick_connect">Quick Connect</string> <plurals name="minutes"> <item quantity="one">%s Minute</item> @@ -444,11 +446,11 @@ <string name="no_limit">Keine Begrenzung</string> <string name="add_row">Reihe hinzufügen</string> <string name="genres_in">Genres in %1$s</string> - <string name="recently_released_in">kürzlich veröffentlicht in %1$s</string> + <string name="recently_released_in">Kürzlich veröffentlicht in %1$s</string> <string name="height">Höhe</string> <string name="apply_all_rows">Auf alle Reihen anwenden</string> <string name="add_row_for">Füge Reihe für %1$s hinzu</string> - <string name="overwrite_server_settings">Einstellungen auf dem Server übverschreiben?</string> + <string name="overwrite_server_settings">Einstellungen auf dem Server überschreiben?</string> <string name="overwrite_local_settings">Lokale Einstellungen überschreiben?</string> <string name="suggestions_for">Vorschläge für %1$s</string> <string name="send_media_info_log_to_server">Sende Media Info Log zum Server</string> @@ -456,25 +458,25 @@ <string name="for_episodes">Für Folgen</string> <string name="display_presets_description">Vorlagen um schnell alle Reihen zu gestalten</string> <string name="customize_home_summary">Wähle Reihen und Bilder auf der Homepage</string> - <string name="start_after">Starte anschließend</string> - <string name="display_preset_series_thumb">Serien Vorschaubilder</string> - <string name="display_preset_episode_thumbnails">Episoden Vorschaubilder</string> - <string name="seerr_login">Einloggen zum Seerr Server</string> - <string name="seerr_jellyfin_user">Jellyfin User</string> - <string name="show_media_management">Zeige Media Management Optionen</string> + <string name="start_after">Starte nach</string> + <string name="display_preset_series_thumb">Serien-Vorschaubilder</string> + <string name="display_preset_episode_thumbnails">Episoden-Vorschaubilder</string> + <string name="seerr_login">Am Seerr-Server einloggen</string> + <string name="seerr_jellyfin_user">Jellyfin-Nutzer</string> + <string name="show_media_management">Medienmanagement-Optionen anzeigen</string> <string name="decrease_all_cards_size">Größe für alle Karten reduzieren</string> <string name="settings_saved">Einstellungen gespeichert</string> <string name="max_age_rating">Maximale Altersfreigabe</string> <string name="no_max">Keine Begrenzung</string> <string name="for_all_ages">Ohne Altersbeschränkung</string> - <string name="up_to_age">Ab %s Jahre</string> + <string name="up_to_age">Bis %s Jahre</string> <string name="screensaver">Bildschirmschoner</string> - <string name="screensaver_settings">Bildschirmschoner-Einstellungen</string> + <string name="screensaver_settings">Bildschirmschoner Einstellungen</string> <string name="start_screensaver">Bildschirmschoner starten</string> - <string name="duration">Länge</string> + <string name="duration">Dauer</string> <string name="photos">Fotos</string> - <string name="include_types">Arten einschließen</string> - <string name="include_types_summary">Für welche Arten von Einträgen sollen Bilder angezeigt werden</string> + <string name="include_types">Medientypen einschließen</string> + <string name="include_types_summary">Für welche Medientypen sollen Bilder angezeigt werden</string> <string name="content_scale_fit">Angepasst</string> <string name="content_scale_crop">Zugeschnitten</string> <string name="content_scale_fill">Ausgefüllt</string> @@ -485,7 +487,7 @@ <string name="ffmpeg_prefer">FFmpeg statt der vorhandenen Decoder verwenden</string> <string name="ffmpeg_never">FFmpeg Decoder nie verwenden</string> <string name="next_up_playback_end">Am Ende einer Playlist</string> - <string name="next_up_outro">Während der Credits/Outro</string> + <string name="next_up_outro">Während des Abspanns</string> <string name="white">Weiß</string> <string name="light_gray">Hellgrau</string> <string name="dark_gray">Dunkelgrau</string> @@ -495,25 +497,25 @@ <string name="subtitle_edge_outline">Umrandung</string> <string name="subtitle_edge_shadow">Schatten</string> <string name="prefer_mpv">MPV bevorzugen</string> - <string name="player_backend_options_subtitles_prefer_mpv">ExoPlayer für die HDR Wiedergabe verwenden</string> + <string name="player_backend_options_subtitles_prefer_mpv">ExoPlayer für HDR-Wiedergabe verwenden</string> <string name="aspect_ratios_poster">Poster (2:3)</string> <string name="aspect_ratios_16_9">16:9</string> <string name="aspect_ratios_4_3">4:3</string> <string name="aspect_ratios_square">Quadrat (1:1)</string> <string name="image_type_primary">Primär</string> <string name="backdrop_style_dynamic">Bild mit dynamischen Farben</string> - <string name="api_key">API Schlüssel</string> + <string name="api_key">API-Schlüssel</string> <string name="seerr_local_user">Lokaler Benutzer</string> <string name="search_for">Suche %s</string> <string name="display_presets">Design-Vorlagen</string> <string name="animate">Animieren</string> <string name="content_scale_fill_width">Breite ausfüllen</string> <string name="content_scale_fill_height">Höhe ausfüllen</string> - <string name="display_preset_default">Wholphin Standard</string> - <string name="display_preset_compact">Wholphin Kompakt</string> + <string name="display_preset_default">Wolfskin Standard</string> + <string name="display_preset_compact">Wolfskin Kompakt</string> <string name="volume_lowest">Niedrigste</string> <string name="volume_low">Niedrig</string> - <string name="volume_medium">mittel</string> + <string name="volume_medium">Mittel</string> <string name="volume_high">Hoch</string> <string name="volume_full">Voll</string> <string name="purple">Lila</string> @@ -521,20 +523,20 @@ <string name="black">Schwarz</string> <string name="backdrop_style_image">Nur Bild</string> <string name="series_continueing">Aktuell</string> - <string name="in_app_screensaver">Eingebauten Bildschirmschoner nutzen</string> + <string name="in_app_screensaver">Integrierten Bildschirmschoner nutzen</string> <string name="delete_item">Bist du sicher, dass du diesen Eintrag löschen möchtest?</string> - <string name="actor">Schauspieler</string> + <string name="actor">Darsteller</string> <string name="composer">Komponist</string> <string name="writer">Autor</string> - <string name="guest_star">Nebenrolle</string> + <string name="guest_star">Gastdarsteller</string> <string name="producer">Produzent</string> <string name="conductor">Dirigent</string> - <string name="lyricist">Liedtext-Schreiber</string> + <string name="lyricist">Liedtexter</string> <string name="artist">Künstler</string> - <string name="favorite_items">%s als Favorit speichern</string> - <string name="home_rows">Startseite - Reihen</string> - <string name="load_from_server">Vom Server Benutzerprofil laden</string> - <string name="save_to_server">Im Server Benutzerprofil speichern</string> + <string name="favorite_items_title">Favorisierte %s</string> + <string name="home_rows">Startseiten-Reihen</string> + <string name="load_from_server">Vom Server-Benutzerprofil laden</string> + <string name="save_to_server">Im Server-Benutzerprofil speichern</string> <string name="load_from_web_client">Vom Webclient laden</string> <string name="increase_all_cards_size">Größe für alle Karten erhöhen</string> <string name="use_thumb_images">Vorschaubilder benutzen</string> @@ -542,10 +544,10 @@ <string name="arranger">Arrangeur</string> <string name="engineer">Ingenieur</string> <string name="bold_blue">Dunkelblau</string> - <string name="image_subtitle_opacity">Transparenz des Bilduntertitels</string> + <string name="image_subtitle_opacity">Deckkraft des Bilduntertitels</string> <string name="image_type_thumb">Vorschaubild</string> - <string name="use_series">Serien Vorschaubilder verwenden</string> - <string name="enter_url_api_key"><![CDATA[URL & API Key eingeben]]></string> + <string name="use_series">Serien-Vorschaubilder verwenden</string> + <string name="enter_url_api_key"><![CDATA[URL & API-Schlüssel eingeben]]></string> <string name="search_and_download_subtitles"><![CDATA[Untertitel suchen & herunterladen]]></string> <string name="creator">Ersteller</string> <string name="background_style_wrap">Textmarker</string> @@ -560,44 +562,201 @@ <string name="instant_mix">Sofortmix</string> <string name="go_to_album">Zu Album wechseln</string> <string name="add_to_queue">Zur Warteschlange hinzufügen</string> - <string name="album_artist">Album Interpret</string> + <string name="album_artist">Albumkünstler</string> <string name="album">Album</string> <string name="popular_songs">Beliebte Lieder</string> - <string name="play_next">Als nächstes abspielen</string> + <string name="play_next">Als Nächstes abspielen</string> <string name="music_videos">Musikvideos</string> <string name="lyrics">Liedtexte</string> <string name="hide_lyrics">Liedtexte ausblenden</string> <string name="show_lyrics">Liedtexte einblenden</string> <string name="song_has_lyrics">Lied hat Liedtext</string> <string name="remove_from_queue">Aus Warteschlange entfernen</string> - <string name="show_album_cover">Album Cover anzeigen</string> + <string name="show_album_cover">Albumcover anzeigen</string> <string name="show_visualizer">Visualizer anzeigen</string> <string name="show_backdrop">Hintergrundbild anzeigen</string> <string name="play_as_type">Abspielen als?</string> <string name="visualizer_rationale">Die Visualisierung der aktuellen Wiedergabe erfordert die Berechtigung zur Audioaufzeichnung.</string> <string name="continue_string">Fortfahren</string> <string name="separate_types">Verschiedene Arten</string> - <string name="prefer_logos">Logos anstatt Titelnamen anzeigen</string> - <string name="updated_toast">Wholphin aktualisiert auf %s</string> + <string name="prefer_logos">Bevorzugt Logo statt Titel anzeigen</string> + <string name="updated_toast">Wolfskin aktualisiert auf %s</string> <string name="remove_continue_watching">Von \"Weiterschauen\" entfernen</string> - <string name="remove_next_up">Serie aus \"Als nächstes Anschauen\" entfernen</string> + <string name="remove_next_up">Serie aus \"Als Nächstes anschauen\" entfernen</string> <string name="view_more">Mehr anzeigen</string> - <string name="discover_tv">TV Serien entdecken</string> + <string name="discover_tv">TV-Serien entdecken</string> <string name="discover_movies">Filme entdecken</string> <string name="studios_in">Studios in %1$s</string> - <string name="view_removed_next_up">Serien anzeigen, die von \"Als nächstes Anschauen\" entfernt wurden</string> + <string name="view_removed_next_up">Serien anzeigen, die von \"Als Nächstes anschauen\" entfernt wurden</string> <string name="next_up_never">Nie</string> <string name="last_played">Zuletzt wiedergegeben</string> - <string name="ass_subtitle_mode_libass">Direkte Wiedergabe mit Libass</string> - <string name="ass_subtitle_mode_exoplayer">Direkte Wiedergabe mit ExoPlayer (eingebaut)</string> - <string name="ass_subtitle_mode_transcode">Einbrennen/Auf Server transkodieren</string> - <string name="ass_subtitle_playback">SSA/ASS Untertitel Wiedergabe</string> + <string name="ass_subtitle_mode_libass">Direkte Wiedergabe mit libass</string> + <string name="ass_subtitle_mode_exoplayer">Direkte Wiedergabe mit ExoPlayer (integriert)</string> + <string name="ass_subtitle_mode_transcode">Einbrennen/auf Server transkodieren</string> + <string name="ass_subtitle_playback">SSA/ASS Untertitel-Wiedergabe</string> <string name="external_player">Externer Player</string> <string name="system_default">Systemstandard</string> <string name="show_more">Zeige %1$s mehr</string> <string name="skip_behavior">\"Überspringen\" Verhalten</string> - <string name="skip_behavior_summary">Für Intros, Credits, etc</string> + <string name="skip_behavior_summary">Für Intros, Credits, etc.</string> <string name="cinema_mode">Kinomodus</string> <string name="play_with">Abspielen mit</string> <string name="transcoding">Transkodieren</string> + <string name="pin_digits_hint">[0–9]</string> + <string name="combined_search_results">Zusammengefasste Suchergebnisse</string> + <string name="combined_search_results_on">Alle Ergebnisse in einer gemeinsamen Liste anzeigen</string> + <string name="combined_search_results_off">Ergebnisse nach Kategorie anzeigen</string> + <string name="results">Ergebnisse</string> + <string name="require_login">Profil benötigt Anmeldung</string> + <string name="visible_ui">Sichtbar</string> + <string name="hidden_ui">Versteckt</string> + <string name="view_options_type_grid">Raster</string> + <string name="view_options_type_list">Liste</string> + <string name="view_options_type_list_compact">Liste kompakt</string> + <string name="show_voice_search_button">Zeige Sprachsuche-Button</string> + <string name="user_interface_language">Sprache Benutzeroberfläche</string> + <string name="profile_protection">Profilschutz</string> + <string name="continue_watching_click_behavior">Klickverhalten bei Weiterschauen/Als nächstes</string> + <string name="continue_watching_click_summary_on">Mit Klick abspielen</string> + <string name="continue_watching_click_summary_off">Mit Klick Details zeigen</string> + <string name="stars">Sterne</string> + <string name="tomatoes">Tomaten</string> + <string name="display_toggles_title">Bewertungen anzeigen</string> + <string name="display_toggles_summary">Anpassen, was angezeigt wird</string> + <string name="sort">Sortieren</string> + <string name="split_into_separate_rows">In unterschiedliche Reihen aufteilen</string> + <string name="trailers_title">Trailer</string> + <string name="other_extras_title">Andere</string> + <string name="behind_the_scenes_title">Behind the Scenes</string> + <string name="theme_songs_title">Titelsongs</string> + <string name="theme_videos_title">Titelvideos</string> + <string name="clips_title">Ausschnitte</string> + <string name="deleted_scenes_title">Gelöschte Szenen</string> + <string name="interviews_title">Interviews</string> + <string name="scenes_title">Szenen</string> + <string name="samples_title">Probe</string> + <string name="featurettes_title">Featurettes</string> + <string name="shorts_title">Shorts</string> + <plurals name="movies"> + <item quantity="one">Film</item> + <item quantity="other">Filme</item> + </plurals> + <plurals name="tv_shows"> + <item quantity="one">Serie</item> + <item quantity="other">Serien</item> + </plurals> + <plurals name="people"> + <item quantity="one">Darsteller</item> + <item quantity="other">Darsteller</item> + </plurals> + <string name="genre_movies">%1$s Filme</string> + <string name="genre_tv_shows">%1$s Serien</string> + <string name="quality_profile">Qualitätsprofil</string> + <string name="is_default">Standard</string> + <string name="root_folder">Wurzelordner</string> + <string name="popularity">Beliebtheit</string> + <string name="production_status">Produktionsstatus</string> + <string name="different_for_hdr">Für HDR anderen Stiel verwenden</string> + <string name="experimental_settings">Experimentelle Einstellungen</string> + <string name="confirm_enable_experimental_title">Experimentelle Einstellungen aktivieren?</string> + <string name="confirm_enable_experimental_body">Die experimentellen Einstellungen sind instabil!\n\n Diese könnten mit jedem Update verändert oder entfernt werden.</string> + <string name="duration_hours_minutes_seconds">%1$dh %2$dm %3$ds</string> + <string name="duration_hours_minutes">%1$dh %2$dm</string> + <string name="duration_minutes_seconds">%1$dm %2$ds</string> + <string name="duration_hours">%1$dh</string> + <string name="duration_minutes">%1$dm</string> + <string name="duration_seconds">%1$ds</string> + <string name="time_left">%1$s verbleibend</string> + <string name="video_tunneling">Video Tunneling</string> + <string name="prefer_ac3_for_surround_summary"><![CDATA[Nicht-Stereo Audio zu AC3/Dolby Digital transcodieren]]></string> + <string name="prefer_ac3_for_surround">AC3 für Surround Sound bevorzugen</string> + <string name="blocklisted">Auf Blockliste hinzugefügt</string> + <string name="unavailable">Nicht verfügbar</string> + <string name="profile_specific_settings_from_server">Benutzerprofil-Einstellungen auf dem Server</string> + <string name="subtitle_mode">Modus Untertitel</string> + <string name="use_user_profile">Nutze deine Profileinstellungen</string> + <string name="subtitle_mode_default">Standard</string> + <string name="subtitle_mode_smart">Intelligent</string> + <string name="subtitle_mode_only_forced">Nur Forced</string> + <string name="subtitle_mode_always">Immer</string> + <string name="subtitle_mode_none">Keine</string> + <string name="preferred_subtitle_language">Bevorzugte Untertitelsprache</string> + <string name="preferred_audio_language">Bevorzugte Audiosprache</string> + <string name="override_user_profile_settings">Weitere Benutzerprofil-Einstellungen</string> + <string name="override_user_profile_settings_summary">Überschreibe deine Benutzerprofileinstellungen mit denen vom Server</string> + <string name="any_language">Beliebige Sprache</string> + <string name="hash_matches">Hash Übereinstimmungen</string> + <string name="subtitles_hearing_impaired">SDH</string> + <string name="programs">TV Programme</string> + <string name="recordings">Aufnahmen</string> + <string name="folders_title">Ordner</string> + <string name="books">Bücher</string> + <string name="audio_books">Hörbücher</string> + <string name="d_pad_seek_mode_title">D-Pad Suchmodus</string> + <string name="dpad_seek_mode_trickplay">Trickplay Vorschau</string> + <string name="user_views">Benutzeransicht</string> + <string name="appears_on">Erscheint auf</string> + <string name="dpad_seek_mode_minimal">Minimal</string> + <string name="dpad_seek_mode_minimal_summary">Kleine Suchleiste am unteren Rand des Bildschirms (Standard)</string> + <string name="brown">Braun</string> + <string name="dpad_seek_mode_trickplay_summary">Suchleiste mit Trickplay Vorschaubildern</string> + <string name="dpad_seek_mode_none_summary">Keine Suchleiste</string> + <string name="server_version_not_supported">Nicht unterstützte Server-Version</string> + <string name="disable_audio_offload">Audio-Hardware-Offload deaktivieren</string> + <string name="disable_audio_offload_summary">Das Auslagern der Audio Decodierung zu deaktivieren könnte Wiedergabeprobleme auf einigen Gräten beheben</string> + <string name="duration_hours_seconds">%1$dh %2$ds</string> + <string name="season_episode_number">S%1$d F%2$d</string> + <string name="season_episode_number_range">S%1$d F%2$d-E%3$d</string> + <string name="season_episode_number_padded">S%1$02dF%2$02d</string> + <string name="season_episode_number_padded_range">S%1$02dF%2$02d-E%3$02d</string> + <string name="episode_number_short">F%1$d</string> + <string name="season_number">Staffel %1$d</string> + <string name="dim_after_delay">Bildschirm nach Verzögerung dimmen</string> + <string name="dim_percentage">Dimmen in Prozent</string> + <string name="dim_screen">Bildschirm dimmen</string> + <string name="photo_albums">Fotoalben</string> + <string name="no_favorites_found">Keine Favoriten vorhanden!</string> + <string name="remove_from_playlist">Von Wiedergabeliste entfernen</string> + <string name="login_credentials_expired">Anmeldeinformationen abgelaufen, bitte erneut anmelden</string> + <string name="login_not_authorized">Du bist aktuell nicht berechtigt auf den Server zuzugreifen</string> + <string name="prefer_dovi_over_hdr10_plus">Dolby Vision gegenüber HDR10+ bevorzugen</string> + <string name="prefer_dovi_over_hdr10_plus_summary">Entfernt HDR10+ Metadaten von Dolby Vision Videos für TVs, welche die Dateien sonst als HDR10+ abspielen würden</string> + <string name="max_resolution">Maximale Auflösung vor dem Transkodieren</string> + <string name="resolution_8k">8K</string> + <string name="resolution_4k">4K</string> + <string name="resolution_1080">1080</string> + <string name="resolution_720">720</string> + <string name="resolution_576">576</string> + <string name="resolution_480">480</string> + <string name="resolution_360">360</string> + <plurals name="episodes_count"> + <item quantity="one">%1$d Folge</item> + <item quantity="other">%1$d Folgen</item> + </plurals> + <string name="top_rated">Am besten bewertet</string> + <string name="hdr_formats">HDR Formate</string> + <string name="hdr10_override">HDR10</string> + <string name="hdr10_plus_override">HDR10+</string> + <string name="dovi_profile_5">Dolby Vision Profile 5</string> + <string name="dovi_profile_7">Dolby Vision Profile 7</string> + <string name="dovi_profile_8">Dolby Vision Profile 8</string> + <string name="server_version_required">Benötigte Server Version: %1$s</string> + <string name="user">Benutzer</string> + <string name="load_from_server_plugin">Standard vom Server laden</string> + <string name="save_to_local">Nur auf diesem Gerät speichern</string> + <string name="home_settings_source_unset">Standard</string> + <string name="home_settings_source_local">Geräte spezifisch</string> + <string name="home_settings_source_server_profile">Server Benutzerprofil</string> + <string name="home_settings_source_plugin">Server Standard</string> + <string name="home_settings_source_web">Benutzer Webclient</string> + <string name="managed_by_server">Vom Server verwaltet</string> + <string name="error">Fehler</string> + <string name="server_version_not_supported_future">Server Version wird bald nicht mehr unterstützt</string> + <string name="chosen_by">Festgelegt von %1$s</string> + <string name="convert_dovi_profile_7">Dolby Vision Profile 7 zu 8.1 konvertieren</string> + <string name="convert_dovi_profile_7_summary">Für Geräte, die zwar Dolby Vision Profil 8, aber nicht Profil 7 dekodieren können, wird das zweischichtige Profil 7 vor der Wiedergabe in das einschichtige Profil 8.1 umgewandelt und dann direkt abgespielt</string> + <string name="artist_source">Interpreten Quelle</string> + <string name="series">Reihen</string> + <string name="force_enable">Aktivieren erzwingen</string> + <string name="force_disable">Deaktivieren erzwingen</string> </resources> diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 6159ff33..732ffe5d 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -1,6 +1,6 @@ <?xml version="1.0" encoding="utf-8"?> <resources> - <string name="movies">Ταινίες</string> + <string name="movies_title">Ταινίες</string> <string name="profile">Προφίλ</string> <string name="home">Αρχική</string> <string name="language">Γλώσσα</string> @@ -18,21 +18,21 @@ <string name="videos">Τα βίντεο</string> <string name="background">Παρασκήνιο</string> <plurals name="theme_songs"> - <item quantity="one">Θεματικά τραγούδια</item> - <item quantity="other"/> + <item quantity="one">Θεματικό τραγούδι</item> + <item quantity="other">Θεματικά τραγούδια</item> </plurals> <plurals name="theme_videos"> - <item quantity="one">Θεματικά βίντεο</item> - <item quantity="other"/> + <item quantity="one">Θεματικό βίντεο</item> + <item quantity="other">Θεματικά βίντεο</item> </plurals> - <string name="next_up">Επόμενο</string> - <string name="rewatch_next_up">Ενεργοποιήστε την επανάληψη παρακολούθησης στα \'Επόμενα\'</string> + <string name="next_up">Επόμενα</string> + <string name="rewatch_next_up">Ενεργοποιήστε την επαναπροβολή στα \'Επόμενα\'</string> <string name="font">Γραμματοσειρά</string> <string name="font_size">Μέγεθος Κειμένου</string> <string name="font_color">Χρώμα Κειμένου</string> - <string name="bold_font">Φωτεινότητα κειμένου</string> - <string name="library">Βιβλιοθήκης</string> - <string name="tv_shows">Σειρές</string> + <string name="bold_font">Έντονη γραμματοσειρά</string> + <string name="library">Βιβλιοθήκη</string> + <string name="tv_shows_title">Σειρές</string> <string name="genres">Είδη</string> <string name="collection">Συλλογή</string> <string name="collections">Συλλογές</string> @@ -42,26 +42,26 @@ <string name="director">Σκηνοθέτης</string> <string name="studios">Στούντιο</string> <string name="sdr">SDR</string> - <string name="cast_and_crew"><![CDATA[Ηθοποιοί και Συνεργείο]]></string> - <string name="more_like_this">Περισσότερα Σαν Αυτό</string> + <string name="cast_and_crew">Ηθοποιοί και Συνεργείο</string> + <string name="more_like_this">Παραπλήσια</string> <string name="episodes">Επεισόδια</string> <string name="jump_letters">#ΑΒΓΔΕΖΗΘΙΚΛΜΝΞΟΠΡΣΤΥΦΧΨΩ</string> <plurals name="downloads"> <item quantity="one">%s λήψη</item> <item quantity="other">%s λήψεις</item> </plurals> - <string name="downloading">λήψη…</string> + <string name="downloading">Γίνεται λήψη…</string> <string name="path">Διαδρομή</string> <string name="name">Όνομα</string> - <string name="container">Δοχείο</string> + <string name="container">Κοντέινερ</string> <string name="file_size">Μέγεθος</string> <string name="codec">Κωδικοποιητής</string> <string name="avc">AVC</string> <string name="bitrate">Ρυθμός bit</string> <string name="bit_depth">Βάθος bit</string> <string name="bit_unit">bit</string> - <string name="forced_track">Εξαναγκασμός</string> - <string name="layout">Σχέδιο</string> + <string name="forced_track">Αναγκαστικό</string> + <string name="layout">Διάταξη</string> <string name="default_track">Προεπιλογή</string> <string name="external_track">Εξωτερικό</string> <string name="add_user">Προσθήκη χρήστη</string> @@ -69,25 +69,25 @@ <string name="chapters">Κεφάλαια</string> <string name="choose_stream">Διάλεξε %1$s</string> <string name="add_server">Προσθήκη διακομιστή</string> - <string name="cancel_recording">Ακύρωση καταχώρησης</string> - <string name="cancel_series_recording">Ακύρωση σειρά καταχώρησης</string> + <string name="cancel_recording">Ακύρωση καταγραφής</string> + <string name="cancel_series_recording">Ακύρωση καταγραφής σειράς</string> <string name="clear_image_cache">Καθαρισμός μνήμης εικόνας</string> <string name="commercial">Διαφήμιση</string> <string name="community_rating">Αξιολόγηση κοινού</string> <string name="compose_error_message_title">Ένα σφάλμα εμφανίστηκε! Πατήστε το κομπί για να στείλετε αρχείο καταγραφής στο διακομιστή.</string> <string name="confirm">Επικύρωση</string> - <string name="continue_watching">Συνεχίστε να βλέπετε</string> + <string name="continue_watching">Συνέχεια προβολής</string> <string name="critic_rating">Κριτική</string> - <string name="decimal_seconds">%.1f δευτερόλεπτα</string> + <string name="decimal_seconds">%.2f δευτερόλεπτα</string> <string name="delete">Διαγραφή</string> <string name="died">Πέθανε</string> <string name="directed_by">Σκηνοθετήθηκε από %1$s</string> - <string name="disabled">Απενεργοποιημένος</string> - <string name="enabled">Ενεργοποιημένος</string> - <string name="enter_server_url">Καταχώρηση διακομιστή IP or URL</string> + <string name="disabled">Απενεργοποιημένο</string> + <string name="enabled">Ενεργοποιημένο</string> + <string name="enter_server_url">Δώσε την IP ή το URL του διακομιστή</string> <string name="error_loading_collection">Σφάλμα φόρτωσης συλλογής %1$s</string> - <string name="go_to_series">Πηγαίνετε στις σειρές</string> - <string name="go_to">Πηγαίνετε στο</string> + <string name="go_to_series">Πήγαινε στη σειρά</string> + <string name="go_to">Πήγαινε σε αυτό</string> <string name="hide_controller_timeout">Απόκρυψη χειρισμού αναπαραγωγής</string> <string name="hide_debug_info">Απόκρυψη πληροφοριών εντοπισμού σφαλμάτων</string> <string name="hide">Απόκρυψη</string> @@ -96,19 +96,19 @@ <string name="license_info">Πληροφορίες άδειας</string> <string name="live_tv">Ζωντανή Τηλεόραση</string> <string name="loading">Φόρτωση…</string> - <string name="mark_entire_series_as_played">Επισήμανση ολόκληρης σειράς ως αναπαραγμένης?</string> - <string name="mark_entire_series_as_unplayed">Επισήμανση ολόκληρης σειράς ως μη αναπαραγμένης?</string> + <string name="mark_entire_series_as_played">Επισήμανση ολόκληρης σειράς ως αναπαραγμένης;</string> + <string name="mark_entire_series_as_unplayed">Επισήμανση ολόκληρης σειράς ως μη αναπαραγμένης;</string> <string name="mark_unwatched">Επισήμανση ως μη ολοκληρωμένο</string> <string name="mark_watched">Επισήμανση ως ολοκληρωμένο</string> <string name="max_bitrate">Μέγιστος ρυθμός μετάδοσης δεδομένων</string> <string name="more">Περισσότερα</string> <string name="no_data">Χωρίς δεδομένα</string> <string name="no_results">Χωρίς αποτελέσματα</string> - <string name="no_scheduled_recordings">Χωρίς προγραμματισμένες καταχωρήσεις</string> + <string name="no_scheduled_recordings">Χωρίς προγραμματισμένες καταγραφές</string> <string name="no_update_available">Χωρίς διαθέσιμη ενημέρωση</string> <string name="play_from_here">Έναρξη από εδώ</string> <string name="play">Έναρξη</string> - <string name="people">Άτομα</string> + <string name="people_title">Άτομα</string> <string name="playback_speed">Ταχύτητα αναπαραγωγής</string> <string name="playback">Αναπαραγωγή</string> <string name="playlist">Λίστα αναπαραγωγής</string> @@ -117,16 +117,16 @@ <string name="profile_specific_settings">Ρυθμίσεις προφίλ χρήστη</string> <string name="queue">Ουρά</string> <string name="recap">Ανακεφαλαίωση</string> - <string name="recently_added_in">Πρόσφατα πρόσθετα σε %1$s</string> - <string name="recently_added">Πρόσφατα πρόσθετα</string> + <string name="recently_added_in">Πρόσφατες προσθήκες σε %1$s</string> + <string name="recently_added">Πρόσφατες προσθήκες</string> <string name="recently_recorded">Πρόσφατα καταγεγραμμένο</string> - <string name="recently_released">Πρόσφατα κυκλοφόρησε</string> + <string name="recently_released">Πρόσφατης κυκλοφορίας</string> <string name="recommended">Προτεινόμενο</string> - <string name="record_program">Πρόγραμμα καταγραφής</string> - <string name="record_series">Σειρά καταγραφής</string> - <string name="remove_favorite">Κατάργηση από τα αγαπημένα</string> + <string name="record_program">Καταγραφή προγράμματος</string> + <string name="record_series">Καταγραφή σειράς</string> + <string name="remove_favorite">Αναίρεση αγαπημένου</string> <string name="restart">Επανεκκίνηση</string> - <string name="resume">Περίληψη</string> + <string name="resume">Συνέχισε</string> <string name="save">Αποθήκευση</string> <string name="search">Αναζήτηση</string> <string name="searching">Αναζήτηση…</string> @@ -136,9 +136,9 @@ <string name="show_next_up_when">Εμφάνιση επόμενου</string> <string name="show">Εμφάνιση</string> <string name="shuffle">Τυχαία αναπαραγωγή</string> - <string name="skip">Αναπήδηση</string> - <string name="sort_by_date_added">Προσθήκη ημερομηνίας</string> - <string name="sort_by_date_episode_added">Προσθήκη ημερομηνίας επεισοδίου</string> + <string name="skip">Παράλειψε</string> + <string name="sort_by_date_added">Ημερομηνία Προσθήκης</string> + <string name="sort_by_date_episode_added">Ημερομηνία Προσθήκης Επεισοδίου</string> <string name="sort_by_date_played">Ημερομηνία αναπαραγωγής</string> <string name="sort_by_date_released">Ημερομηνία κυκλοφορίας</string> <string name="sort_by_name">Όνομα</string> @@ -146,17 +146,17 @@ <string name="submit">Υποβολή</string> <string name="active_recordings">Ενεργές καταγραφές</string> <string name="none">Κανένας</string> - <string name="playback_debug_info">Εμφάνιση πληροφοριών αναπαραγωγής σφαλμάτων</string> - <string name="subtitle_download_too_long">Λήψη αρχείων διαρκεί πολύ χρονο, ίσως χρειαστεί να επανεκκινήσετε την αναπαραγωγή</string> + <string name="playback_debug_info">Εμφάνιση πληροφοριών σφαλμάτων αναπαραγωγής</string> + <string name="subtitle_download_too_long">Καθυστέρηση της λήψης, ίσως απαιτείται επανεκκίνηση αναπαραγωγής</string> <string name="switch_servers">Αλλαγή διακομιστή</string> - <string name="switch_user">Αντικατάσταση</string> - <string name="top_unwatched">Κορυφαίες βαθμολογίες σε ταινίες που δεν έχω δει</string> + <string name="switch_user">Αλλαγή</string> + <string name="top_unwatched">Κορυφαίας βαθμολογίας που δεν έχουν προβληθεί</string> <string name="tv_guide">Οδηγός</string> <string name="tv_season">Σεζόν</string> <string name="tv_seasons">Περισσότερες Σεζόν</string> <string name="unknown">Άγνωστο</string> <string name="updates">Αναβαθμίσεις</string> - <string name="version">Εκδοχή</string> + <string name="version">Έκδοση</string> <string name="video_scale">Κλίμακα βίντεο</string> <string name="watch_live">Ζωντανή μετάδοση</string> <string name="media_information">Πληροφορίες μέσων</string> @@ -167,46 +167,227 @@ <string name="italic_font">Πλάγια γραμματοσειρά</string> <string name="success">Επιτυχής</string> <string name="official_rating">Γονεική αξιολόγηση</string> - <string name="runtime_sort">Χρόνος εκτέλεσης</string> - <string name="extras">Επιπρόσθετα</string> + <string name="runtime_sort">Διάρκεια</string> + <string name="extras_title">Επιπρόσθετα</string> <plurals name="other_extras"> <item quantity="one">Άλλο</item> - <item quantity="other"/> + <item quantity="other">Άλλα</item> </plurals> <plurals name="behind_the_scenes"> - <item quantity="one">Παρασκήνια</item> - <item quantity="other"/> + <item quantity="one">Παρασκήνιο</item> + <item quantity="other">Παρασκήνια</item> </plurals> <plurals name="clips"> - <item quantity="one">Αποσπάσματα</item> - <item quantity="other"/> + <item quantity="one">Απόσπασμα</item> + <item quantity="other">Αποσπάσματα</item> </plurals> <plurals name="deleted_scenes"> - <item quantity="one">Διαγραμμένες σκηνές</item> - <item quantity="other"/> + <item quantity="one">Διαγραμμένη σκηνή</item> + <item quantity="other">Διαγραμμένες σκηνές</item> </plurals> <plurals name="interviews"> - <item quantity="one">Συνεντεύξεις</item> - <item quantity="other"/> + <item quantity="one">Συνέντευξη</item> + <item quantity="other">Συνεντεύξεις</item> </plurals> <plurals name="scenes"> - <item quantity="one">Σκηνές</item> - <item quantity="other"/> + <item quantity="one">Σκηνή</item> + <item quantity="other">Σκηνές</item> </plurals> <plurals name="samples"> - <item quantity="one">Δείγματα</item> - <item quantity="other"/> + <item quantity="one">Δείγμα</item> + <item quantity="other">Δείγματα</item> </plurals> <plurals name="featurettes"> - <item quantity="one">Χαρακτηριστικά</item> - <item quantity="other"/> + <item quantity="one">Προωθητικό βίντεο</item> + <item quantity="other">Προωθητικά βίντεο</item> </plurals> <string name="auto_check_for_updates">Αυτόματος έλεγχος για ενημερώσεις</string> <string name="auto_play_next_delay">Καθυστέρηση πριν την έναρξη του επόμενου</string> <string name="auto_play_next">Αυτόματη έναρξη επόμενου</string> <string name="check_for_updates">Έλεχγος για ενημερώσεις</string> <string name="combine_continue_next_summary">Ισχύει μόνο για τηλεοπτικές σειρές</string> - <string name="global_content_scale">Προεπιλεγμένη αναπαραγωγή περιεχομένου</string> + <string name="global_content_scale">Προεπιλεγμένη κλίμακα περιεχομένου</string> <string name="install_update">Εγκατάσταση ενημέρωσης</string> - <string name="installed_version">Εγκατεστημένη εκδοχή</string> + <string name="installed_version">Εγκατεστημένη έκδοση</string> + <string name="about">Πληροφορίες</string> + <string name="duration_hours_minutes_seconds">%1$dω %2$dλ %3$dδ</string> + <string name="duration_hours_minutes">%1$dω %2$dλ</string> + <string name="duration_minutes_seconds">%1$dλ %2$dδ</string> + <string name="duration_hours">%1$dω</string> + <string name="duration_minutes">%1$dλ</string> + <string name="duration_seconds">%1$dδ</string> + <string name="time_left">%1$s απέμεινε</string> + <string name="duration_hours_seconds">%1$dω %2$dδ</string> + <string name="discovered_servers">Εντοπισμένοι διακομιστές</string> + <string name="ends_at">Τελειώνει σε %1$s</string> + <string name="resolution_switching">Εναλλαγή ανάλυσης</string> + <string name="local">Τοπικό</string> + <string name="play_trailer">Αναπαραγωγή τρέιλερ</string> + <string name="no_trailers">Λείπει τρέιλερ</string> + <string name="clear_track_choices">Καθαρισμός επιλογών καναλιού</string> + <string name="enter_server_address">Δώσε την διεύθυνση διακομιστή</string> + <string name="no_servers_found">Δεν βρέθηκαν διακομιστές</string> + <string name="play_count">Μετρητής προβολών</string> + <string name="only_forced_subtitles">Μόνο αναγκαστικοί υπότιτλοι</string> + <string name="outro">Τίτλοι τέλους</string> + <string name="voice_error_audio">Σφάλμα καταγραφής ήχου</string> + <string name="visualizer_rationale">Η οπτικοποίηση του ήχου αναπαραγωγής απαιτεί άδεια καταγραφής ήχου.</string> + <string name="recordings">Καταγραφές</string> + <string name="profile_specific_settings_from_server">Ρυθμίσεις του προφίλ χρήστη στο διακομιστή</string> + <string name="subtitle_mode">Λειτουργία υποτίτλων</string> + <string name="use_user_profile">Χρήση των ρυθμίσεων του προφίλ</string> + <string name="subtitle_mode_default">Προεπιλογή</string> + <string name="subtitle_mode_smart">Έξυπνο</string> + <string name="subtitle_mode_only_forced">Μόνο αναγκαστικοί</string> + <string name="subtitle_mode_always">Πάντα</string> + <string name="subtitle_mode_none">Κανένα</string> + <string name="preferred_subtitle_language">Προτιμώμενη γλώσσα υποτίτλων</string> + <string name="preferred_audio_language">Προτιμώμενη γλώσσα ήχου</string> + <string name="override_user_profile_settings">Περισσότερες ρυθμίσεις του προφίλ χρήστη</string> + <string name="override_user_profile_settings_summary">Παράκαμψη των ρυθμίσεων του προφίλ χρήστη στον διακομιστή</string> + <string name="any_language">Οποιαδήποτε γλώσσα</string> + <string name="cinema_mode">Λειτουργία σινεμά</string> + <string name="download_and_update"><![CDATA[Λήψη και ενημέρωση]]></string> + <plurals name="movies"> + <item quantity="one">Ταινία</item> + <item quantity="other">Ταινίες</item> + </plurals> + <plurals name="people"> + <item quantity="one">Άτομο</item> + <item quantity="other">Άτομα</item> + </plurals> + <string name="search_and_download"><![CDATA[Αναζήτηση και λήψη]]></string> + <plurals name="trailers"> + <item quantity="one">Τρέιλερ</item> + <item quantity="other">Τρέιλερ</item> + </plurals> + <string name="tv_dvr_schedule">Προγραμματισμός DVR</string> + <plurals name="tv_shows"> + <item quantity="one">Σειρά</item> + <item quantity="other">Σειρές</item> + </plurals> + <string name="ui_interface">Διεπαφή</string> + <plurals name="years_old"> + <item quantity="one">%1$d έτους</item> + <item quantity="other">%1$d ετών</item> + </plurals> + <string name="play_with_transcoding">Αναπαραγωγή με μετατροπή</string> + <string name="aired_episode_order">Σειρά τηλεοπτικής προβολής</string> + <string name="one_click_pause_summary_on">Παύση/Αναπαραγωγή με το κεντρικό κουμπί του σταυρού πλοήγησης</string> + <plurals name="shorts"> + <item quantity="one">Μικρού μήκους</item> + <item quantity="other">Μικρού μήκους</item> + </plurals> + <plurals name="hours"> + <item quantity="one">%d ώρα</item> + <item quantity="other">%d ώρες</item> + </plurals> + <plurals name="items"> + <item quantity="one">%d αντικείμενο</item> + <item quantity="other">%d αντικείμενα</item> + </plurals> + <plurals name="seconds"> + <item quantity="one">%d δευτερόλεπτο</item> + <item quantity="other">%d δευτερόλεπτα</item> + </plurals> + <string name="ac3_supported">Η συσκευή υποστηρίζει AC3/Dolby Digital</string> + <string name="advanced_ui">Προηγμένη διεπαφή</string> + <string name="combine_continue_next"><![CDATA[Συγχώνευση Συνέχεια προβολής και Επόμενα]]></string> + <string name="max_days_next_up">Ανώτατο όριο ημερών στα Επόμενα</string> + <string name="next_up_playback_end">Στο τέλος της αναπαραγωγής</string> + <string name="next_up_outro">Κατά τη διάρκεια των τίτλων τέλους</string> + <string name="remove_next_up">Αφαίρεση σειράς από τα Επόμενα</string> + <string name="view_removed_next_up">Εμφάνιση σειρών που αφαιρέθηκαν από τα Επόμενα</string> + <string name="next_up_never">Ποτέ</string> + <string name="continue_watching_click_behavior">Συμπεριφορά επιλογής του Συνέχεια προβολής/Επόμενο</string> + <string name="remove_continue_watching">Αφαίρεση από συνέχεια προβολής</string> + <string name="continue_watching_click_summary_on">Αναπαραγωγή κατά την επιλογή</string> + <string name="continue_watching_click_summary_off">Προβολή λεπτομέρειον κατά την επιλογή</string> + <string name="direct_play_ass">Χρήση libass για ASS υπότιτλους</string> + <string name="direct_play_pgs">Απευθείας αναπαραγωγή PGS υπότιτλων</string> + <string name="downmix_stereo">Πάντα υποβιβασμός σε στέρεο</string> + <string name="ffmpeg_extension_pref">Χρήση αποκωδικοποιητή FFmpeg</string> + <string name="max_homepage_items">Ανώτατο όριο αντικειμένων στις γραμμές της αρχικής</string> + <string name="nav_drawer_pins_summary">Διάλεξε τα προεπιλεγμένα αντικείμενα για εμφάνιση, θα γίνει απόκρυψη των υπόλοιπων</string> + <string name="remember_selected_tab">Απομνημόνευση επιλεγμένων καρτελών</string> + <string name="settings">Ρυθμίσεις</string> + <string name="discover">Ανακάλυψε</string> + <string name="request">Αίτημα</string> + <string name="pending">Εκκρεμί</string> + <string name="password">Κωδικός</string> + <string name="username">Όνομα χρήστη</string> + <string name="trending">Τάσεις</string> + <string name="red">Κόκκινο</string> + <string name="green">Πράσινο</string> + <string name="blue">Μπλε</string> + <string name="blur">Θόλωση</string> + <string name="height">Ύψος</string> + <string name="content_scale_fit">Προσαρμογή</string> + <string name="content_scale_crop">Περικοπή</string> + <string name="content_scale_fill">Γέμισμα</string> + <string name="volume_lowest">Χαμηλότερο</string> + <string name="volume_low">Χαμηλό</string> + <string name="volume_medium">Μεσαίο</string> + <string name="volume_high">Υψηλό</string> + <string name="volume_full">Τέρμα</string> + <string name="purple">Μωβ</string> + <string name="orange">Πορτοκαλί</string> + <string name="black">Μαύρο</string> + <string name="skip_ignore">Αγνόηση</string> + <string name="white">Άσπρο</string> + <string name="yellow">Κίτρινο</string> + <string name="cyan">Κυανό</string> + <string name="magenta">Πορφυρώ</string> + <string name="subtitle_edge_outline">Περίγραμμα</string> + <string name="subtitle_edge_shadow">Σκιά</string> + <string name="background_style_wrap">Αναδίπλωση</string> + <string name="background_style_boxed">Πλαίσιο</string> + <string name="duration">Διάρκεια</string> + <string name="screensaver">Προστασία οθόνης</string> + <string name="photos">Φωτογραφίες</string> + <string name="actor">Ηθοποιός</string> + <string name="composer">Συνθέτης</string> + <string name="writer">Συγγραφέας</string> + <string name="producer">Παραγωγός</string> + <string name="conductor">Μαέστρος</string> + <string name="lyricist">Στιχουργός</string> + <string name="arranger">Ενορχηστρωτής</string> + <string name="mixer">Υπεύθυνος μίξης</string> + <string name="creator">Δημιουργός</string> + <string name="artist">Καλλιτέχνης</string> + <string name="albums">Άλμπουμ</string> + <string name="artists">Καλλιτέχνες</string> + <string name="songs">Τραγούδια</string> + <string name="album">Άλμπουμ</string> + <string name="lyrics">Στίχοι</string> + <string name="continue_string">Συνεχεία</string> + <string name="transcoding">Μετατροπή</string> + <string name="reset">Επαναφορά</string> + <string name="played">Παίχτηκε</string> + <string name="filter">Φίλτρο</string> + <string name="year">Έτος</string> + <string name="decade">Δεκαετία</string> + <string name="remove">Αφαίρεση</string> + <string name="subtitle_margin">Περιθώριο</string> + <string name="incorrect">Λανθασμένο</string> + <string name="columns">Στήλες</string> + <string name="spacing">Διάστιχο</string> + <string name="automatic">Αυτόματα</string> + <string name="login">Σύνδεση</string> + <string name="general">Γενικά</string> + <string name="title">Τίτλος</string> + <string name="level">Επίπεδο</string> + <string name="resolution">Ανάλυση</string> + <string name="anamorphic">Αναμορφικό</string> + <string name="interlaced">Πεπλεγμένο</string> + <string name="framerate">Ρυθμός ανανέωσης</string> + <string name="live_tv_repeat">Επανάληψη</string> + <string name="retry">Επανάλαβε</string> + <string name="brightness">Φωτεινότητα</string> + <string name="contrast">Αντίθεση</string> + <string name="saturation">Κορεσμός</string> + <string name="hue">Απόχρωση</string> + <string name="subtitle_delay">Καθυστέρηση υποτίτλων</string> + <string name="discover_movies">Ανακάλυψε ταινίες</string> + <string name="discover_tv">Ανακάλυψε σειρές</string> </resources> diff --git a/app/src/main/res/values-en-rGB/strings.xml b/app/src/main/res/values-en-rGB/strings.xml new file mode 100644 index 00000000..4df26a67 --- /dev/null +++ b/app/src/main/res/values-en-rGB/strings.xml @@ -0,0 +1,60 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <string name="add_favorite">Favourite</string> + <string name="favorites">Favourites</string> + <string name="license_info">Licence information</string> + <string name="record_program">Record Programme</string> + <string name="remove_favorite">Unfavourite</string> + <string name="voice_error_no_match">No speech recognised</string> + <string name="one_click_pause_summary_on">Press D-Pad centre to pause/play</string> + <string name="italic_font">Italicise font</string> + <string name="favorite_items_title">Favourite %s</string> + <string name="nav_drawer_pins">Customise Navigation Drawer Items</string> + <string name="skip_commercials_behavior">Skip commercials behaviour</string> + <string name="skip_intro_behavior">Skip intro behaviour</string> + <string name="skip_outro_behavior">Skip outro behaviour</string> + <string name="skip_previews_behavior">Skip previews behaviour</string> + <string name="skip_recap_behavior">Skip recap behaviour</string> + <string name="font_color">Font colour</string> + <string name="edge_color">Edge colour</string> + <string name="background_color">Background colour</string> + <string name="press_enter_to_confirm">Press centre to confirm</string> + <string name="color_space">Colour space</string> + <string name="color_transfer">Colour transfer</string> + <string name="color_primaries">Colour primaries</string> + <string name="favorite_channels_at_beginning">Show favourite channels first</string> + <string name="color_code_programs">Colour-code programmes</string> + <string name="quick_connect_summary">Authorise another device to log in to your account</string> + <string name="quick_connect_success">Device authorised successfully</string> + <string name="contrast">Contrast</string> + <string name="customize_home">Customise home page</string> + <string name="light_gray">Light grey</string> + <string name="dark_gray">Dark grey</string> + <string name="backdrop_style_dynamic">Image with dynamic colour</string> + <string name="show_visualizer">Show visualiser</string> + <string name="visualizer_rationale">Visualising the currently playing audio requires permission to record audio.</string> + <string name="skip_behavior">Skip behaviour</string> + <string name="display_toggles_summary">Customise which to display</string> + <string name="continue_watching_click_behavior">Continue watching/Next up click behaviour</string> + <string name="about">About</string> + <string name="active_recordings">Active Recordings</string> + <string name="add_server">Add Server</string> + <string name="add_user">Add User</string> + <string name="audio">Audio</string> + <string name="birthplace">Birthplace</string> + <string name="bitrate">Bitrate</string> + <string name="born">Born</string> + <string name="cancel_recording">Cancel Recording</string> + <string name="cancel_series_recording">Cancel Series Recording</string> + <string name="duration_hours_seconds">%1$dh %2$ds</string> + <string name="duration_minutes_seconds">%1$dm %2$ds</string> + <string name="duration_hours">%1$dh</string> + <string name="cancel">Cancel</string> + <string name="chapters">Chapters</string> + <string name="choose_stream">Choose %1$s</string> + <string name="remove_from_playlist">Remove from playlist</string> + <string name="login_credentials_expired">Credentials expired, please login in again</string> + <string name="login_not_authorized">You are not authorised to access the server at this time</string> + <string name="clear_image_cache">Clear image cache</string> + <string name="collection">Collection</string> +</resources> diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index fa14435e..714ae611 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -36,7 +36,7 @@ <string name="no_results">Sin resultados</string> <string name="none">Ninguno</string> <string name="outro">Créditos finales</string> - <string name="people">Personas</string> + <string name="people_title">Personas</string> <string name="play">Reproducir</string> <string name="playback">Reproducción</string> <string name="playlist">Lista de reproducción</string> @@ -55,7 +55,7 @@ <string name="select_server">Seleccionar servidor</string> <string name="select_user">Seleccionar Usuario</string> <string name="sort_by_name">Nombre</string> - <string name="sort_by_random">Aleatorio</string> + <string name="sort_by_random">Selección aleatoria</string> <string name="studios">Estudios</string> <string name="submit">Enviar</string> <string name="subtitle">Subtítulo</string> @@ -63,18 +63,21 @@ <string name="suggestions">Sugerencias</string> <string name="switch_servers">Cambiar servidor</string> <string name="switch_user">Cambiar</string> - <string name="trailer">Tráiler</string> <plurals name="trailers"> <item quantity="one">Tráiler</item> - <item quantity="many">Tráilers</item> - <item quantity="other"/> + <item quantity="many">Tráileres</item> + <item quantity="other">Tráileres</item> </plurals> - <string name="tv_shows">Series</string> + <string name="tv_shows_title">Series</string> <string name="ui_interface">Interfaz</string> <string name="version">Versión</string> <string name="video">Vídeo</string> <string name="videos">Vídeos</string> - <string name="years_old">%1$d años</string> + <plurals name="years_old"> + <item quantity="one">%1$d año</item> + <item quantity="many">%1$d años</item> + <item quantity="other">%1$d años</item> + </plurals> <string name="play_with_transcoding">Reproducir con transcodificación</string> <string name="show_clock">Mostrar reloj</string> <string name="create_playlist">Crear nueva lista de reproducción</string> @@ -83,11 +86,11 @@ <string name="one_click_pause_summary_on">Pulsa el botón central de la cruceta para reproducir/pausar</string> <string name="success">Éxito</string> <string name="runtime_sort">Duración</string> - <string name="extras">Extras</string> + <string name="extras_title">Extras</string> <plurals name="other_extras"> <item quantity="one">Otro</item> <item quantity="many">Otros</item> - <item quantity="other"/> + <item quantity="other">Otros</item> </plurals> <string name="advanced_settings">Configuración avanzada</string> <string name="advanced_ui">Interfaz avanzada</string> @@ -102,7 +105,7 @@ <string name="remove_favorite">Quitar de favoritos</string> <string name="show_debug_info">Mostrar información de depuración</string> <string name="show">Mostrar</string> - <string name="shuffle">Aleatorio</string> + <string name="shuffle">Reproducción aleatoria</string> <string name="skip">Saltar</string> <string name="sort_by_date_episode_added">Ordenar por fecha de inclusión del episodio</string> <string name="about">Acerca de</string> @@ -134,7 +137,7 @@ <string name="mark_unwatched">Marcar como no visto</string> <string name="mark_watched">Marcar como visto</string> <string name="more_like_this">Más como esto</string> - <string name="movies">Películas</string> + <string name="movies_title">Películas</string> <string name="next_up">Siguiente</string> <string name="no_data">Sin datos</string> <string name="no_scheduled_recordings">Sin grabaciones programadas</string> @@ -164,19 +167,19 @@ <string name="aired_episode_order">Orden de emisión</string> <string name="italic_font">Cursiva</string> <plurals name="behind_the_scenes"> - <item quantity="one">Detrás de cámaras</item> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Detrás de las cámaras</item> + <item quantity="many">Detrás de las cámaras</item> + <item quantity="other">Detrás de las cámaras</item> </plurals> <plurals name="theme_songs"> - <item quantity="one">Canción principal</item> - <item quantity="many">Canciones principales</item> - <item quantity="other"/> + <item quantity="one">Tema musical</item> + <item quantity="many">Temas musicales</item> + <item quantity="other">Temas musicales</item> </plurals> <plurals name="theme_videos"> - <item quantity="one">Vídeos temáticos</item> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Vídeo temático</item> + <item quantity="many">Vídeos temáticos</item> + <item quantity="other">Vídeos temáticos</item> </plurals> <plurals name="clips"> <item quantity="one">Clip</item> @@ -186,52 +189,52 @@ <plurals name="deleted_scenes"> <item quantity="one">Escena eliminada</item> <item quantity="many">Escenas eliminadas</item> - <item quantity="other"/> + <item quantity="other">Escenas eliminadas</item> </plurals> <plurals name="interviews"> <item quantity="one">Entrevista</item> <item quantity="many">Entrevistas</item> - <item quantity="other"/> + <item quantity="other">Entrevistas</item> </plurals> <plurals name="scenes"> <item quantity="one">Escena</item> <item quantity="many">Escenas</item> - <item quantity="other"/> + <item quantity="other">Escenas</item> </plurals> <plurals name="samples"> <item quantity="one">Muestra</item> <item quantity="many">Muestras</item> - <item quantity="other"/> + <item quantity="other">Muestras</item> </plurals> <plurals name="featurettes"> - <item quantity="one">Mini-documentales</item> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Reportaje</item> + <item quantity="many">Reportajes</item> + <item quantity="other">Reportajes</item> </plurals> <plurals name="shorts"> - <item quantity="one">CortoCortos</item> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Corto</item> + <item quantity="many">Cortos</item> + <item quantity="other">Cortos</item> </plurals> <plurals name="downloads"> <item quantity="one">%s descarga</item> <item quantity="many">%s descargas</item> - <item quantity="other"/> + <item quantity="other">%s descargas</item> </plurals> <plurals name="hours"> <item quantity="one">%d hora</item> <item quantity="many">%d horas</item> - <item quantity="other"/> + <item quantity="other">%d horas</item> </plurals> <plurals name="items"> <item quantity="one">%d elemento</item> <item quantity="many">%d elementos</item> - <item quantity="other"/> + <item quantity="other">%d elementos</item> </plurals> <plurals name="seconds"> <item quantity="one">%d segundo</item> <item quantity="many">%d segundos</item> - <item quantity="other"/> + <item quantity="other">%d segundos</item> </plurals> <string name="ac3_supported">El dispositivo soporta AC3/Dolby Digital</string> <string name="auto_check_for_updates">Buscar actualizaciones automáticamente</string> @@ -294,7 +297,7 @@ <string name="skip_segment_outro">Saltar créditos finales</string> <string name="skip_segment_intro">Saltar cabecera</string> <string name="played">Reproducido</string> - <string name="filter">FIltrar</string> + <string name="filter">Filtrar</string> <string name="year">Año</string> <string name="decade">Década</string> <string name="remove">Eliminar</string> @@ -374,7 +377,7 @@ <string name="sort_channels_recently_watched">Ordenar los canales por vistos recientemente</string> <string name="color_code_programs">Programas codificados por colores</string> <string name="subtitle_delay">Retardo de subtítulos</string> - <string name="backdrop_display">Estilo del fondo</string> + <string name="backdrop_display">Estilo del fanart</string> <string name="only_forced_subtitles">Solo Subtítulos Forzados</string> <string name="voice_search">Búsqueda por voz</string> <string name="voice_starting">Iniciando</string> @@ -406,7 +409,7 @@ <string name="dolby_digital">DD</string> <string name="dolby_digital_plus">DD+</string> <string name="dts">DTS</string> - <string name="force_dovi_profile_7">Direct play Dolby Vision Profile 7</string> + <string name="force_dovi_profile_7">Reproducción directa de Dolby Vision Perfil 7</string> <string name="force_dovi_profile_7_summary">Ignora las comprobaciones de compatibilidad del dispositivo</string> <string name="discover">Descubrir</string> <string name="request">Solicitar</string> @@ -480,7 +483,7 @@ <string name="display_presets_description">Preajustes integrados para estilizar todas las filas</string> <string name="customize_home_summary">Elegir filas e imágenes en la página de inicio</string> <string name="recently_released_in">Lanzado recientemente en %1$s</string> - <string name="favorite_items">Favoritos%s</string> + <string name="favorite_items_title">Favoritos%s</string> <string name="content_scale_fit">Ajustar</string> <string name="content_scale_crop">Recortar</string> <string name="content_scale_fill">Rellenar</string> @@ -527,7 +530,7 @@ <string name="image_type_thumb">Miniatura</string> <string name="backdrop_style_dynamic">Imagen con color dinámico</string> <string name="backdrop_style_image">Imagen</string> - <string name="enter_url_api_key"><![CDATA[Enter URL & API Key]]></string> + <string name="enter_url_api_key"><![CDATA[Introduce la URL y la clave API]]></string> <string name="api_key">Clave API</string> <string name="seerr_login">Iniciar sesión en el servidor Seerr</string> <string name="seerr_jellyfin_user">Usuario de Jellyfin</string> @@ -573,7 +576,7 @@ <string name="show_album_cover">Mostrar portada del álbum</string> <string name="show_visualizer">Mostrar visualizador</string> <string name="show_backdrop">Mostrar imagen de fondo</string> - <string name="play_as_type">Reproducir como...</string> + <string name="play_as_type">Modo de reproducción</string> <string name="visualizer_rationale">Para visualizar el audio en reproducción, se requiere permiso para grabar audio.</string> <string name="continue_string">Continuar</string> <string name="albums">Álbumes</string> @@ -596,7 +599,7 @@ <string name="remove_from_queue">Eliminar de la cola</string> <string name="separate_types">Separar por tipos</string> <string name="prefer_logos">Preferir logotipos para los títulos</string> - <string name="updated_toast">Wholphin se ha actualizado a la versión %s</string> + <string name="updated_toast">Wolfskin se ha actualizado a la versión %s</string> <string name="view_more">Ver más</string> <string name="discover_tv">Descubrir series</string> <string name="discover_movies">Descubrir películas</string> @@ -618,4 +621,139 @@ <string name="cinema_mode">Modo cine</string> <string name="play_with">Reproducir con</string> <string name="transcoding">Transcodificar</string> + <string name="pin_digits_hint">[0-9]</string> + <string name="combined_search_results">Resultados de búsqueda combinados</string> + <string name="combined_search_results_on">Mostrar todos los resultados en una sola lista</string> + <string name="combined_search_results_off">Mostrar resultados por categoría</string> + <string name="results">Resultados</string> + <string name="continue_watching_click_behavior">Comportamiento al pulsar en “Seguir viendo” / “Siguiente”</string> + <string name="continue_watching_click_summary_on">Reproducir al pulsar</string> + <string name="continue_watching_click_summary_off">Ver detalles al pulsar</string> + <string name="require_login">Requerir inicio de sesión para el perfil</string> + <string name="display_toggles_title">Visualización de valoraciones</string> + <string name="display_toggles_summary">Personalizar qué mostrar</string> + <string name="profile_protection">Protección del perfil</string> + <string name="stars">Estrellas</string> + <string name="tomatoes">\"Tomates\"</string> + <string name="show_voice_search_button">Mostrar botón de búsqueda por voz</string> + <string name="visible_ui">Visible</string> + <string name="hidden_ui">Oculto</string> + <string name="user_interface_language">Idioma de la interfaz de usuario</string> + <string name="view_options_type_grid">Cuadrícula</string> + <string name="view_options_type_list">Lista</string> + <string name="view_options_type_list_compact">Lista compacta</string> + <string name="sort">Ordenar</string> + <string name="split_into_separate_rows">Separar en filas independientes</string> + <string name="other_extras_title">Otro</string> + <string name="behind_the_scenes_title">Detrás de las cámaras</string> + <string name="theme_songs_title">Temas musicales</string> + <string name="theme_videos_title">Vídeos temáticos</string> + <string name="clips_title">Fragmentos de vídeo</string> + <string name="deleted_scenes_title">Escenas eliminadas</string> + <string name="interviews_title">Entrevistas</string> + <string name="scenes_title">Escenas</string> + <string name="samples_title">Muestras</string> + <string name="featurettes_title">Reportajes</string> + <string name="shorts_title">Cortos</string> + <plurals name="movies"> + <item quantity="one">Película</item> + <item quantity="many">Películas</item> + <item quantity="other">Películas</item> + </plurals> + <plurals name="tv_shows"> + <item quantity="one">Serie</item> + <item quantity="many">Series</item> + <item quantity="other">Series</item> + </plurals> + <plurals name="people"> + <item quantity="one">Persona</item> + <item quantity="many">Personas</item> + <item quantity="other">Personas</item> + </plurals> + <string name="trailers_title">Tráileres</string> + <string name="duration_hours_minutes_seconds">%1$dh %2$dm %3$ds</string> + <string name="duration_hours_minutes">%1$dh %2$dm</string> + <string name="duration_minutes_seconds">%1$dm %2$ds</string> + <string name="duration_hours">%1$dh</string> + <string name="duration_minutes">%1$dm</string> + <string name="duration_seconds">%1$ds</string> + <string name="time_left">%1$s restantes</string> + <string name="prefer_ac3_for_surround_summary"><![CDATA[Transcodificar audio no estéreo a AC3/Dolby Digital]]></string> + <string name="prefer_ac3_for_surround">Usar AC3 para audio multicanal</string> + <string name="genre_movies">%1$s Películas</string> + <string name="genre_tv_shows">%1$s Series</string> + <string name="quality_profile">Configuración de calidad</string> + <string name="is_default">Predeterminado</string> + <string name="root_folder">Carpeta principal</string> + <string name="popularity">Popularidad</string> + <string name="production_status">Estado</string> + <string name="different_for_hdr">Usar estilo diferente para HDR</string> + <string name="experimental_settings">Ajustes experimentales</string> + <string name="confirm_enable_experimental_title">¿Habilitar ajustes experimentales?</string> + <string name="confirm_enable_experimental_body">¡Los ajustes experimentales pueden ser inestables!\n\n Pueden ser cambiados o eliminados en cualquier lanzamiento.</string> + <string name="video_tunneling">Tunelización de video</string> + <string name="blocklisted">Bloqueado</string> + <string name="unavailable">No disponible</string> + <string name="profile_specific_settings_from_server">Configuración de usuario desde el servidor</string> + <string name="subtitle_mode">Modo de subtítulos</string> + <string name="use_user_profile">Usar tu configuración de usuario</string> + <string name="subtitle_mode_default">Predeterminado</string> + <string name="subtitle_mode_smart">Inteligente</string> + <string name="subtitle_mode_only_forced">Solo forzados</string> + <string name="subtitle_mode_always">Siempre</string> + <string name="subtitle_mode_none">Ninguno</string> + <string name="preferred_subtitle_language">Idioma de subtítulos preferido</string> + <string name="preferred_audio_language">Idioma de audio preferido</string> + <string name="override_user_profile_settings">Más ajustes de usuario</string> + <string name="override_user_profile_settings_summary">Sobreescribir los ajustes de usuario del servidor</string> + <string name="any_language">Cualquier idioma</string> + <string name="d_pad_seek_mode_title">Modo de búsqueda con mando</string> + <string name="dpad_seek_mode_trickplay">Fotograma</string> + <string name="hash_matches">Coincidencias de valores hash</string> + <string name="subtitles_hearing_impaired">SDH</string> + <string name="programs">Programas de televisión</string> + <string name="recordings">Grabaciones</string> + <string name="folders_title">Carpetas</string> + <string name="user_views">Vistas de usuario</string> + <string name="books">Libros</string> + <string name="audio_books">Audiolibros</string> + <string name="dpad_seek_mode_minimal">Mínima</string> + <string name="dpad_seek_mode_minimal_summary">Barra de progreso pequeña en la parte inferior de la pantalla (predeterminado)</string> + <string name="dpad_seek_mode_trickplay_summary">Barra de progreso con vista previa de fotogramas</string> + <string name="dpad_seek_mode_none_summary">Sin barra de progreso</string> + <string name="brown">Marrón</string> + <string name="server_version_not_supported">Versión de servidor no soportada</string> + <string name="appears_on">Aparece en</string> + <string name="disable_audio_offload">Desactivar el procesamiento de audio por hardware</string> + <string name="disable_audio_offload_summary">Desactivar el procesamiento de audio por hardware puede solucionar los bloqueos de reproducción en algunos dispositivos</string> + <string name="dim_after_delay">Atenuar la pantalla tras un tiempo de inactividad</string> + <string name="dim_percentage">Porcentaje de atenuación</string> + <string name="dim_screen">Atenuar pantalla</string> + <string name="photo_albums">Álbumes de fotos</string> + <string name="no_favorites_found">¡No se encontraron favoritos!</string> + <string name="duration_hours_seconds">%1$dh %2$ds</string> + <string name="season_episode_number">T%1$d E%2$d</string> + <string name="season_episode_number_range">T%1$d E%2$d-E%3$d</string> + <string name="season_episode_number_padded">T%1$02dE%2$02d</string> + <string name="season_episode_number_padded_range">T%1$02dE%2$02d-E%3$02d</string> + <string name="episode_number_short">E%1$d</string> + <string name="season_number">Temporada %1$d</string> + <string name="remove_from_playlist">Eliminar de la lista de reproducción</string> + <string name="login_credentials_expired">Credenciales caducadas, vuelve a iniciar sesión</string> + <string name="login_not_authorized">No tienes autorización para acceder al servidor en este momento</string> + <string name="prefer_dovi_over_hdr10_plus">Preferir Dolby Vision sobre HDR10+</string> + <string name="prefer_dovi_over_hdr10_plus_summary">Elimina los metadatos HDR10+ de vídeos en Dolby Vision para televisores que, de lo contrario, los reproducirían como HDR10+</string> + <string name="max_resolution">Resolución máxima antes de transcodificar</string> + <string name="resolution_8k">8K</string> + <string name="resolution_4k">4K</string> + <string name="resolution_1080">1080</string> + <string name="resolution_720">720</string> + <string name="resolution_576">576</string> + <string name="resolution_480">480</string> + <string name="resolution_360">360</string> + <plurals name="episodes_count"> + <item quantity="one">%1$d Episodio</item> + <item quantity="many">%1$d Episodios</item> + <item quantity="other">%1$d Episodios</item> + </plurals> </resources> diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index 2297f6a7..2008bf2c 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -62,7 +62,7 @@ <string name="max_bitrate">Suurim bitikiirus</string> <string name="more_like_this">Veel sarnaseid</string> <string name="more">Veel</string> - <string name="movies">Filmid</string> + <string name="movies_title">Filmid</string> <string name="name">Nimi</string> <string name="next_up">Järgmisena esitamisel</string> <string name="no_data">Andmed puuduvad</string> @@ -72,7 +72,7 @@ <string name="none">Määramata</string> <string name="outro">Väljajuhatus</string> <string name="path">Rada</string> - <string name="people">Inimesed</string> + <string name="people_title">Inimesed</string> <string name="play_count">Esitusi</string> <string name="play_from_here">Esita siit kohast</string> <string name="play">Esita</string> @@ -123,7 +123,7 @@ <string name="success">Valmis</string> <string name="official_rating">Vanemlik hinnang</string> <string name="runtime_sort">Kestus</string> - <string name="extras">Lisad</string> + <string name="extras_title">Lisad</string> <plurals name="other_extras"> <item quantity="one">Muu</item> <item quantity="other">Muud</item> @@ -157,8 +157,8 @@ <item quantity="other">Stseenid</item> </plurals> <plurals name="samples"> - <item quantity="one">Näide</item> - <item quantity="other">Näited</item> + <item quantity="one">Näidislõik</item> + <item quantity="other">Näidislõigud</item> </plurals> <plurals name="featurettes"> <item quantity="one">Keskmise pikkusega film</item> @@ -220,19 +220,21 @@ <string name="record_program">Salvesta programm</string> <string name="record_series">Salvesta telesari</string> <string name="top_unwatched">Parima hinnanguga vaatamata sisu</string> - <string name="trailer">Treiler</string> <plurals name="trailers"> - <item quantity="one">Treilerid</item> - <item quantity="other"/> + <item quantity="one">Treiler</item> + <item quantity="other">Treilerid</item> </plurals> <string name="tv_dvr_schedule">DVR-i kava</string> <string name="tv_guide">Telekava</string> - <string name="tv_shows">Telesaated</string> + <string name="tv_shows_title">Telesaated</string> <string name="video_scale">Video skaleerimine</string> <string name="video">Video</string> <string name="videos">Videod</string> <string name="watch_live">Vaata otse-eetris</string> - <string name="years_old">%1$d aasta(t) vana</string> + <plurals name="years_old"> + <item quantity="one">%1$d aasta vana</item> + <item quantity="other">%1$d aastat vana</item> + </plurals> <string name="play_with_transcoding">Esita koos teisendamisega</string> <string name="show_clock">Näita kella</string> <string name="aired_episode_order">Eetris olnud osade järjestus</string> @@ -463,7 +465,7 @@ <string name="display_presets">Kuvamise eelseadistused</string> <string name="display_presets_description">Rakenduses kaasasolevad eelseadistused kõikide ridade kiireks muutmiseks</string> <string name="customize_home_summary">Vali kodulehe/avalehe read ja pildid</string> - <string name="favorite_items">Märgi %s lemmikuks</string> + <string name="favorite_items_title">Märgi %s lemmikuks</string> <plurals name="minutes"> <item quantity="one">%s minut</item> <item quantity="other">%s minutit</item> @@ -533,8 +535,8 @@ <string name="content_scale_fill">Täida</string> <string name="content_scale_fill_width">Täida laiuses</string> <string name="content_scale_fill_height">Täida kõrguses</string> - <string name="display_preset_default">Wholphini vaikeseadistus</string> - <string name="display_preset_compact">Wholphini kompaktne vaade</string> + <string name="display_preset_default">Wolfskini vaikeseadistus</string> + <string name="display_preset_compact">Wolfskini kompaktne vaade</string> <string name="display_preset_series_thumb">Sarja pisipildid</string> <string name="display_preset_episode_thumbnails">Osa pisipildid</string> <string name="volume_lowest">Vaikseim</string> @@ -578,7 +580,7 @@ <string name="continue_string">Jätka</string> <string name="separate_types">Eraldi tüübid</string> <string name="prefer_logos">Pealkirjade puhul eelista logode kuvamist</string> - <string name="updated_toast">Wholphin on uuendatud versioonini %s</string> + <string name="updated_toast">Wolfskin on uuendatud versioonini %s</string> <string name="remove_continue_watching">Eemalda „Jätka vaatamist“ valikust</string> <string name="remove_next_up">Eemalda sari valikust „Järgmisena esitamisel“</string> <string name="view_removed_next_up">Vaata sarja, mis sai eemaldatud valikust „Järgmisena esitamisel“</string> @@ -600,4 +602,161 @@ <string name="skip_behavior_summary">Sissejuhatuste, lõputiitrite jmt jaoks</string> <string name="play_with">Esita rakendusega</string> <string name="transcoding">Teisendan</string> + <string name="pin_digits_hint">[0-9]</string> + <string name="combined_search_results">Kombineeritud otsingutulemused</string> + <string name="combined_search_results_on">Näita kõiki tulemusi ühe loendina</string> + <string name="combined_search_results_off">Näita tulemusi kategooria alusel</string> + <string name="results">Tulemused</string> + <string name="continue_watching_click_behavior">Jätka vaatamist / järgmisena esitamisel - käitumine klõpsimisel</string> + <string name="continue_watching_click_summary_on">Klõpsimisel esita</string> + <string name="continue_watching_click_summary_off">Klõpsimisel näita üksikasju</string> + <string name="require_login">Eelda profiili jaoks sisselogimist</string> + <string name="display_toggles_title">Hinnangute kuvamine</string> + <string name="display_toggles_summary">Kohenda kuvatavat</string> + <string name="profile_protection">Profiili kaitse</string> + <string name="stars">Tärne</string> + <string name="tomatoes">Tomateid</string> + <string name="show_voice_search_button">Näita häälotsingu nuppu</string> + <string name="visible_ui">Nähtav</string> + <string name="hidden_ui">Peidetud</string> + <string name="user_interface_language">Kasutajaliidese keel</string> + <string name="view_options_type_grid">Ruudustik</string> + <string name="view_options_type_list">Loend</string> + <string name="view_options_type_list_compact">Kompaktne loend</string> + <string name="sort">Järjesta</string> + <string name="split_into_separate_rows">Eralda ridadena</string> + <string name="trailers_title">Treilerid</string> + <string name="other_extras_title">Muud</string> + <string name="behind_the_scenes_title">Kaadri tagaKaadri taga</string> + <string name="theme_songs_title">Tunnuslood</string> + <string name="theme_videos_title">Tunnusvideod</string> + <string name="clips_title">Klipid</string> + <string name="deleted_scenes_title">Väljajäetud lõigud</string> + <string name="interviews_title">Intervjuud</string> + <string name="scenes_title">Stseenid</string> + <string name="samples_title">Näidislõigud</string> + <string name="featurettes_title">Keskmise pikkusega filmid</string> + <string name="shorts_title">Lühifilmid</string> + <plurals name="movies"> + <item quantity="one">Film</item> + <item quantity="other">Filmid</item> + </plurals> + <plurals name="tv_shows"> + <item quantity="one">Telesaade</item> + <item quantity="other">Telesaated</item> + </plurals> + <plurals name="people"> + <item quantity="one">Isik</item> + <item quantity="other">Inimesed</item> + </plurals> + <string name="genre_movies">%1$s filmi</string> + <string name="genre_tv_shows">%1$s telesaadet</string> + <string name="quality_profile">Kvaliteedi profiil</string> + <string name="is_default">Vaikimisi</string> + <string name="root_folder">Juurkaust</string> + <string name="popularity">Populaarsus</string> + <string name="production_status">Tootmise olek</string> + <string name="different_for_hdr">Kasuta HDR-i puhul muud stiili</string> + <string name="experimental_settings">Katselised seadistused</string> + <string name="confirm_enable_experimental_title">Kas võtad katselised seadistused kasutusele?</string> + <string name="confirm_enable_experimental_body">Katselised seadistused ei pruugi olla päris töökindlad!\n\n Lisaks võib arendaja neid omal äranägemisel muuta või sootuks eemaldada.</string> + <string name="video_tunneling">Video tunneldus</string> + <string name="duration_hours_minutes_seconds">%1$d t %2$d min %3$d sek</string> + <string name="duration_hours_minutes">%1$d t %2$d min</string> + <string name="duration_minutes_seconds">%1$d min %2$d sek</string> + <string name="duration_hours">%1$d t</string> + <string name="duration_minutes">%1$d min</string> + <string name="duration_seconds">%1$d sek</string> + <string name="time_left">jäänud %1$s</string> + <string name="blocklisted">Keeluloendis</string> + <string name="unavailable">Pole saadaval</string> + <string name="prefer_ac3_for_surround_summary"><![CDATA[Kodeeri mittestereoheli AC3/Dolby Digital vormingusse]]></string> + <string name="prefer_ac3_for_surround">Kasuta ruumilise heli jaoks AC3 koodekit</string> + <string name="profile_specific_settings_from_server">Kasutajaprofiili seadistus serverist</string> + <string name="subtitle_mode">Subtiitrite režiim</string> + <string name="use_user_profile">Kasuta oma profiili seadistust</string> + <string name="subtitle_mode_default">Vaikimisi</string> + <string name="subtitle_mode_smart">Nutikas</string> + <string name="subtitle_mode_only_forced">Ainult sundkorras</string> + <string name="subtitle_mode_always">Alati</string> + <string name="subtitle_mode_none">Määramata</string> + <string name="preferred_subtitle_language">Subtiitrite eelistatud keel</string> + <string name="preferred_audio_language">Heliriba eelistatud keel</string> + <string name="override_user_profile_settings">Veel kasutajaprofiili seadistusi</string> + <string name="override_user_profile_settings_summary">Sürjuta serveris leiduvad kasutajaprofiili seadistused</string> + <string name="any_language">Iga keel</string> + <string name="brown">Pruun</string> + <string name="programs">Teleprogrammid</string> + <string name="recordings">Salvestused</string> + <string name="folders_title">Kaustad</string> + <string name="user_views">Kasutajate vaatamisi</string> + <string name="books">Raamatud</string> + <string name="audio_books">Heliraamatud</string> + <string name="server_version_not_supported">Serveri versioon pole toetatud</string> + <string name="dpad_seek_mode_minimal">Minimaalne</string> + <string name="d_pad_seek_mode_title">D-Pad\'i kerimisriba tööpõhimõte</string> + <string name="dpad_seek_mode_minimal_summary">Väike kerimisriba ekraani allosas (vaikimisi variant)</string> + <string name="dpad_seek_mode_trickplay">Trikitamine</string> + <string name="dpad_seek_mode_trickplay_summary">Kerimisriba trikitamise eelvaatepiltidega</string> + <string name="dpad_seek_mode_none_summary">Kerimisriba pole</string> + <string name="hash_matches">Räsivasted</string> + <string name="subtitles_hearing_impaired">SDH</string> + <string name="appears_on">On osalenud siin:</string> + <string name="disable_audio_offload">Lülita graafikaprotsessori kasutamine heli töötluseks välja</string> + <string name="disable_audio_offload_summary">Kui lülita graafikaprotsessori kasutamise heli töötluseks välja, siis see võib mõnede seadmete puhul parandada taasesituse hangumise vead</string> + <string name="dim_after_delay">Hämarda ekraani peale viivitust</string> + <string name="dim_percentage">Hämardamise protsent</string> + <string name="dim_screen">Hämarda ekraani</string> + <string name="photo_albums">Fotoalbumid</string> + <string name="no_favorites_found">Ühtegi lemmikut ei leidu!</string> + <string name="duration_hours_seconds">%1$d t %2$d sek</string> + <string name="season_episode_number">S%1$d E%2$d</string> + <string name="season_episode_number_range">S%1$d E%2$d-E%3$d</string> + <string name="season_episode_number_padded">S%1$02dE%2$02d</string> + <string name="season_episode_number_padded_range">S%1$02dE%2$02d-E%3$02d</string> + <string name="episode_number_short">E%1$d</string> + <string name="season_number">%1$d. hooaeg</string> + <string name="remove_from_playlist">Eemalda esitusloendist</string> + <string name="login_credentials_expired">Autentimiseandmed on aegunud, palun logi uuesti sisse</string> + <string name="login_not_authorized">Sul pole hetkel õigusi ligipääsuks sellele serverile</string> + <string name="prefer_dovi_over_hdr10_plus">HDR10+ asemel eelista Dolby Visionit</string> + <string name="prefer_dovi_over_hdr10_plus_summary">Kui teler muul juhul kasutaks esituseks HDR10+ vormingut, siis eemaldab HDR10+ metateabe Dolby Visioni videotest</string> + <string name="max_resolution">Maksimaalne resolutsioon enne kodeeringu muutmist</string> + <string name="resolution_8k">8K</string> + <string name="resolution_4k">4K</string> + <string name="resolution_1080">1080</string> + <string name="resolution_720">720</string> + <string name="resolution_576">576</string> + <string name="resolution_480">480</string> + <string name="resolution_360">360</string> + <plurals name="episodes_count"> + <item quantity="one">%1$d osa</item> + <item quantity="other">%1$d osa</item> + </plurals> + <string name="top_rated">Parima hinnanguga</string> + <string name="hdr_formats">HDR-vormingud</string> + <string name="hdr10_override">HDR10</string> + <string name="hdr10_plus_override">HDR10+</string> + <string name="dovi_profile_5">Dolby Vision Profile 5</string> + <string name="dovi_profile_7">Dolby Vision Profile 7</string> + <string name="dovi_profile_8">Dolby Vision Profile 8</string> + <string name="server_version_required">Vajalik serveriversioon: %1$s</string> + <string name="server_version_not_supported_future">Serveriversioon ei saa lähitulevikus olema toetatud</string> + <string name="user">Kasutaja</string> + <string name="series">Sai</string> + <string name="load_from_server_plugin">Vaikimisi laadi serverist</string> + <string name="save_to_local">Salvesta vaid siia seadmesse</string> + <string name="home_settings_source_unset">Vaikimisi</string> + <string name="home_settings_source_local">Seadmekohane</string> + <string name="home_settings_source_server_profile">Kasutajaprofiil serveris</string> + <string name="home_settings_source_plugin">Vaikimisi väärtus serveris</string> + <string name="home_settings_source_web">Kasutaja veebiklient</string> + <string name="chosen_by">Valija: %1$s</string> + <string name="force_enable">Jõusta sundkorras</string> + <string name="force_disable">Lõpeta sundkorras jõustamine</string> + <string name="managed_by_server">Hallatud serveri poolt</string> + <string name="error">Viga</string> + <string name="convert_dovi_profile_7">Teisenda Dolby Vision Profile 7 → 8.1</string> + <string name="convert_dovi_profile_7_summary">Muudab kahekihilise „Profile 7“ enne dekooderit ühekihiliseks profiiliks 8.1 ja esitab seda seadmetel otse, mille dekooder toetab profiili 8, kuid mitte profiili 7</string> + <string name="artist_source">Esitaja allikas</string> </resources> diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 56d182c9..85603d28 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -48,7 +48,7 @@ <string name="mark_unwatched">Marquer comme non vu</string> <string name="mark_watched">Marquer comme vu</string> <string name="max_bitrate">Débit maximal</string> - <string name="movies">Films</string> + <string name="movies_title">Films</string> <string name="name">Nom</string> <string name="next_up">À suivre</string> <string name="no_data">Aucune donnée</string> @@ -58,7 +58,7 @@ <string name="none">Aucun</string> <string name="outro">Générique de fin</string> <string name="path">Chemin</string> - <string name="people">Personnes</string> + <string name="people_title">Personnes</string> <string name="play_from_here">Lire à partir d’ici</string> <string name="play">Lire</string> <string name="playback_speed">Vitesse de lecture</string> @@ -98,15 +98,14 @@ <string name="suggestions">Suggestions</string> <string name="switch_servers">Changer de serveur</string> <string name="switch_user">Changer</string> - <string name="trailer">Bande-annonce</string> <plurals name="trailers"> - <item quantity="one">Bandes-annonces</item> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Bandes-annonce</item> + <item quantity="many">Bandes-annonces</item> + <item quantity="other">Bandes-annonces</item> </plurals> <string name="tv_season">Saison</string> <string name="tv_seasons">Saisons</string> - <string name="tv_shows">Séries TV</string> + <string name="tv_shows_title">Séries TV</string> <string name="unknown">Inconnu</string> <string name="updates">Mises à jour</string> <string name="version">Version</string> @@ -126,34 +125,34 @@ <string name="success">Succès</string> <string name="runtime_sort">Durée</string> <plurals name="other_extras"> - <item quantity="one">Autres</item> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Autre</item> + <item quantity="many">Autres</item> + <item quantity="other">Autres</item> </plurals> <plurals name="clips"> - <item quantity="one">Extraits</item> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Extrait</item> + <item quantity="many">Extraits</item> + <item quantity="other">Extraits</item> </plurals> <plurals name="deleted_scenes"> <item quantity="one">Scènes supprimées</item> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="many">Scène supprimé</item> + <item quantity="other">Scènes supprimées</item> </plurals> <plurals name="interviews"> <item quantity="one">Interviews</item> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="many">Interview</item> + <item quantity="other">Interviews</item> </plurals> <plurals name="scenes"> - <item quantity="one">Scènes</item> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Scène</item> + <item quantity="many">Scènes</item> + <item quantity="other">Scènes</item> </plurals> <plurals name="samples"> - <item quantity="one">Échantillons</item> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Échantillon</item> + <item quantity="many">Échantillons</item> + <item quantity="other">Échantillons</item> </plurals> <string name="ac3_supported">Appareil compatible AC3/Dolby Digital</string> <string name="advanced_settings">Paramètres avancés</string> @@ -179,14 +178,18 @@ <string name="tv_guide">Guide</string> <string name="ui_interface">Interface</string> <string name="video_scale">Échelle de la vidéo</string> - <string name="years_old">%1$d ans</string> + <plurals name="years_old"> + <item quantity="one">%1$d an</item> + <item quantity="many">%1$d ans</item> + <item quantity="other">%1$d ans</item> + </plurals> <string name="italic_font">Mettre la police en italique</string> <string name="official_rating">Classification parentale</string> - <string name="extras">Bonus</string> + <string name="extras_title">Bonus</string> <plurals name="behind_the_scenes"> <item quantity="one">Dans les coulisses</item> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="many">Dans les coulisses</item> + <item quantity="other">Dans les coulisses</item> </plurals> <string name="auto_play_next_delay">Délai avant de lire \"À suivre\"</string> <string name="auto_play_next">Lecture automatique de \"À suivre\"</string> @@ -248,9 +251,9 @@ <string name="subtitle_download_too_long">Le téléchargement prend du temps, vous devrez peut-être redémarrer la lecture</string> <string name="top_unwatched">Les mieux notés non vus</string> <plurals name="shorts"> - <item quantity="one">Courts-métrages</item> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Court-métrages</item> + <item quantity="many">Courts-métrages</item> + <item quantity="other">Courts-métrages</item> </plurals> <plurals name="hours"> <item quantity="one">%d heure</item> @@ -281,13 +284,13 @@ <string name="skip_recap_behavior">Comportement de saut du récapitulatif</string> <plurals name="theme_songs"> <item quantity="one">Chanson à thème</item> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="many">Chansons à thème</item> + <item quantity="other">Chanson à thème</item> </plurals> <plurals name="theme_videos"> <item quantity="one">Vidéos à thème</item> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="many">Vidéos à thème</item> + <item quantity="other">Vidéos à thème</item> </plurals> <string name="combine_continue_next"><![CDATA[Combiner Continuer à regarder & À suivre]]></string> <string name="download_and_update"><![CDATA[Télécharger & Mettre à jour]]></string> @@ -297,6 +300,8 @@ <string name="one_click_pause_summary_on">Appuyez au centre de la croix directionnelle pour mettre en pause/reprendre</string> <plurals name="featurettes"> <item quantity="one">Bonus</item> + <item quantity="many">Bonus</item> + <item quantity="other">Bonus</item> </plurals> <string name="dolby_vision">Dolby Vision</string> <string name="dolby_atmos">Dolby Atmos</string> @@ -478,7 +483,7 @@ <string name="display_presets">Afficher les préréglages</string> <string name="display_presets_description">Préréglages intégrés pour styliser rapidement toutes les lignes</string> <string name="customize_home_summary">Choisissez des lignes et des images sur la page d\'accueil</string> - <string name="favorite_items">Favori %s</string> + <string name="favorite_items_title">Favori %s</string> <plurals name="minutes"> <item quantity="one">%s minute</item> <item quantity="many">%s minutes</item> @@ -502,8 +507,8 @@ <string name="content_scale_fill">Remplir</string> <string name="content_scale_fill_width">Largeur de remplissage</string> <string name="content_scale_fill_height">Hauteur de remplissage</string> - <string name="display_preset_default">Wholphin Par défaut</string> - <string name="display_preset_compact">Wholphin Compact</string> + <string name="display_preset_default">Wolfskin Par défaut</string> + <string name="display_preset_compact">Wolfskin Compact</string> <string name="display_preset_series_thumb">Images miniatures de la série</string> <string name="display_preset_episode_thumbnails">Images miniatures de l\'épisode</string> <string name="volume_lowest">Le plus bas</string> @@ -594,7 +599,7 @@ <string name="continue_string">Suivant</string> <string name="separate_types">Séparer par types</string> <string name="prefer_logos">Préférer les logos pour les titres</string> - <string name="updated_toast">Wholphin a été mis à jour vers la version %s</string> + <string name="updated_toast">Wolfskin a été mis à jour vers la version %s</string> <string name="view_more">Voir plus</string> <string name="discover_tv">Parcourir les séries</string> <string name="discover_movies">Parcourir les films</string> @@ -610,4 +615,171 @@ <string name="ass_subtitle_playback">Lecture des sous-titres SSA/ASS</string> <string name="external_player">Lecteur externe</string> <string name="system_default">Paramètres système par défaut</string> + <string name="pin_digits_hint">[0-9]</string> + <string name="combined_search_results">Résultats de recherche combinés</string> + <string name="combined_search_results_on">Afficher tous les résultats dans une seule liste</string> + <string name="combined_search_results_off">Afficher les résultats par catégorie</string> + <string name="results">Résultats</string> + <string name="cinema_mode">Mode cinéma</string> + <string name="show_more">Afficher %1$s de plus</string> + <string name="skip_behavior">Comportement de saut</string> + <string name="skip_behavior_summary">Pour les génériques d\'ouverture, les génériques de fin, etc</string> + <string name="play_with">Jouer avec</string> + <string name="transcoding">Transcodage</string> + <string name="require_login">Connexion requise pour le profil</string> + <string name="show_voice_search_button">Afficher le bouton de recherche vocale</string> + <string name="visible_ui">Visible</string> + <string name="hidden_ui">Caché</string> + <string name="view_options_type_grid">Grille</string> + <string name="view_options_type_list">Liste</string> + <string name="view_options_type_list_compact">Liste compacte</string> + <string name="user_interface_language">Langue de l\'interface utilisateur</string> + <string name="display_toggles_title">Affichage des notes</string> + <string name="display_toggles_summary">Personnaliser les éléments à afficher</string> + <string name="profile_protection">Protection du profil</string> + <string name="continue_watching_click_behavior">Comportement de clic sur « Continuer à regarder »/« Suivant »</string> + <string name="continue_watching_click_summary_on">Jouez au clic</string> + <string name="continue_watching_click_summary_off">Afficher les détails en cliquant</string> + <string name="stars">Étoiles</string> + <string name="tomatoes">Tomates</string> + <string name="sort">Trier</string> + <string name="split_into_separate_rows">Diviser en lignes distinctes</string> + <string name="trailers_title">Bandes-annonces</string> + <string name="other_extras_title">Autre</string> + <string name="behind_the_scenes_title">Coulisses</string> + <string name="theme_songs_title">Musiques de générique</string> + <string name="theme_videos_title">Vidéos thématiques</string> + <string name="clips_title">Clips</string> + <string name="deleted_scenes_title">Scènes coupées</string> + <string name="interviews_title">Interviews</string> + <string name="scenes_title">Scènes</string> + <string name="samples_title">Extraits</string> + <string name="featurettes_title">Featurettes</string> + <string name="shorts_title">Courts-métrages</string> + <plurals name="movies"> + <item quantity="one">Film</item> + <item quantity="many">Films</item> + <item quantity="other">Film</item> + </plurals> + <plurals name="tv_shows"> + <item quantity="one">Série télévisée</item> + <item quantity="many">Séries télévisées</item> + <item quantity="other">Série télévisée</item> + </plurals> + <plurals name="people"> + <item quantity="one">Acteur</item> + <item quantity="many">Acteurs</item> + <item quantity="other">Acteur</item> + </plurals> + <string name="genre_movies">%1$s Films</string> + <string name="genre_tv_shows">%1$s Séries télévisées</string> + <string name="quality_profile">Profil de qualité</string> + <string name="is_default">Par défaut</string> + <string name="root_folder">Dossier racine</string> + <string name="popularity">Popularité</string> + <string name="production_status">État de la production</string> + <string name="different_for_hdr">Utiliser un style différent pour le HDR</string> + <string name="experimental_settings">Conditions expérimentales</string> + <string name="confirm_enable_experimental_title">Activer les paramètres expérimentaux ?</string> + <string name="confirm_enable_experimental_body">Les paramètres expérimentaux peuvent être instables !\n\nIls sont susceptibles d\'être supprimés ou modifiés dans n\'importe quelle version.</string> + <string name="video_tunneling">Tunneling vidéo</string> + <string name="duration_hours_minutes_seconds">%1$dh %2$dm%3$ds</string> + <string name="duration_hours_minutes">%1$dh %2$dm</string> + <string name="duration_minutes_seconds">%1$dm %2$ds</string> + <string name="duration_hours">%1$dh</string> + <string name="duration_minutes">%1$dm</string> + <string name="duration_seconds">%1$ds</string> + <string name="time_left">%1$s restant</string> + <string name="prefer_ac3_for_surround_summary"><![CDATA[Transcoder l\'audio non stéréo vers AC3/Dolby Digital]]></string> + <string name="prefer_ac3_for_surround">Utilisez AC3 pour l\'audio surround</string> + <string name="blocklisted">sur liste noire</string> + <string name="unavailable">Indisponible</string> + <string name="subtitle_mode_only_forced">forcés uniquement</string> + <string name="subtitle_mode_none">Aucun</string> + <string name="preferred_subtitle_language">Langue de sous-titre préférée</string> + <string name="preferred_audio_language">Langue audio préférée</string> + <string name="profile_specific_settings_from_server">Paramètres du profil utilisateur à partir du serveur</string> + <string name="subtitle_mode">Mode sous-titres</string> + <string name="use_user_profile">Utiliser vos paramètres</string> + <string name="subtitle_mode_default">Par défaut</string> + <string name="subtitle_mode_smart">Intelligent</string> + <string name="subtitle_mode_always">Toujours</string> + <string name="override_user_profile_settings">Autres paramètres du profil utilisateur</string> + <string name="override_user_profile_settings_summary">Remplacer les paramètres de votre profil utilisateur à partir du serveur</string> + <string name="any_language">N\'importe quelle langue</string> + <string name="brown">Marron</string> + <string name="d_pad_seek_mode_title">Mode « barre de défilement » du D-Pad</string> + <string name="dpad_seek_mode_minimal">Minimum</string> + <string name="dpad_seek_mode_trickplay">fonctionnalités de lecture avancées</string> + <string name="dpad_seek_mode_minimal_summary">Petite barre de recherche située en bas de l\'écran (par défaut)</string> + <string name="dpad_seek_mode_trickplay_summary">Barre de recherche avec images d\'aperçu des vidéos « trickplay »</string> + <string name="dpad_seek_mode_none_summary">Pas de barre de progression</string> + <string name="hash_matches">Correspondances de hachage</string> + <string name="subtitles_hearing_impaired">SDH</string> + <string name="programs">Émissions de télévision</string> + <string name="recordings">Enregistrements</string> + <string name="folders_title">Dossiers</string> + <string name="user_views">Avis des utilisateurs</string> + <string name="books">Livres</string> + <string name="audio_books">Livre audio</string> + <string name="server_version_not_supported">Version du serveur non prise en charge</string> + <string name="appears_on">Apparaît dans</string> + <string name="duration_hours_seconds">%1$dh %2$ds</string> + <string name="season_episode_number">S%1$d E%2$d</string> + <string name="season_episode_number_range">S%1$d E%2$d-E%3$d</string> + <string name="season_episode_number_padded">S%1$02dE%2$02d</string> + <string name="season_episode_number_padded_range">S%1$02dE%2$02d-E%3$02d</string> + <string name="episode_number_short">E%1$d</string> + <string name="season_number">Saison %1$d</string> + <string name="disable_audio_offload">Désactiver le délestage matériel audio</string> + <string name="disable_audio_offload_summary">La désactivation du déchargement du décodage audio peut résoudre les problèmes de blocage de la lecture sur certains appareils</string> + <string name="prefer_dovi_over_hdr10_plus">Préférez Dolby Vision à HDR10+</string> + <string name="prefer_dovi_over_hdr10_plus_summary">Supprime les métadonnées HDR10+ des vidéos Dolby Vision, pour les téléviseurs qui, sans cela, les liraient en HDR10+</string> + <string name="dim_after_delay">Écran en mode veille après un certain délai</string> + <string name="dim_percentage">Pourcentage de réduction de luminosité</string> + <string name="dim_screen">Écran sombre</string> + <string name="photo_albums">Albums photos</string> + <string name="no_favorites_found">Aucun favori trouvé !</string> + <string name="remove_from_playlist">Supprimer de la liste de lecture</string> + <string name="login_credentials_expired">Vos identifiants ont expiré. Veuillez vous reconnecter</string> + <string name="login_not_authorized">Vous n\'êtes pas autorisé(e) à accéder au serveur pour le moment</string> + <string name="max_resolution">Résolution maximale avant le transcodage</string> + <string name="resolution_8k">8K</string> + <string name="resolution_4k">4K</string> + <string name="resolution_1080">1080</string> + <string name="resolution_720">720</string> + <string name="resolution_576">576</string> + <string name="resolution_480">480</string> + <string name="resolution_360">360</string> + <plurals name="episodes_count"> + <item quantity="one">%1$d Épisode</item> + <item quantity="many">%1$d Épisodes</item> + <item quantity="other">%1$d Épisode</item> + </plurals> + <string name="top_rated">Les mieux notés</string> + <string name="hdr_formats">Formats HDR</string> + <string name="hdr10_override">HDR10</string> + <string name="hdr10_plus_override">HDR10+</string> + <string name="dovi_profile_5">Dolby Vision Profile 5</string> + <string name="dovi_profile_7">Dolby Vision Profile 7</string> + <string name="dovi_profile_8">Dolby Vision Profile 8</string> + <string name="force_enable">Activation forcée</string> + <string name="force_disable">Désactivation forcée</string> + <string name="server_version_not_supported_future">La version du serveur ne sera bientôt plus prise en charge</string> + <string name="server_version_required">Version requise du serveur : %1$s</string> + <string name="chosen_by">Sélectionné par %1$s</string> + <string name="user">Utilisateur</string> + <string name="series">Séries</string> + <string name="load_from_server_plugin">Charger les paramètres par défaut depuis le serveur</string> + <string name="save_to_local">Enregistrer uniquement sur cet appareil</string> + <string name="home_settings_source_unset">Défaut</string> + <string name="home_settings_source_local">Spécifique à l\'appareil</string> + <string name="home_settings_source_server_profile">Profil utilisateur du serveur</string> + <string name="home_settings_source_plugin">Paramètres par défaut du serveur</string> + <string name="home_settings_source_web">Client Web utilisateur</string> + <string name="convert_dovi_profile_7">Convertir le profil Dolby Vision 7 en 8.1</string> + <string name="convert_dovi_profile_7_summary">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</string> + <string name="artist_source">Source de l\'artiste</string> + <string name="managed_by_server">Géré par le serveur</string> + <string name="error">Erreur</string> </resources> diff --git a/app/src/main/res/values-hr/strings.xml b/app/src/main/res/values-hr/strings.xml new file mode 100644 index 00000000..1eb5b58b --- /dev/null +++ b/app/src/main/res/values-hr/strings.xml @@ -0,0 +1,744 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <string name="about">O aplikaciji</string> + <string name="active_recordings">Aktivna snimanja</string> + <string name="add_favorite">Favorit</string> + <string name="add_server">Dodaj poslužitelj</string> + <string name="add_user">Dodaj korisnika</string> + <string name="audio">Zvuk</string> + <string name="birthplace">Mjesto rođenja</string> + <string name="born">Rođenje</string> + <string name="cancel_recording">Otkaži snimanje</string> + <string name="cancel_series_recording">Otkaži snimanje serije</string> + <string name="cancel">Odustani</string> + <string name="chapters">Poglavlja</string> + <string name="choose_stream">Odaberi %1$s</string> + <string name="clear_image_cache">Očisti predmemoriju slika</string> + <string name="collection">Kolekcija</string> + <string name="collections">Kolekcije</string> + <string name="commercial">Reklama</string> + <string name="community_rating">Ocjena zajednice</string> + <string name="compose_error_message_title">Došlo je do greške! Pritisnite gumb za slanje zapisa na poslužitelj.</string> + <string name="confirm">Potvrdi</string> + <string name="continue_watching">Nastavi gledanje</string> + <string name="critic_rating">Ocjena kritike</string> + <string name="decimal_seconds">%.2f sekundi</string> + <string name="duration_hours_minutes_seconds">%1$dh %2$dm %3$ds</string> + <string name="duration_hours_minutes">%1$dh %2$dm</string> + <string name="duration_minutes_seconds">%1$dm %2$ds</string> + <string name="duration_hours">%1$dh</string> + <string name="duration_minutes">%1$dm</string> + <string name="duration_seconds">%1$ds</string> + <string name="default_track">Zadano</string> + <string name="delete">Izbriši</string> + <string name="died">Smrt</string> + <string name="directed_by">Režija %1$s</string> + <string name="director">Redatelj</string> + <string name="disabled">Onemogućeno</string> + <string name="discovered_servers">Pronađeni poslužitelji</string> + <string name="download_and_update"><![CDATA[Preuzmi i ažuriraj]]></string> + <string name="downloading">Preuzimanje…</string> + <string name="enabled">Omogućeno</string> + <string name="ends_at">Završava u %1$s</string> + <string name="enter_server_url">Unesite IP ili URL poslužitelja</string> + <string name="enter_server_address">Unesite adresu poslužitelja</string> + <string name="episodes">Epizode</string> + <string name="error_loading_collection">Greška pri učitavanju kolekcije %1$s</string> + <string name="external_track">Vanjski</string> + <string name="favorites">Favoriti</string> + <string name="file_size">Veličina</string> + <string name="forced_track">Prisilni</string> + <string name="genres">Žanrovi</string> + <string name="go_to_series">Idi na seriju</string> + <string name="go_to">Idi na</string> + <string name="hide_controller_timeout">Sakrij kontrole reprodukcije</string> + <string name="hide_debug_info">Sakrij informacije za otklanjanje grešaka</string> + <string name="hide">Sakrij</string> + <string name="home">Početna</string> + <string name="immediate">Odmah</string> + <string name="intro">Uvod</string> + <string name="jump_letters">#ABCDEFGHIJKLMNOPQRSTUVWXYZ</string> + <string name="library">Biblioteka</string> + <string name="license_info">Informacije o licenci</string> + <string name="live_tv">Live TV</string> + <string name="loading">Učitavanje…</string> + <string name="mark_entire_series_as_played">Označiti cijelu seriju kao pogledanu?</string> + <string name="mark_entire_series_as_unplayed">Označiti cijelu seriju kao nepogledanu?</string> + <string name="mark_unwatched">Označi kao nepogledano</string> + <string name="mark_watched">Označi kao pogledano</string> + <string name="max_bitrate">Maks. bitrate</string> + <string name="more_like_this">Slično ovome</string> + <string name="more">Više</string> + <string name="movies_title">Filmovi</string> + <string name="name">Ime</string> + <string name="next_up">Slijedi</string> + <string name="no_data">Nema podataka</string> + <string name="no_results">Nema rezultata</string> + <string name="no_servers_found">Nije pronađen nijedan poslužitelj</string> + <string name="no_scheduled_recordings">Nema zakazanih snimanja</string> + <string name="no_update_available">Nema dostupnih ažuriranja</string> + <string name="bitrate">Bitrate</string> + <string name="none">Nijedan</string> + <string name="only_forced_subtitles">Samo prisilni titlovi</string> + <string name="outro">Odjava</string> + <string name="path">Putanja</string> + <string name="people_title">Osobe</string> + <string name="play_count">Broj reprodukcija</string> + <string name="play_from_here">Reproduciraj odavde</string> + <string name="play">Reproduciraj</string> + <string name="playback_debug_info">Prikaži informacije za otklanjanje grešaka reprodukcije</string> + <string name="playback_speed">Brzina reprodukcije</string> + <string name="playback">Reprodukcija</string> + <string name="playlist">Popis za reprodukciju</string> + <string name="playlists">Popisi za reprodukciju</string> + <string name="preview">Pretpregled</string> + <string name="profile_specific_settings">Postavke korisničkog profila</string> + <string name="profile_specific_settings_from_server">Postavke korisničkog profila s poslužitelja</string> + <string name="queue">Red čekanja</string> + <string name="recap">Sažetak</string> + <string name="recently_added_in">Nedavno dodano u %1$s</string> + <string name="recently_added">Nedavno dodano</string> + <string name="recently_recorded">Nedavno snimljeno</string> + <string name="recently_released">Nedavno objavljeno</string> + <string name="recommended">Preporučeno</string> + <string name="record_program">Snimi program</string> + <string name="record_series">Snimi seriju</string> + <string name="remove_favorite">Ukloni iz favorita</string> + <string name="restart">Ponovno pokreni</string> + <string name="resume">Nastavi</string> + <string name="save">Spremi</string> + <string name="search_and_download"><![CDATA[Pretraži i preuzmi]]></string> + <string name="search">Pretraži</string> + <string name="searching">Pretraživanje…</string> + <string name="voice_search">Glasovno pretraživanje</string> + <string name="voice_starting">Pokretanje</string> + <string name="voice_search_prompt">Govorite za pretraživanje</string> + <string name="processing">Obrada</string> + <string name="press_back_to_cancel">Pritisnite Natrag za odustajanje</string> + <string name="voice_error_audio">Greška pri snimanju zvuka</string> + <string name="voice_error_client">Greška pri prepoznavanju glasa</string> + <string name="voice_error_permissions">Potrebno je dopuštenje za mikrofon</string> + <string name="voice_error_network">Greška mreže</string> + <string name="voice_error_network_timeout">Isteklo vrijeme mreže</string> + <string name="voice_error_no_match">Govor nije prepoznat</string> + <string name="voice_error_busy">Prepoznavanje glasa zauzeto</string> + <string name="voice_error_server">Greška poslužitelja</string> + <string name="voice_error_speech_timeout">Govor nije otkriven</string> + <string name="voice_error_unknown">Nepoznata greška</string> + <string name="voice_error_start_failed">Pokretanje prepoznavanja glasa nije uspjelo</string> + <string name="voice_error_timeout">Isteklo vrijeme prepoznavanja glasa</string> + <string name="retry">Pokušaj ponovno</string> + <string name="select_server">Odaberite poslužitelj</string> + <string name="select_user">Odaberite korisnika</string> + <string name="show_debug_info">Prikaži informacije za otklanjanje grešaka</string> + <string name="show_next_up_when">Prikaži \"Slijedi\"</string> + <string name="show">Prikaži</string> + <string name="shuffle">Nasumično</string> + <string name="skip">Preskoči</string> + <string name="sort_by_date_added">Datum dodavanja</string> + <string name="sort_by_date_episode_added">Datum dodavanja epizode</string> + <string name="sort_by_date_played">Datum reprodukcije</string> + <string name="sort_by_date_released">Datum izdavanja</string> + <string name="sort_by_name">Ime</string> + <string name="sort_by_random">Nasumično</string> + <string name="studios">Studiji</string> + <string name="submit">Pošalji</string> + <string name="subtitle_download_too_long">Preuzimanje traje dugo, možda ćete morati ponovno pokrenuti reprodukciju</string> + <string name="subtitle">Titl</string> + <string name="subtitles">Titlovi</string> + <string name="suggestions">Prijedlozi</string> + <string name="switch_servers">Promijeni poslužitelja</string> + <string name="switch_user">Promijeni</string> + <string name="time_left">%1$s preostalo</string> + <string name="top_unwatched">Najbolje ocijenjeno nepogledano</string> + <string name="trailers_title">Najave</string> + <string name="tv_dvr_schedule">Raspored DVR-a</string> + <string name="tv_guide">Vodič</string> + <string name="tv_season">Sezona</string> + <string name="tv_seasons">Sezone</string> + <string name="tv_shows_title">TV serije</string> + <string name="ui_interface">Sučelje</string> + <string name="unknown">Nepoznato</string> + <string name="updates">Ažuriranja</string> + <string name="version">Verzija</string> + <string name="video_scale">Skaliranje videa</string> + <string name="video">Video</string> + <string name="videos">Videozapisi</string> + <string name="watch_live">Gledaj uživo</string>g + <plurals name="years_old"> + <item quantity="one">%1$d godina</item> + <item quantity="few">%1$d godine</item> + <item quantity="other">%1$d godina</item> + </plurals> + <string name="play_with_transcoding">Reproduciraj s transkodiranjem</string> + <string name="media_information">Informacije o mediju</string> + <string name="show_clock">Prikaži sat</string> + <string name="aired_episode_order">Redoslijed emitiranja epizoda</string> + <string name="create_playlist">Stvori novi popis za reprodukciju</string> + <string name="add_to_playlist">Dodaj na popis za reprodukciju</string> + <string name="one_click_pause">Pauza jednim klikom</string> + <string name="one_click_pause_summary_on">Pritisnite sredinu D-Pada za pauzu/reprodukciju</string> + <plurals name="deleted_scenes"> + <item quantity="one">Izbrisana scena</item> + <item quantity="few">Izbrisane scene</item> + <item quantity="other">Izbrisanih scena</item> + </plurals> + <plurals name="interviews"> + <item quantity="one">Intervju</item> + <item quantity="few">Intervjua</item> + <item quantity="other">Intervjua</item> + </plurals> + <plurals name="scenes"> + <item quantity="one">Scena</item> + <item quantity="few">Scene</item> + <item quantity="other">Scena</item> + </plurals> + <plurals name="samples"> + <item quantity="one">Isječak</item> + <item quantity="few">Isječaka</item> + <item quantity="other">Isječaka</item> + </plurals> + <plurals name="featurettes"> + <item quantity="one">Prateći film</item> + <item quantity="few">Prateća filma</item> + <item quantity="other">Pratećih filmova</item> + </plurals> + <plurals name="shorts"> + <item quantity="one">Kratki film</item> + <item quantity="few">Kratka filma</item> + <item quantity="other">Kratkih filmova</item> + </plurals> + <plurals name="downloads"> + <item quantity="one">%s preuzimanje</item> + <item quantity="few">%s preuzimanja</item> + <item quantity="other">%s preuzimanja</item> + </plurals> + <plurals name="hours"> + <item quantity="one">%d sat</item> + <item quantity="few">%d sata</item> + <item quantity="other">%d sati</item> + </plurals> + <plurals name="days"> + <item quantity="one">%s dan</item> + <item quantity="few">%s dana</item> + <item quantity="other">%s dana</item> + </plurals> + <plurals name="items"> + <item quantity="one">%d stavka</item> + <item quantity="few">%d stavke</item> + <item quantity="other">%d stavki</item> + </plurals> + <plurals name="seconds"> + <item quantity="one">%d sekunda</item> + <item quantity="few">%d sekunde</item> + <item quantity="other">%d sekundi</item> + </plurals> + <plurals name="minutes"> + <item quantity="one">%s minuta</item> + <item quantity="few">%s minute</item> + <item quantity="other">%s minuta</item> + </plurals> + <plurals name="movies"> + <item quantity="one">Film</item> + <item quantity="few">Filma</item> + <item quantity="other">Filmova</item> + </plurals> + <plurals name="tv_shows"> + <item quantity="one">TV serija</item> + <item quantity="few">TV serije</item> + <item quantity="other">TV serija</item> + </plurals> + <plurals name="people"> + <item quantity="one">Osoba</item> + <item quantity="few">Osobe</item> + <item quantity="other">Osoba</item> + </plurals> + <string name="ac3_supported">Uređaj podržava AC3/Dolby Digital</string> + <string name="advanced_settings">Napredne postavke</string> + <string name="advanced_ui">Napredno sučelje</string> + <string name="app_theme">Tema aplikacije</string> + <string name="auto_check_for_updates">Automatski provjeravaj ažuriranja</string> + <string name="auto_play_next_delay">Odgoda prije reprodukcije sljedećeg</string> + <string name="auto_play_next">Automatska reprodukcija sljedećeg</string> + <string name="check_for_updates">Provjeri ažuriranja</string> + <string name="combine_continue_next_summary">Primjenjuje se samo na TV serije</string> + <string name="combine_continue_next"><![CDATA[Spoji \"Nastavi gledanje\" i \"Slijedi\"]]></string> + <string name="direct_play_ass">Koristi libass za ASS titlove</string> + <string name="direct_play_pgs">Izravna reprodukcija PGS titlova</string> + <string name="downmix_stereo">Uvijek smiksaj na stereo</string> + <string name="ffmpeg_extension_pref">Koristi FFmpeg modul dekodera</string> + <string name="global_content_scale">Zadano skaliranje sadržaja</string> + <string name="install_update">Instaliraj ažuriranje</string> + <string name="installed_version">Instalirana verzija</string> + <string name="max_homepage_items">Maks. stavki u redovima početne stranice</string> + <string name="nav_drawer_pins_summary">Odaberite zadane stavke za prikaz, ostale će biti skrivene</string> + <string name="nav_drawer_pins">Prilagodi stavke navigacijske ladice</string> + <string name="nav_drawer_switch_on_focus_summary_off">Kliknite za promjenu stranica</string> + <string name="nav_drawer_switch_on_focus">Promijeni stranice navigacijske ladice pri fokusu</string> + <string name="pass_out_protection">Pauza kad zaspete</string> + <string name="play_theme_music">Reproduciraj tematsku glazbu</string> + <string name="playback_overrides">Nadjačavanja reprodukcije</string> + <string name="remember_selected_tab">Zapamti odabrane kartice</string> + <string name="rewatch_next_up">Omogući ponovno gledanje u \"Slijedi\"</string> + <string name="seek_bar_steps">Koraci trake za premotavanje</string> + <string name="send_app_logs_summary">Korisno za otklanjanje grešaka</string> + <string name="send_app_logs">Pošalji zapise aplikacije na trenutni poslužitelj</string> + <string name="send_crash_reports_summary">Pokušat će poslati na zadnji povezani poslužitelj</string> + <string name="send_crash_reports">Pošalji izvješća o rušenju</string> + <string name="settings">Postavke</string> + <string name="skip_back_on_resume_preference">Premotaj unatrag pri nastavku reprodukcije</string> + <string name="skip_back_preference">Premotaj unatrag</string> + <string name="skip_commercials_behavior">Ponašanje preskakanja reklama</string> + <string name="skip_forward_preference">Premotaj unaprijed</string> + <string name="skip_intro_behavior">Ponašanje preskakanja uvoda</string> + <string name="skip_outro_behavior">Ponašanje preskakanja odjave</string> + <string name="skip_previews_behavior">Ponašanje preskakanja najava</string> + <string name="skip_recap_behavior">Ponašanje preskakanja sažetka</string> + <string name="update_available">Dostupno ažuriranje</string> + <string name="update_url_summary">URL za provjeru ažuriranja aplikacije</string> + <string name="update_url">URL za ažuriranje</string> + <string name="font_size">Veličina fonta</string> + <string name="font_color">Boja fonta</string> + <string name="font_opacity">Neprozirnost fonta</string> + <string name="edge_style">Stil rubova</string> + <string name="edge_color">Boja rubova</string> + <string name="background_opacity">Neprozirnost pozadine</string> + <string name="background_style">Stil pozadine</string> + <string name="subtitle_style">Stil titlova</string> + <string name="background_color">Boja pozadine</string> + <string name="reset">Vratiti zadano</string> + <string name="bold_font">Podebljani font</string> + <string name="player_backend">Pozadinski sustav reprodukcije</string> + <string name="mpv_hardware_decoding">MPV: Koristi hardversko dekodiranje</string> + <string name="disable_if_crash">Onemogućite ako dolazi do rušenja</string> + <string name="mpv_options">MPV Opcije</string> + <string name="exoplayer_options">ExoPlayer opcije</string> + <string name="skip_segment_unknown">Preskoči segment</string> + <string name="skip_segment_commercial">Preskoči reklame</string> + <string name="skip_segment_preview">Preskoči najavu</string> + <string name="skip_segment_recap">Preskoči sažetak</string> + <string name="skip_segment_outro">Preskoči odjavu</string> + <string name="skip_segment_intro">Preskoči uvod</string> + <string name="ffmpeg_prefer">Preferiraj FFmpeg umjesto ugrađenih dekodera</string> + <string name="ffmpeg_never">Nikad ne koristi FFmpeg dekodere</string> + <string name="next_up_playback_end">Na kraju reprodukcije</string> + <string name="next_up_outro">Tijekom odjavne špice/odjave</string> + <string name="next_up_never">Nikad</string> + <string name="white">Bijela</string> + <string name="light_gray">Svijetlosiva</string> + <string name="dark_gray">Tamnosiva</string> + <string name="yellow">Žuta</string> + <string name="cyan">Tirkizna</string> + <string name="magenta">Magenta</string> + <string name="subtitle_edge_outline">Obrub</string> + <string name="subtitle_edge_shadow">Sjena</string> + <string name="background_style_wrap">Obavijeno</string> + <string name="background_style_boxed">U okviru</string> + <string name="prefer_mpv">Preferiraj MPV</string> + <string name="external_player">Vanjski player</string> + <string name="player_backend_options_subtitles_prefer_mpv">Koristi ExoPlayer za HDR reprodukciju</string> + <string name="aspect_ratios_poster">Plakat (2:3)</string> + <string name="aspect_ratios_16_9">16:9</string> + <string name="aspect_ratios_4_3">4:3</string> + <string name="aspect_ratios_square">Kvadrat (1:1)</string> + <string name="image_type_primary">Glavna</string> + <string name="image_type_thumb">Sličica</string> + <string name="view_options_type_grid">Mreža</string> + <string name="view_options_type_list">Popis</string> + <string name="view_options_type_list_compact">Zbijeni popis</string> + <string name="backdrop_style_dynamic">Slika s dinamičnom bojom</string> + <string name="backdrop_style_image">Samo slika</string> + <string name="enter_url_api_key"><![CDATA[Unesite URL i API ključ]]></string> + <string name="italic_font">Ukošeni font</string> + <string name="font">Font</string> + <string name="background">Pozadina</string> + <string name="success">Uspjeh</string> + <string name="official_rating">Roditeljska ocjena</string> + <string name="runtime_sort">Trajanje</string> + <string name="extras_title">Dodaci</string> + <string name="other_extras_title">Ostalo</string> + <string name="behind_the_scenes_title">Iza kulisa</string> + <string name="theme_songs_title">Tematske pjesme</string> + <string name="theme_videos_title">Tematski videozapisi</string> + <string name="clips_title">Isječci</string> + <string name="deleted_scenes_title">Izbrisane scene</string> + <string name="interviews_title">Intervjui</string> + <string name="scenes_title">Scene</string> + <string name="samples_title">Isječci</string> + <string name="featurettes_title">Prateći filmovi</string> + <string name="shorts_title">Kratki filmovi</string> + <string name="favorite_items_title">Favoriti %s</string> + <plurals name="trailers"> + <item quantity="one">Najava</item> + <item quantity="few">Najave</item> + <item quantity="other">Najava</item> + </plurals> + <plurals name="other_extras"> + <item quantity="one">Ostalo</item> + <item quantity="few">Ostalo</item> + <item quantity="other">Ostalo</item> + </plurals> + <plurals name="behind_the_scenes"> + <item quantity="one">Iza kulisa</item> + <item quantity="few">Iza kulisa</item> + <item quantity="other">Iza kulisa</item> + </plurals> + <plurals name="theme_songs"> + <item quantity="one">Tematska pjesma</item> + <item quantity="few">Tematske pjesme</item> + <item quantity="other">Tematskih pjesama</item> + </plurals> + <plurals name="theme_videos"> + <item quantity="one">Tematski videozapis</item> + <item quantity="few">Tematska videozapisa</item> + <item quantity="other">Tematskih videozapisa</item> + </plurals> + <plurals name="clips"> + <item quantity="one">Isječak</item> + <item quantity="few">Isječka</item> + <item quantity="other">Isječaka</item> + </plurals> + <string name="played">Pogledano</string> + <string name="filter">Filtar</string> + <string name="year">Godina</string> + <string name="decade">Desetljeće</string> + <string name="remove">Ukloni</string> + <string name="dolby_vision">Dolby Vision</string> + <string name="dolby_atmos">Dolby Atmos</string> + <string name="mpv_use_gpu_next">MPV: Koristi gpu-next</string> + <string name="mpv_conf">Uredi mpv.conf</string> + <string name="discard_change">Odbaciti promjene?</string> + <string name="subtitle_margin">Margina</string> + <string name="verbose_logging">Detaljno bilježenje</string> + <string name="enter_pin">Unesite PIN</string> + <string name="sign_in_auto">Automatska prijava</string> + <string name="use_server_credentials">Prijava putem poslužitelja</string> + <string name="press_enter_to_confirm">Pritisnite srednju tipku za potvrdu</string> + <string name="require_pin_code">Zahtijevaj PIN za profil</string> + <string name="require_login">Zahtijevaj prijavu za profil</string> + <string name="confirm_pin">Potvrdite PIN</string> + <string name="incorrect">Netočno</string> + <string name="pin_too_short">PIN mora imati 4 ili više znamenki</string> + <string name="will_remove_pin">Ukloniti će PIN</string> + <string name="pin_digits_hint">[0-9]</string> + <string name="image_cache_size">Veličina predmemorije slika na disku (MB)</string> + <string name="disable_audio_offload">Onemogući hardversko rasterećenje zvuka</string> + <string name="disable_audio_offload_summary">Onemogućavanje rasterećenja dekodiranja zvuka može riješiti zastajkivanja reprodukcije na nekim uređajima</string> + <string name="view_options">Opcije prikaza</string> + <string name="columns">Stupci</string> + <string name="spacing">Razmak</string> + <string name="aspect_ratio">Omjer slike</string> + <string name="show_details">Prikaži pojedinosti</string> + <string name="image_type">Vrsta slike</string> + <string name="cast_and_crew"><![CDATA[Glumci i filmska ekipa]]></string> + <string name="guest_stars">Gostujuće zvijezde</string> + <string name="edge_size">Veličina rubova</string> + <string name="refresh_rate_switching">Promjena frekvencije osvježavanja</string> + <string name="automatic">Automatski</string> + <string name="username_or_password">Koristi korisničko ime/lozinku</string> + <string name="login">Prijava</string> + <string name="general">Općenito</string> + <string name="container">Kontejner</string> + <string name="title">Naslov</string> + <string name="codec">Kodek</string> + <string name="profile">Profil</string> + <string name="level">Razina</string> + <string name="resolution">Razlučivost</string> + <string name="anamorphic">Anamorfni</string> + <string name="interlaced">Isprepleteni</string> + <string name="framerate">Broj sličica</string> + <string name="bit_depth">Dubina boje</string> + <string name="video_range">Raspon videa</string> + <string name="video_range_type">Vrsta raspona videa</string> + <string name="color_space">Prostor boja</string> + <string name="color_transfer">Prijenos boja</string> + <string name="color_primaries">Primarne boje</string> + <string name="pixel_format">Format piksela</string> + <string name="ref_frames">Referentne sličice</string> + <string name="nal">NAL</string> + <string name="language">Jezik</string> + <string name="layout">Raspored</string> + <string name="channels">Kanali</string> + <string name="sample_rate">Frekvencija uzorkovanja</string> + <string name="avc">AVC</string> + <string name="yes">Da</string> + <string name="no">Ne</string> + <string name="sdr">SDR</string> + <string name="hdr">HDR</string> + <string name="hdr10">HDR10</string> + <string name="hdr10_plus">HDR10+</string> + <string name="hlg">HLG</string> + <string name="bit_unit">bit</string> + <string name="sample_rate_unit">Hz</string> + <string name="show_titles">Prikaži naslove</string> + <string name="live_tv_repeat">Ponavljanje</string> + <string name="favorite_channels_at_beginning">Prvo prikaži omiljene kanale</string> + <string name="sort_channels_recently_watched">Sortiraj kanale po nedavno gledanima</string> + <string name="color_code_programs">Programi u boji</string> + <string name="subtitle_delay">Kašnjenje titlova</string> + <string name="backdrop_display">Stil naslovne slike</string> + <string name="combined_search_results">Kombinirani rezultati pretraživanja</string> + <string name="combined_search_results_on">Prikaži sve rezultate na jednom popisu</string> + <string name="combined_search_results_off">Prikaži rezultate po kategoriji</string> + <string name="show_voice_search_button">Prikaži gumb za glasovno pretraživanje</string> + <string name="visible_ui">Vidljivo</string> + <string name="hidden_ui">Skriveno</string> + <string name="results">Rezultati</string> + <string name="resolution_switching">Promjena razlučivosti</string> + <string name="local">Lokalno</string> + <string name="play_trailer">Reproduciraj najavu</string> + <string name="no_trailers">Bez najava</string> + <string name="clear_track_choices">Očisti odabir zapisa</string> + <string name="dts_x">DTS:X</string> + <string name="dts_hd">DTS:HD</string> + <string name="dts_hd_ma">DTS:HD MA</string> + <string name="truehd">TrueHD</string> + <string name="dolby_digital">DD</string> + <string name="dolby_digital_plus">DD+</string> + <string name="dts">DTS</string> + <string name="force_dovi_profile_7">Izravna reprodukcija Dolby Vision Profile 7</string> + <string name="force_dovi_profile_7_summary">Zanemaruje provjere kompatibilnosti uređaja</string> + <string name="discover">Otkrij</string> + <string name="request">Zatraži</string> + <string name="pending">Na čekanju</string> + <string name="seerr_integration">Seerr integracija</string> + <string name="remove_seerr_server">Ukloni Seerr poslužitelj</string> + <string name="seerr_server_added">Seerr poslužitelj dodan</string> + <string name="quick_connect">Quick Connect</string> + <string name="quick_connect_summary">Autorizirajte drugi uređaj za prijavu na vaš račun</string> + <string name="quick_connect_code">Unesite Quick Connect kod</string> + <string name="quick_connect_code_error">Kod mora imati 6 znamenki</string> + <string name="quick_connect_success">Uređaj uspješno autoriziran</string> + <string name="password">Lozinka</string> + <string name="username">Korisničko ime</string> + <string name="url">URL</string> + <string name="trending">U trendu</string> + <string name="upcoming_movies">Nadolazeći filmovi</string> + <string name="upcoming_tv">Nadolazeće TV serije</string> + <string name="request_4k">Zatraži u 4K</string> + <string name="software_decoding_av1">AV1 programsko dekodiranje</string> + <string name="upgrade_mpv_toast">MPV je sada zadani player osim za HDR.\nOvo možete promijeniti u postavkama.</string> + <string name="hdr_subtitle_style">HDR stil titlova</string> + <string name="image_subtitle_opacity">Neprozirnost slikovnih titlova</string> + <string name="brightness">Svjetlina</string> + <string name="contrast">Kontrast</string> + <string name="saturation">Zasićenost</string> + <string name="hue">Nijansa</string> + <string name="red">Crvena</string> + <string name="green">Zelena</string> + <string name="blue">Plava</string> + <string name="blur">Zamućenje</string> + <string name="save_for_album">Spremi u album</string> + <string name="play_slideshow">Pokreni prezentaciju</string> + <string name="stop_slideshow">Zaustavi prezentaciju</string> + <string name="slideshow_at_beginning">Na početak</string> + <string name="no_more_images">Nema više fotografija</string> + <string name="rotate_left">Rotiraj ulijevo</string> + <string name="rotate_right">Rotiraj udesno</string> + <string name="zoom_in">Približi</string> + <string name="zoom_out">Udalji</string> + <string name="slideshow_duration">Trajanje prezentacije</string> + <string name="play_videos_during_slideshow">Reproduciraj videozapise tijekom prezentacije</string> + <string name="send_media_info_log_to_server">Pošalji zapis o mediju na poslužitelj</string> + <string name="no_limit">Bez ograničenja</string> + <string name="max_days_next_up">Maks. dana u \"Slijedi\"</string> + <string name="albums">Albumi</string> + <string name="artists">Izvođači</string> + <string name="songs">Pjesme</string> + <string name="go_to_artist">Idi na izvođača</string> + <string name="now_playing">Trenutno svira</string> + <string name="instant_mix">Trenutni miks</string> + <string name="go_to_album">Idi na album</string> + <string name="add_to_queue">Dodaj u red čekanja</string> + <string name="add_row">Dodaj red</string> + <string name="genres_in">Žanrovi u %1$s</string> + <string name="recently_released_in">Nedavno objavnjeno u %1$s</string> + <string name="height">Visina</string> + <string name="apply_all_rows">Primijeni na sve redove</string> + <string name="customize_home">Prilagodi početnu stranicu</string> + <string name="home_rows">Redovi početne stranice</string> + <string name="load_from_server">Učitaj s korisničkog profila poslužitelja</string> + <string name="save_to_server">Spremi na korisnički profil poslužitelja</string> + <string name="load_from_web_client">Učitaj s web klijenta</string> + <string name="use_series">Koristi sliku serije</string> + <string name="add_row_for">Dodaj red za %1$s</string> + <string name="overwrite_server_settings">Prebrisati postavke na poslužitelju?</string> + <string name="overwrite_local_settings">Prebrisati lokalne postavke?</string> + <string name="for_episodes">Za epizode</string> + <string name="suggestions_for">Prijedlozi za %1$s</string> + <string name="increase_all_cards_size">Povećaj veličinu svih kartica</string> + <string name="decrease_all_cards_size">Smanji veličinu svih kartica</string> + <string name="use_thumb_images">Koristi sličice</string> + <string name="settings_saved">Postavke spremljene</string> + <string name="display_presets">Zadane postavke prikaza</string> + <string name="display_presets_description">Ugrađene postavke za brzo oblikovanje svih redova</string> + <string name="customize_home_summary">Odaberite redove i slike na početnoj stranici</string> + <string name="prefer_ac3_for_surround_summary"><![CDATA[Transkodiraj ne-stereo zvuk u AC3/Dolby Digital]]></string> + <string name="prefer_ac3_for_surround">Koristi AC3 za surround zvuk</string> + <string name="start_after">Počni nakon</string> + <string name="animate">Animiraj</string> + <string name="max_age_rating">Maks. dobna ocjena</string> + <string name="no_max">Bez maksimuma</string> + <string name="for_all_ages">Za sve uzraste</string> + <string name="up_to_age">Do dobi %s</string> + <string name="screensaver">Čuvar zaslona</string> + <string name="screensaver_settings">Postavke čuvara zaslona</string> + <string name="start_screensaver">Pokreni čuvar zaslona</string> + <string name="duration">Trajanje</string> + <string name="photos">Fotografije</string> + <string name="include_types">Uključi vrste</string> + <string name="include_types_summary">Za koje vrste stavki prikazivati slike</string> + <string name="content_scale_fit">Prilagodi</string> + <string name="content_scale_crop">Obreži</string> + <string name="content_scale_fill">Ispuni</string> + <string name="content_scale_fill_width">Ispuni širinu</string> + <string name="content_scale_fill_height">Ispuni visinu</string> + <string name="display_preset_default">Wolfskin zadani</string> + <string name="display_preset_compact">Wolfskin zbijeni</string> + <string name="display_preset_series_thumb">Slike sličica serije</string> + <string name="display_preset_episode_thumbnails">Slike sličica epizoda</string> + <string name="volume_lowest">Najniža</string> + <string name="volume_low">Niska</string> + <string name="volume_medium">Srednja</string> + <string name="volume_high">Visoka</string> + <string name="volume_full">Puna</string> + <string name="purple">Ljubičasta</string> + <string name="orange">Narančasta</string> + <string name="bold_blue">Jarko plava</string> + <string name="black">Crna</string> + <string name="brown">Smeđa</string> + <string name="skip_ignore">Zanemari</string> + <string name="skip_automatically">Automatski preskoči</string> + <string name="skip_ask">Pitaj za preskakanje</string> + <string name="ffmpeg_fallback">Koristi FFmpeg samo ako ne postoji ugrađeni dekoder</string> + <string name="api_key">API ključ</string> + <string name="seerr_login">Prijava na Seerr poslužitelj</string> + <string name="seerr_jellyfin_user">Jellyfin korisnik</string> + <string name="seerr_local_user">Lokalni korisnik</string> + <string name="search_and_download_subtitles"><![CDATA[Pretraži i preuzmi titlove]]></string> + <string name="no_subtitles_found">Nisu pronađeni udaljeni titlovi</string> + <string name="series_continueing">U tijeku</string> + <string name="in_app_screensaver">Koristi ugrađeni čuvar zaslona</string> + <string name="delete_item">Sigurno želite izbrisati ovu stavku?</string> + <string name="show_media_management">Prikaži opcije upravljanja medijima</string> + <string name="search_for">Pretraži %s</string> + <string name="actor">Glumac</string> + <string name="composer">Skladatelj</string> + <string name="writer">Scenarist</string> + <string name="guest_star">Gostujuća zvijezda</string> + <string name="producer">Producent</string> + <string name="conductor">Dirigent</string> + <string name="lyricist">Tekstopisac</string> + <string name="arranger">Aranžer</string> + <string name="engineer">Tonski inženjer</string> + <string name="mixer">Miks majstor</string> + <string name="creator">Autor</string> + <string name="artist">Izvođač</string> + <string name="album_artist">Izvođač albuma</string> + <string name="album">Album</string> + <string name="popular_songs">Popularne pjesme</string> + <string name="play_next">Reproduciraj sljedeće</string> + <string name="music_videos">Glazbeni spotovi</string> + <string name="lyrics">Tekst pjesme</string> + <string name="hide_lyrics">Sakrij tekst</string> + <string name="show_lyrics">Prikaži tekst</string> + <string name="song_has_lyrics">Pjesma ima tekst</string> + <string name="remove_from_queue">Ukloni iz reda čekanja</string> + <string name="show_album_cover">Prikaži omot albuma</string> + <string name="show_visualizer">Prikaži vizualizator</string> + <string name="show_backdrop">Prikaži pozadinsku sliku</string> + <string name="play_as_type">Reproduciraj kao?</string> + <string name="visualizer_rationale">Vizualizacija zvuka koji se trenutno reproducira zahtijeva dopuštenje za snimanje zvuka.</string> + <string name="continue_string">Nastavi</string> + <string name="select_all">Odaberi sve</string> + <string name="separate_types">Odvojene vrste</string> + <string name="prefer_logos">Preferiraj prikaz logotipa za naslove</string> + <string name="updated_toast">Wolfskin ažuriran na %s</string> + <string name="remove_continue_watching">Ukloni iz \"Nastavi gledanje\"</string> + <string name="remove_next_up">Ukloni seriju iz \"Slijedi\"</string> + <string name="view_removed_next_up">Prikaži serije uklonjene iz \"Slijedi\"</string> + <string name="view_more">Prikaži više</string> + <string name="discover_tv">Otkrij TV serije</string> + <string name="discover_movies">Otkrij filmove</string> + <string name="studios_in">Studiji u %1$s</string> + <string name="last_played">Zadnje reproducirano</string> + <string name="system_default">Zadano sustavom</string> + <string name="ass_subtitle_mode_libass">Izravna reprodukcija s libass</string> + <string name="ass_subtitle_mode_exoplayer">Izravna reprodukcija s ExoPlayer ugrađenim</string> + <string name="ass_subtitle_mode_transcode">Ugradi/transkodiraj na poslužitelju</string> + <string name="ass_subtitle_playback">Reprodukcija SSA/ASS titlova</string> + <string name="cinema_mode">Kino način</string> + <string name="show_more">Prikaži %1$s više</string> + <string name="skip_behavior">Ponašanje preskakanja</string> + <string name="skip_behavior_summary">Za uvode, špice itd.</string> + <string name="play_with">Reproduciraj pomoću</string> + <string name="transcoding">Transkodiranje</string> + <string name="user_interface_language">Jezik korisničkog sučelja</string> + <string name="display_toggles_title">Prikaz ocjena</string> + <string name="display_toggles_summary">Prilagodite što prikazati</string> + <string name="profile_protection">Zaštita profila</string> + <string name="continue_watching_click_behavior">Ponašanje klika za Nastavi gledanje/Slijedi</string> + <string name="continue_watching_click_summary_on">Reproduciraj na klik</string> + <string name="continue_watching_click_summary_off">Prikaži pojedinosti na klik</string> + <string name="stars">Zvjezdice</string> + <string name="tomatoes">Rajčice</string> + <string name="sort">Sortiraj</string> + <string name="split_into_separate_rows">Podijeli u odvojene redove</string> + <string name="genre_movies">%1$s filmovi</string> + <string name="genre_tv_shows">%1$s TV serije</string> + <string name="quality_profile">Profil kvalitete</string> + <string name="is_default">Zadano</string> + <string name="root_folder">Korijenska mapa</string> + <string name="popularity">Popularnost</string> + <string name="production_status">Status produkcije</string> + <string name="different_for_hdr">Koristi drugačiji stil za HDR</string> + <string name="experimental_settings">Eksperimentalne postavke</string> + <string name="confirm_enable_experimental_title">Omogućiti eksperimentalne postavke?</string> + <string name="confirm_enable_experimental_body">Eksperimentalne postavke mogu biti nestabilne!\n\n Mogu biti uklonjene ili promijenjene u bilo kojem izdanju.</string> + <string name="video_tunneling">Video tunneling</string> + <string name="d_pad_seek_mode_title">Način trake za premotavanje D-Padom</string> + <string name="dpad_seek_mode_minimal">Minimalno</string> + <string name="dpad_seek_mode_trickplay">Trickplay</string> + <string name="dpad_seek_mode_minimal_summary">Mala traka za premotavanje uz dno zaslona (zadano)</string> + <string name="dpad_seek_mode_trickplay_summary">Traka za premotavanje s trickplay slikama pretpregleda</string> + <string name="dpad_seek_mode_none_summary">Bez trake za premotavanje</string> + <string name="blocklisted">Na popisu blokiranih</string> + <string name="unavailable">Nedostupno</string> + <string name="subtitle_mode">Način titlova</string> + <string name="use_user_profile">Koristi postavku vašeg profila</string> + <string name="subtitle_mode_default">Zadano</string> + <string name="subtitle_mode_smart">Pametno</string> + <string name="subtitle_mode_only_forced">Samo prisilni</string> + <string name="subtitle_mode_always">Uvijek</string> + <string name="subtitle_mode_none">Ništa</string> + <string name="preferred_subtitle_language">Preferirani jezik titlova</string> + <string name="preferred_audio_language">Preferirani jezik zvuka</string> + <string name="override_user_profile_settings">Više postavki korisničkog profila</string> + <string name="override_user_profile_settings_summary">Nadjačaj postavke korisničkog profila s poslužitelja</string> + <string name="any_language">Bilo koji jezik</string> + <string name="hash_matches">Podudaranja hasha</string> + <string name="subtitles_hearing_impaired">SDH</string> + <string name="programs">TV programi</string> + <string name="recordings">Snimke</string> + <string name="folders_title">Mape</string> + <string name="user_views">Korisnički pogledi</string> + <string name="books">Knjige</string> + <string name="audio_books">Audioknjige</string> + <string name="server_version_not_supported">Verzija poslužitelja nije podržana</string> + <string name="appears_on">Pojavljuje se na</string> + <string name="dim_after_delay">Zatamni zaslon nakon odgode</string> + <string name="dim_percentage">Postotak zatamnjenja</string> + <string name="dim_screen">Zatamni zaslon</string> + <string name="duration_hours_seconds">%1$dh %2$ds</string> + <string name="season_episode_number">S%1$d E%2$d</string> + <string name="season_episode_number_range">S%1$d E%2$d-E%3$d</string> + <string name="season_episode_number_padded">S%1$02dE%2$02d</string> + <string name="season_episode_number_padded_range">S%1$02dE%2$02d-E%3$02d</string> + <string name="episode_number_short">E%1$d</string> + <string name="season_number">Sezona %1$d</string> + <string name="photo_albums">Albumi slika</string> + <string name="no_favorites_found">Nijedan favorit nije pronađen!</string> + <string name="remove_from_playlist">Ukloni s popisa za reprodukciju</string> + <string name="login_credentials_expired">Vjerodajnice su istekle, prijavite se ponovno</string> + <string name="login_not_authorized">Trenutno nemate ovlasti za pristup poslužitelju</string> +</resources> diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index f292b891..f078a5f7 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -25,7 +25,7 @@ <string name="director">Rendező</string> <string name="disabled">Letiltva</string> <string name="discovered_servers">Felfedezett szerverek</string> - <string name="download_and_update"><![CDATA[Letöltés & Frissítés]]></string> + <string name="download_and_update"><![CDATA[Letöltés és frissítés]]></string> <string name="downloading">Letöltés folyamatban…</string> <string name="enabled">Engedélyezve</string> <string name="enter_server_url">Adja meg a szerver IP-t vagy URL-t</string> @@ -43,7 +43,7 @@ <string name="forced_track">Kiegészítő</string> <string name="genres">Műfajok</string> <string name="go_to_series">Ugrás a sorozathoz</string> - <string name="go_to">Ugrás ide:</string> + <string name="go_to">Ugrás ide</string> <string name="hide_controller_timeout">Lejátszási gombok elrejtése</string> <string name="hide_debug_info">Hibakeresési infó elrejtése</string> <string name="hide">Elrejt</string> @@ -62,7 +62,7 @@ <string name="max_bitrate">Maximális bitráta</string> <string name="more_like_this">Hasonlóak</string> <string name="more">Továbbiak</string> - <string name="movies">Filmek</string> + <string name="movies_title">Filmek</string> <string name="name">Név</string> <string name="next_up">Következő</string> <string name="no_data">Nincs adat</string> @@ -72,7 +72,7 @@ <string name="none">Nincs</string> <string name="outro">Stáblista</string> <string name="path">Elérési út</string> - <string name="people">Stábtagok</string> + <string name="people_title">Stábtagok</string> <string name="play_count">Lejátszások száma</string> <string name="play_from_here">Lejátszás innen</string> <string name="play">Lejátszás</string> @@ -95,7 +95,7 @@ <string name="restart">Újraindítás</string> <string name="resume">Folytatás</string> <string name="save">Mentés</string> - <string name="search_and_download"><![CDATA[Keresés & Letöltés]]></string> + <string name="search_and_download"><![CDATA[Keresés és letöltés]]></string> <string name="search">Keresés</string> <string name="searching">Keresés…</string> <string name="select_server">Szerver kiválasztása</string> @@ -120,16 +120,15 @@ <string name="switch_servers">Váltás másik szerverre</string> <string name="switch_user">Váltás</string> <string name="top_unwatched">Legjobbra értékelt, nem megnézett</string> - <string name="trailer">Előzetes</string> <plurals name="trailers"> - <item quantity="one">Előzetesek</item> - <item quantity="other"/> + <item quantity="one">Előzetes</item> + <item quantity="other">Előzetesek</item> </plurals> <string name="tv_dvr_schedule">DVR Időzítés</string> <string name="tv_guide">Műsorújság</string> <string name="tv_season">Évad</string> <string name="tv_seasons">Évadok</string> - <string name="tv_shows">Sorozatok</string> + <string name="tv_shows_title">Sorozatok</string> <string name="ui_interface">Kezelőfelület</string> <string name="unknown">Ismeretlen</string> <string name="updates">Frissítések</string> @@ -138,7 +137,10 @@ <string name="video">Videó</string> <string name="videos">Videók</string> <string name="watch_live">Nézze élőben</string> - <string name="years_old">%1$d éves</string> + <plurals name="years_old"> + <item quantity="one">%1$d éves</item> + <item quantity="other">%1$d éves</item> + </plurals> <string name="play_with_transcoding">Lejátszás átalakítással</string> <string name="show_clock">Aktuális idő mutatása</string> <string name="aired_episode_order">Sorrend epizód vetítése alapján</string> @@ -152,14 +154,14 @@ <string name="success">Sikeres</string> <string name="official_rating">Korhatár-besorolás</string> <string name="runtime_sort">Hossz</string> - <string name="extras">Extrák</string> + <string name="extras_title">Extrák</string> <plurals name="other_extras"> <item quantity="one">Egyéb</item> <item quantity="other">Egyebek</item> </plurals> <plurals name="behind_the_scenes"> - <item quantity="one">Színfalak mögött</item> - <item quantity="other"/> + <item quantity="one">A kulisszák mögött</item> + <item quantity="other">A kulisszák mögött</item> </plurals> <plurals name="theme_songs"> <item quantity="one">Főcímdal</item> @@ -199,19 +201,19 @@ </plurals> <plurals name="downloads"> <item quantity="one">%s letöltés</item> - <item quantity="other">%s letöltés</item> + <item quantity="other">%s letöltések</item> </plurals> <plurals name="hours"> <item quantity="one">%d óra</item> - <item quantity="other">%d óra</item> + <item quantity="other">%d órák</item> </plurals> <plurals name="items"> <item quantity="one">%d elem</item> - <item quantity="other">%d elem</item> + <item quantity="other">%d elemek</item> </plurals> <plurals name="seconds"> <item quantity="one">%d másodperc</item> - <item quantity="other">%d másodperc</item> + <item quantity="other">%d másodpercek</item> </plurals> <string name="ends_at">%1$s-kor ér véget</string> <string name="no_servers_found">Nem található szerver</string> @@ -233,7 +235,7 @@ <string name="global_content_scale">Tartalom alapértelmezett méretezése</string> <string name="install_update">Frissítés telepítése</string> <string name="installed_version">Telepített verzió</string> - <string name="max_homepage_items">Elemek száma soronként a kezdőlapon</string> + <string name="max_homepage_items">A kezdőlap sorainak maximális elemszáma</string> <string name="nav_drawer_pins_summary">Válassza ki az alapértelmezett elemeket, amelyek megjelennek, a többi el lesz rejtve</string> <string name="nav_drawer_pins">Navigációs menü elemeinek testreszabása</string> <string name="nav_drawer_switch_on_focus_summary_off">Kattintson az oldalak közti váltáshoz</string> @@ -302,10 +304,10 @@ <string name="will_remove_pin">PIN-kód eltávolítása</string> <string name="view_options">Nézetbeállítások</string> <string name="columns">Oszlopok</string> - <string name="spacing">Helyköz</string> + <string name="spacing">Térköz</string> <string name="aspect_ratio">Képarány</string> - <string name="show_details">Részletek mutatása</string> - <string name="image_type">Kép típusa</string> + <string name="show_details">Részletek megjelenítése</string> + <string name="image_type">Képtípus</string> <string name="guest_stars">Vendégszereplők</string> <string name="edge_size">Szegély mérete</string> <string name="refresh_rate_switching">Tartalomhoz igazodó képfrissítési gyakoriság</string> @@ -353,10 +355,10 @@ <string name="subtitle_delay">Felirat késleltetése</string> <string name="backdrop_display">Háttér stílusa</string> <string name="enter_server_address">Adja meg a szerver címét</string> - <string name="combine_continue_next"><![CDATA[Folytatás & Következő menü összevonása]]></string> + <string name="combine_continue_next"><![CDATA[A „Folytatás” és a „Következő epizód” összevonása]]></string> <string name="player_backend">Lejátszás backend</string> <string name="image_cache_size">Kép-gyorsítótár mérete (MB)</string> - <string name="cast_and_crew"><![CDATA[Stábtagok]]></string> + <string name="cast_and_crew"><![CDATA[Szereplők és stáb]]></string> <string name="nav_drawer_switch_on_focus">Navigációs menü oldalainak váltása fókusznál</string> <string name="playback_overrides">Lejátszás felülbírálatai</string> <string name="resolution_switching">Felbontásváltás</string> @@ -371,4 +373,275 @@ <string name="dts">DTS</string> <string name="only_forced_subtitles">Csak kiegészítő felirat</string> <string name="clear_track_choices">Sávválasztás törlése</string> + <string name="duration_hours_minutes_seconds">%1$dó %2$dp %3$dmp</string> + <string name="duration_hours_minutes">%1$dó %2$dp</string> + <string name="duration_hours_seconds">%1$dó %2$dmp</string> + <string name="duration_minutes_seconds">%1$dp %2$dmp</string> + <string name="duration_hours">%1$dó</string> + <string name="duration_minutes">%1$dp</string> + <string name="duration_seconds">%1$dmp</string> + <string name="profile_specific_settings_from_server">Felhasználói profilbeállítások a szerverről</string> + <string name="voice_search">Hangalapú keresés</string> + <string name="voice_starting">Indítás</string> + <string name="voice_search_prompt">Keresés hanggal</string> + <string name="processing">Feldolgozás</string> + <string name="press_back_to_cancel">A megszakításhoz nyomd meg a Vissza gombot</string> + <string name="voice_error_audio">Hangfelvételi hiba</string> + <string name="voice_error_client">Hangfelismerési hiba</string> + <string name="voice_error_permissions">Mikrofonengedély szükséges</string> + <string name="voice_error_network">Hálózati hiba</string> + <string name="voice_error_network_timeout">Hálózati időtúllépés</string> + <string name="voice_error_no_match">Nem sikerült felismerni a beszédet</string> + <string name="voice_error_busy">A hangfelismerés foglalt</string> + <string name="voice_error_server">Szerverhiba</string> + <string name="voice_error_speech_timeout">Nem észlelhető beszéd</string> + <string name="voice_error_unknown">Ismeretlen hiba</string> + <string name="voice_error_start_failed">Nem sikerült elindítani a hangfelismerést</string> + <string name="voice_error_timeout">A hangfelismerés időtúllépés miatt megszakadt</string> + <string name="retry">Újrapróbálás</string> + <string name="trailers_title">Előzetesek</string> + <string name="season_number">%1$d. évad</string> + <string name="other_extras_title">Egyéb</string> + <string name="behind_the_scenes_title">Werkfilmek</string> + <string name="theme_songs_title">Főcímdalok</string> + <string name="theme_videos_title">Főcímvideók</string> + <string name="clips_title">Klipek</string> + <string name="deleted_scenes_title">Törölt jelenetek</string> + <string name="interviews_title">Interjúk</string> + <string name="scenes_title">Jelenetek</string> + <string name="samples_title">Minták</string> + <string name="featurettes_title">Rövid werkfilmek</string> + <string name="shorts_title">Rövidfilmek</string> + <string name="favorite_items_title">Kedvenc %s</string> + <plurals name="days"> + <item quantity="one">%s nap</item> + <item quantity="other">%s napok</item> + </plurals> + <plurals name="minutes"> + <item quantity="one">%s perc</item> + <item quantity="other">%s percek</item> + </plurals> + <plurals name="movies"> + <item quantity="one">Film</item> + <item quantity="other">Filmek</item> + </plurals> + <plurals name="tv_shows"> + <item quantity="one">Sorozat</item> + <item quantity="other">Sorozatok</item> + </plurals> + <plurals name="people"> + <item quantity="one">Személy</item> + <item quantity="other">Személyek</item> + </plurals> + <string name="require_login">Bejelentkezés szükséges a profil használatához</string> + <string name="pin_digits_hint">[0–9]</string> + <string name="disable_audio_offload">Hardveres hangfeldolgozás kikapcsolása</string> + <string name="disable_audio_offload_summary">A hardveres hangdekódolás kikapcsolása egyes eszközökön megoldhatja a lejátszás közbeni lefagyásokat</string> + <string name="combined_search_results">Összesített keresési találatok</string> + <string name="combined_search_results_on">Az összes találat megjelenítése egyetlen listában</string> + <string name="combined_search_results_off">Találatok megjelenítése kategóriánként</string> + <string name="show_voice_search_button">Hangalapú keresés gomb megjelenítése</string> + <string name="visible_ui">Látható</string> + <string name="hidden_ui">Rejtett</string> + <string name="results">Találatok</string> + <string name="force_dovi_profile_7">Dolby Vision 7-es profil közvetlen lejátszása</string> + <string name="force_dovi_profile_7_summary">Figyelmen kívül hagyja az eszközkompatibilitás ellenőrzését</string> + <string name="prefer_dovi_over_hdr10_plus">A Dolby Vision előnyben részesítése a HDR10+-szal szemben</string> + <string name="prefer_dovi_over_hdr10_plus_summary">Eltávolítja a HDR10+ metaadatokat a Dolby Vision-videókból azoknál a tévéknél, amelyek egyébként HDR10+-ként játszanák le őket</string> + <string name="discover">Felfedezés</string> + <string name="request">Kérés</string> + <string name="pending">Függőben</string> + <string name="seerr_integration">Seerr-integráció</string> + <string name="remove_seerr_server">Seerr-szerver eltávolítása</string> + <string name="seerr_server_added">Seerr-szerver hozzáadva</string> + <string name="quick_connect">Gyors csatlakozás</string> + <string name="quick_connect_summary">Engedélyezd egy másik eszköznek a bejelentkezést a fiókodba</string> + <string name="quick_connect_code">Add meg a gyors csatlakozási kódot</string> + <string name="quick_connect_code_error">A kódnak 6 számjegyből kell állnia</string> + <string name="quick_connect_success">Az eszköz engedélyezése sikeres</string> + <string name="password">Jelszó</string> + <string name="username">Felhasználónév</string> + <string name="url">URL</string> + <string name="trending">Népszerű most</string> + <string name="upcoming_movies">Hamarosan megjelenő filmek</string> + <string name="upcoming_tv">Hamarosan induló sorozatok</string> + <string name="request_4k">Kérés 4K-ban</string> + <string name="software_decoding_av1">AV1 szoftveres dekódolása</string> + <string name="upgrade_mpv_toast">Az MPV mostantól az alapértelmezett lejátszó, kivéve HDR-tartalmak esetén. \nEzt a beállításokban módosíthatod.</string> + <string name="hdr_subtitle_style">HDR-feliratok stílusa</string> + <string name="image_subtitle_opacity">Képalapú feliratok átlátszatlansága</string> + <string name="brightness">Fényerő</string> + <string name="contrast">Kontraszt</string> + <string name="saturation">Telítettség</string> + <string name="hue">Színárnyalat</string> + <string name="save_for_album">Mentés albumhoz</string> + <string name="play_slideshow">Diavetítés indítása</string> + <string name="stop_slideshow">Diavetítés leállítása</string> + <string name="slideshow_at_beginning">Az elején</string> + <string name="no_more_images">Nincs több fénykép</string> + <string name="rotate_left">Forgatás balra</string> + <string name="rotate_right">Forgatás jobbra</string> + <string name="zoom_in">Nagyítás</string> + <string name="zoom_out">Kicsinyítés</string> + <string name="slideshow_duration">Diavetítés időtartama</string> + <string name="play_videos_during_slideshow">Videók lejátszása diavetítés közben</string> + <string name="send_media_info_log_to_server">Médiaadat-napló küldése a szervernek</string> + <string name="no_limit">Korlátlan</string> + <string name="max_days_next_up">A „Következő” listában megjelenő napok maximális száma</string> + <string name="albums">Albumok</string> + <string name="artists">Előadók</string> + <string name="songs">Dalok</string> + <string name="go_to_artist">Ugrás az előadóhoz</string> + <string name="now_playing">Most játszott tartalom</string> + <string name="instant_mix">Azonnali mix</string> + <string name="go_to_album">Ugrás az albumhoz</string> + <string name="add_to_queue">Hozzáadás a lejátszási sorhoz</string> + <string name="add_row">Sor hozzáadása</string> + <string name="genres_in">Műfajok %1$s</string> + <string name="recently_released_in">Nemrég megjelent %1$s</string> + <string name="height">Magasság</string> + <string name="apply_all_rows">Alkalmazás az összes sorra</string> + <string name="customize_home">Kezdőlap testreszabása</string> + <string name="home_rows">Kezdőlap sorai</string> + <string name="load_from_server">Betöltés a szerver felhasználói profiljából</string> + <string name="save_to_server">Mentés a szerver felhasználói profiljába</string> + <string name="load_from_web_client">Betöltés a webes kliensből</string> + <string name="use_series">Sorozat képének használata</string> + <string name="add_row_for">Sor hozzáadása ehhez: %1$s</string> + <string name="overwrite_server_settings">Felülírod a szerveren lévő beállításokat?</string> + <string name="overwrite_local_settings">Felülírod a helyi beállításokat?</string> + <string name="for_episodes">Epizódokhoz</string> + <string name="suggestions_for">Javaslatok ehhez: %1$s</string> + <string name="increase_all_cards_size">Az összes kártya méretének növelése</string> + <string name="decrease_all_cards_size">Az összes kártya méretének csökkentése</string> + <string name="use_thumb_images">Bélyegképek használata</string> + <string name="settings_saved">Beállítások mentve</string> + <string name="display_presets">Kijelző-beállítások</string> + <string name="display_presets_description">Beépített előbeállítások az összes sor gyors formázásához</string> + <string name="customize_home_summary">Sorok és képek kiválasztása a kezdőlapon</string> + <string name="prefer_ac3_for_surround_summary"><![CDATA[Nem sztereó hang átkódolása AC3/Dolby Digital formátumba]]></string> + <string name="prefer_ac3_for_surround">Térhatású hang használata AC3 formátumban</string> + <string name="start_after">Kezdés ennyi idő múlva</string> + <string name="animate">Animálás</string> + <string name="max_age_rating">Maximális korhatár</string> + <string name="no_max">Nincs felső korlát</string> + <string name="for_all_ages">Minden korosztály számára</string> + <string name="up_to_age">%s éves korig</string> + <string name="screensaver">Képernyővédő</string> + <string name="screensaver_settings">Képernyővédő beállítások</string> + <string name="start_screensaver">Képernyővédő elindítása</string> + <string name="duration">Időtartam</string> + <string name="photos">Fotók</string> + <string name="include_types">Típusok felvétele</string> + <string name="include_types_summary">Mely elemtípusokhoz jelenjenek meg képek</string> + <string name="content_scale_fit">Illesztés</string> + <string name="content_scale_crop">Kivágás</string> + <string name="content_scale_fill">Kitöltés</string> + <string name="content_scale_fill_width">Szélesség kitöltése</string> + <string name="content_scale_fill_height">Magasság kitöltése</string> + <string name="display_preset_default">Wholphin alapértelmezett beállítása</string> + <string name="display_preset_compact">Wholphin kompakt elrendezés</string> + <string name="display_preset_series_thumb">A sorozat miniatűrképei</string> + <string name="display_preset_episode_thumbnails">Az epizódok miniatűrképei</string> + <string name="volume_lowest">Legalacsonyabb</string> + <string name="volume_low">Alacsony</string> + <string name="volume_medium">Közepes</string> + <string name="volume_high">Magas</string> + <string name="volume_full">Teljes</string> + <string name="purple">Lila</string> + <string name="orange">Narancs</string> + <string name="bold_blue">Élénk kék</string> + <string name="black">Fekete</string> + <string name="brown">Barna</string> + <string name="skip_ignore">Kihagyás</string> + <string name="skip_automatically">Automatikus átugrás</string> + <string name="skip_ask">Rákérdezés a kihagyásra</string> + <string name="ffmpeg_fallback">Az FFmpeg használata csak akkor, ha nincs beépített dekóder</string> + <string name="ffmpeg_prefer">Az FFmpeg előnyben részesítése a beépített dekóderekkel szemben</string> + <string name="ffmpeg_never">FFmpeg-dekóderek használatának tiltása</string> + <string name="next_up_playback_end">A lejátszás végén</string> + <string name="hdr_formats">HDR-formátumok</string> + <string name="hdr10_override">HDR10</string> + <string name="hdr10_plus_override">HDR10+</string> + <string name="dovi_profile_5">Dolby Vision 5-ös profil</string> + <string name="dovi_profile_7">Dolby Vision 7-es profil</string> + <string name="dovi_profile_8">Dolby Vision 8-as profil</string> + <string name="force_enable">Kényszerített engedélyezés</string> + <string name="force_disable">Kényszerített letiltás</string> + <string name="red">Piros</string> + <string name="green">Zöld</string> + <string name="blue">Kék</string> + <string name="blur">Elmosódás</string> + <string name="next_up_outro">A stáblista és a zárójelenetek alatt</string> + <string name="next_up_never">Soha</string> + <string name="white">Fehér</string> + <string name="light_gray">Világosszürke</string> + <string name="dark_gray">Sötétszürke</string> + <string name="yellow">Sárga</string> + <string name="cyan">Cián</string> + <string name="magenta">Magenta</string> + <string name="subtitle_edge_outline">Körvonal</string> + <string name="subtitle_edge_shadow">Árnyék</string> + <string name="background_style_wrap">Sortörés</string> + <string name="prefer_mpv">Az MPV előnyben részesítése</string> + <string name="external_player">Külső lejátszó</string> + <string name="player_backend_options_subtitles_prefer_mpv">Az ExoPlayer használata HDR-lejátszáshoz</string> + <string name="aspect_ratios_poster">Poszter (2:3)</string> + <string name="aspect_ratios_16_9">16:9</string> + <string name="aspect_ratios_4_3">4:3</string> + <string name="aspect_ratios_square">Négyzet (1:1)</string> + <string name="image_type_primary">Elsődleges</string> + <string name="image_type_thumb">Bélyegkép</string> + <string name="view_options_type_grid">Rács</string> + <string name="view_options_type_list">Lista</string> + <string name="view_options_type_list_compact">Összefoglaló lista</string> + <string name="backdrop_style_dynamic">Dinamikus színű kép</string> + <string name="backdrop_style_image">Csak kép</string> + <string name="enter_url_api_key"><![CDATA[Add meg az URL-t és az API-kulcsot]]></string> + <string name="api_key">API-kulcs</string> + <string name="seerr_login">Bejelentkezés a Seerr-szerverre</string> + <string name="seerr_jellyfin_user">Jellyfin-felhasználó</string> + <string name="seerr_local_user">Helyi felhasználó</string> + <string name="search_and_download_subtitles"><![CDATA[Feliratok keresése és letöltése]]></string> + <string name="no_subtitles_found">Nem található távoli felirat</string> + <string name="series_continueing">Jelenlegi</string> + <string name="in_app_screensaver">Az alkalmazáson belüli képernyővédő használata</string> + <string name="delete_item">Biztosan törölni szeretnéd ezt az elemet?</string> + <string name="show_media_management">Médiakezelési lehetőségek megjelenítése</string> + <string name="search_for">Keresés %s</string> + <string name="actor">Színész</string> + <string name="composer">Zeneszerző</string> + <string name="writer">Író</string> + <string name="guest_star">Vendégszereplő</string> + <string name="producer">Producer</string> + <string name="conductor">Karmester</string> + <string name="lyricist">Dalszövegíró</string> + <string name="arranger">Rendezés</string> + <string name="engineer">Mérnök</string> + <string name="mixer">Hangmérnök</string> + <string name="creator">Alkotó</string> + <string name="artist">Művész</string> + <string name="album_artist">Album előadója</string> + <string name="album">Album</string> + <string name="popular_songs">Népszerű dalok</string> + <string name="play_next">Lejátszás következőként</string> + <string name="music_videos">Videóklipek</string> + <string name="lyrics">Dalszövegek</string> + <string name="hide_lyrics">Dalszöveg elrejtése</string> + <string name="show_lyrics">Dalszöveg megjelenítése</string> + <string name="remove_from_queue">Eltávolítás a lejátszási sorból</string> + <string name="show_album_cover">Albumborító megjelenítése</string> + <string name="show_visualizer">Vizuális effektusok megjelenítése</string> + <string name="show_backdrop">Háttér megjelenítése</string> + <string name="play_as_type">Lejátszás másként?</string> + <string name="visualizer_rationale">A jelenleg lejátszott hang vizualizálásához engedélyezni kell a hangrögzítést.</string> + <string name="continue_string">Folytatás</string> + <string name="select_all">Összes kijelölése</string> + <string name="separate_types">Típusok különválasztása</string> + <string name="prefer_logos">A címek logóinak előnyben részesítése</string> + <string name="updated_toast">A Wholphin frissült a %s verzióra</string> + <string name="remove_continue_watching">Eltávolítás a „Folytatás” listából</string> + <string name="remove_next_up">Sorozat eltávolítása a „Következő epizód” listából</string> + <string name="view_removed_next_up">A „Következő epizód” listából eltávolított sorozatok megtekintése</string> + <string name="view_more">További információk</string> </resources> diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index ab59c3df..623ef596 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -18,11 +18,11 @@ <string name="collection">Koleksi</string> <string name="collections">Koleksi</string> <string name="commercial">Komersial</string> - <string name="community_rating">Peringkat Komunitas</string> + <string name="community_rating">Rating Komunitas</string> <string name="compose_error_message_title">Terjadi kesalahan! Tekan tombol untuk mengirim log ke server kamu.</string> <string name="confirm">Konfirmasi</string> <string name="continue_watching">Lanjutkan menonton</string> - <string name="critic_rating">Penilaian Kritis</string> + <string name="critic_rating">Rating Kritis</string> <string name="decimal_seconds">%.2f detik</string> <string name="default_track">Bawaan</string> <string name="delete">Hapus</string> @@ -62,7 +62,7 @@ <string name="max_bitrate">Bitrate maks</string> <string name="more_like_this">Tampilkan yang serupa</string> <string name="more">Lainnya</string> - <string name="movies">Film</string> + <string name="movies_title">Film</string> <string name="name">Nama</string> <string name="next_up">Selanjutnya</string> <string name="no_data">Tidak ada data</string> @@ -72,7 +72,7 @@ <string name="none">Tidak ada</string> <string name="outro">Outro</string> <string name="path">Berkas</string> - <string name="people">Orang</string> + <string name="people_title">Orang</string> <string name="play_count">Total Pemutaran</string> <string name="play_from_here">Putar dari sini</string> <string name="play">Putar</string> @@ -121,7 +121,6 @@ <string name="switch_servers">Ganti Server</string> <string name="switch_user">Ganti</string> <string name="top_unwatched">Terbaik Belum Ditonton</string> - <string name="trailer">Trailer</string> <plurals name="trailers"> <item quantity="other">Trailer</item> </plurals> @@ -129,7 +128,7 @@ <string name="tv_guide">Panduan</string> <string name="tv_season">Season</string> <string name="tv_seasons">Season</string> - <string name="tv_shows">Acara TV</string> + <string name="tv_shows_title">Acara TV</string> <string name="ui_interface">Antarmuka</string> <string name="unknown">Tidak Diketahui</string> <string name="updates">Pembaruan</string> @@ -138,7 +137,9 @@ <string name="video">Video</string> <string name="videos">Video</string> <string name="watch_live">Tonton langsung</string> - <string name="years_old">%1$d tahun</string> + <plurals name="years_old"> + <item quantity="other">%1$d tahun</item> + </plurals> <string name="play_with_transcoding">Putar dengan transcoding</string> <string name="show_clock">Tampilkan Jam</string> <string name="aired_episode_order">Urutan Episode Tayang</string> @@ -150,26 +151,26 @@ <string name="font">Font</string> <string name="background">Latar Belakang</string> <string name="success">Berhasil</string> - <string name="official_rating">Peringkat Orang Tua</string> + <string name="official_rating">Rating Orang Tua</string> <string name="runtime_sort">Durasi</string> - <string name="extras">Ekstra</string> + <string name="extras_title">Ekstra</string> <plurals name="other_extras"> <item quantity="other">Lainnya</item> </plurals> <plurals name="behind_the_scenes"> - <item quantity="other">Di Balik Layar</item> + <item quantity="other">Di balik layar</item> </plurals> <plurals name="theme_songs"> - <item quantity="other">Lagu Tema</item> + <item quantity="other">Lagu tema</item> </plurals> <plurals name="theme_videos"> - <item quantity="other">Video Tema</item> + <item quantity="other">Video tema</item> </plurals> <plurals name="clips"> <item quantity="other">Klip</item> </plurals> <plurals name="deleted_scenes"> - <item quantity="other">Adegan Dihapus</item> + <item quantity="other">Adegan dihapus</item> </plurals> <plurals name="interviews"> <item quantity="other">Wawancara</item> @@ -184,7 +185,7 @@ <item quantity="other">Featurette</item> </plurals> <plurals name="shorts"> - <item quantity="other">Klip Pendek</item> + <item quantity="other">Klip pendek</item> </plurals> <plurals name="hours"> <item quantity="other">%d jam</item> @@ -446,7 +447,7 @@ <string name="display_presets">Preset tampilan</string> <string name="display_presets_description">Preset bawaan untuk kustomisasi cepat semua baris</string> <string name="customize_home_summary">Pilih baris dan gambar di halaman beranda</string> - <string name="favorite_items">Favoritkan %s</string> + <string name="favorite_items_title">Favoritkan %s</string> <string name="artist">Artis</string> <plurals name="minutes"> <item quantity="other">%s menit</item> @@ -464,8 +465,8 @@ <string name="photos">Foto</string> <string name="include_types">Sertakan jenis</string> <string name="include_types_summary">Tampilkan gambar untuk jenis item</string> - <string name="display_preset_default">Wholphin Bawaan</string> - <string name="display_preset_compact">Wholphin Ringkas</string> + <string name="display_preset_default">Wolfskin Bawaan</string> + <string name="display_preset_compact">Wolfskin Ringkas</string> <string name="display_preset_series_thumb">Gambar Thumbnail Seri</string> <string name="display_preset_episode_thumbnails">Gambar Thumbnail Episode</string> <string name="volume_lowest">Terendah</string> @@ -560,7 +561,7 @@ <string name="separate_types">Pisahkan jenis</string> <string name="go_to_artist">Buka artis</string> <string name="go_to_album">Buka album</string> - <string name="updated_toast">Wholphin diperbarui ke %s</string> + <string name="updated_toast">Wolfskin diperbarui ke %s</string> <string name="remove_continue_watching">Hapus dari \"Lanjutkan menonton\"</string> <string name="remove_next_up">Hapus seri dari \"Berikutnya\"</string> <string name="view_removed_next_up">Lihat seri yang dihapus dari \"Berikutnya\"</string> @@ -582,4 +583,131 @@ <string name="play_with">Putar dengan</string> <string name="skip_behavior_summary">Untuk intro, kredit, dll</string> <string name="skip_behavior">Perilaku lewati</string> + <string name="pin_digits_hint">[0–9]</string> + <string name="combined_search_results">Hasil pencarian gabungan</string> + <string name="combined_search_results_on">Tampilkan semua hasil dalam satu daftar</string> + <string name="combined_search_results_off">Tampilkan hasil berdasarkan kategori</string> + <string name="results">Hasil</string> + <string name="view_options_type_list_compact">Daftar Ringkas</string> + <string name="require_login">Wajibkan login untuk profil</string> + <string name="show_voice_search_button">Tampilkan tombol pencarian suara</string> + <string name="visible_ui">Terlihat</string> + <string name="hidden_ui">Tersembunyi</string> + <string name="view_options_type_grid">Grid</string> + <string name="view_options_type_list">List</string> + <string name="user_interface_language">Bahasa antarmuka pengguna</string> + <string name="display_toggles_title">Tampilan rating</string> + <string name="display_toggles_summary">Sesuaikan item yang akan ditampilkan</string> + <string name="profile_protection">Perlindungan profil</string> + <string name="continue_watching_click_behavior">Perilaku klik Lanjut menonton/Berikutnya</string> + <string name="continue_watching_click_summary_on">Putar saat diklik</string> + <string name="continue_watching_click_summary_off">Tampilkan detail saat diklik</string> + <string name="stars">Bintang</string> + <string name="tomatoes">Tomatoes</string> + <string name="trailers_title">Trailer</string> + <string name="other_extras_title">Lainnya</string> + <string name="behind_the_scenes_title">Di Balik Layar</string> + <string name="theme_songs_title">Lagu Tema</string> + <string name="theme_videos_title">Video Tema</string> + <string name="clips_title">Klip</string> + <string name="deleted_scenes_title">Adegan Dihapus</string> + <plurals name="movies"> + <item quantity="other">Film</item> + </plurals> + <plurals name="tv_shows"> + <item quantity="other">Acara TV</item> + </plurals> + <string name="interviews_title">Wawancara</string> + <string name="scenes_title">Adegan</string> + <string name="samples_title">Sampel</string> + <string name="featurettes_title">Featurette</string> + <string name="shorts_title">Shorts</string> + <string name="sort">Urutkan</string> + <string name="split_into_separate_rows">Pisahkan menjadi baris terpisah</string> + <string name="genre_movies">%1$s Film</string> + <string name="genre_tv_shows">%1$s Acara TV</string> + <string name="quality_profile">Profil kualitas</string> + <string name="is_default">Default</string> + <string name="root_folder">Folder root</string> + <string name="popularity">Popularitas</string> + <string name="production_status">Status produksi</string> + <string name="different_for_hdr">Gunakan gaya berbeda untuk HDR</string> + <string name="experimental_settings">Pengaturan eksperimental</string> + <string name="confirm_enable_experimental_title">Aktifkan pengaturan eksperimental?</string> + <string name="confirm_enable_experimental_body">Pengaturan eksperimental mungkin tidak stabil!\n\nPengaturan ini dapat dihapus atau diubah pada rilisan mana pun.</string> + <string name="video_tunneling">Video tunneling</string> + <plurals name="people"> + <item quantity="other">Orang</item> + </plurals> + <string name="prefer_ac3_for_surround">Gunakan AC3 untuk surround sound</string> + <string name="duration_hours_minutes_seconds">%1$dj %2$dm %3$dd</string> + <string name="duration_hours_minutes">%1$dj %2$dm</string> + <string name="duration_minutes_seconds">%1$dm %2$dd</string> + <string name="duration_hours">%1$dj</string> + <string name="duration_minutes">%1$dm</string> + <string name="duration_seconds">%1$dd</string> + <string name="time_left">Sisa %1$s</string> + <string name="prefer_ac3_for_surround_summary"><![CDATA[Transkode audio non-stereo ke AC3/Dolby Digital]]></string> + <string name="unavailable">Tidak tersedia</string> + <string name="profile_specific_settings_from_server">Pengaturan profil pengguna dari server</string> + <string name="subtitle_mode">Mode subtitel</string> + <string name="use_user_profile">Gunakan pengaturan profil kamu</string> + <string name="subtitle_mode_default">Default</string> + <string name="subtitle_mode_smart">Pintar</string> + <string name="subtitle_mode_only_forced">Hanya Paksaan</string> + <string name="subtitle_mode_always">Selalu</string> + <string name="subtitle_mode_none">Tidak ada</string> + <string name="preferred_subtitle_language">Bahasa subtitel preferensi</string> + <string name="preferred_audio_language">Bahasa audio preferensi</string> + <string name="override_user_profile_settings">Pengaturan profil pengguna lainnya</string> + <string name="override_user_profile_settings_summary">Timpa pengaturan profil pengguna dari server</string> + <string name="any_language">Semua bahasa</string> + <string name="brown">Cokelat</string> + <string name="d_pad_seek_mode_title">Mode bilah geser D-Pad</string> + <string name="duration_hours_seconds">%1$dj %2$dd</string> + <string name="season_episode_number">S%1$d E%2$d</string> + <string name="season_episode_number_range">S%1$d E%2$d-E%3$d</string> + <string name="season_episode_number_padded">S%1$02dE%2$02d</string> + <string name="season_episode_number_padded_range">S%1$02dE%2$02d-E%3$02d</string> + <string name="episode_number_short">E%1$d</string> + <string name="season_number">Season %1$d</string> + <string name="disable_audio_offload">Nonaktifkan offload audio hardware</string> + <string name="disable_audio_offload_summary">Menonaktifkan dekode audio offload dapat mengatasi masalah pemutaran macet pada beberapa perangkat</string> + <string name="dpad_seek_mode_minimal">Minimal</string> + <string name="dpad_seek_mode_trickplay">Trickplay</string> + <string name="dpad_seek_mode_minimal_summary">Bilah geser kecil di sepanjang bawah layar (default)</string> + <string name="dpad_seek_mode_trickplay_summary">Bilah geser dengan gambar pratinjau trickplay</string> + <string name="dpad_seek_mode_none_summary">Tanpa bilah geser</string> + <string name="blocklisted">Daftar blokir</string> + <string name="hash_matches">Hash sesuai</string> + <string name="subtitles_hearing_impaired">SDH</string> + <string name="programs">Acara TV</string> + <string name="recordings">Rekaman</string> + <string name="folders_title">Folder</string> + <string name="user_views">Tampilan pengguna</string> + <string name="books">Buku</string> + <string name="audio_books">Buku audio</string> + <string name="server_version_not_supported">Versi server tidak didukung</string> + <string name="appears_on">Tampil pada</string> + <string name="dim_after_delay">Redupkan layar setelah penundaan</string> + <string name="dim_percentage">Persentase redup</string> + <string name="dim_screen">Redupkan layar</string> + <string name="photo_albums">Album foto</string> + <string name="no_favorites_found">Favorit tidak ditemukan!</string> + <plurals name="episodes_count"> + <item quantity="other">%1$d episode</item> + </plurals> + <string name="prefer_dovi_over_hdr10_plus">Utamakan Dolby Vision dibanding HDR10+</string> + <string name="prefer_dovi_over_hdr10_plus_summary">Hapus metadata HDR10+ dari video Dolby Vision, untuk TV yang dapat memutarnya sebagai HDR10+</string> + <string name="remove_from_playlist">Hapus dari playlist</string> + <string name="login_credentials_expired">Kredensial kedaluwarsa, silakan login kembali</string> + <string name="login_not_authorized">Kamu tidak diizinkan mengakses server saat ini</string> + <string name="max_resolution">Resolusi maksimum sebelum transkode</string> + <string name="resolution_8k">8K</string> + <string name="resolution_4k">4K</string> + <string name="resolution_1080">1080</string> + <string name="resolution_720">720</string> + <string name="resolution_576">576</string> + <string name="resolution_480">480</string> + <string name="resolution_360">360</string> </resources> diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 95db240f..be2f53e3 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -34,7 +34,7 @@ <string name="loading">Caricamento…</string> <string name="library">Libreria</string> <string name="live_tv">Diretta TV</string> - <string name="movies">Film</string> + <string name="movies_title">Film</string> <string name="no_results">Nessun risultato</string> <string name="max_bitrate">Bitrate massimo</string> <string name="mark_unwatched">Segna come non visto</string> @@ -69,7 +69,7 @@ <string name="sort_by_random">Casuale</string> <string name="tv_season">Stagione</string> <string name="tv_seasons">Stagioni</string> - <string name="tv_shows">Serie TV</string> + <string name="tv_shows_title">Serie TV</string> <string name="ui_interface">Interfaccia</string> <string name="unknown">Sconosciuto</string> <string name="updates">Aggiornamenti</string> @@ -81,24 +81,24 @@ <string name="font">Carattere</string> <string name="background">Sfondo</string> <plurals name="behind_the_scenes"> - <item quantity="one">Dietro le Scena</item> - <item quantity="many">Dietro le Scene</item> - <item quantity="other">Dietro le Scene</item> + <item quantity="one">Dietro la scena</item> + <item quantity="many">Dietro le scene</item> + <item quantity="other">Dietro le scene</item> </plurals> <plurals name="theme_songs"> <item quantity="one">Sigla</item> - <item quantity="many">sigle</item> - <item quantity="other">sigle</item> + <item quantity="many">Sigle</item> + <item quantity="other">Sigle</item> </plurals> <plurals name="interviews"> - <item quantity="one">Interviste</item> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Intervista</item> + <item quantity="many">Interviste</item> + <item quantity="other">Interviste</item> </plurals> <plurals name="scenes"> - <item quantity="one">Scene</item> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Scena</item> + <item quantity="many">Scene</item> + <item quantity="other">Scene</item> </plurals> <string name="watch_live">Guarda diretta</string> <string name="tv_guide">Guida TV</string> @@ -110,7 +110,7 @@ <string name="critic_rating">Valutazione della critica</string> <string name="default_track">Predefinita</string> <string name="discovered_servers">Server Scoperti</string> - <string name="download_and_update"><![CDATA[Scarica & Aggiorna]]></string> + <string name="download_and_update"><![CDATA[Scarica e Aggiorna]]></string> <string name="downloading">Scaricando…</string> <string name="forced_track">Forzata</string> <string name="go_to">Vai a</string> @@ -122,7 +122,7 @@ <string name="hide_debug_info">Nascondi informazioni debug</string> <string name="font_color">Colore testo</string> <string name="font_opacity">Opacità testo</string> - <string name="extras">Extra</string> + <string name="extras_title">Extra</string> <plurals name="other_extras"> <item quantity="one">Altro</item> <item quantity="many">Altri</item> @@ -164,7 +164,7 @@ <string name="name">Nome</string> <string name="next_up">Prossimo Episodio</string> <string name="no_scheduled_recordings">Nessuna registrazione programmata</string> - <string name="people">Persone</string> + <string name="people_title">Persone</string> <string name="show_debug_info">Mostra informazioni debug</string> <string name="sort_by_date_added">Data di Aggiunta</string> <string name="sort_by_date_played">Data di riproduzione</string> @@ -174,17 +174,20 @@ <string name="suggestions">Suggerimenti</string> <string name="switch_servers">Cambia server</string> <string name="switch_user">Cambia</string> - <string name="trailer">Trailer</string> <plurals name="trailers"> - <item quantity="one">I trailer</item> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Trailer</item> + <item quantity="many">Trailer</item> + <item quantity="other">Trailer</item> </plurals> <string name="tv_dvr_schedule">Programma DVR</string> <string name="video_scale">Scala Video</string> <string name="video">Video</string> <string name="videos">I video</string> - <string name="years_old">%1$d anni</string> + <plurals name="years_old"> + <item quantity="one">%1$d anno</item> + <item quantity="many">%1$d anni</item> + <item quantity="other">%1$d anni</item> + </plurals> <string name="play_with_transcoding">Riproduci con transcodifica</string> <string name="immediate">Immediato</string> <string name="show_next_up_when">Mostra prossimo episodio</string> @@ -213,19 +216,19 @@ <string name="background_opacity">Opacità sfondo</string> <string name="skip_segment_outro">Salta Outro</string> <plurals name="theme_videos"> - <item quantity="one">Temi Video</item> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Tema Video</item> + <item quantity="many">Temi Video</item> + <item quantity="other">Temi Video</item> </plurals> <plurals name="deleted_scenes"> - <item quantity="one">Scene Eliminate</item> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Scena Eliminata</item> + <item quantity="many">Scene Eliminate</item> + <item quantity="other">Scene Eliminate</item> </plurals> <plurals name="shorts"> - <item quantity="one">Video brevi</item> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Video breve</item> + <item quantity="many">Video brevi</item> + <item quantity="other">Video brevi</item> </plurals> <string name="advanced_settings">Impostazioni Avanzate</string> <string name="check_for_updates">Cerca aggiornamenti</string> @@ -254,14 +257,14 @@ </plurals> <string name="remember_selected_tab">Ricorda schede selezionate</string> <plurals name="samples"> - <item quantity="one">Campioni</item> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Campione</item> + <item quantity="many">Campioni</item> + <item quantity="other">Campioni</item> </plurals> <plurals name="featurettes"> - <item quantity="one">Featurettes</item> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Contenuto extra</item> + <item quantity="many">Contenuti extra</item> + <item quantity="other">Contenuti extra</item> </plurals> <string name="combine_continue_next"><![CDATA[Combina Continua a Guardare & Prossimo Episodio]]></string> <string name="downmix_stereo">Converti sempre in stereo</string> @@ -271,8 +274,8 @@ <string name="runtime_sort">Durata</string> <plurals name="clips"> <item quantity="one">Clip</item> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="many">Clip</item> + <item quantity="other">Clip</item> </plurals> <plurals name="downloads"> <item quantity="one">%s scaricamento</item> @@ -480,14 +483,14 @@ <string name="load_from_web_client">Carica dal client web</string> <string name="use_series">Usa immagine delle serie</string> <string name="customize_home_summary">Scegli righe e immagini nella home page</string> - <string name="favorite_items">Preferito %s</string> + <string name="favorite_items_title">Preferito %s</string> <string name="content_scale_fit">Adatta</string> <string name="content_scale_crop">Ritaglia</string> <string name="content_scale_fill">Riempi</string> <string name="content_scale_fill_width">Riempi larghezza</string> <string name="content_scale_fill_height">Riempi altezza</string> - <string name="display_preset_default">Wholphin predefinito</string> - <string name="display_preset_compact">Wholphin Compatto</string> + <string name="display_preset_default">Wolfskin predefinito</string> + <string name="display_preset_compact">Wolfskin Compatto</string> <string name="display_preset_series_thumb">Immagini miniatura serie</string> <string name="display_preset_episode_thumbnails">Immagini miniatura episodio</string> <string name="volume_lowest">Minimo</string> @@ -517,7 +520,7 @@ <string name="subtitle_edge_shadow">Ombra</string> <string name="background_style_wrap">Avvolgi</string> <string name="background_style_boxed">Con riquadro</string> - <string name="prefer_mpv">Preferisci MVP</string> + <string name="prefer_mpv">Preferisci MPV</string> <string name="player_backend_options_subtitles_prefer_mpv">Usa ExoPlayer per la riproduzione HDR</string> <string name="aspect_ratios_poster">Copertina (2:3)</string> <string name="aspect_ratios_16_9">16:9</string> @@ -596,7 +599,7 @@ <string name="continue_string">Continua</string> <string name="separate_types">Tipi separati</string> <string name="prefer_logos">Preferisci mostrare i loghi per i titoli</string> - <string name="updated_toast">Wholphin aggiornato a %s</string> + <string name="updated_toast">Wolfskin aggiornato a %s</string> <string name="remove_continue_watching">Rimuovi da Continua a guardare</string> <string name="remove_next_up">Rimuovi serie da Prossimo</string> <string name="view_removed_next_up">Visualizza serie rimosse da Prossimo</string> @@ -618,4 +621,162 @@ <string name="skip_behavior_summary">Per intro, titoli di coda e altro</string> <string name="play_with">Riproduci con</string> <string name="transcoding">Trascodifica</string> + <string name="pin_digits_hint">[0–9]</string> + <string name="results">Risultati</string> + <string name="require_login">Richiedi login per il profilo</string> + <string name="combined_search_results">Risultati di ricerca combinati</string> + <string name="combined_search_results_on">Mostra tutti i risultati in una lista singola</string> + <string name="combined_search_results_off">Mostra risultati per categoria</string> + <string name="profile_protection">Protezione profilo</string> + <string name="continue_watching_click_summary_on">Riproduci al click</string> + <string name="continue_watching_click_summary_off">Mostra dettagli al click</string> + <string name="stars">Stelle</string> + <string name="tomatoes">Pomodori</string> + <string name="show_voice_search_button">Mostra pulsante di ricerca vocale</string> + <string name="visible_ui">Visibile</string> + <string name="hidden_ui">Nascosto</string> + <string name="view_options_type_grid">Griglia</string> + <string name="view_options_type_list">Lista</string> + <string name="split_into_separate_rows">Dividi in righe separate</string> + <string name="view_options_type_list_compact">Lista compatta</string> + <string name="user_interface_language">Lingua dell\'interfaccia</string> + <string name="display_toggles_title">Visualizzazione valutazioni</string> + <string name="display_toggles_summary">Personalizza quali mostrare</string> + <string name="continue_watching_click_behavior">Comportamento click su Continua a guardare / Prossimo</string> + <string name="sort">Ordina</string> + <string name="trailers_title">Trailer</string> + <string name="other_extras_title">Altri</string> + <string name="behind_the_scenes_title">Dietro le scene</string> + <string name="theme_songs_title">Sigle</string> + <string name="theme_videos_title">Video delle sigle</string> + <string name="clips_title">Clip</string> + <string name="deleted_scenes_title">Scene cancellate</string> + <string name="interviews_title">Interviste</string> + <string name="scenes_title">Scene</string> + <string name="samples_title">Campioni</string> + <string name="shorts_title">Corti</string> + <string name="featurettes_title">Contenuti extra</string> + <plurals name="movies"> + <item quantity="one">Film</item> + <item quantity="many">Film</item> + <item quantity="other">Film</item> + </plurals> + <plurals name="tv_shows"> + <item quantity="one">Serie TV</item> + <item quantity="many">Serie TV</item> + <item quantity="other">Serie TV</item> + </plurals> + <plurals name="people"> + <item quantity="one">Persona</item> + <item quantity="many">Persone</item> + <item quantity="other">Persone</item> + </plurals> + <string name="genre_movies">%1$s Film</string> + <string name="genre_tv_shows">%1$s Serie TV</string> + <string name="quality_profile">Profilo di qualità</string> + <string name="is_default">Predefinito</string> + <string name="root_folder">Cartella principale</string> + <string name="popularity">Popolarità</string> + <string name="production_status">Stato di produzione</string> + <string name="different_for_hdr">Usa stile differente per HDR</string> + <string name="experimental_settings">Impostazioni sperimentali</string> + <string name="confirm_enable_experimental_title">Abilita impostazioni sperimentali?</string> + <string name="confirm_enable_experimental_body">Le impostazioni sperimentali possono essere instabili!\nPossono essere rimosse o cambiate in qualsiasi aggiornamento.</string> + <string name="video_tunneling">Tunneling video</string> + <string name="duration_hours_minutes_seconds">%1$do %2$dm %3$ds</string> + <string name="duration_hours_minutes">%1$do %2$dm</string> + <string name="duration_minutes_seconds">%1$dm %2$ds</string> + <string name="duration_hours">%1$do</string> + <string name="duration_minutes">%1$dm</string> + <string name="duration_seconds">%1$ds</string> + <string name="time_left">%1$s rimanenti</string> + <string name="prefer_ac3_for_surround_summary"><![CDATA[Transcodifica l’audio non stereo in AC3/Dolby Digital]]></string> + <string name="prefer_ac3_for_surround">Usa AC3 per l’audio surround</string> + <string name="blocklisted">Bloccati</string> + <string name="unavailable">Non disponibile</string> + <string name="subtitle_mode_default">Predefinito</string> + <string name="subtitle_mode_none">Nessuno</string> + <string name="profile_specific_settings_from_server">Impostazioni profilo utente dal server</string> + <string name="subtitle_mode">Modalità sottotitoli</string> + <string name="use_user_profile">Usa le impostazioni del tuo profilo</string> + <string name="subtitle_mode_smart">Intelligente</string> + <string name="subtitle_mode_only_forced">Solo forzati</string> + <string name="subtitle_mode_always">Sempre</string> + <string name="preferred_subtitle_language">Lingua preferita per i sottotitoli</string> + <string name="preferred_audio_language">Lingua preferita per l\'audio</string> + <string name="override_user_profile_settings">Altre impostazioni del profilo utente</string> + <string name="override_user_profile_settings_summary">Sovrascrivi le impostazioni del tuo profilo utente dal server</string> + <string name="any_language">Qualsiasi lingua</string> + <string name="brown">Marrone</string> + <string name="dpad_seek_mode_trickplay">Trickplay</string> + <string name="subtitles_hearing_impaired">SDH</string> + <string name="programs">Programmi TV</string> + <string name="recordings">Registrazioni</string> + <string name="folders_title">Cartelle</string> + <string name="books">Libri</string> + <string name="d_pad_seek_mode_title">Modalità barra di avanzamento con D-Pad</string> + <string name="dpad_seek_mode_minimal">Minimale</string> + <string name="dpad_seek_mode_minimal_summary">Barra di avanzamento piccola in basso allo schermo (predefinita)</string> + <string name="dpad_seek_mode_trickplay_summary">Barra di avanzamento con anteprime trickplay</string> + <string name="dpad_seek_mode_none_summary">Nessuna barra di avanzamento</string> + <string name="hash_matches">Corrispondenze hash</string> + <string name="user_views">Viste utente</string> + <string name="audio_books">Audiolibri</string> + <string name="server_version_not_supported">Versione del server non supportata</string> + <string name="appears_on">Appare in</string> + <string name="disable_audio_offload">Disabilita offload hardware audio</string> + <string name="disable_audio_offload_summary">Disabilitare l’offload della decodifica audio può risolvere i blocchi di riproduzione su alcuni dispositivi</string> + <string name="dim_after_delay">Scurisci lo schermo dopo un ritardo</string> + <string name="dim_percentage">Percentuale di oscuramento</string> + <string name="dim_screen">Scurisci schermo</string> + <string name="photo_albums">Album fotografici</string> + <string name="no_favorites_found">Nessun preferito trovato!</string> + <string name="duration_hours_seconds">%1$do %2$ds</string> + <string name="season_episode_number">S%1$d E%2$d</string> + <string name="season_episode_number_range">S%1$d E%2$d-E%3$d</string> + <string name="season_episode_number_padded">S%1$02dE%2$02d</string> + <string name="season_episode_number_padded_range">S%1$02dE%2$02d-E%3$02d</string> + <string name="episode_number_short">E%1$d</string> + <string name="season_number">Stagione %1$d</string> + <string name="remove_from_playlist">Rimuovi dalla playlist</string> + <string name="login_credentials_expired">Credenziali scadute, effettua di nuovo l’accesso</string> + <string name="login_not_authorized">Non sei autorizzato ad accedere al server in questo momento</string> + <string name="resolution_8k">8K</string> + <string name="resolution_4k">4K</string> + <string name="resolution_1080">1080</string> + <string name="resolution_720">720</string> + <string name="resolution_576">576</string> + <string name="resolution_480">480</string> + <string name="resolution_360">360</string> + <plurals name="episodes_count"> + <item quantity="one">%1$d Episodio</item> + <item quantity="many">%1$d Episodi</item> + <item quantity="other">%1$d Episodi</item> + </plurals> + <string name="prefer_dovi_over_hdr10_plus">Preferisci Dolby Vision a HDR10+</string> + <string name="prefer_dovi_over_hdr10_plus_summary">Rimuove i metadati HDR10+ dai video Dolby Vision, per le TV che altrimenti li riproducono come HDR10+</string> + <string name="max_resolution">Risoluzione massima prima della trascodifica</string> + <string name="hdr10_override">HDR10</string> + <string name="hdr10_plus_override">HDR10+</string> + <string name="top_rated">I più votati</string> + <string name="hdr_formats">Formati HDR</string> + <string name="dovi_profile_5">Dolby Vision Profilo 5</string> + <string name="dovi_profile_7">Dolby Vision Profilo 7</string> + <string name="dovi_profile_8">Dolby Vision Profilo 8</string> + <string name="force_enable">Forza abilitazione</string> + <string name="force_disable">Forza disabilitazione</string> + <string name="server_version_not_supported_future">La versione del server non sarà più supportata a breve</string> + <string name="server_version_required">Versione del server richiesta: %1$s</string> + <string name="series">Serie</string> + <string name="error">Errore</string> + <string name="user">Utente</string> + <string name="load_from_server_plugin">Carica predefinito dal server</string> + <string name="save_to_local">Salva solo su questo dispositivo</string> + <string name="home_settings_source_unset">Predefinito</string> + <string name="home_settings_source_local">Specifico del dispositivo</string> + <string name="home_settings_source_server_profile">Profilo utente del server</string> + <string name="home_settings_source_plugin">Predefinito del server</string> + <string name="home_settings_source_web">Client web utente</string> + <string name="managed_by_server">Gestito dal server</string> + <string name="chosen_by">Scelto da %1$s</string> </resources> diff --git a/app/src/main/res/values-iw/strings.xml b/app/src/main/res/values-iw/strings.xml index 8d0f42d5..d05db022 100644 --- a/app/src/main/res/values-iw/strings.xml +++ b/app/src/main/res/values-iw/strings.xml @@ -11,7 +11,7 @@ <string name="cancel_recording">בטל הקלטה</string> <string name="cancel_series_recording">בטל הקלטת סדרה</string> <string name="cancel">בטל</string> - <string name="chapters">פרקי משנה</string> + <string name="chapters">סצינות</string> <string name="clear_image_cache">נקה זיכרון מטמון</string> <string name="collection">אוסף</string> <string name="collections">אוספים</string> @@ -51,11 +51,10 @@ </plurals> <string name="tv_seasons">עונות</string> <string name="tv_season">עונה</string> - <string name="trailer">טריילר</string> <plurals name="trailers"> - <item quantity="one">טריילר</item> - <item quantity="two">טריילרים</item> - <item quantity="other">טריילרים</item> + <item quantity="one">קדימון</item> + <item quantity="two">קדימונים</item> + <item quantity="other">קדימונים</item> </plurals> <string name="subtitle">כתובית</string> <string name="subtitles">כתוביות</string> @@ -105,8 +104,8 @@ <string name="play">נגן</string> <string name="playback_speed">מהירות ניגון</string> <string name="playback">ניגון</string> - <string name="playlist">פלייליסט</string> - <string name="playlists">פלייליסטים</string> + <string name="playlist">רשימת־השמעה</string> + <string name="playlists">רשימות־השמעה</string> <plurals name="downloads"> <item quantity="one">הורדה %s</item> <item quantity="two">%s הורדות</item> @@ -141,7 +140,7 @@ <string name="mark_watched">סמן כנצפה</string> <string name="max_bitrate">מקסימום קצב נתונים</string> <string name="more_like_this">עוד דומים</string> - <string name="download_and_update"><![CDATA[הורד & עדכן]]></string> + <string name="download_and_update"><![CDATA[הורדה ועדכון]]></string> <string name="forced_track">כפוי</string> <string name="only_forced_subtitles">כתוביות כפויות בלבד</string> <string name="hide_controller_timeout">הסתר בקרי ניגון</string> @@ -172,7 +171,7 @@ <string name="restart">התחל מחדש</string> <string name="resume">המשך ניגון</string> <string name="save">שמור</string> - <string name="search_and_download"><![CDATA[חיפוש & הורדה]]></string> + <string name="search_and_download"><![CDATA[חיפוש והורדה]]></string> <string name="voice_search">חיפוש קולי</string> <string name="voice_search_prompt">חפש בקולך</string> <string name="processing">מעבד</string> @@ -216,13 +215,17 @@ <string name="video_scale">קנה מידה של הוידאו</string> <string name="video">וידאו</string> <string name="watch_live">צפה בשידור חי</string> - <string name="years_old">בגיל %1$d</string> - <string name="play_with_transcoding">נגן עם המרת קידוד</string> + <plurals name="years_old"> + <item quantity="one">בגיל שנה</item> + <item quantity="two">בגיל שנתיים</item> + <item quantity="other">בגיל %1$d</item> + </plurals> + <string name="play_with_transcoding">נגן עם המרת־קידוד</string> <string name="media_information">פרטי המדיה</string> <string name="show_clock">הצג שעון</string> <string name="aired_episode_order">סדר שידור הפרקים</string> - <string name="create_playlist">צור פלייליסט חדש</string> - <string name="add_to_playlist">הוסף לפלייליסט</string> + <string name="create_playlist">צור רשימת־השמעה חדשה</string> + <string name="add_to_playlist">הוסף לרשימת־השמעה</string> <string name="one_click_pause">השהה בלחיצה אחת</string> <string name="one_click_pause_summary_on">לחץ על מרכז כפתור הניווט להשהייה/ניגון</string> <string name="italic_font">גופן נטוי</string> @@ -231,41 +234,63 @@ <string name="success">הצלחה</string> <string name="official_rating">דירוג הורים</string> <string name="runtime_sort">אורך</string> - <string name="extras">תכנים נוספים</string> + <string name="extras_title">תכנים נוספים</string> <plurals name="other_extras"> <item quantity="one">אחר</item> + <item quantity="two">אחרים</item> + <item quantity="other">אחרים</item> </plurals> <plurals name="behind_the_scenes"> <item quantity="one">מאחורי הקלעים</item> + <item quantity="two">מאחורי הקלעים</item> + <item quantity="other">מאחורי הקלעים</item> </plurals> <plurals name="theme_songs"> - <item quantity="one">שירי נושא</item> + <item quantity="one">שיר־נושא</item> + <item quantity="two">שירי־נושא</item> + <item quantity="other">שירי־נושא</item> </plurals> <plurals name="theme_videos"> - <item quantity="one">סרטוני נושא</item> + <item quantity="one">סרטון־נושא</item> + <item quantity="two">סרטוני־נושא</item> + <item quantity="other">סרטוני־נושא</item> </plurals> <plurals name="clips"> - <item quantity="one">קליפים</item> + <item quantity="one">קליפ</item> + <item quantity="two">קליפים</item> + <item quantity="other">קליפים</item> </plurals> <plurals name="deleted_scenes"> - <item quantity="one">סצינות שנמחקו</item> + <item quantity="one">סצינה גנוזה</item> + <item quantity="two">סצינות גנוזות</item> + <item quantity="other">סצינות גנוזות</item> </plurals> <plurals name="interviews"> - <item quantity="one">ראיונות</item> + <item quantity="one">ראיון</item> + <item quantity="two">ראיונות</item> + <item quantity="other">ראיונות</item> </plurals> <plurals name="scenes"> - <item quantity="one">סצינות</item> + <item quantity="one">סצינה</item> + <item quantity="two">סצינות</item> + <item quantity="other">סצינות</item> </plurals> <plurals name="samples"> - <item quantity="one">דגימות</item> + <item quantity="one">דגימה</item> + <item quantity="two">דגימות</item> + <item quantity="other">דגימות</item> </plurals> <plurals name="featurettes"> - <item quantity="one">סרטונים מיוחדים</item> + <item quantity="one">קטע־בונוס</item> + <item quantity="two">קטעי־בונוס</item> + <item quantity="other">קטעי־בונוס</item> </plurals> <plurals name="shorts"> - <item quantity="one">קטעים קצרים</item> + <item quantity="one">קטע קצר</item> + <item quantity="two">קטעים קצרים</item> + <item quantity="other">קטעים קצרים</item> </plurals> - <string name="favorite_items">מועדפים: %s</string> + <string name="favorite_items_title">מועדפים: %s</string> <string name="ac3_supported">המכשיר תומך ב־AC3/Dolby Digital</string> <string name="advanced_settings">הגדרות מתקדמות</string> <string name="advanced_ui">ממשק מתקדם</string> @@ -398,8 +423,8 @@ <string name="backdrop_display">סגנון תמונת־רקע</string> <string name="resolution_switching">החלפת רזולוציית־תצוגה</string> <string name="local">מקומי</string> - <string name="play_trailer">נגן טריילר</string> - <string name="no_trailers">אין טריילרים</string> + <string name="play_trailer">נגן קדימון</string> + <string name="no_trailers">אין קדימונים</string> <string name="clear_track_choices">ניקוי בחירת רצועות</string> <string name="force_dovi_profile_7">ניגון ישיר של Dolby Vision Profile 7</string> <string name="force_dovi_profile_7_summary">התעלם מבדיקות תאימות של המכשיר</string> @@ -492,8 +517,8 @@ <string name="content_scale_fill">מתיחה לתצוגה</string> <string name="content_scale_fill_width">מתיחה לרוחב</string> <string name="content_scale_fill_height">מתיחה לגובה</string> - <string name="display_preset_default">ברירת המחדל של Wholphin</string> - <string name="display_preset_compact">Wholphin קומפקטי</string> + <string name="display_preset_default">ברירת המחדל של Wolfskin</string> + <string name="display_preset_compact">Wolfskin קומפקטי</string> <string name="display_preset_series_thumb">תמונות־ממוזערות של סדרה</string> <string name="display_preset_episode_thumbnails">תמונות־ממוזערות של פרק</string> <string name="bold_blue">כחול עז</string> @@ -569,7 +594,7 @@ <string name="select_all">בחר הכול</string> <string name="separate_types">סוגים נפרדים</string> <string name="prefer_logos">העדף להציג לוגו על פני כותרת</string> - <string name="updated_toast">Wholphin עודכן ל־%s</string> + <string name="updated_toast">Wolfskin עודכן ל־%s</string> <string name="remove_continue_watching">מחק מהמשך צפייה</string> <string name="remove_next_up">מחק סדרה מ־\"הבא בתור\"</string> <string name="view_removed_next_up">הצג סדרות שנמחקו מ־\"הבא בתור\"</string> @@ -588,7 +613,7 @@ <string name="skip_behavior">התנהגות דילוג</string> <string name="skip_behavior_summary">עבור פתיחים, כתוביות סיום וכו\'</string> <string name="play_with">נגן עם</string> - <string name="transcoding">המרת קידוד</string> + <string name="transcoding">המרת־קידוד</string> <string name="nal">NAL</string> <string name="avc">AVC</string> <string name="sdr">SDR</string> @@ -608,4 +633,127 @@ <string name="live_tv_repeat">שידור חוזר</string> <string name="color_code_programs">קידוד צבע לתוכניות</string> <string name="slideshow_at_beginning">מההתחלה</string> + <string name="pin_digits_hint">[0–9]</string> + <string name="combined_search_results">תוצאות חיפוש ממוזגות</string> + <string name="combined_search_results_on">הצג את כל התוצאות ברשימה אחת</string> + <string name="combined_search_results_off">הצג תוצאות לפי קטגוריה</string> + <string name="results">תוצאות</string> + <string name="require_login">דרוש אימות בכניסה לפרופיל</string> + <string name="show_voice_search_button">הצג כפתור לחיפוש קולי</string> + <string name="visible_ui">מוצג</string> + <string name="hidden_ui">חבוי</string> + <string name="view_options_type_grid">רשת</string> + <string name="view_options_type_list">רשימה</string> + <string name="view_options_type_list_compact">רשימה קומפקטית</string> + <string name="user_interface_language">שפת ממשק המשתמש</string> + <string name="display_toggles_title">תצוגת דירוגים</string> + <string name="display_toggles_summary">התאם אישית מה להציג</string> + <string name="profile_protection">אבטחת פרופיל</string> + <string name="continue_watching_click_behavior">התנהגות לחיצה על המשך צפייה / הבא בתור</string> + <string name="continue_watching_click_summary_on">נגן בלחיצה</string> + <string name="continue_watching_click_summary_off">הצג פרטים בלחיצה</string> + <string name="stars">כוכבים</string> + <string name="tomatoes">עגבניות</string> + <string name="sort">מיון</string> + <string name="split_into_separate_rows">פצל לשורות נפרדות</string> + <string name="movies_title">סרטים</string> + <string name="people_title">אנשים</string> + <string name="trailers_title">קדימונים</string> + <string name="tv_shows_title">סדרות</string> + <string name="other_extras_title">אחר</string> + <string name="behind_the_scenes_title">מאחורי הקלעים</string> + <string name="theme_songs_title">שירי־נושא</string> + <string name="theme_videos_title">סרטוני־נושא</string> + <string name="clips_title">קליפים</string> + <string name="deleted_scenes_title">סצינות גנוזות</string> + <string name="interviews_title">ראיונות</string> + <string name="scenes_title">סצינות</string> + <string name="samples_title">דגימות</string> + <string name="featurettes_title">קטעי בונוס</string> + <string name="shorts_title">קטעים קצרים</string> + <string name="duration_hours_minutes_seconds">%1$d שע\' %2$d דק\' %3$d שנ\'</string> + <string name="duration_hours_minutes">%1$d שע\' %2$d דק\'</string> + <string name="duration_minutes_seconds">%1$d דק\' %2$d שנ\'</string> + <string name="duration_hours">%1$d שע\'</string> + <string name="duration_minutes">%1$d דק\'</string> + <string name="duration_seconds">%1$d שנ\'</string> + <string name="time_left">%1$s נותרו</string> + <string name="prefer_ac3_for_surround_summary"><![CDATA[לקודד מחדש שמע שאינו סטריאו ל־AC3/Dolby Digital]]></string> + <string name="prefer_ac3_for_surround">להשתמש ב־AC3 עבור שמע סראונד</string> + <string name="genre_movies">סרטי %1$s</string> + <string name="genre_tv_shows">סדרות %1$s</string> + <string name="quality_profile">פרופיל איכות</string> + <string name="is_default">ברירת מחדל</string> + <string name="root_folder">תיקייה ראשית</string> + <string name="popularity">פופולריות</string> + <string name="production_status">מצב הפקה</string> + <string name="different_for_hdr">השתמש בסגנון שונה עבור HDR</string> + <string name="experimental_settings">הגדרות ניסיוניות</string> + <string name="confirm_enable_experimental_title">האם לאפשר הגדרות ניסיוניות?</string> + <string name="confirm_enable_experimental_body">ההגדרות הניסיוניות עשויות להיות לא יציבות!\n\nהן עשויות להימחק או להשתנות בכל גרסה.</string> + <string name="unavailable">לא זמין</string> + <string name="blocklisted">ברשימה חסומה</string> + <string name="video_tunneling">מנהור וידאו</string> + <string name="brown">חום</string> + <string name="dpad_seek_mode_minimal">מינימלי</string> + <string name="dpad_seek_mode_trickplay">Trickplay</string> + <string name="subtitle_mode">מצב כתוביות</string> + <string name="subtitle_mode_default">ברירת מחדל</string> + <string name="subtitle_mode_smart">חכם</string> + <string name="subtitle_mode_only_forced">כפויות בלבד</string> + <string name="subtitle_mode_always">תמיד</string> + <string name="subtitle_mode_none">ללא</string> + <string name="preferred_subtitle_language">שפה מועדפת לכתוביות</string> + <string name="preferred_audio_language">שפה מועדפת לשמע</string> + <string name="programs">תוכניות טלוויזיה</string> + <string name="recordings">הקלטות</string> + <string name="folders_title">תיקיות</string> + <string name="user_views">צפיות משתמש</string> + <string name="books">ספרים</string> + <string name="server_version_not_supported">גרסת השרת לא נתמכת</string> + <string name="profile_specific_settings_from_server">הגדרות פרופיל משתמש מהשרת</string> + <string name="d_pad_seek_mode_title">מצב פס הניגון של כפתור הניווט</string> + <string name="dpad_seek_mode_minimal_summary">פס ניגון קטן בתחתית המסך (ברירת מחדל)</string> + <string name="dpad_seek_mode_trickplay_summary">פס ניגון עם תצוגת תמונות Trickplay</string> + <string name="dpad_seek_mode_none_summary">ללא פס ניגון</string> + <string name="use_user_profile">השתמש בהגדרות הפרופיל שלך</string> + <string name="override_user_profile_settings">עוד הגדרות פרופיל משתמש</string> + <string name="override_user_profile_settings_summary">דרוס את הגדרות הפרופיל שלך מהשרת</string> + <string name="any_language">כל שפה</string> + <string name="subtitles_hearing_impaired">כתוביות לכבדי שמיעה</string> + <string name="audio_books">ספרי שמע</string> + <string name="hash_matches">התאמות של פונקציית־גיבוב</string> + <string name="appears_on">מופיע ב־</string> + <string name="disable_audio_offload_summary">השבתת פענוח אודיו בהורדה עשויה לתקן קפיאות בניגון במכשירים מסוימים</string> + <string name="disable_audio_offload">השבתת פענוח אודיו בהורדה</string> + <string name="dim_after_delay">עמעם תצוגה אחרי השהייה</string> + <string name="dim_percentage">עוצמת העמעום</string> + <string name="dim_screen">עמעם תצוגה</string> + <string name="photo_albums">אלבומי תמונות</string> + <string name="no_favorites_found">לא נמצאו מועדפים!</string> + <string name="duration_hours_seconds">%1$d שע\' %2$d שנ\'</string> + <string name="season_episode_number">עונה %1$d פרק %2$d</string> + <string name="season_episode_number_range">עונה %1$d פרקים %2$d-%3$d</string> + <string name="season_episode_number_padded">עונה %1$02d פרק %2$02d</string> + <string name="season_episode_number_padded_range">עונה %1$02d פרקים %2$02d-%3$02d</string> + <string name="episode_number_short">פרק %1$d</string> + <string name="season_number">עונה %1$d</string> + <string name="prefer_dovi_over_hdr10_plus">העדפת Dolby Vision על פני HDR10+</string> + <string name="prefer_dovi_over_hdr10_plus_summary">מחיקת מטא-נתונים של HDR10+ ממדיה מסוג Dolby Vision עבור טלוויזיות שמעדיפות לנגן HDR10+ במצב זה</string> + <string name="remove_from_playlist">מחיקה מרשימת־השמעה</string> + <string name="login_credentials_expired">פרטי ההתחברות פגו, נא להתחבר מחדש</string> + <string name="login_not_authorized">אינך מורשה לגשת לשרת בשלב זה</string> + <string name="max_resolution">רזולוציה מירבית לפני המרת־קידוד</string> + <string name="resolution_8k">8K</string> + <string name="resolution_4k">4K</string> + <string name="resolution_1080">1080</string> + <string name="resolution_720">720</string> + <string name="resolution_576">576</string> + <string name="resolution_480">480</string> + <string name="resolution_360">360</string> + <plurals name="episodes_count"> + <item quantity="one">פרק %1$d</item> + <item quantity="two">%1$d פרקים</item> + <item quantity="other">%1$d פרקים</item> + </plurals> </resources> diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml new file mode 100644 index 00000000..045e125f --- /dev/null +++ b/app/src/main/res/values-ja/strings.xml @@ -0,0 +1,3 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> +</resources> diff --git a/app/src/main/res/values-kab/strings.xml b/app/src/main/res/values-kab/strings.xml index 43c74c5a..7b8e9032 100644 --- a/app/src/main/res/values-kab/strings.xml +++ b/app/src/main/res/values-kab/strings.xml @@ -6,8 +6,8 @@ <string name="bitrate">Aktum</string> <string name="cancel">Semmet</string> <string name="chapters">Ixfawen</string> - <string name="collection">Tagrumma</string> - <string name="collections">Tigrummiwin</string> + <string name="collection">Talkensit</string> + <string name="collections">Tilkensa</string> <string name="confirm">Serggeg</string> <string name="default_track">Amezwer</string> <string name="delete">Kkes</string> @@ -43,4 +43,14 @@ <string name="hdr10_plus">HDR10+</string> <string name="hlg">HLG</string> <string name="choose_stream">Fren %1$s</string> + <string name="more">Ugar</string> + <string name="name">Isem</string> + <string name="none">Ula yiwen</string> + <string name="subtitle">Aduzwel</string> + <string name="subtitles">Iduzwilen</string> + <string name="suggestions">Isumar</string> + <string name="settings">Iɣewwaren</string> + <string name="reset">Wennez</string> + <string name="remove">Kkes</string> + <string name="subtitle_margin">Tama</string> </resources> diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml new file mode 100644 index 00000000..045e125f --- /dev/null +++ b/app/src/main/res/values-ko/strings.xml @@ -0,0 +1,3 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> +</resources> diff --git a/app/src/main/res/values-lo/strings.xml b/app/src/main/res/values-lo/strings.xml new file mode 100644 index 00000000..045e125f --- /dev/null +++ b/app/src/main/res/values-lo/strings.xml @@ -0,0 +1,3 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> +</resources> diff --git a/app/src/main/res/values-lv/strings.xml b/app/src/main/res/values-lv/strings.xml index adb54e50..50efdb0c 100644 --- a/app/src/main/res/values-lv/strings.xml +++ b/app/src/main/res/values-lv/strings.xml @@ -31,7 +31,7 @@ <string name="director">Režisors</string> <string name="disabled">Atslēgts</string> <string name="discovered_servers">Atrastie serveri</string> - <string name="download_and_update"><![CDATA[Lejupielādēt & Atjaunināt]]></string> + <string name="download_and_update">Lejupielādēt & Atjaunināt</string> <string name="downloading">Lejupielādē…</string> <string name="enabled">Ieslēgts</string> <string name="ends_at">Beigsies %1$s</string> @@ -65,8 +65,7 @@ <string name="more">Vairāk</string> <plurals name="movies"> <item quantity="zero">Filmas</item> - <item quantity="one"/> - <item quantity="other"/> + <item quantity="other">Filmas</item> </plurals> <string name="name">Nosaukums</string> <string name="next_up">Rindā</string> @@ -81,8 +80,7 @@ <string name="path">Ceļš</string> <plurals name="people"> <item quantity="zero">Cilvēki</item> - <item quantity="one"/> - <item quantity="other"/> + <item quantity="other">Cilvēki</item> </plurals> <string name="play_count">Skatījumu skaits</string> <string name="play_from_here">Atskaņot no šejienes</string> @@ -107,7 +105,7 @@ <string name="restart">Restartēt</string> <string name="resume">Turpināt</string> <string name="save">Saglabāt</string> - <string name="search_and_download"><![CDATA[Meklēt & Lejupielādēt]]></string> + <string name="search_and_download">Meklēt & Lejupielādēt</string> <string name="search">Meklēt</string> <string name="searching">Meklē…</string> <string name="voice_search">Balss meklēšana</string> @@ -150,11 +148,9 @@ <string name="switch_servers">Mainīt serverus</string> <string name="switch_user">Pārslēgt</string> <string name="top_unwatched">Augstāk novērtētie un neredzētie</string> - <string name="trailer">Treileris</string> <plurals name="trailers"> <item quantity="zero">Treileri</item> - <item quantity="one"/> - <item quantity="other"/> + <item quantity="other">Treileri</item> </plurals> <string name="tv_dvr_schedule">Ierakstu plāns</string> <string name="tv_guide">Programma</string> @@ -162,8 +158,7 @@ <string name="tv_seasons">Sezonas</string> <plurals name="tv_shows"> <item quantity="zero">TV Šovi</item> - <item quantity="one"/> - <item quantity="other"/> + <item quantity="other">TV Šovi</item> </plurals> <string name="ui_interface">Saskarne</string> <string name="unknown">Nezināms</string> @@ -173,7 +168,9 @@ <string name="video">Video</string> <string name="videos">Video</string> <string name="watch_live">Skatīties tiešraidi</string> - <string name="years_old">%1$d gadus vecs</string> + <plurals name="years_old"> + <item quantity="other">%1$d gadus vecs</item> + </plurals> <string name="play_with_transcoding">Atskaņot ar transkodēšanu</string> <string name="media_information">Informācija par failu</string> <string name="show_clock">Rādīt Pulksteni</string> @@ -188,63 +185,52 @@ <string name="success">Veiksmīgi</string> <string name="official_rating">Vecuma ierobežojums</string> <string name="runtime_sort">Ilgums</string> - <string name="extras">Ekstras</string> + <string name="extras_title">Ekstras</string> <plurals name="other_extras"> <item quantity="zero">Cits</item> - <item quantity="one"/> - <item quantity="other"/> + <item quantity="other">Cits</item> </plurals> <plurals name="behind_the_scenes"> <item quantity="zero">Aizkadri</item> - <item quantity="one"/> - <item quantity="other"/> + <item quantity="other">Aizkadri</item> </plurals> <plurals name="theme_songs"> <item quantity="zero">Tituldziesmas</item> - <item quantity="one"/> - <item quantity="other"/> + <item quantity="other">Tituldziesmas</item> </plurals> <plurals name="theme_videos"> <item quantity="zero">Titulvideo</item> - <item quantity="one"/> - <item quantity="other"/> + <item quantity="other">Titulvideo</item> </plurals> <plurals name="clips"> <item quantity="zero">Klipi</item> - <item quantity="one"/> - <item quantity="other"/> + <item quantity="other">Klipi</item> </plurals> <plurals name="deleted_scenes"> <item quantity="zero">Izgrieztās ainas</item> - <item quantity="one"/> - <item quantity="other"/> + <item quantity="other">Izgrieztās ainas</item> </plurals> <plurals name="interviews"> <item quantity="zero">Intervijas</item> - <item quantity="one"/> - <item quantity="other"/> + <item quantity="other">Intervijas</item> </plurals> <plurals name="scenes"> <item quantity="zero">Ainas</item> - <item quantity="one"/> - <item quantity="other"/> + <item quantity="other">Ainas</item> </plurals> <plurals name="samples"> <item quantity="zero">Paraugi</item> - <item quantity="one"/> - <item quantity="other"/> + <item quantity="other">Paraugi</item> </plurals> <plurals name="featurettes"> <item quantity="zero">Īssižeti</item> - <item quantity="one"/> - <item quantity="other"/> + <item quantity="other">Īssižeti</item> </plurals> <plurals name="shorts"> <item quantity="zero">Īsie rullīši</item> - <item quantity="one"/> - <item quantity="other"/> + <item quantity="other">Īsie rullīši</item> </plurals> - <string name="favorite_items">Izlase %s</string> + <string name="favorite_items_title">Izlase %s</string> <plurals name="downloads"> <item quantity="zero">%s lejupielāžu</item> <item quantity="one">%s lejupielāde</item> diff --git a/app/src/main/res/values-nb-rNO/strings.xml b/app/src/main/res/values-nb-rNO/strings.xml index d892ebf3..c8066cec 100644 --- a/app/src/main/res/values-nb-rNO/strings.xml +++ b/app/src/main/res/values-nb-rNO/strings.xml @@ -23,11 +23,11 @@ <string name="confirm">Bekreft</string> <string name="continue_watching">Fortsett å se</string> <string name="critic_rating">Kritikervurdering</string> - <string name="decimal_seconds">%.1f sekunder</string> + <string name="decimal_seconds">%.2f sekunder</string> <string name="director">Regissør</string> <string name="disabled">Deaktivert</string> <string name="discovered_servers">Oppdagede servere</string> - <string name="download_and_update"><![CDATA[Last ned og oppdater]]></string> + <string name="download_and_update">Last ned og oppdater</string> <string name="downloading">Laster ned…</string> <string name="enabled">Aktivert</string> <string name="enter_server_url">Oppgi server-IP eller URL</string> @@ -59,7 +59,7 @@ <string name="max_bitrate">Maks bitrate</string> <string name="more_like_this">Mer som dette</string> <string name="more">Mer</string> - <string name="movies">Filmer</string> + <string name="movies_title">Filmer</string> <string name="name">Navn</string> <string name="next_up">Neste</string> <string name="no_data">Ingen data</string> @@ -70,7 +70,7 @@ <string name="none">Ingen</string> <string name="outro">Outro</string> <string name="path">Filbane</string> - <string name="people">Personer</string> + <string name="people_title">Personer</string> <string name="play_count">Antall avspillinger</string> <string name="play_from_here">Spill av herfra</string> <string name="play">Spill av</string> @@ -94,7 +94,7 @@ <string name="restart">Start på nytt</string> <string name="resume">Fortsett</string> <string name="save">Lagre</string> - <string name="search_and_download"><![CDATA[Søk og last ned]]></string> + <string name="search_and_download">Søk og last ned</string> <string name="search">Søk</string> <string name="searching">Søker…</string> <string name="select_server">Velg server</string> @@ -119,16 +119,15 @@ <string name="switch_servers">Bytt server</string> <string name="switch_user">Bytt bruker</string> <string name="top_unwatched">Best vurderte usette</string> - <string name="trailer">Trailer</string> <plurals name="trailers"> - <item quantity="one">Trailere</item> - <item quantity="other"/> + <item quantity="one">Trailer</item> + <item quantity="other">Trailere</item> </plurals> <string name="tv_dvr_schedule">DVR-plan</string> <string name="tv_guide">Programoversikt</string> <string name="tv_season">Sesong</string> <string name="tv_seasons">Sesonger</string> - <string name="tv_shows">TV-serier</string> + <string name="tv_shows_title">TV-serier</string> <string name="ui_interface">Grensesnitt</string> <string name="unknown">Ukjent</string> <string name="updates">Oppdateringer</string> @@ -137,7 +136,10 @@ <string name="video">Video</string> <string name="videos">Videoer</string> <string name="watch_live">Se direkte</string> - <string name="years_old">%1$d år gammel</string> + <plurals name="years_old"> + <item quantity="one">%1$d år gammel</item> + <item quantity="other">%1$d år gammel</item> + </plurals> <string name="play_with_transcoding">Spill av med transkoding</string> <string name="media_information">Mediainformasjon</string> <string name="show_clock">Vis klokke</string> @@ -152,50 +154,50 @@ <string name="success">Fullført</string> <string name="official_rating">Aldersgrense</string> <string name="runtime_sort">Spilletid</string> - <string name="extras">Ekstramateriale</string> + <string name="extras_title">Ekstramateriale</string> <plurals name="other_extras"> <item quantity="one">Annet</item> - <item quantity="other"/> + <item quantity="other">Annet</item> </plurals> <plurals name="behind_the_scenes"> <item quantity="one">Bak kulissene</item> - <item quantity="other"/> + <item quantity="other">Bak kulissene</item> </plurals> <plurals name="theme_songs"> - <item quantity="one">Temasanger</item> - <item quantity="other"/> + <item quantity="one">Kjenningsmelodi</item> + <item quantity="other">Kjenningsmelodier</item> </plurals> <plurals name="theme_videos"> - <item quantity="one">Temavideoer</item> - <item quantity="other"/> + <item quantity="one">Kjenningsvideo</item> + <item quantity="other">Kjenningsvideoer</item> </plurals> <plurals name="clips"> <item quantity="one">Klipp</item> - <item quantity="other"/> + <item quantity="other">Klipp</item> </plurals> <plurals name="deleted_scenes"> - <item quantity="one">Slettede scener</item> - <item quantity="other"/> + <item quantity="one">Slettet scene</item> + <item quantity="other">Slettede scener</item> </plurals> <plurals name="interviews"> - <item quantity="one">Intervjuer</item> - <item quantity="other"/> + <item quantity="one">Intervju</item> + <item quantity="other">Intervjuer</item> </plurals> <plurals name="scenes"> - <item quantity="one">Scener</item> - <item quantity="other"/> + <item quantity="one">Scene</item> + <item quantity="other">Scener</item> </plurals> <plurals name="samples"> - <item quantity="one">Smakebiter</item> - <item quantity="other"/> + <item quantity="one">Smakebit</item> + <item quantity="other">Smakebiter</item> </plurals> <plurals name="featurettes"> - <item quantity="one">Bakomfilmer</item> - <item quantity="other"/> + <item quantity="one">Novellefilm</item> + <item quantity="other">Novellefilmer</item> </plurals> <plurals name="shorts"> - <item quantity="one">Kortfilmer</item> - <item quantity="other"/> + <item quantity="one">Kortfilm</item> + <item quantity="other">Kortfilmer</item> </plurals> <plurals name="downloads"> <item quantity="one">%s nedlasting</item> @@ -221,8 +223,8 @@ <string name="auto_play_next">Spill neste automatisk</string> <string name="check_for_updates">Sjekk etter oppdateringer</string> <string name="combine_continue_next_summary">Gjelder kun TV-serier</string> - <string name="combine_continue_next"><![CDATA[Slå sammen \'Fortsett å se\' og \'Neste\']]></string> - <string name="direct_play_ass">Direkteavspilling av ASS-undertekster</string> + <string name="combine_continue_next">Slå sammen \'Fortsett å se\' og \'Neste\'</string> + <string name="direct_play_ass">Bruk libass for ASS-undertekster</string> <string name="direct_play_pgs">Direkteavspilling av PGS-undertekster</string> <string name="downmix_stereo">Alltid nedmiks til stereo</string> <string name="ffmpeg_extension_pref">Bruk FFmpeg-dekodermodul</string> @@ -306,7 +308,7 @@ <string name="aspect_ratio">Bildeforhold</string> <string name="show_details">Vis detaljer</string> <string name="image_type">Bildetype</string> - <string name="cast_and_crew"><![CDATA[Skuespillere og medarbeidere]]></string> + <string name="cast_and_crew">Skuespillere og medarbeidere</string> <string name="guest_stars">Gjesteroller</string> <string name="edge_size">Kantstørrelse</string> <string name="refresh_rate_switching">Bytting av bildeoppdateringsfrekvens</string> @@ -364,4 +366,347 @@ <string name="play_trailer">Spill trailer</string> <string name="no_trailers">Ingen trailere</string> <string name="clear_track_choices">Fjern valg av spor</string> + <string name="duration_hours_minutes_seconds">%1$dh %2$dm %3$ds</string> + <string name="duration_hours_minutes">%1$dt %2$dmin</string> + <string name="duration_minutes_seconds">%1$dmin %2$ds</string> + <string name="duration_hours">%1$dt</string> + <string name="duration_minutes">%1$dmin</string> + <string name="duration_seconds">%1$ds</string> + <string name="only_forced_subtitles">Bare Tvungne Undertekster</string> + <string name="profile_specific_settings_from_server">Brukerprofil instillinger fra server</string> + <string name="voice_search">Stemmesøk</string> + <string name="voice_starting">Begynner</string> + <string name="voice_search_prompt">Snakk for å søke</string> + <string name="processing">Behandler</string> + <string name="press_back_to_cancel">Trykk tilbake for å avbryte</string> + <string name="voice_error_audio">Lydopptaksfeil</string> + <string name="voice_error_client">Stemmegjenkjenningsfeil</string> + <string name="voice_error_permissions">Mikrofonadgang nødvendig</string> + <string name="voice_error_network">Nettverksfeil</string> + <string name="voice_error_network_timeout">Nettverksavbrudd</string> + <string name="voice_error_no_match">Ingen tale gjenkjent</string> + <string name="voice_error_busy">Stemmegjenkjenning opptatt</string> + <string name="voice_error_server">Tjenerfeil</string> + <string name="voice_error_speech_timeout">Ingen tale oppdaget</string> + <string name="voice_error_unknown">Ukjent feil</string> + <string name="voice_error_start_failed">Feil ved oppstart av stemmegjenkjenning</string> + <string name="voice_error_timeout">Tidsavbrudd ved stemmegjenkjenning</string> + <string name="retry">Prøv igjen</string> + <string name="time_left">%1$s gjenstår</string> + <string name="trailers_title">Trailere</string> + <string name="other_extras_title">Annet</string> + <string name="behind_the_scenes_title">Bak kulissene</string> + <string name="theme_songs_title">Kjenningsmelodier</string> + <string name="theme_videos_title">Kjenningsvideoer</string> + <string name="clips_title">Klipp</string> + <string name="deleted_scenes_title">Slettede Scener</string> + <string name="interviews_title">Intervju</string> + <string name="scenes_title">Scener</string> + <string name="samples_title">Prøver</string> + <string name="featurettes_title">Novellefilmer</string> + <string name="shorts_title">Kortfilmer</string> + <string name="favorite_items_title">Favoritt %s</string> + <plurals name="days"> + <item quantity="one">%s dag</item> + <item quantity="other">%s dager</item> + </plurals> + <plurals name="minutes"> + <item quantity="one">%s minutt</item> + <item quantity="other">%s minutter</item> + </plurals> + <plurals name="movies"> + <item quantity="one">Film</item> + <item quantity="other">Filmer</item> + </plurals> + <plurals name="tv_shows"> + <item quantity="one">Serie</item> + <item quantity="other">Serier</item> + </plurals> + <plurals name="people"> + <item quantity="one">Person</item> + <item quantity="other">Personer</item> + </plurals> + <string name="require_login">Krev innlogging for profil</string> + <string name="pin_digits_hint">[0-9]</string> + <string name="disable_audio_offload">Deaktiver avlasting av lydmaskinvare</string> + <string name="disable_audio_offload_summary">Deaktivering av lyddekodingavlasting kan fikse fryst bilde ved avspilling på noen enheter</string> + <string name="combined_search_results">Kombinert søkeresultater</string> + <string name="combined_search_results_on">Vis alle resultater i én liste</string> + <string name="combined_search_results_off">Vis resultater etter kategori</string> + <string name="show_voice_search_button">Vis knapp for stemmesøk</string> + <string name="visible_ui">Synlig</string> + <string name="hidden_ui">Skjult</string> + <string name="results">Resultater</string> + <string name="dts_x">DTS:X</string> + <string name="dts_hd">DTS:HD</string> + <string name="dts_hd_ma">DTS:HD MA</string> + <string name="truehd">TrueHD</string> + <string name="dolby_digital">DD</string> + <string name="dolby_digital_plus">DD+</string> + <string name="dts">DTS</string> + <string name="force_dovi_profile_7">Direct play Dolby Vision Profile 7</string> + <string name="force_dovi_profile_7_summary">Ignorer kompatibilitetsjekk av enheten</string> + <string name="discover">Oppdag</string> + <string name="request">Forespørsel</string> + <string name="pending">Venter</string> + <string name="seerr_integration">Seerr integrering</string> + <string name="remove_seerr_server">Fjern Seerr Tjener</string> + <string name="seerr_server_added">Seerr tjener lagt til</string> + <string name="quick_connect">Rask Tilkobling</string> + <string name="quick_connect_summary">Autoriser annen enhet til å logge på kontoen din</string> + <string name="for_episodes">For episoder</string> + <string name="suggestions_for">Forslag for %1$s</string> + <string name="increase_all_cards_size">Øk størrelsen for alle kort</string> + <string name="decrease_all_cards_size">Reduser størrelsen for alle kort</string> + <string name="use_thumb_images">Bruk miniatyrbilder</string> + <string name="settings_saved">Instillinger lagret</string> + <string name="display_presets">Vis forhåndsinnstillinger</string> + <string name="display_presets_description">Innebygde forhåndsinnstillinger for å raskt style alle rader</string> + <string name="customize_home_summary">Velg rader og bilder på hjemmesiden</string> + <string name="prefer_ac3_for_surround_summary"><![CDATA[Transkode ikke-stereo lyd til AV3/Dolby Digital]]></string> + <string name="prefer_ac3_for_surround">Bruk AC3 for surround sound lyd</string> + <string name="start_after">Start etter</string> + <string name="animate">Animer</string> + <string name="max_age_rating">Maks aldersgrense</string> + <string name="no_max">Ingen maks</string> + <string name="for_all_ages">For alle aldre</string> + <string name="up_to_age">Opp til %s år</string> + <string name="screensaver">Skjermsparer</string> + <string name="screensaver_settings">Innstillinger for skjermsparer</string> + <string name="start_screensaver">Start skjermsparer</string> + <string name="duration">Varighet</string> + <string name="photos">Bilder</string> + <string name="include_types">Inkluder typer</string> + <string name="include_types_summary">Hvilke elementer å vise bilder for</string> + <string name="content_scale_fit">Tilpass</string> + <string name="content_scale_crop">Beskjær</string> + <string name="content_scale_fill">Fyll</string> + <string name="content_scale_fill_width">Fyll bredde</string> + <string name="content_scale_fill_height">Fyll høyde</string> + <string name="display_preset_default">Wolfskin Standard</string> + <string name="display_preset_compact">Wolfskin Kompakt</string> + <string name="display_preset_series_thumb">Serieminiatyrbilder</string> + <string name="display_preset_episode_thumbnails">Episodeminiatyrbilder</string> + <string name="volume_lowest">Lavest</string> + <string name="volume_low">Lav</string> + <string name="volume_medium">Middels</string> + <string name="volume_high">Høy</string> + <string name="volume_full">Full</string> + <string name="purple">Lilla</string> + <string name="orange">Oransje</string> + <string name="bold_blue">Dristig Blå</string> + <string name="black">Svart</string> + <string name="brown">Brun</string> + <string name="skip_ignore">Ignorer</string> + <string name="skip_automatically">Hopp over automatisk</string> + <string name="skip_ask">Spør om å hoppe over</string> + <string name="ffmpeg_fallback">Bare bruk FFmpeg dersom ingen innebygd dekoder eksisterer</string> + <string name="ffmpeg_prefer">Foretrekk å bruke FFmpeg over innebygde dekodere</string> + <string name="ffmpeg_never">Aldri bruk FFmpeg dekodere</string> + <string name="next_up_playback_end">Ved slutten av avspilling</string> + <string name="next_up_outro">Under rulletekst/avslutning</string> + <string name="next_up_never">Aldri</string> + <string name="white">Hvit</string> + <string name="light_gray">Lysegrå</string> + <string name="dark_gray">Mørkegrå</string> + <string name="yellow">Gul</string> + <string name="cyan">Cyan</string> + <string name="magenta">Magenta</string> + <string name="subtitle_edge_outline">Kant</string> + <string name="subtitle_edge_shadow">Skygge</string> + <string name="background_style_wrap">Innpakning</string> + <string name="background_style_boxed">Innebokset</string> + <string name="prefer_mpv">Foretrekk MPV</string> + <string name="external_player">Ekstern Spiller</string> + <string name="player_backend_options_subtitles_prefer_mpv">Bruk ExoPlayer for avspilling av HDR</string> + <string name="aspect_ratios_poster">Plakat (2:3)</string> + <string name="aspect_ratios_16_9">16:9</string> + <string name="aspect_ratios_4_3">4:3</string> + <string name="aspect_ratios_square">Kvadrat (1:1)</string> + <string name="image_type_primary">Primær</string> + <string name="image_type_thumb">Miniatyr</string> + <string name="view_options_type_grid">Rutenett</string> + <string name="view_options_type_list">Liste</string> + <string name="view_options_type_list_compact">Kompakt Liste</string> + <string name="backdrop_style_dynamic">Bilde med dynamisk farge</string> + <string name="backdrop_style_image">Bare bilde</string> + <string name="enter_url_api_key"><![CDATA[Skriv inn URL & API-nøkkel]]></string> + <string name="api_key">API-nøkkel</string> + <string name="seerr_login">Logg inn på Seerr-tjener</string> + <string name="seerr_jellyfin_user">Jellyfinbruker</string> + <string name="seerr_local_user">Lokal bruker</string> + <string name="search_and_download_subtitles"><![CDATA[Søk og last ned undertekst]]></string> + <string name="no_subtitles_found">Ingen eksterne undertekster ble funnet</string> + <string name="series_continueing">Nåværende</string> + <string name="in_app_screensaver">Bruk appens skjermsparer</string> + <string name="delete_item">Er du sikker på at du vil slette dette elementet?</string> + <string name="show_media_management">Vis alternativ for behandling av medier</string> + <string name="search_for">Søk %s</string> + <string name="actor">Skuespiller</string> + <string name="composer">Komponist</string> + <string name="writer">Forfatter</string> + <string name="guest_star">Gjesteartist</string> + <string name="producer">Produsent</string> + <string name="conductor">Dirigent</string> + <string name="lyricist">Sangtekstforfatter</string> + <string name="arranger">Arrangør</string> + <string name="engineer">Ingeniør</string> + <string name="mixer">Lydmikser</string> + <string name="creator">Skaper</string> + <string name="artist">Artist</string> + <string name="album_artist">Albumartist</string> + <string name="album">Album</string> + <string name="popular_songs">Populære sanger</string> + <string name="play_next">Spill av neste</string> + <string name="music_videos">Musikkvideoer</string> + <string name="lyrics">Sangtekst</string> + <string name="hide_lyrics">Skjul sangtekst</string> + <string name="show_lyrics">Vis sangtekst</string> + <string name="song_has_lyrics">Låt har sangtekst</string> + <string name="remove_from_queue">Fjern fra kø</string> + <string name="show_album_cover">Vis albumomslag</string> + <string name="show_visualizer">Vis visualizer</string> + <string name="show_backdrop">Vis bakgrunn</string> + <string name="play_as_type">Spill av som?</string> + <string name="visualizer_rationale">Visualisering av lyden som spilles av, krever tillatelse til å ta opp lyd.</string> + <string name="continue_string">Fortsett</string> + <string name="select_all">Velg alle</string> + <string name="separate_types">Seperate typer</string> + <string name="prefer_logos">Foretrekk å vise logoer for titler</string> + <string name="updated_toast">Wolfskin oppdatert til %s</string> + <string name="remove_continue_watching">Fjern fra fortsett å se</string> + <string name="remove_next_up">Fjern serier fra neste</string> + <string name="view_removed_next_up">Vis serier fjernet fra neste</string> + <string name="view_more">Vis mer</string> + <string name="discover_tv">Oppdag TV-serier</string> + <string name="discover_movies">Oppdag Filmer</string> + <string name="studios_in">Studioer i %1$s</string> + <string name="last_played">Sist avspilt</string> + <string name="system_default">System standard</string> + <string name="ass_subtitle_mode_libass">Direkteavspilling med libass</string> + <string name="ass_subtitle_mode_exoplayer">Direkteavspilling med ExoPlayer innebygd</string> + <string name="ass_subtitle_mode_transcode">Brenn/transkode på tjener</string> + <string name="ass_subtitle_playback">SSA/ASS avspilling av undertekst</string> + <string name="cinema_mode">Kinomodus</string> + <string name="show_more">Vis %1$s mer</string> + <string name="skip_behavior">Hopp over oppførsel</string> + <string name="skip_behavior_summary">For introer, rulletekst, etc</string> + <string name="play_with">Spill med</string> + <string name="transcoding">Transkoding</string> + <string name="user_interface_language">Språk på brukergrensesnitt</string> + <string name="display_toggles_title">Visning av anmeldelser</string> + <string name="display_toggles_summary">Tilpass hva å vise</string> + <string name="profile_protection">Profilbeskyttelse</string> + <string name="continue_watching_click_behavior">Fortsett å se/Neste klikkoppførsel</string> + <string name="continue_watching_click_summary_on">Spill av ved klikk</string> + <string name="continue_watching_click_summary_off">Vis detaljer ved klikk</string> + <string name="stars">Stjerner</string> + <string name="tomatoes">Tomater</string> + <string name="sort">Sorter</string> + <string name="split_into_separate_rows">Del inn i separate rader</string> + <string name="genre_movies">%1$s Filmer</string> + <string name="genre_tv_shows">%1$s TV-Serier</string> + <string name="quality_profile">Kvalitetsprofil</string> + <string name="is_default">Standard</string> + <string name="root_folder">Root-mappe</string> + <string name="popularity">Populæritet</string> + <string name="production_status">Produksjonstatus</string> + <string name="different_for_hdr">Bruk annen stil for HDR</string> + <string name="experimental_settings">Eksperimentelle innstillinger</string> + <string name="confirm_enable_experimental_title">Aktiver eksperimentelle innstillinger?</string> + <string name="confirm_enable_experimental_body">Eksperimentelle innstillinger kan være ustabile!\n\nDisse kan fjernes eller endre i hvilken som helst versjon.</string> + <string name="quick_connect_code">Skriv inn Quick Connect kode</string> + <string name="quick_connect_code_error">Kode må være 6 siffer</string> + <string name="quick_connect_success">Enhet autorisering vellykket</string> + <string name="password">Passord</string> + <string name="username">Brukernavn</string> + <string name="url">URL</string> + <string name="trending">Trender</string> + <string name="upcoming_movies">Kommende Filmer</string> + <string name="upcoming_tv">Kommende TV-Serier</string> + <string name="request_4k">Forespør i 4K</string> + <string name="software_decoding_av1">AV1 programvaredekoding</string> + <string name="upgrade_mpv_toast">MPV er nå standardspiller utenom for HDR.\nDu kan endre dette i innstillinger.</string> + <string name="hdr_subtitle_style">HDR undertekststil</string> + <string name="image_subtitle_opacity">Bildeundertekst ugjennomsiktighet</string> + <string name="brightness">Lysstyrke</string> + <string name="contrast">Kontrast</string> + <string name="saturation">Metning</string> + <string name="hue">Tone</string> + <string name="red">Rød</string> + <string name="green">Grønn</string> + <string name="blue">Blå</string> + <string name="blur">Uklarhet</string> + <string name="save_for_album">Lagre for album</string> + <string name="play_slideshow">Start lysbildefremvisning</string> + <string name="stop_slideshow">Stans lysbildefremvisning</string> + <string name="slideshow_at_beginning">Ved start</string> + <string name="no_more_images">Ingen flere bilder</string> + <string name="rotate_left">Roter venstre</string> + <string name="rotate_right">Roter høyre</string> + <string name="zoom_in">Zoom inn</string> + <string name="zoom_out">Zoom ut</string> + <string name="slideshow_duration">Lysbildefremvisningens varighet</string> + <string name="play_videos_during_slideshow">Spill av video under lysbildefremvisning</string> + <string name="send_media_info_log_to_server">Send mediainformasjonslogg til tjener</string> + <string name="no_limit">Ingen grense</string> + <string name="max_days_next_up">Maks dager i Neste</string> + <string name="albums">Album</string> + <string name="artists">Artister</string> + <string name="songs">Sanger</string> + <string name="go_to_artist">Gå til artist</string> + <string name="now_playing">Spilles nå</string> + <string name="instant_mix">Øyeblikkelig miks</string> + <string name="go_to_album">Gå til album</string> + <string name="add_to_queue">Legg til i kø</string> + <string name="add_row">Legg til rad</string> + <string name="genres_in">Sjangre i %1$s</string> + <string name="recently_released_in">Nylig utgitt i %1$s</string> + <string name="height">Høyde</string> + <string name="apply_all_rows">Bruk på alle rader</string> + <string name="customize_home">Tilpass hjemmesiden</string> + <string name="home_rows">Hjemmerader</string> + <string name="load_from_server">Last inn fra tjeners brukerprofil</string> + <string name="save_to_server">Lagre på tjeners brukerprofil</string> + <string name="load_from_web_client">Last inn fra web-client</string> + <string name="use_series">Bruk seriebilde</string> + <string name="add_row_for">Legg til rad for %1$s</string> + <string name="overwrite_server_settings">Overskriv innstillinger på tjener?</string> + <string name="overwrite_local_settings">Overskriv lokale innstillinger?</string> + <string name="video_tunneling">Videotunnelering</string> + <string name="d_pad_seek_mode_title">D-Pad søkelinjemodus</string> + <string name="dpad_seek_mode_minimal">Minimal</string> + <string name="dpad_seek_mode_trickplay">Lurespilling</string> + <string name="dpad_seek_mode_minimal_summary">Liten søkelinje langs bunnen av skjermen (standard)</string> + <string name="dpad_seek_mode_trickplay_summary">Søkelinje med lurespilling forhåndsvisningsbilde</string> + <string name="dpad_seek_mode_none_summary">Ingen søkelinje</string> + <string name="blocklisted">Blokkeringslistet</string> + <string name="unavailable">Utilgjengelig</string> + <string name="subtitle_mode">Undertekstmodus</string> + <string name="use_user_profile">Din brukerprofilinnstilling</string> + <string name="subtitle_mode_default">Standard</string> + <string name="subtitle_mode_smart">Smart</string> + <string name="subtitle_mode_only_forced">Bare Tvunget</string> + <string name="subtitle_mode_always">Alltid</string> + <string name="subtitle_mode_none">Ingen</string> + <string name="preferred_subtitle_language">Foretrukket undertekstspråk</string> + <string name="preferred_audio_language">Foretrukket lydspråk</string> + <string name="override_user_profile_settings">Flere brukerprofilinnstillinger</string> + <string name="override_user_profile_settings_summary">Overskriv brukerprofilinnstillingene dine fra tjeneren</string> + <string name="any_language">Hvilket som helst språk</string> + <string name="hash_matches">Hash-matcher</string> + <string name="subtitles_hearing_impaired">SDH</string> + <string name="programs">TV-program</string> + <string name="recordings">Opptak</string> + <string name="folders_title">Mapper</string> + <string name="user_views">Brukervisninger</string> + <string name="books">Bøker</string> + <string name="audio_books">Lydbøker</string> + <string name="server_version_not_supported">Tjenerversjon ikke støttet</string> + <string name="appears_on">Opptrer på</string> + <string name="dim_after_delay">Dim skjerm etter forsinking</string> + <string name="dim_percentage">Dim prosent</string> + <string name="dim_screen">Dim skjerm</string> + <string name="photo_albums">Fotoalbum</string> + <string name="no_favorites_found">Ingen favoritter funnet!</string> </resources> diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index ecef75fd..1e4891ba 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -42,8 +42,8 @@ <string name="file_size">Grootte</string> <string name="forced_track">Geforceerd</string> <string name="genres">Genres</string> - <string name="go_to_series">Ga naar serie</string> - <string name="go_to">Ga naar</string> + <string name="go_to_series">Naar seizoenen</string> + <string name="go_to">Naar inhoud</string> <string name="hide_controller_timeout">Afspeelbediening verbergen</string> <string name="hide_debug_info">Foutopsporingsinformatie verbergen</string> <string name="hide">Verbergen</string> @@ -62,7 +62,7 @@ <string name="max_bitrate">Max. bitsnelheid</string> <string name="more_like_this">Soortgelijk</string> <string name="more">Meer</string> - <string name="movies">Films</string> + <string name="movies_title">Films</string> <string name="name">Naam</string> <string name="next_up">Volgende</string> <string name="no_data">Geen informatie</string> @@ -72,7 +72,7 @@ <string name="none">Geen</string> <string name="outro">Aftiteling</string> <string name="path">Locatie</string> - <string name="people">Personen</string> + <string name="people_title">Personen</string> <string name="play_count">Aantal keer bekeken</string> <string name="play_from_here">Vanaf hier afspelen</string> <string name="play">Afspelen</string> @@ -121,7 +121,6 @@ <string name="switch_servers">Andere server kiezen</string> <string name="switch_user">Kiezen</string> <string name="top_unwatched">Best beoordeeld niet-bekeken</string> - <string name="trailer">Trailer</string> <plurals name="trailers"> <item quantity="one">Trailer</item> <item quantity="other">Trailers</item> @@ -130,7 +129,7 @@ <string name="tv_guide">Gids</string> <string name="tv_season">Seizoen</string> <string name="tv_seasons">Seizoenen</string> - <string name="tv_shows">Series</string> + <string name="tv_shows_title">Series</string> <string name="ui_interface">Vormgeving</string> <string name="unknown">Onbekend</string> <string name="updates">Updates</string> @@ -139,7 +138,9 @@ <string name="video">Video</string> <string name="videos">Video\'s</string> <string name="watch_live">Live kijken</string> - <string name="years_old">%1$d jaar oud</string> + <plurals name="years_old"> + <item quantity="other">%1$d jaar oud</item> + </plurals> <string name="play_with_transcoding">Afspelen met transcodering</string> <string name="show_clock">Klok tonen</string> <string name="aired_episode_order">Volgorde van uitgezonden afleveringen</string> @@ -153,50 +154,50 @@ <string name="success">Voltooid</string> <string name="official_rating">Kijkwijzer</string> <string name="runtime_sort">Duur</string> - <string name="extras">Extra\'s</string> + <string name="extras_title">Extra\'s</string> <plurals name="other_extras"> <item quantity="one">Overig</item> - <item quantity="other"/> + <item quantity="other">Overig</item> </plurals> <plurals name="behind_the_scenes"> <item quantity="one">Achter de schermen</item> - <item quantity="other"/> + <item quantity="other">Achter de schermen</item> </plurals> <plurals name="theme_songs"> <item quantity="one">Titelmuziek</item> - <item quantity="other"/> + <item quantity="other">Titelmuziek</item> </plurals> <plurals name="theme_videos"> - <item quantity="one">Titelvideo\'s</item> - <item quantity="other"/> + <item quantity="one">Titelvideo</item> + <item quantity="other">Titelvideo\'s</item> </plurals> <plurals name="clips"> - <item quantity="one">Clips</item> - <item quantity="other"/> + <item quantity="one">Clip</item> + <item quantity="other">Clips</item> </plurals> <plurals name="deleted_scenes"> - <item quantity="one">Verwijderde scènes</item> - <item quantity="other"/> + <item quantity="one">Verwijderde scène</item> + <item quantity="other">Verwijderde scènes</item> </plurals> <plurals name="interviews"> - <item quantity="one">Interviews</item> - <item quantity="other"/> + <item quantity="one">Interview</item> + <item quantity="other">Interviews</item> </plurals> <plurals name="scenes"> - <item quantity="one">Scènes</item> - <item quantity="other"/> + <item quantity="one">Scène</item> + <item quantity="other">Scènes</item> </plurals> <plurals name="samples"> - <item quantity="one">Voorvertoningen</item> - <item quantity="other"/> + <item quantity="one">Voorvertoning</item> + <item quantity="other">Voorvertoningen</item> </plurals> <plurals name="featurettes"> - <item quantity="one">Featurettes</item> - <item quantity="other"/> + <item quantity="one">Featurette</item> + <item quantity="other">Featurettes</item> </plurals> <plurals name="shorts"> - <item quantity="one">Shorts</item> - <item quantity="other"/> + <item quantity="one">Short</item> + <item quantity="other">Shorts</item> </plurals> <plurals name="downloads"> <item quantity="one">%s download</item> @@ -224,7 +225,7 @@ <string name="check_for_updates">Controleren op updates</string> <string name="combine_continue_next_summary">Alleen van toepassing op tv-series</string> <string name="combine_continue_next"><![CDATA[Hervatten en Volgende combineren]]></string> - <string name="direct_play_ass">ASS-ondertiteling automatisch tonen</string> + <string name="direct_play_ass">ASS-ondertiteling automatischh tonen</string> <string name="direct_play_pgs">PGS-ondertiteling automatisch tonen</string> <string name="downmix_stereo">Geluid altijd omzetten naar stereo</string> <string name="ffmpeg_extension_pref">FFmpeg-decodering gebruiken</string> @@ -362,7 +363,7 @@ <string name="zoom_out">Zoom uit</string> <string name="skip_ignore">Negeren</string> <string name="skip_ask">Vraag om overslaan</string> - <string name="favorite_items">Favoriete %s</string> + <string name="favorite_items_title">Favoriete %s</string> <string name="duration">Duur</string> <string name="add_row">Rij toevoegen</string> <string name="for_all_ages">Alle leeftijden</string> @@ -479,8 +480,8 @@ <string name="content_scale_fill">Vullen</string> <string name="content_scale_fill_width">Vulbreedte</string> <string name="content_scale_fill_height">Vulhoogte</string> - <string name="display_preset_default">Wholphin standaard</string> - <string name="display_preset_compact">Wholphin compact</string> + <string name="display_preset_default">Wolfskin standaard</string> + <string name="display_preset_compact">Wolfskin compact</string> <string name="volume_low">Laag</string> <string name="volume_medium">Middel</string> <string name="volume_high">Hoog</string> @@ -544,4 +545,121 @@ <string name="skip_behavior_summary">Voor intro\'s, aftiteling, etc</string> <string name="ass_subtitle_playback">SSA/ASS ondertiteling tonen</string> <string name="ass_subtitle_mode_libass">Direct play met libass</string> + <string name="play_with">Afspelen met</string> + <string name="other_extras_title">Overig</string> + <string name="behind_the_scenes_title">Achter de schermen</string> + <string name="theme_songs_title">Themaliedjes</string> + <string name="theme_videos_title">Themavideo’s</string> + <string name="clips_title">Clips</string> + <string name="deleted_scenes_title">Verwijderde scènes</string> + <string name="samples_title">Voorbeelden</string> + <string name="featurettes_title">Korte documentaire</string> + <string name="shorts_title">Korte films</string> + <plurals name="movies"> + <item quantity="one">Film</item> + <item quantity="other">Films</item> + </plurals> + <plurals name="tv_shows"> + <item quantity="one">Serie</item> + <item quantity="other">Series</item> + </plurals> + <plurals name="people"> + <item quantity="one">Persoon</item> + <item quantity="other">Personen</item> + </plurals> + <string name="require_login">Aanmelding vereisen voor dit profiel</string> + <string name="combined_search_results">Gecombineerde zoekresultaten</string> + <string name="combined_search_results_on">Alle resultaten in één lijst weergeven</string> + <string name="combined_search_results_off">Resultaten per categorie weergeven</string> + <string name="show_voice_search_button">Knop voor gesproken zoekopdrachten weergeven</string> + <string name="visible_ui">Zichtbaar</string> + <string name="hidden_ui">Verborgen</string> + <string name="results">Resultaten</string> + <string name="force_dovi_profile_7">Direct afspelen Dolby Vision Profile 7</string> + <string name="quick_connect">Snel verbinden</string> + <string name="trending">Populair</string> + <string name="software_decoding_av1">AV1-softwaredecodering</string> + <string name="contrast">Contrast</string> + <string name="hue">Tint</string> + <string name="red">Rood</string> + <string name="green">Groen</string> + <string name="blue">Blauw</string> + <string name="blur">Vervaging</string> + <string name="go_to_artist">Ga naar artiest</string> + <string name="go_to_album">Ga naar album</string> + <string name="recently_released_in">Recent uitgebracht in %1$s</string> + <string name="height">Hoogte</string> + <string name="apply_all_rows">Op alle rijen toepassen</string> + <string name="load_from_server">Gebruikersprofiel van de server laden</string> + <string name="save_to_server">Gebruikersprofiel op de server opslaan</string> + <string name="load_from_web_client">Laad van de web client</string> + <string name="use_series">Afbeelding van de serie gebruiken</string> + <string name="add_row_for">Voeg rij toe voor %1$s</string> + <string name="overwrite_server_settings">Instellingen op de server overschrijven?</string> + <string name="overwrite_local_settings">Lokale Instellingen overschrijven?</string> + <string name="next_up_never">Nooit</string> + <string name="trailers_title">Trailers</string> + <string name="confirm_enable_experimental_body">Experimentele instellingen kunnen instabiel zijn!\n\nDeze instellingen kunnen in elke nieuwe versie worden gewijzigd of verwijderd.</string> + <string name="confirm_enable_experimental_title">Experimentele instellingen inschakelen?</string> + <string name="experimental_settings">Experimentele instellingen</string> + <string name="search_and_download_subtitles"><![CDATA[Zoek & download ondertiteling]]></string> + <string name="updated_toast">Wolfskin is geüpdatet naar %s</string> + <string name="last_played">Laatst afgespeeld</string> + <string name="ass_subtitle_mode_exoplayer">Direct afspelen met de ingebouwde ExoPlayer</string> + <string name="ass_subtitle_mode_transcode">Ondertitels inbranden/transcoderen op de server</string> + <string name="cinema_mode">Bioscoop modus</string> + <string name="show_more">Laat %1$s meer zien</string> + <string name="transcoding">Transcoderen</string> + <string name="user_interface_language">Taal van de gebruikersinterface</string> + <string name="display_toggles_title">Beoordelingsweergave</string> + <string name="display_toggles_summary">Pas aan wat wordt weergegeven</string> + <string name="profile_protection">Profiel beveiliging</string> + <string name="continue_watching_click_behavior">Klikgedrag voor ‘Verder kijken’ en ‘Volgende aflevering’</string> + <string name="continue_watching_click_summary_on">Afspelen op klik</string> + <string name="continue_watching_click_summary_off">Laat details zien op klik</string> + <string name="stars">Sterren</string> + <string name="sort">Sorteren</string> + <string name="split_into_separate_rows">In afzonderlijke rijen weergeven</string> + <string name="genre_movies">%1$s Films</string> + <string name="genre_tv_shows">%1$s Series</string> + <string name="quality_profile">Kwaliteitsprofiel</string> + <string name="root_folder">Root map</string> + <string name="popularity">Populariteit</string> + <string name="production_status">Productie status</string> + <string name="different_for_hdr">Gebruik verschillende stylen voor HDR</string> + <string name="dts_hd">DTS:HD</string> + <string name="dts_hd_ma">DTS:HD MA</string> + <string name="truehd">TrueHD</string> + <string name="dolby_digital">DD</string> + <string name="dolby_digital_plus">DD+</string> + <string name="dts">DTS</string> + <string name="url">URL</string> + <string name="albums">Albums</string> + <string name="instant_mix">Instant mix</string> + <string name="genres_in">Genres in %1$s</string> + <string name="magenta">Magenta</string> + <string name="background_style_wrap">Wrap</string> + <string name="background_style_boxed">Boxed</string> + <string name="external_player">Externe speler</string> + <string name="aspect_ratios_poster">Poster (2:3)</string> + <string name="aspect_ratios_16_9">16:9</string> + <string name="aspect_ratios_4_3">4:3</string> + <string name="view_options_type_grid">Raster</string> + <string name="view_options_type_list">Lijst</string> + <string name="view_options_type_list_compact">Compacte lijst</string> + <string name="enter_url_api_key"><![CDATA[Vul URL & API sleutel in]]></string> + <string name="api_key">API Sleutel</string> + <string name="mixer">Mixer</string> + <string name="album">Album</string> + <string name="studios_in">Studios in %1$s</string> + <string name="system_default">Systeemstandaard</string> + <string name="tomatoes">Tomatoes</string> + <string name="is_default">Standaard</string> + <string name="video_tunneling">Videotunneling</string> + <string name="interviews_title">Interviews</string> + <string name="scenes_title">Scenes</string> + <string name="pin_digits_hint">[0-9]</string> + <string name="dts_x">DTS:X</string> + <string name="override_user_profile_settings">Meer instellingen gebruikersprofiel</string> + <string name="d_pad_seek_mode_title">"D-pad zoekbalk"</string> </resources> diff --git a/app/src/main/res/values-nn/strings.xml b/app/src/main/res/values-nn/strings.xml index db921e51..eb7a6fe7 100644 --- a/app/src/main/res/values-nn/strings.xml +++ b/app/src/main/res/values-nn/strings.xml @@ -32,7 +32,7 @@ <string name="director">Regissør</string> <string name="disabled">Deaktivert</string> <string name="discovered_servers">Funne servarar</string> - <string name="download_and_update"><![CDATA[Last ned og oppdater]]></string> + <string name="download_and_update">Last ned og oppdater</string> <string name="downloading">Lastar ned…</string> <string name="enabled">Aktivert</string> <string name="enter_server_url">Oppgi servar-IP eller URL</string> @@ -64,7 +64,7 @@ <string name="max_bitrate">Maks bitrate</string> <string name="more_like_this">Meir som dette</string> <string name="more">Meir</string> - <string name="movies">Filmar</string> + <string name="movies_title">Filmar</string> <string name="name">Namn</string> <string name="next_up">Neste</string> <string name="no_data">Ingen data</string> @@ -74,7 +74,7 @@ <string name="no_update_available">Ingen oppdatering tilgjengeleg</string> <string name="outro">Outro</string> <string name="path">Filveg</string> - <string name="people">Folk</string> + <string name="people_title">Folk</string> <string name="play_count">Antall avspelingar</string> <string name="play_from_here">Spel av herfrå</string> <string name="play">Spel av</string> @@ -98,7 +98,7 @@ <string name="restart">Start på nytt</string> <string name="resume">Hald fram</string> <string name="save">Lagre</string> - <string name="search_and_download"><![CDATA[Søk og last ned]]></string> + <string name="search_and_download">Søk og last ned</string> <string name="search">Søk</string> <string name="searching">Søker…</string> <string name="select_server">Vel servar</string> @@ -122,16 +122,15 @@ <string name="switch_servers">Byt servar</string> <string name="switch_user">Byt brukar</string> <string name="top_unwatched">Best vurderte usette</string> - <string name="trailer">Trailer</string> <plurals name="trailers"> <item quantity="one">Trailerar</item> - <item quantity="other"/> + <item quantity="other">Trailerar</item> </plurals> <string name="tv_dvr_schedule">DVR-plan</string> <string name="tv_guide">Programoversikt</string> <string name="tv_season">Sesong</string> <string name="tv_seasons">Sesongar</string> - <string name="tv_shows">TV-seriar</string> + <string name="tv_shows_title">TV-seriar</string> <string name="ui_interface">Grensesnitt</string> <string name="unknown">Ukjend</string> <string name="updates">Oppdateringar</string> @@ -140,7 +139,9 @@ <string name="video">Video</string> <string name="videos">Videoar</string> <string name="watch_live">Sjå direkte</string> - <string name="years_old">%1$d år gammal</string> + <plurals name="years_old"> + <item quantity="other">%1$d år gammal</item> + </plurals> <string name="play_with_transcoding">Spel av med transkoding</string> <string name="media_information">Mediainformasjon</string> <string name="show_clock">Vis klokke</string> @@ -155,50 +156,50 @@ <string name="success">Fullført</string> <string name="official_rating">Aldersgrense</string> <string name="runtime_sort">Speltid</string> - <string name="extras">Ekstra</string> + <string name="extras_title">Ekstra</string> <plurals name="other_extras"> <item quantity="one">Anna</item> - <item quantity="other"/> + <item quantity="other">Anna</item> </plurals> <plurals name="behind_the_scenes"> <item quantity="one">Bak kulissane</item> - <item quantity="other"/> + <item quantity="other">Bak kulissane</item> </plurals> <plurals name="theme_songs"> <item quantity="one">Temasongar</item> - <item quantity="other"/> + <item quantity="other">Temasongar</item> </plurals> <plurals name="theme_videos"> <item quantity="one">Temavideoar</item> - <item quantity="other"/> + <item quantity="other">Temavideoar</item> </plurals> <plurals name="clips"> <item quantity="one">Klipp</item> - <item quantity="other"/> + <item quantity="other">Klipp</item> </plurals> <plurals name="deleted_scenes"> <item quantity="one">Sletta sener</item> - <item quantity="other"/> + <item quantity="other">Sletta sener</item> </plurals> <plurals name="interviews"> <item quantity="one">Intervju</item> - <item quantity="other"/> + <item quantity="other">Intervju</item> </plurals> <plurals name="scenes"> <item quantity="one">Scener</item> - <item quantity="other"/> + <item quantity="other">Scener</item> </plurals> <plurals name="samples"> <item quantity="one">Smakebitar</item> - <item quantity="other"/> + <item quantity="other">Smakebitar</item> </plurals> <plurals name="featurettes"> <item quantity="one">Bakomfilmar</item> - <item quantity="other"/> + <item quantity="other">Bakomfilmar</item> </plurals> <plurals name="shorts"> <item quantity="one">Kortfilmar</item> - <item quantity="other"/> + <item quantity="other">Kortfilmar</item> </plurals> <string name="advanced_settings">Avanserte innstillingar</string> <string name="advanced_ui">Avansert grensesnitt</string> @@ -208,7 +209,7 @@ <string name="auto_play_next">Spel neste automatisk</string> <string name="check_for_updates">Sjekk etter oppdateringar</string> <string name="combine_continue_next_summary">Gjeld berre TV-seriar</string> - <string name="combine_continue_next"><![CDATA[Slå saman \'Hald fram med å sjå\' og \'Neste\']]></string> + <string name="combine_continue_next">Slå saman \'Hald fram med å sjå\' og \'Neste\'</string> <string name="direct_play_ass">Direkteavspeling av ASS-undertekstar</string> <string name="direct_play_pgs">Direkteavspeling av PGS-undertekstar</string> <string name="downmix_stereo">Alltid nedmiks til stereo</string> @@ -292,7 +293,7 @@ <string name="aspect_ratio">Bileteforhold</string> <string name="show_details">Vis detaljar</string> <string name="image_type">Bilettype</string> - <string name="cast_and_crew"><![CDATA[Skodespelarar og medarbeidarar]]></string> + <string name="cast_and_crew">Skodespelarar og medarbeidarar</string> <string name="guest_stars">Gjesteroller</string> <string name="edge_size">Kantstorleik</string> <string name="refresh_rate_switching">Byting av bildeoppdateringsfrekvens</string> @@ -378,7 +379,7 @@ <string name="voice_error_start_failed">Klarte ikkje starta talegjenkjenning</string> <string name="voice_error_timeout">Tidsavbrot for talegjenkjenning</string> <string name="retry">Prøv på nytt</string> - <string name="favorite_items">Favoritt %s</string> + <string name="favorite_items_title">Favoritt %s</string> <string name="resolution_switching">Oppløysingsbyte</string> <string name="local">Lokal</string> <string name="play_trailer">Spel trailer</string> @@ -477,8 +478,8 @@ <string name="content_scale_fill">Fyll</string> <string name="content_scale_fill_width">Fyll breidde</string> <string name="content_scale_fill_height">Fyll høgde</string> - <string name="display_preset_default">Wholphin-standard</string> - <string name="display_preset_compact">Wholphin-kompakt</string> + <string name="display_preset_default">Wolfskin-standard</string> + <string name="display_preset_compact">Wolfskin-kompakt</string> <string name="display_preset_series_thumb">Miniatyrbilete for seriar</string> <string name="display_preset_episode_thumbnails">Miniatyrbilete for episodar</string> <string name="volume_lowest">Lågast</string> @@ -518,12 +519,12 @@ <string name="image_type_thumb">Miniatyr</string> <string name="backdrop_style_dynamic">Bilete med dynamisk farge</string> <string name="backdrop_style_image">Berre bilete</string> - <string name="enter_url_api_key"><![CDATA[Skriv inn URL og API-nøkkel]]></string> + <string name="enter_url_api_key">Skriv inn URL og API-nøkkel</string> <string name="api_key">API-nøkkel</string> <string name="seerr_login">Logg inn på Seerr-tenar</string> <string name="seerr_jellyfin_user">Jellyfin-brukar</string> <string name="seerr_local_user">Lokal brukar</string> - <string name="search_and_download_subtitles"><![CDATA[Søk og last ned undertekstar]]></string> + <string name="search_and_download_subtitles">Søk og last ned undertekstar</string> <string name="no_subtitles_found">Fann ingen eksterne undertekstar</string> <string name="series_continueing">No</string> <string name="in_app_screensaver">Bruk skjermsparar i appen</string> diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 9172ab6e..3bb8567e 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -20,7 +20,7 @@ <string name="max_bitrate">Maksymalny bitrate</string> <string name="more_like_this">Podobne</string> <string name="more">Więcej</string> - <string name="movies">Filmy</string> + <string name="movies_title">Filmy</string> <string name="next_up">Następnie</string> <string name="no_data">Brak danych</string> <string name="no_results">Brak wyników</string> @@ -33,7 +33,7 @@ <string name="skip">Pomiń</string> <string name="sort_by_date_added">Data dodania</string> <string name="sort_by_date_episode_added">Data dodania odcinka</string> - <string name="tv_shows">Seriale</string> + <string name="tv_shows_title">Seriale</string> <string name="tv_season">Sezon</string> <string name="tv_seasons">Sezony</string> <string name="subtitle">Napisy</string> @@ -42,7 +42,6 @@ <string name="switch_servers">Zmień serwer</string> <string name="switch_user">Zmień</string> <string name="top_unwatched">Najlepiej oceniane nieobejrzane</string> - <string name="trailer">Zwiastun</string> <plurals name="trailers"> <item quantity="one">Zwiastun</item> <item quantity="few">Zwiastuny</item> @@ -121,16 +120,16 @@ <string name="one_click_pause">Pauza jednym kliknięciem</string> <string name="one_click_pause_summary_on">Wciśnij środek by pauzować/wznowić</string> <plurals name="interviews"> - <item quantity="one">Wywiady</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Wywiad</item> + <item quantity="few">Wywiady</item> + <item quantity="many">Wywiadów</item> + <item quantity="other">Wywiadów</item> </plurals> <plurals name="scenes"> - <item quantity="one">Sceny</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Scena</item> + <item quantity="few">Sceny</item> + <item quantity="many">Scen</item> + <item quantity="other">Scen</item> </plurals> <plurals name="theme_videos"> <item quantity="one">Czołówka</item> @@ -261,18 +260,17 @@ <string name="video_scale">Skalowanie obrazu</string> <string name="font_opacity">Przezroczystość czcionki</string> <string name="update_url_summary">Adres URL do sprawdzenia aktualizacji</string> - <string name="people">Obsada</string> + <string name="people_title">Obsada</string> <string name="none">Brak</string> <string name="unknown">Nieznany</string> - <string name="years_old">%1$d lat temu</string> <string name="success">Powodzenie</string> <string name="official_rating">Ocena rodzicielska</string> - <string name="extras">Dodatki</string> + <string name="extras_title">Dodatki</string> <plurals name="other_extras"> - <item quantity="one">Inne</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Inny</item> + <item quantity="few">Inne</item> + <item quantity="many">Innych</item> + <item quantity="other">Innych</item> </plurals> <plurals name="deleted_scenes"> <item quantity="one">Usunięta scena</item> @@ -381,15 +379,15 @@ <string name="start_screensaver">Pokaż wygaszacz ekranu</string> <string name="duration">Czas trwania</string> <string name="photos">Zdjęcia</string> - <string name="include_types">Dołącz typy</string> + <string name="include_types">Wybierz typ mediów</string> <string name="include_types_summary">Dla jakich typów mediów wyświetlać zdjęcia?</string> <string name="content_scale_fit">Dopasuj</string> <string name="content_scale_crop">Przytnij</string> <string name="content_scale_fill">Wypełnij</string> <string name="content_scale_fill_width">Pełna szerokość</string> <string name="content_scale_fill_height">Pełna wysokość</string> - <string name="display_preset_default">Domyślny Wholphin</string> - <string name="display_preset_compact">Kompaktowy Wholphin</string> + <string name="display_preset_default">Domyślny Wolfskin</string> + <string name="display_preset_compact">Kompaktowy Wolfskin</string> <string name="display_preset_series_thumb">Miniatury seriali</string> <string name="display_preset_episode_thumbnails">Zdjęcie odcinków</string> <string name="volume_lowest">Najniższa</string> @@ -399,7 +397,7 @@ <string name="volume_full">Najwyższa</string> <string name="purple">Fioletowy</string> <string name="orange">Pomarańczowy</string> - <string name="bold_blue">Niebieski</string> + <string name="bold_blue">Odważny niebieski</string> <string name="black">Czarny</string> <string name="skip_ignore">Ignoruj</string> <string name="skip_automatically">Pomiń automatycznie</string> @@ -433,7 +431,7 @@ <string name="seerr_login">Zaloguj się do Seerr</string> <string name="seerr_jellyfin_user">Użytkownik Jellyfin</string> <string name="seerr_local_user">Użytkownik lokalny</string> - <string name="favorite_items">Ulubione %s</string> + <string name="favorite_items_title">Ulubione %s</string> <plurals name="days"> <item quantity="one">%s dzień</item> <item quantity="few">%s dni</item> @@ -459,7 +457,7 @@ <string name="force_dovi_profile_7">Odtwarzanie bezpośrednie Dolby Vision Profile 7</string> <string name="force_dovi_profile_7_summary">Ignoruj sprawdzanie zgodności urządzenia</string> <string name="discover">Odkryj</string> - <string name="request">Prośby</string> + <string name="request">Poproś</string> <string name="pending">Oczekujące</string> <string name="seerr_integration">Integracja z Seerr</string> <string name="remove_seerr_server">Usuń serwer Seerr</string> @@ -474,7 +472,7 @@ <string name="voice_error_start_failed">Nie udało się uruchomić rozpoznawania głosu</string> <string name="voice_error_timeout">Przekroczono limit czasu rozpoznawania głosu</string> <plurals name="theme_songs"> - <item quantity="one">Piosenka tematyczne</item> + <item quantity="one">Piosenka tematyczna</item> <item quantity="few">Piosenki tematyczne</item> <item quantity="many">Piosenek tematycznych</item> <item quantity="other">Piosenek tematycznych</item> @@ -578,7 +576,7 @@ <string name="select_all">Wybierz wszystkie</string> <string name="separate_types">Oddzielne typy</string> <string name="prefer_logos">Wyświetlanie logo zamiast tytułu</string> - <string name="updated_toast">Wholphin zaktualizowany do %s</string> + <string name="updated_toast">Wolfskin zaktualizowany do %s</string> <string name="remove_continue_watching">Usuń z Kontynuuj oglądanie</string> <string name="remove_next_up">Usuń serial z Następnie</string> <string name="view_removed_next_up">Zobacz serię usuniętą z Następnie</string> @@ -590,28 +588,28 @@ <string name="aired_episode_order">Kolejność emisji odcinków</string> <string name="runtime_sort">Czas trwania</string> <plurals name="clips"> - <item quantity="one">Klipy</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Klip</item> + <item quantity="few">Klipy</item> + <item quantity="many">Klipów</item> + <item quantity="other">Klipów</item> </plurals> <plurals name="samples"> - <item quantity="one">Próbki</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Próbka</item> + <item quantity="few">Próbki</item> + <item quantity="many">Próbek</item> + <item quantity="other">Próbek</item> </plurals> <plurals name="featurettes"> - <item quantity="one">Filmy krótkometrażowe</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Film krótkometrażowy</item> + <item quantity="few">Filmy krótkometrażowe</item> + <item quantity="many">Filmów krótkometrażowych</item> + <item quantity="other">Filmów krótkometrażowych</item> </plurals> <plurals name="shorts"> - <item quantity="one">Krótkie filmy</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Krótki filmy</item> + <item quantity="few">Krótkie filmy</item> + <item quantity="many">Krótkich filmy</item> + <item quantity="other">Krótkich filmy</item> </plurals> <string name="aspect_ratio">Proporcje ekranu</string> <string name="guest_stars">Aktorzy gościnni</string> @@ -636,4 +634,175 @@ <string name="skip_behavior_summary">Dla wstępu, napisów końcowych itp.</string> <string name="transcoding">Transkodowanie</string> <string name="play_with">Odtwórz z</string> + <string name="pin_digits_hint">[0-9]</string> + <string name="combined_search_results">Połączone wyniki wyszukiwania</string> + <string name="combined_search_results_on">Pokaż wszystkie wyniki jako jedna lista</string> + <string name="combined_search_results_off">Podziel wyniki wyszukiwania na kategorie</string> + <string name="results">Wyniki</string> + <string name="require_login">Wymagaj logowania do profilu</string> + <string name="show_voice_search_button">Pokaż przycisk wyszukiwania głosowego</string> + <string name="visible_ui">Widoczne</string> + <string name="hidden_ui">Ukryte</string> + <string name="display_toggles_title">Pokaż oceny</string> + <string name="display_toggles_summary">Dostosuj co chcesz wyświetlić</string> + <string name="profile_protection">Zabezpieczenie profilu</string> + <string name="continue_watching_click_behavior">Zachowanie kliknięcia Kontynuuj oglądanie/Następnie</string> + <string name="continue_watching_click_summary_on">Odtwórz po kliknięciu</string> + <string name="continue_watching_click_summary_off">Pokaż szczegóły po kliknięciu</string> + <string name="stars">Gwiazdki</string> + <string name="tomatoes">Pomidor</string> + <string name="view_options_type_list">Lista</string> + <string name="view_options_type_list_compact">Lista kompaktowa</string> + <string name="user_interface_language">Język interfejsu</string> + <string name="view_options_type_grid">Siatka</string> + <string name="sort">Sortuj</string> + <string name="split_into_separate_rows">Podziel na osobne rzędy</string> + <string name="trailers_title">Zwiastuny</string> + <string name="other_extras_title">Inne</string> + <string name="behind_the_scenes_title">Za kulisami</string> + <string name="theme_songs_title">Piosenki tematyczne</string> + <string name="theme_videos_title">Filmy tematyczne</string> + <string name="clips_title">Klipy</string> + <string name="deleted_scenes_title">Usunięte sceny</string> + <string name="interviews_title">Wywiady</string> + <string name="scenes_title">Sceny</string> + <string name="samples_title">Próbki</string> + <string name="featurettes_title">Materiały dodatkowe</string> + <string name="shorts_title">Szorty</string> + <plurals name="movies"> + <item quantity="one">Film</item> + <item quantity="few">Filmy</item> + <item quantity="many">Filmów</item> + <item quantity="other">Filmów</item> + </plurals> + <plurals name="people"> + <item quantity="one">Osoba</item> + <item quantity="few">Osoby</item> + <item quantity="many">Osób</item> + <item quantity="other">Osób</item> + </plurals> + <plurals name="tv_shows"> + <item quantity="one">Serial</item> + <item quantity="few">Seriale</item> + <item quantity="many">Serialów</item> + <item quantity="other">Serialów</item> + </plurals> + <string name="genre_movies">Filmy z gatunku: %1$s</string> + <string name="genre_tv_shows">Seriale z gatunku: %1$s</string> + <string name="quality_profile">Profil jakości</string> + <string name="is_default">Domyślny</string> + <string name="root_folder">Folder główny</string> + <string name="popularity">Popularność</string> + <string name="production_status">Status produkcji</string> + <string name="different_for_hdr">Użyj innych styli dla HDR</string> + <string name="experimental_settings">Ustawienia eksperymentalne</string> + <string name="confirm_enable_experimental_title">Włączyć ustawienia eksperymentalne?</string> + <string name="confirm_enable_experimental_body">Eksperymentalne ustawienia mogą powodować problemy!\n\nMogą być usunięte bądź zmienione w kolejnym wydaniu.</string> + <string name="video_tunneling">Tunelowanie wideo</string> + <string name="time_left">Pozostało %1$s</string> + <string name="duration_minutes">%1$d min.</string> + <string name="duration_seconds">%1$d sek.</string> + <string name="duration_hours">%1$d godz.</string> + <string name="duration_hours_minutes_seconds">%1$d godz. %2$d min. %3$d sek.</string> + <string name="duration_hours_minutes">%1$d godz. %2$d min.</string> + <string name="duration_minutes_seconds">%1$d min. %2$d sek.</string> + <string name="blocklisted">Na czarnej liście</string> + <string name="unavailable">Niedostępne</string> + <string name="prefer_ac3_for_surround_summary"><![CDATA[Transkoduj dźwięk nie-stereo do formatu AC3/Dolby Digital]]></string> + <string name="prefer_ac3_for_surround">Używaj dźwięku AC3 jako dźwięku przestrzennego</string> + <string name="profile_specific_settings_from_server">Ustawienia użytkownika z serwera</string> + <plurals name="years_old"> + <item quantity="one">%1$d rok</item> + <item quantity="few">%1$d lata</item> + <item quantity="many">%1$d lat</item> + <item quantity="other">%1$d lat</item> + </plurals> + <string name="subtitle_mode">Tryb napisów</string> + <string name="use_user_profile">Użyj ustawień użytkownika</string> + <string name="subtitle_mode_default">Domyślny</string> + <string name="subtitle_mode_smart">Inteligentny</string> + <string name="subtitle_mode_only_forced">Tylko wymuszone</string> + <string name="subtitle_mode_always">Wszystkie</string> + <string name="subtitle_mode_none">Brak</string> + <string name="preferred_subtitle_language">Preferowany język napisów</string> + <string name="preferred_audio_language">Preferowany język dźwięku</string> + <string name="override_user_profile_settings">Więcej ustawień użytkownika</string> + <string name="any_language">Dowolny język</string> + <string name="override_user_profile_settings_summary">Napisz swoje ustawienia użytkownika, ustawieniami z serwera</string> + <string name="brown">Brązowy</string> + <string name="d_pad_seek_mode_title">Tryb paska wyszukiwania D-Pad</string> + <string name="dpad_seek_mode_minimal">Minimalistyczny</string> + <string name="dpad_seek_mode_trickplay">Podgląd scen</string> + <string name="dpad_seek_mode_minimal_summary">Mały pasek przewijania u dołu ekranu (domyślnie)</string> + <string name="dpad_seek_mode_trickplay_summary">Pasek wyszukiwania z podglądem wideo</string> + <string name="dpad_seek_mode_none_summary">Brak paska przewijania</string> + <string name="hash_matches">Dopasowania hashy</string> + <string name="subtitles_hearing_impaired">SDH</string> + <string name="programs">Programy TV</string> + <string name="recordings">Nagrania</string> + <string name="folders_title">Foldery</string> + <string name="user_views">Widoki użytkownika</string> + <string name="books">Książki</string> + <string name="audio_books">Audiobooki</string> + <string name="server_version_not_supported">Wersja serwera nie jest wspierana</string> + <string name="appears_on">Wystąpił w</string> + <string name="dim_screen">Przyciemnienie ekranu</string> + <string name="dim_after_delay">Opóźnienie przyciemniana ekranu</string> + <string name="dim_percentage">Procent przyciemniane</string> + <string name="photo_albums">Albumy zdjęć</string> + <string name="no_favorites_found">Nic jeszcze nie polubiono!</string> + <string name="season_number">Sezon %1$d</string> + <string name="remove_from_playlist">Usuń z listy odtwarzania</string> + <string name="login_credentials_expired">Sesja wygasła, zaloguj się ponownie</string> + <string name="login_not_authorized">Nie jesteś upoważniony do dostępu do serwera w tym momencie</string> + <string name="episode_number_short">O%1$d</string> + <string name="duration_hours_seconds">%1$dh %2$ds</string> + <string name="season_episode_number">S%1$d O%2$d</string> + <string name="season_episode_number_range">S%1$d O%2$d-%3$d</string> + <string name="season_episode_number_padded">S%1$02d O%2$02d</string> + <string name="season_episode_number_padded_range">S%1$02d O%2$02d-%3$02d</string> + <string name="disable_audio_offload">Wyłącz sprzętowe wsparcie dźwięku</string> + <string name="disable_audio_offload_summary">Wyłączenie sprzętowego wsparcie dźwięku może naprawić błąd z zatrzymaniem się odtwarzania na niektórych urządzeniach</string> + <string name="resolution_8k">8K</string> + <string name="resolution_4k">4K</string> + <string name="resolution_1080">1080p</string> + <string name="resolution_720">720p</string> + <string name="resolution_576">576p</string> + <string name="resolution_480">480p</string> + <string name="resolution_360">360p</string> + <plurals name="episodes_count"> + <item quantity="one">%1$d odcinek</item> + <item quantity="few">%1$d odcinki</item> + <item quantity="many">%1$d odcinków</item> + <item quantity="other">%1$d odcinków</item> + </plurals> + <string name="max_resolution">Maksymalna rozdzielczość przed transkodowaniem</string> + <string name="prefer_dovi_over_hdr10_plus">Preferuj Dolby Vision nad HDR10+</string> + <string name="prefer_dovi_over_hdr10_plus_summary">Usuwa metadane HDR10+ z filmów Dolby Vision, dla telewizorów, które w przeciwnym razie odtwarzają je jako HDR10+</string> + <string name="top_rated">Najlepiej oceniane</string> + <string name="hdr_formats">Formaty HDR</string> + <string name="hdr10_override">HDR10</string> + <string name="hdr10_plus_override">HDR10+</string> + <string name="dovi_profile_5">Dolby Vision P5</string> + <string name="dovi_profile_7">Dolby Vision P7</string> + <string name="dovi_profile_8">Dolby Vision P8</string> + <string name="force_enable">Wymuś włączenie</string> + <string name="force_disable">Wymuś wyłączenie</string> + <string name="server_version_not_supported_future">Wersja serwera wkrótce przestanie być wspierana</string> + <string name="server_version_required">Wymagana wersja serwera: %1$s</string> + <string name="chosen_by">Wybrane przez %1$s</string> + <string name="user">Użytkownik</string> + <string name="series">Seriale</string> + <string name="load_from_server_plugin">Załaduj domyślne z serwera</string> + <string name="save_to_local">Zapisuj tylko na tym urządzeniu</string> + <string name="home_settings_source_unset">Domyślnie</string> + <string name="home_settings_source_local">Zależne od urządzenia</string> + <string name="home_settings_source_server_profile">Profil użytkownika na serwerze</string> + <string name="home_settings_source_plugin">Domyślnie z serwera</string> + <string name="home_settings_source_web">Użytkownik klienta webowego</string> + <string name="convert_dovi_profile_7">Konwertuj Dolby Vision 7 do 8.1</string> + <string name="convert_dovi_profile_7_summary">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</string> + <string name="artist_source">Źródło artysty</string> + <string name="managed_by_server">Zarządzane przez serwer</string> + <string name="error">Błąd</string> </resources> diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index bf4a7c94..af1e0d9f 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -149,12 +149,6 @@ <string name="switch_servers">Trocar de servidores</string> <string name="switch_user">Trocar</string> <string name="top_unwatched">Não assistidos com melhor avaliação</string> - <string name="trailer">Trailer</string> - <plurals name="trailers"> - <item quantity="one">Trailer</item> - <item quantity="many">Trailers</item> - <item quantity="other">Trailers</item> - </plurals> <string name="tv_dvr_schedule">Agenda do DVR</string> <string name="tv_guide">Guia</string> <string name="tv_season">Temporada</string> @@ -172,7 +166,9 @@ <string name="video">Vídeo</string> <string name="videos">Vídeos</string> <string name="watch_live">Assista ao vivo</string> - <string name="years_old">%1$d anos de idade</string> + <plurals name="years_old"> + <item quantity="other">%1$d anos de idade</item> + </plurals> <string name="play_with_transcoding">Reproduzir com transcodificação</string> <string name="media_information">Informação de Mídia</string> <string name="show_clock">Mostrar Relógio</string> @@ -187,7 +183,7 @@ <string name="success">Sucesso</string> <string name="official_rating">Classificação Parental</string> <string name="runtime_sort">Duração</string> - <string name="extras">Extras</string> + <string name="extras_title">Extras</string> <plurals name="other_extras"> <item quantity="one">Outro</item> <item quantity="many">Outros</item> @@ -238,7 +234,7 @@ <item quantity="many">Curtas</item> <item quantity="other">Curtas</item> </plurals> - <string name="favorite_items">Favoritar %s</string> + <string name="favorite_items_title">Favoritar %s</string> <plurals name="downloads"> <item quantity="one">%s download</item> <item quantity="many">%s downloads</item> @@ -502,8 +498,8 @@ <string name="content_scale_fill">Preencher</string> <string name="content_scale_fill_width">Preencher largura</string> <string name="content_scale_fill_height">Preencher altura</string> - <string name="display_preset_default">Padrão do Wholphin</string> - <string name="display_preset_compact">Wholphin Compacto</string> + <string name="display_preset_default">Padrão do Wolfskin</string> + <string name="display_preset_compact">Wolfskin Compacto</string> <string name="display_preset_series_thumb">Prévia das imagens das séries</string> <string name="display_preset_episode_thumbnails">Prévias das imagens do episódio</string> <string name="volume_lowest">Mais baixo</string> @@ -567,20 +563,19 @@ <string name="mixer">Mixagem</string> <plurals name="featurettes"> <item quantity="one">Bastidores</item> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="other">Bastidores</item> </plurals> <plurals name="minutes"> <item quantity="one">%s minuto</item> <item quantity="many">%s minutos</item> <item quantity="other">%s minutos</item> </plurals> - <string name="enter_url_api_key"><![CDATA[Insira a URL e a chave de API]]></string> - <string name="search_and_download_subtitles"><![CDATA[Procurar e baixar legendas]]></string> - <string name="download_and_update"><![CDATA[Baixar e atualizar]]></string> - <string name="search_and_download"><![CDATA[Procurar e baixar]]></string> - <string name="combine_continue_next"><![CDATA[Combinar \"Continue Assistindo\" e \"A Seguir\"]]></string> - <string name="cast_and_crew"><![CDATA[Elenco e equipe]]></string> + <string name="enter_url_api_key">Insira a URL e a chave de API</string> + <string name="search_and_download_subtitles">Procurar e baixar legendas</string> + <string name="download_and_update">Baixar e atualizar</string> + <string name="search_and_download">Procurar e baixar</string> + <string name="combine_continue_next">Combinar \"Continue Assistindo\" e \"A Seguir\"</string> + <string name="cast_and_crew">Elenco e equipe</string> <string name="albums">Álbuns</string> <string name="artists">Artistas</string> <string name="songs">Músicas</string> @@ -610,7 +605,7 @@ <string name="select_all">Selecionar todos</string> <string name="separate_types">Tipos separados</string> <string name="prefer_logos">Preferir a exibição de logos para títulos</string> - <string name="updated_toast">Wholphin atualizado para %s</string> + <string name="updated_toast">Wolfskin atualizado para %s</string> <string name="remove_continue_watching">Remover de continuar assistindo</string> <string name="remove_next_up">Remover série de a seguir</string> <string name="view_removed_next_up">Ver séries removidas de a seguir</string> @@ -630,4 +625,96 @@ <string name="skip_behavior_summary">Para introduções, créditos, e etc</string> <string name="play_with">Reproduzir com</string> <string name="transcoding">Transcodificação</string> + <string name="movies_title">Filmes</string> + <string name="people_title">Pessoas</string> + <string name="trailers_title">Trailers</string> + <string name="tv_shows_title">Programas de TV</string> + <string name="other_extras_title">Outro</string> + <string name="behind_the_scenes_title">Nos Bastidores</string> + <string name="theme_songs_title">Músicas Tema</string> + <string name="deleted_scenes_title">Cenas Deletadas</string> + <string name="interviews_title">Entrevistas</string> + <string name="scenes_title">Cenas</string> + <string name="samples_title">Amostras</string> + <string name="combined_search_results">Resultados de pesquisa combinados</string> + <string name="combined_search_results_on">Mostrar todos os resultados em lista única</string> + <string name="combined_search_results_off">Mostrar resultados por categoria</string> + <string name="show_voice_search_button">Mostrar botão de pesquisa por voz</string> + <string name="visible_ui">Visível</string> + <string name="hidden_ui">Oculto</string> + <string name="results">Resultados</string> + <string name="view_options_type_list">Lista</string> + <string name="view_options_type_grid">Grade</string> + <string name="view_options_type_list_compact">Lista Compacta</string> + <string name="theme_videos_title">Vídeos Tema</string> + <string name="clips_title">Clipes</string> + <string name="duration_hours_minutes_seconds">%1$dh %2$dm %3$ds</string> + <string name="duration_minutes_seconds">%1$dm %2$ds</string> + <string name="duration_hours">%1$dh</string> + <string name="duration_minutes">%1$dm</string> + <string name="duration_seconds">%1$ds</string> + <string name="profile_specific_settings_from_server">Configuração de Perfil de Usuário do servidor</string> + <string name="time_left">%1$s left</string> + <string name="featurettes_title">Conteúdos extras</string> + <string name="shorts_title">Curtos</string> + <plurals name="trailers"> + <item quantity="one">Trailer</item> + <item quantity="many">Trailers</item> + <item quantity="other">Trailers</item> + </plurals> + <string name="require_login">Exigir login para o perfil</string> + <string name="pin_digits_hint">0-9</string> + <string name="disable_audio_offload">Desativar aceleração de hardware de áudio</string> + <string name="disable_audio_offload_summary">Desativar a aceleração de hardware de áudio pode corrigir travamentos durante a reprodução em alguns dispositivos</string> + <string name="prefer_ac3_for_surround_summary"><![CDATA[Transcodificar áudio não estéreo para AC3/Dolby Digital]]></string> + <string name="prefer_ac3_for_surround">Usar AC3 para áudio surround</string> + <string name="brown">Marrom</string> + <string name="user_interface_language">Idioma de exibição</string> + <string name="display_toggles_title">Exibir classificações</string> + <string name="display_toggles_summary">Personalizar itens exibidos</string> + <string name="profile_protection">Proteção do perfil</string> + <string name="continue_watching_click_behavior">Clique em Continuar assistindo/Próximo</string> + <string name="continue_watching_click_summary_on">Reproduzir ao clicar</string> + <string name="continue_watching_click_summary_off">Mostrar detalhes ao clicar</string> + <string name="stars">Estrelas</string> + <string name="tomatoes">Tomates</string> + <string name="sort">Ordenar</string> + <string name="split_into_separate_rows">Dividir em linhas separadas</string> + <string name="genre_movies">%1$s Filmes</string> + <string name="genre_tv_shows">%1$s Séries</string> + <string name="quality_profile">Perfil de qualidade</string> + <string name="is_default">Padrão</string> + <string name="root_folder">Pasta raiz</string> + <string name="popularity">Popularidade</string> + <string name="production_status">Status de produção</string> + <string name="different_for_hdr">Usar estilo diferente para HDR</string> + <string name="experimental_settings">Configurações experimentais</string> + <string name="confirm_enable_experimental_title">Ativar configurações experimentais?</string> + <string name="confirm_enable_experimental_body">Configurações experimentais podem ser instáveis!\n\nElas podem ser removidas ou alteradas em qualquer versão.</string> + <string name="video_tunneling">Tunelamento de vídeo</string> + <string name="d_pad_seek_mode_title">Modo da barra de busca com D-Pad</string> + <string name="dpad_seek_mode_minimal">Mínimo</string> + <string name="dpad_seek_mode_trickplay">Navegação rápida</string> + <string name="dpad_seek_mode_minimal_summary">Barra de progresso pequena na parte inferior da tela (padrão)</string> + <string name="dpad_seek_mode_trickplay_summary">Barra de progresso com imagens de pré-visualização do Trickplay</string> + <string name="dpad_seek_mode_none_summary">Sem barra de progresso</string> + <string name="blocklisted">Bloqueado</string> + <string name="unavailable">Indisponível</string> + <string name="subtitle_mode">Modo de legenda</string> + <string name="use_user_profile">Usar configuração do seu perfil</string> + <string name="subtitle_mode_default">Padrão</string> + <string name="subtitle_mode_smart">Inteligente</string> + <string name="subtitle_mode_only_forced">Somente forçadas</string> + <string name="subtitle_mode_always">Sempre</string> + <string name="subtitle_mode_none">Nenhuma</string> + <string name="preferred_subtitle_language">Idioma preferido das legendas</string> + <string name="preferred_audio_language">Idioma preferido do áudio</string> + <string name="override_user_profile_settings">Mais configurações do perfil</string> + <string name="override_user_profile_settings_summary">Substituir as configurações do seu perfil definidas pelo servidor</string> + <string name="any_language">Qualquer idioma</string> + <string name="hash_matches">Correspondências de hash</string> + <string name="subtitles_hearing_impaired">SDH</string> + <string name="programs">Programas de TV</string> + <string name="recordings">Gravações</string> + <string name="folders_title">Pastas</string> </resources> diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 28eedccb..f65e53af 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -59,7 +59,7 @@ <string name="max_bitrate">Bitrate Máximo</string> <string name="more_like_this">Mais como isto</string> <string name="more">Mais</string> - <string name="movies">Filmes</string> + <string name="movies_title">Filmes</string> <string name="name">Nome</string> <string name="next_up">A Seguir</string> <string name="no_data">Sem dados</string> @@ -68,7 +68,7 @@ <string name="no_update_available">Nenhuma atualização disponível</string> <string name="none">Nenhum</string> <string name="outro">Créditos finais</string> - <string name="people">Pessoas</string> + <string name="people_title">Pessoas</string> <string name="play_count">Reproduções</string> <string name="play_from_here">Reproduzir a partir daqui</string> <string name="play">Reproduzir</string> @@ -119,7 +119,6 @@ <string name="switch_servers">Mudar Servidores</string> <string name="switch_user">Mudar</string> <string name="top_unwatched">Mais Votados Não Assistidos</string> - <string name="trailer">Trailer</string> <plurals name="trailers"> <item quantity="one">Trailer</item> <item quantity="many">Trailers</item> @@ -129,7 +128,7 @@ <string name="tv_guide">Guia</string> <string name="tv_season">Temporada</string> <string name="tv_seasons">Temporadas</string> - <string name="tv_shows">Séries TV</string> + <string name="tv_shows_title">Séries TV</string> <string name="ui_interface">Interface</string> <string name="unknown">Desconhecido</string> <string name="updates">Atualizações</string> @@ -138,7 +137,11 @@ <string name="video">Vídeo</string> <string name="videos">Vídeos</string> <string name="watch_live">Ver em directo</string> - <string name="years_old">%1$d anos</string> + <plurals name="years_old"> + <item quantity="one">%1$d ano</item> + <item quantity="many">%1$d anos</item> + <item quantity="other">%1$d anos</item> + </plurals> <string name="play_with_transcoding">Reproduzir com transcodificação</string> <string name="show_clock">Mostrar Relógio</string> <string name="aired_episode_order">Ordem de Emissão</string> @@ -152,7 +155,7 @@ <string name="success">Êxito</string> <string name="official_rating">Classificação Parental</string> <string name="runtime_sort">Duração</string> - <string name="extras">Extras</string> + <string name="extras_title">Extras</string> <plurals name="other_extras"> <item quantity="one">Outro</item> <item quantity="many">Outros</item> @@ -164,49 +167,49 @@ <item quantity="other">Nos Bastidores</item> </plurals> <plurals name="theme_songs"> - <item quantity="one">Músicas temáticas</item> - <item quantity="many">Músicas temáticas</item> - <item quantity="other">Músicas temáticas</item> + <item quantity="one">Tema de abertura</item> + <item quantity="many">Temas de abertura</item> + <item quantity="other">Temas de abertura</item> </plurals> <plurals name="theme_videos"> - <item quantity="one">Vídeos Temáticos</item> - <item quantity="many">Vídeos Temáticos</item> - <item quantity="other">Vídeos Temáticos</item> + <item quantity="one">Genérico</item> + <item quantity="many">Genéricos</item> + <item quantity="other">Genéricos</item> </plurals> <plurals name="clips"> - <item quantity="one">Clipes</item> + <item quantity="one">Clipe</item> <item quantity="many">Clipes</item> <item quantity="other">Clipes</item> </plurals> <plurals name="deleted_scenes"> - <item quantity="one">Cenas Excluídas</item> - <item quantity="many">Cenas Excluídas</item> - <item quantity="other">Cenas Excluídas</item> + <item quantity="one">Cena cortada</item> + <item quantity="many">Cenas cortadas</item> + <item quantity="other">Cenas cortadas</item> </plurals> <plurals name="interviews"> - <item quantity="one">Entrevistas</item> + <item quantity="one">Entrevista</item> <item quantity="many">Entrevistas</item> <item quantity="other">Entrevistas</item> </plurals> <plurals name="scenes"> - <item quantity="one">Cenas</item> + <item quantity="one">Cena</item> <item quantity="many">Cenas</item> <item quantity="other">Cenas</item> </plurals> <plurals name="samples"> - <item quantity="one">Amostras</item> + <item quantity="one">Amostra</item> <item quantity="many">Amostras</item> <item quantity="other">Amostras</item> </plurals> <plurals name="featurettes"> - <item quantity="one">Curta-metragens</item> - <item quantity="many">Curta-metragens</item> - <item quantity="other">Curta-metragens</item> + <item quantity="one">Video promocional</item> + <item quantity="many">Videos promocionais</item> + <item quantity="other">Videos promocionais</item> </plurals> <plurals name="shorts"> - <item quantity="one">Curtos</item> - <item quantity="many">Curtos</item> - <item quantity="other">Curtos</item> + <item quantity="one">Curta</item> + <item quantity="many">Curtas</item> + <item quantity="other">Curtas</item> </plurals> <plurals name="downloads"> <item quantity="one">%s transferido</item> @@ -253,7 +256,7 @@ <string name="nav_drawer_switch_on_focus_summary_off">Clique para mudar de página</string> <string name="nav_drawer_switch_on_focus">Alternar páginas da barra de navegação ao focar</string> <string name="pass_out_protection">Protecção Contra Inatividade</string> - <string name="play_theme_music">Reproduzir música temática</string> + <string name="play_theme_music">Reproduzir tema de abertura</string> <string name="playback_overrides">Substituições de reprodução</string> <string name="remember_selected_tab">Lembrar separadores selecionados</string> <string name="rewatch_next_up">Activar a revisão em \'A seguir\'</string> @@ -480,7 +483,7 @@ <string name="display_presets_description">Predefinições embarcadas para definir todas as linhas rapidamente</string> <string name="customize_home_summary">Escolha as linhas e imagens na página inicial</string> <string name="decrease_all_cards_size">Diminuir o tamanho de todos os cartões</string> - <string name="favorite_items">Favoritos %s</string> + <string name="favorite_items_title">Favoritos %s</string> <plurals name="minutes"> <item quantity="one">%s minuto</item> <item quantity="many">%s minutos</item> @@ -504,8 +507,8 @@ <string name="content_scale_fill">Preencher</string> <string name="content_scale_fill_width">Preencher largura</string> <string name="content_scale_fill_height">Preencher altura</string> - <string name="display_preset_default">Padrão do Wholphin</string> - <string name="display_preset_compact">Wholphin Compacto</string> + <string name="display_preset_default">Padrão do Wolfskin</string> + <string name="display_preset_compact">Wolfskin Compacto</string> <string name="display_preset_series_thumb">Imagens em miniatura da série</string> <string name="display_preset_episode_thumbnails">Imagens em miniatura do episódio</string> <string name="volume_lowest">Mínimo</string> @@ -596,7 +599,7 @@ <string name="continue_string">Continuar</string> <string name="separate_types">Separar por tipos</string> <string name="prefer_logos">Prefiro mostrar logótipos nos títulos</string> - <string name="updated_toast">Wholphin foi atualizado para %s</string> + <string name="updated_toast">Wolfskin foi atualizado para %s</string> <string name="remove_continue_watching">Remover de «Continuar a ver»</string> <string name="remove_next_up">Remover série do «Continuar a ver»</string> <string name="view_removed_next_up">Ver séries removidas do «Continuar a ver»</string> @@ -618,4 +621,164 @@ <string name="skip_behavior_summary">Para intros, créditos, etc</string> <string name="play_with">Reproduzir com</string> <string name="transcoding">Transcodificar</string> + <string name="pin_digits_hint">[0–9]</string> + <string name="combined_search_results">Combinar resultados da pesquisa</string> + <string name="combined_search_results_on">Mostrar todos os resultados numa única lista</string> + <string name="combined_search_results_off">Mostrar resultados por categoria</string> + <string name="results">Resultados</string> + <string name="require_login">Exigir início de sessão para o perfil</string> + <string name="show_voice_search_button">Mostrar botão de pesquisa por voz</string> + <string name="visible_ui">Visível</string> + <string name="hidden_ui">Escondido</string> + <string name="view_options_type_grid">Grelha</string> + <string name="view_options_type_list">Lista</string> + <string name="view_options_type_list_compact">Lista Compacta</string> + <string name="user_interface_language">Língua do interface</string> + <string name="display_toggles_title">Visualização de avaliações</string> + <string name="display_toggles_summary">Personalizar o que mostrar</string> + <string name="profile_protection">Protecção do perfil</string> + <string name="continue_watching_click_behavior">Comportamento ao clicar em «Continuar a ver»/«A Seguir»</string> + <string name="continue_watching_click_summary_on">Reproduzir ao clicar</string> + <string name="continue_watching_click_summary_off">Mostrar detalhes ao clicar</string> + <string name="stars">Estrelas</string> + <string name="tomatoes">Tomates</string> + <string name="sort">Ordenar</string> + <string name="split_into_separate_rows">Dividir em linhas separadas</string> + <string name="trailers_title">Trailers</string> + <string name="other_extras_title">Outro</string> + <string name="behind_the_scenes_title">Nos bastidores</string> + <string name="clips_title">Clipes</string> + <string name="deleted_scenes_title">Cenas cortadas</string> + <string name="interviews_title">Entrevistas</string> + <string name="scenes_title">Cenas</string> + <string name="samples_title">Amostras</string> + <string name="shorts_title">Curtas</string> + <string name="theme_videos_title">Genéricos</string> + <string name="theme_songs_title">Temas de abertura</string> + <plurals name="movies"> + <item quantity="one">Filme</item> + <item quantity="many">Filmes</item> + <item quantity="other">Filmes</item> + </plurals> + <plurals name="tv_shows"> + <item quantity="one">Série de TV</item> + <item quantity="many">Séries de TV</item> + <item quantity="other">Séries de TV</item> + </plurals> + <plurals name="people"> + <item quantity="one">Pessoa</item> + <item quantity="many">Pessoas</item> + <item quantity="other">Pessoas</item> + </plurals> + <string name="featurettes_title">Videos promocionais</string> + <string name="duration_hours_minutes_seconds">%1$dh %2$dm %3$ds</string> + <string name="duration_hours_minutes">%1$dh %2$dm</string> + <string name="duration_minutes_seconds">%1$dm %2$ds</string> + <string name="duration_hours">%1$dh</string> + <string name="duration_minutes">%1$dm</string> + <string name="duration_seconds">%1$ds</string> + <string name="profile_specific_settings_from_server">Definições do perfil do utilizador a partir do servidor</string> + <string name="time_left">%1$s restantes</string> + <string name="prefer_ac3_for_surround_summary"><![CDATA[Transcodificar áudio não estéreo para AC3/Dolby Digital]]></string> + <string name="prefer_ac3_for_surround">Utilize o AC3 para áudio surround</string> + <string name="genre_movies">%1$s Filmes</string> + <string name="genre_tv_shows">%1$s Séries TV</string> + <string name="quality_profile">Perfil de qualidade</string> + <string name="is_default">Padrão</string> + <string name="root_folder">Passa raiz</string> + <string name="popularity">Popularidade</string> + <string name="production_status">Estado da produção</string> + <string name="different_for_hdr">Utilizar um estilo diferente para HDR</string> + <string name="experimental_settings">Definições experimentais</string> + <string name="confirm_enable_experimental_title">Ativar definições experimentais?</string> + <string name="confirm_enable_experimental_body">As definições experimentais podem ser instáveis!\n\nPodem ser removidas ou alteradas em qualquer atualização.</string> + <string name="video_tunneling">Tunelamento de vídeo</string> + <string name="blocklisted">Lista de bloqueio</string> + <string name="unavailable">Indisponível</string> + <string name="subtitle_mode">Modo de legendas</string> + <string name="use_user_profile">Utilize as definições do seu perfil</string> + <string name="subtitle_mode_default">Padrão</string> + <string name="subtitle_mode_smart">Inteligente</string> + <string name="subtitle_mode_only_forced">Apenas forçado</string> + <string name="subtitle_mode_always">Sempre</string> + <string name="subtitle_mode_none">Nenhum</string> + <string name="preferred_subtitle_language">Idioma preferido das legendas</string> + <string name="preferred_audio_language">Idioma de áudio preferido</string> + <string name="override_user_profile_settings">Mais definições do perfil</string> + <string name="override_user_profile_settings_summary">Substituir as definições do seu perfil a partir do servidor</string> + <string name="any_language">Qualquer idioma</string> + <string name="brown">Castanho</string> + <string name="d_pad_seek_mode_title">Modo de barra de busca do D-Pad</string> + <string name="dpad_seek_mode_minimal">Mínimo</string> + <string name="dpad_seek_mode_trickplay">Fotograma</string> + <string name="dpad_seek_mode_minimal_summary">Barra de pesquisa pequena na parte inferior do ecrã (por predefinição)</string> + <string name="dpad_seek_mode_trickplay_summary">Barra de pesquisa com imagens de pré-visualização de fotogramas</string> + <string name="dpad_seek_mode_none_summary">Sem barra de pesquisa</string> + <string name="hash_matches">Correspondências de hash</string> + <string name="subtitles_hearing_impaired">SDH</string> + <string name="programs">Programas de TV</string> + <string name="recordings">Gravações</string> + <string name="folders_title">Pastas</string> + <string name="user_views">Visualizações do utilizador</string> + <string name="books">Livros</string> + <string name="audio_books">Audiolivros</string> + <string name="server_version_not_supported">Versão do servidor não suportada</string> + <string name="appears_on">Aparece em</string> + <string name="disable_audio_offload">Desativar a processamento de áudio por hardware</string> + <string name="disable_audio_offload_summary">Desativar o processamento de áudio por hardware pode resolver os bloqueios de reprodução em alguns dispositivos</string> + <string name="dim_after_delay">Escurecimento do ecrã após tempo de inatividade</string> + <string name="dim_percentage">Percentagem do escurecimento</string> + <string name="dim_screen">Escurecer ecrã</string> + <string name="photo_albums">Álbum de fotos</string> + <string name="no_favorites_found">Não foram encontrados favoritos!</string> + <string name="duration_hours_seconds">%1$dh %2$ds</string> + <string name="season_episode_number">T%1$d E%2$d</string> + <string name="season_episode_number_range">T%1$d E%2$d-E%3$d</string> + <string name="season_episode_number_padded">T%1$02dE%2$02d</string> + <string name="season_episode_number_padded_range">T%1$02dE%2$02d-E%3$02d</string> + <string name="episode_number_short">E%1$d</string> + <string name="season_number">Temporada %1$d</string> + <string name="remove_from_playlist">Remover da lista de reprodução</string> + <string name="login_credentials_expired">As credenciais expiraram. Inicie sessão novamente</string> + <string name="login_not_authorized">Não tem autorização para aceder ao servidor neste momento</string> + <string name="top_rated">Mais votados</string> + <string name="hdr_formats">Formato HDR</string> + <string name="hdr10_override">HDR10</string> + <string name="hdr10_plus_override">HDR10+</string> + <string name="dovi_profile_5">Dolby Vision Profile 5</string> + <string name="dovi_profile_7">Dolby Vision Profile 7</string> + <string name="dovi_profile_8">Dolby Vision Profile 8</string> + <string name="force_enable">Ativação forçada</string> + <string name="force_disable">Desativação forçada</string> + <string name="prefer_dovi_over_hdr10_plus">Preferir Dolby Vision sobre HDR10+</string> + <string name="prefer_dovi_over_hdr10_plus_summary">Remove os metadados HDR10+ dos vídeos em Dolby Vision para televisores que, de outra forma, os reproduziriam como HDR10+</string> + <string name="convert_dovi_profile_7">Converte Dolby Vision Profile 7 para 8.1</string> + <string name="convert_dovi_profile_7_summary">Converte o Perfil 7 de camada dupla no Perfil 8.1 de camada única antes do descodificador e reproduz-o diretamente, para dispositivos cujo descodificador suporta o Perfil 8, mas não o Perfil 7</string> + <string name="server_version_not_supported_future">A versão do servidor deixará de ser suportada em breve</string> + <string name="server_version_required">Versão do servidor necessária: %1$s</string> + <string name="max_resolution">Resolução máxima antes da transcodificação</string> + <string name="resolution_8k">8K</string> + <string name="resolution_4k">4K</string> + <string name="resolution_1080">1080</string> + <string name="resolution_720">720</string> + <string name="resolution_576">576</string> + <string name="resolution_480">480</string> + <string name="resolution_360">360</string> + <plurals name="episodes_count"> + <item quantity="one">%1$d Episódio</item> + <item quantity="many">%1$d Episódios</item> + <item quantity="other">%1$d Episódios</item> + </plurals> + <string name="chosen_by">Escolhido por %1$s</string> + <string name="user">Utilizador</string> + <string name="series">Séries</string> + <string name="load_from_server_plugin">Carregar as predefinições do servidor</string> + <string name="save_to_local">Guardar apenas neste dispositivo</string> + <string name="home_settings_source_unset">Padrão</string> + <string name="home_settings_source_local">Específico do dispositivo</string> + <string name="home_settings_source_server_profile">Perfil de utilizador do servidor</string> + <string name="home_settings_source_plugin">Padrão do servidor</string> + <string name="home_settings_source_web">Utilizador do cliente web</string> + <string name="managed_by_server">Gerido pelo servidor</string> + <string name="error">Erro</string> </resources> diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index 46a38847..d6a2d917 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -42,8 +42,7 @@ <string name="no_update_available">Nu este disponibilă nicio actualizare</string> <plurals name="movies"> <item quantity="one">Filme</item> - <item quantity="few"/> - <item quantity="other"/> + <item quantity="other">Filme</item> </plurals> <string name="library">Librărie</string> <string name="go_to">Mergi la</string> @@ -55,8 +54,7 @@ <string name="mark_watched">Marchează ca vizionat</string> <plurals name="people"> <item quantity="one">Persoane</item> - <item quantity="few"/> - <item quantity="other"/> + <item quantity="other">Persoane</item> </plurals> <string name="play_count">Număr de redări</string> <string name="more">Mai mult</string> @@ -88,7 +86,7 @@ <string name="aspect_ratio">Rată de aspect</string> <string name="show_details">Înregistrare detaliată</string> <string name="image_type">Tip imagine</string> - <string name="cast_and_crew"><![CDATA[Distribuție și echipă]]></string> + <string name="cast_and_crew">Distribuție și echipă</string> <string name="guest_stars">Vedete invitate</string> <string name="level">Nivel</string> <string name="channels">Canale</string> @@ -173,7 +171,7 @@ <string name="api_key">Cheie API</string> <string name="seerr_login">Conectează-te la serverul Seerr</string> <string name="discovered_servers">Servere descoperite</string> - <string name="download_and_update"><![CDATA[Descarcă & Actualizează]]></string> + <string name="download_and_update">Descarcă & Actualizează</string> <string name="commercial">Reclamă</string> <string name="community_rating">Evaluarea comunității</string> <string name="compose_error_message_title">A apărut o eroare! Apasă butonul pentru a trimite jurnalele către serverul tău.</string> @@ -215,21 +213,21 @@ <string name="tv_seasons">Sezoane</string> <plurals name="tv_shows"> <item quantity="one">Seriale TV</item> - <item quantity="few"/> - <item quantity="other"/> + <item quantity="other">Seriale TV</item> </plurals> <string name="ui_interface">Interfață</string> <string name="unknown">Necunoscut</string> <string name="updates">Actualizări</string> <string name="version">Versiune</string> <string name="show_clock">Arată ceasul</string> - <string name="years_old">%1$d ani vechime</string> + <plurals name="years_old"> + <item quantity="other">%1$d ani vechime</item> + </plurals> <string name="tv_guide">Ghid</string> <string name="one_click_pause">Pauză cu un click</string> <plurals name="interviews"> <item quantity="one">Interviuri</item> - <item quantity="few"/> - <item quantity="other"/> + <item quantity="other">Interviuri</item> </plurals> <string name="background">Fundal</string> <plurals name="downloads"> @@ -297,7 +295,7 @@ <string name="skip_ask">Cere să sari peste</string> <string name="seerr_jellyfin_user">Utilizator Jellyfin</string> <string name="seerr_local_user">Utilizator local</string> - <string name="search_and_download_subtitles"><![CDATA[Caută & descarcă subtitrări]]></string> + <string name="search_and_download_subtitles">Caută & descarcă subtitrări</string> <string name="composer">Compozitor</string> <string name="writer">Scriitor</string> <string name="lyrics">Versuri</string> @@ -319,7 +317,7 @@ <string name="playback_debug_info">Afișează informații de depanare privind redarea</string> <string name="only_forced_subtitles">Doar subtitrări forțate</string> <string name="save">Salvează</string> - <string name="search_and_download"><![CDATA[Caută & Descarcă]]></string> + <string name="search_and_download">Caută & Descarcă</string> <string name="resume">Reia</string> <string name="voice_starting">Începe</string> <string name="voice_search_prompt">Vorbește pentru a căuta</string> @@ -329,8 +327,7 @@ <string name="top_unwatched">Cele mai bine cotate, nevizionate</string> <plurals name="trailers"> <item quantity="one">Trailere</item> - <item quantity="few"></item> - <item quantity="other"></item> + <item quantity="other">Trailere</item> </plurals> <string name="play_with_transcoding">Redă cu transcodare</string> <string name="media_information">Informații media</string> @@ -338,33 +335,28 @@ <string name="add_to_playlist">Adaugă în lista de redare</string> <plurals name="other_extras"> <item quantity="one">Altele</item> - <item quantity="few"></item> - <item quantity="other"></item> + <item quantity="other">Altele</item> </plurals> <plurals name="behind_the_scenes"> <item quantity="one">În spatele scenelor</item> - <item quantity="few"></item> - <item quantity="other"></item> + <item quantity="other">În spatele scenelor</item> </plurals> <plurals name="theme_songs"> <item quantity="one">Melodii tematice</item> - <item quantity="few"></item> - <item quantity="other"></item> + <item quantity="other">Melodii tematice</item> </plurals> <plurals name="deleted_scenes"> <item quantity="one">Scene șterse</item> - <item quantity="few"></item> - <item quantity="other"></item> + <item quantity="other">Scene șterse</item> </plurals> <string name="official_rating">Evaluare parentală</string> <string name="runtime_sort">Timp de execuție</string> - <string name="extras">Extra</string> + <string name="extras_title">Extra</string> <plurals name="clips"> <item quantity="one">Clipuri</item> - <item quantity="few"></item> - <item quantity="other"></item> + <item quantity="other">Clipuri</item> </plurals> - <string name="favorite_items">%s favorit</string> + <string name="favorite_items_title">%s favorit</string> <string name="app_theme">Temă aplicație</string> <string name="auto_check_for_updates">Verifică automat dacă există actualizări</string> <string name="ac3_supported">Dispozitivul acceptă formatele AC3/Dolby Digital</string> diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index f94fc36b..ca59ccb6 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -1,33 +1,33 @@ <?xml version="1.0" encoding="utf-8"?> <resources> - <string name="about">Информация</string> + <string name="about">О приложении</string> <string name="add_favorite">В избранное</string> <string name="add_server">Добавить сервер</string> - <string name="add_user">Добавить Пользователя</string> - <string name="audio">Аудиодорожка</string> + <string name="add_user">Добавить пользователя</string> + <string name="audio">Аудио</string> <string name="birthplace">Место рождения</string> <string name="bitrate">Битрейт</string> - <string name="born">Рождён</string> + <string name="born">Дата рождения</string> <string name="cancel_recording">Отменить запись</string> <string name="cancel">Отмена</string> <string name="chapters">Главы</string> - <string name="choose_stream">Выберите %1$s</string> + <string name="choose_stream">Выбор: %1$s</string> <string name="clear_image_cache">Очистить кэш изображений</string> <string name="collection">Коллекция</string> <string name="collections">Коллекции</string> <string name="community_rating">Оценка сообщества</string> - <string name="compose_error_message_title">Произошла ошибка! Нажмите клавишу, чтобы отправить файл журнала на ваш сервер.</string> + <string name="compose_error_message_title">Произошла ошибка! Нажмите кнопку, чтобы отправить журналы на ваш сервер.</string> <string name="confirm">Подтвердить</string> <string name="continue_watching">Продолжить просмотр</string> <string name="critic_rating">Оценка критиков</string> - <string name="decimal_seconds">%.1f секунда</string> + <string name="decimal_seconds">%.2f с</string> <string name="default_track">По умолчанию</string> <string name="delete">Удалить</string> - <string name="died">Умер</string> - <string name="directed_by">Режиссёр %1$s</string> + <string name="died">Дата смерти</string> + <string name="directed_by">Режиссёр: %1$s</string> <string name="director">Режиссёр</string> <string name="discovered_servers">Обнаруженные серверы</string> - <string name="download_and_update"><![CDATA[Загрузить и Обновить]]></string> + <string name="download_and_update"><![CDATA[Загрузить и обновить]]></string> <string name="downloading">Скачивание…</string> <string name="enter_server_url">Введите IP или URL сервера</string> <string name="episodes">Эпизоды</string> @@ -38,7 +38,7 @@ <string name="app_theme">Тема оформления</string> <string name="auto_check_for_updates">Автоматически проверять наличие обновлений</string> <string name="check_for_updates">Проверить наличие обновлений</string> - <string name="downmix_stereo">Всегда преобразовывать в стерео</string> + <string name="downmix_stereo">Всегда сводить звук в стерео</string> <string name="ffmpeg_extension_pref">Использовать декодер FFmpeg</string> <string name="file_size">Размер</string> <string name="genres">Жанры</string> @@ -46,35 +46,35 @@ <string name="intro">Вступление</string> <string name="library">Библиотека</string> <string name="license_info">Информация о лицензии</string> - <string name="live_tv">Эфирное ТВ</string> + <string name="live_tv">Прямой эфир</string> <string name="loading">Загрузка…</string> <string name="mark_unwatched">Отметить как непросмотренное</string> <string name="mark_watched">Отметить как просмотренное</string> <string name="max_bitrate">Максимальный битрейт</string> <string name="more_like_this">Похожее</string> - <string name="movies">Фильмы</string> - <string name="name">Имя</string> + <string name="movies_title">Фильмы</string> + <string name="name">Название</string> <string name="next_up">Далее</string> <string name="no_data">Нет информации</string> <string name="no_results">Нет результатов</string> <string name="no_update_available">Нет доступных обновлений</string> <string name="path">Путь</string> - <string name="people">Люди</string> - <string name="playback_debug_info">Показать отладочную инфомацию воспроизведения</string> + <string name="people_title">Люди</string> + <string name="playback_debug_info">Показать отладочную информацию о воспроизведении</string> <string name="playback_speed">Скорость воспроизведения</string> <string name="playback">Воспроизведение</string> <string name="playlist">Плейлист</string> <string name="playlists">Плейлисты</string> <string name="profile_specific_settings">Настройки профиля</string> <string name="queue">Очередь</string> - <string name="recently_added_in">Недавно добавленное в %1$s</string> + <string name="recently_added_in">Недавно добавленное в разделе «%1$s»</string> <string name="recently_added">Недавно добавленное</string> - <string name="recommended">Рекомендовано Вам</string> + <string name="recommended">Рекомендации</string> <string name="remove_favorite">Убрать из избранного</string> <string name="resume">Продолжить</string> <string name="save">Сохранить</string> - <string name="search_and_download"><![CDATA[Найти и скачать]]></string> - <string name="search">Найти</string> + <string name="search_and_download">Найти и скачать</string> + <string name="search">Поиск</string> <string name="searching">Поиск…</string> <string name="select_server">Выберите сервер</string> <string name="select_user">Выберите пользователя</string> @@ -89,13 +89,12 @@ <string name="sort_by_name">Название</string> <string name="sort_by_random">Случайно</string> <string name="studios">Студии</string> - <string name="subtitle">Файл субтитров</string> + <string name="subtitle">Субтитры</string> <string name="subtitles">Субтитры</string> <string name="suggestions">Предложения</string> <string name="switch_servers">Сменить сервер</string> <string name="switch_user">Сменить</string> - <string name="top_unwatched">Лучшее непросмотренное</string> - <string name="trailer">Трейлер</string> + <string name="top_unwatched">Лучшие из непросмотренных</string> <plurals name="trailers"> <item quantity="one">Трейлер</item> <item quantity="few">Трейлера</item> @@ -105,14 +104,13 @@ <string name="tv_guide">Телегид</string> <string name="tv_season">Сезон</string> <string name="tv_seasons">Сезоны</string> - <string name="tv_shows">Телешоу</string> + <string name="tv_shows_title">Сериалы</string> <string name="unknown">Неизвестно</string> <string name="updates">Обновления</string> <string name="version">Версия</string> <string name="video_scale">Масштаб видео</string> <string name="video">Видео</string> <string name="videos">Видео</string> - <string name="years_old">%1$d возраст</string> <string name="play_with_transcoding">Воспроизвести с перекодированием</string> <string name="show_clock">Показывать часы</string> <string name="create_playlist">Создать новый плейлист</string> @@ -120,22 +118,22 @@ <string name="one_click_pause">Пауза одним нажатием</string> <string name="one_click_pause_summary_on">Нажмите центральную кнопку для паузы/воспроизведения</string> <string name="font">Шрифт</string> - <string name="background">Фоновое изображение</string> + <string name="background">Фон</string> <string name="success">Успех</string> <string name="official_rating">Возрастной рейтинг</string> <string name="runtime_sort">Продолжительность</string> - <string name="extras">Дополнительно</string> + <string name="extras_title">Дополнительные материалы</string> <plurals name="other_extras"> - <item quantity="one">Другое</item> - <item quantity="few">Других</item> - <item quantity="many">Других</item> - <item quantity="other">Других</item> + <item quantity="one">Другой материал</item> + <item quantity="few">Других материала</item> + <item quantity="many">Других материалов</item> + <item quantity="other">Других материалов</item> </plurals> <plurals name="behind_the_scenes"> <item quantity="one">За кадром</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="few">За кадром</item> + <item quantity="many">За кадром</item> + <item quantity="other">За кадром</item> </plurals> <plurals name="clips"> <item quantity="one">Клип</item> @@ -144,7 +142,7 @@ <item quantity="other">Клипов</item> </plurals> <plurals name="deleted_scenes"> - <item quantity="one">Удаленная сцена</item> + <item quantity="one">Удалённая сцена</item> <item quantity="few">Удалённые сцены</item> <item quantity="many">Удалённых сцен</item> <item quantity="other">Удалённых сцен</item> @@ -163,7 +161,7 @@ </plurals> <plurals name="downloads"> <item quantity="one">%s загрузка</item> - <item quantity="few">%s загрузок</item> + <item quantity="few">%s загрузки</item> <item quantity="many">%s загрузок</item> <item quantity="other">%s загрузок</item> </plurals> @@ -188,22 +186,22 @@ <string name="recently_released">Недавно вышедшее</string> <string name="commercial">Реклама</string> <string name="jump_letters">#ABCDEFGHIJKLMNOPQRSTUVWXYZАБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯ</string> - <string name="mark_entire_series_as_played">Отметить все сезоны как воспроизведённые?</string> - <string name="mark_entire_series_as_unplayed">Отметить все сезоны как невоспроизведённые?</string> - <string name="hide_debug_info">Скрыть отладочниую информацию</string> - <string name="home">Домашняя страница</string> + <string name="mark_entire_series_as_played">Отметить весь сериал как просмотренный?</string> + <string name="mark_entire_series_as_unplayed">Отметить весь сериал как непросмотренный?</string> + <string name="hide_debug_info">Скрыть отладочную информацию</string> + <string name="home">Главная</string> <string name="disabled">Отключено</string> <string name="enabled">Включено</string> - <string name="hide_controller_timeout">Скрывать элементы управления воспроизведением</string> + <string name="hide_controller_timeout">Задержка скрытия элементов управления</string> <string name="more">Ещё</string> <string name="no_scheduled_recordings">Нет запланированных записей</string> <string name="play">Воспроизвести</string> - <string name="restart">Перезапустить</string> + <string name="restart">С начала</string> <string name="play_from_here">Воспроизвести отсюда</string> <string name="watch_live">Смотреть прямой эфир</string> <string name="aired_episode_order">Порядок выхода эпизодов</string> <string name="reset">Сброс</string> - <string name="cast_and_crew"><![CDATA[Снимались и снимали]]></string> + <string name="cast_and_crew"><![CDATA[Актёры и съёмочная группа]]></string> <string name="guest_stars">Приглашённые звёзды</string> <string name="aspect_ratio">Соотношение сторон</string> <string name="show_details">Показать подробности</string> @@ -212,15 +210,15 @@ <string name="dolby_atmos">Dolby Atmos</string> <string name="mpv_conf">Редактировать mpv.conf</string> <string name="discard_change">Отменить изменения?</string> - <string name="enter_pin">Ведите ПИН-код</string> + <string name="enter_pin">Введите ПИН-код</string> <string name="sign_in_auto">Автоматический вход</string> - <string name="pin_too_short">ПИН-код должен содержать минимум 4 символа</string> + <string name="pin_too_short">ПИН-код должен содержать не менее 4 знаков</string> <string name="mpv_options">Настройки MPV</string> <string name="exoplayer_options">Настройки ExoPlayer</string> <string name="year">Год</string> <string name="decade">Десятилетие</string> <string name="remove">Убрать</string> - <string name="mpv_hardware_decoding">MPV: Использовать аппаратное декодирование</string> + <string name="mpv_hardware_decoding">MPV: использовать аппаратное декодирование</string> <string name="filter">Фильтр</string> <string name="outro">Заключительные титры</string> <string name="preview">Превью</string> @@ -234,31 +232,31 @@ <string name="play_count">Количество воспроизведений</string> <string name="recap">Краткое содержание</string> <string name="use_server_credentials">Вход через сервер</string> - <string name="press_enter_to_confirm">Нажмите центральную клавишу для подтверждения</string> + <string name="press_enter_to_confirm">Нажмите центральную кнопку для подтверждения</string> <string name="require_pin_code">Требовать ПИН-код профиля</string> <string name="confirm_pin">Подтвердите ПИН-код</string> <string name="incorrect">Неверно</string> - <string name="view_options">Показать опции</string> + <string name="view_options">Параметры отображения</string> <string name="columns">Колонки</string> <string name="subtitle_style">Стиль субтитров</string> <string name="background_color">Цвет фона</string> <string name="bold_font">Жирный шрифт</string> <string name="background_style">Стиль фона</string> <string name="image_type">Тип изображения</string> - <string name="will_remove_pin">Удалит ПИН-код</string> + <string name="will_remove_pin">ПИН-код будет удалён</string> <string name="image_cache_size">Размер кэша изображений на диске (Мбайт)</string> <string name="skip_segment_recap">Пропустить краткое содержание</string> <string name="skip_segment_outro">Пропустить заключительные титры</string> <string name="verbose_logging">Подробный журнал</string> <string name="disable_if_crash">Отключите при нестабильной работе</string> <string name="mpv_use_gpu_next">MPV: использовать gpu-next</string> - <string name="immediate">Немедленный</string> + <string name="immediate">Сразу</string> <string name="subtitle_margin">Отступ</string> <string name="skip_segment_unknown">Пропустить сегмент</string> <string name="skip_segment_preview">Пропустить превью</string> - <string name="settings">Параметры</string> - <string name="skip_back_on_resume_preference">Перемотать назад при возобновлении воспроизведения</string> - <string name="skip_back_preference">Перемотать назад</string> + <string name="settings">Настройки</string> + <string name="skip_back_on_resume_preference">Перемотка назад при возобновлении воспроизведения</string> + <string name="skip_back_preference">Перемотка назад</string> <string name="skip_commercials_behavior">Поведение при пропуске рекламы</string> <string name="skip_forward_preference">Перемотка вперёд</string> <string name="skip_intro_behavior">Поведение при пропуске вступления</string> @@ -269,28 +267,28 @@ <string name="update_url_summary">URL для проверки обновлений приложения</string> <string name="played">Воспроизведено</string> <string name="send_crash_reports">Отправлять отчёты об ошибках</string> - <string name="show_next_up_when">Показать Далее</string> + <string name="show_next_up_when">Когда показывать «Далее»</string> <string name="italic_font">Курсив</string> <string name="advanced_ui">Расширенный интерфейс</string> - <string name="auto_play_next_delay">Задержка перед воспроизведением следующей серии</string> - <string name="auto_play_next">Автовоспроизведение следующей серии</string> - <string name="combine_continue_next_summary">Применяется только к Телешоу</string> - <string name="combine_continue_next"><![CDATA[Объединить Продолжить просмотр и Далее]]></string> - <string name="direct_play_ass">Прямое воспроизведение ASS-субтитров</string> + <string name="auto_play_next_delay">Задержка перед воспроизведением следующего элемента</string> + <string name="auto_play_next">Автовоспроизведение следующего элемента</string> + <string name="combine_continue_next_summary">Применяется только к сериалам</string> + <string name="combine_continue_next"><![CDATA[Объединять «Продолжить просмотр» и «Далее»]]></string> + <string name="direct_play_ass">Использовать libass для ASS-субтитров</string> <string name="direct_play_pgs">Прямое воспроизведение PGS-субтитров</string> <string name="font_size">Размер шрифта</string> - <string name="font_color">Цвет щрифта</string> - <string name="font_opacity">Прозрачность шрифта</string> + <string name="font_color">Цвет шрифта</string> + <string name="font_opacity">Непрозрачность шрифта</string> <string name="edge_style">Стиль обводки</string> <string name="edge_color">Цвет обводки</string> - <string name="background_opacity">Прозрачность фона</string> + <string name="background_opacity">Непрозрачность фона</string> <string name="install_update">Установить обновление</string> - <string name="installed_version">Версия приложения</string> + <string name="installed_version">Установленная версия</string> <string name="send_app_logs">Отправить журнал на текущий сервер</string> - <string name="send_crash_reports_summary">Совершит попытку отправки на последний подключённый сервер</string> + <string name="send_crash_reports_summary">Приложение попытается отправить отчёты на последний подключённый сервер</string> <string name="go_to_series">Перейти к сериалу</string> <string name="tv_dvr_schedule">Расписание записей</string> - <string name="update_url">Ссылка для обновлениий</string> + <string name="update_url">URL для обновлений</string> <plurals name="items"> <item quantity="one">%d объект</item> <item quantity="few">%d объекта</item> @@ -298,50 +296,50 @@ <item quantity="other">%d объектов</item> </plurals> <plurals name="samples"> - <item quantity="one">Отрывки</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Образец</item> + <item quantity="few">Образца</item> + <item quantity="many">Образцов</item> + <item quantity="other">Образцов</item> </plurals> <plurals name="featurettes"> - <item quantity="one">Дополнительные материалы</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Дополнительный материал</item> + <item quantity="few">Дополнительных материала</item> + <item quantity="many">Дополнительных материалов</item> + <item quantity="other">Дополнительных материалов</item> </plurals> <string name="player_backend">Выбор плеера</string> <string name="edge_size">Толщина обводки</string> <string name="global_content_scale">Масштаб по умолчанию</string> - <string name="max_homepage_items">Максимальное количество элементов в ряду на домашней странице</string> + <string name="max_homepage_items">Максимум элементов в ряду на главной</string> <string name="forced_track">Форсированные</string> <plurals name="theme_songs"> - <item quantity="one">Саундтреки</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Тематическая песня</item> + <item quantity="few">Тематические песни</item> + <item quantity="many">Тематических песен</item> + <item quantity="other">Тематических песен</item> </plurals> <plurals name="theme_videos"> - <item quantity="one">Видеоролики</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Тематическое видео</item> + <item quantity="few">Тематических видео</item> + <item quantity="many">Тематических видео</item> + <item quantity="other">Тематических видео</item> </plurals> <string name="nav_drawer_pins">Настроить элементы боковой панели</string> <string name="nav_drawer_pins_summary">Выберите элементы для показа на панели, остальные элементы будут скрыты</string> <string name="play_theme_music">Воспроизведение фоновой музыки</string> - <string name="playback_overrides">Приоритеты воспроизведения</string> + <string name="playback_overrides">Переопределения воспроизведения</string> <string name="remember_selected_tab">Запоминать выбранные вкладки</string> - <string name="rewatch_next_up">Разрешить просмотренное в \"Далее\"</string> + <string name="rewatch_next_up">Разрешить повторный просмотр в «Далее»</string> <string name="seek_bar_steps">Интервал перемотки</string> <string name="send_app_logs_summary">Используется при отладке</string> <string name="nav_drawer_switch_on_focus_summary_off">Смена страниц по нажатию</string> <string name="nav_drawer_switch_on_focus">Смена страниц по выделению</string> - <string name="pass_out_protection">Остановка воспроизведения при бездействии</string> + <string name="pass_out_protection">Не запускать следующий элемент при бездействии</string> <plurals name="shorts"> - <item quantity="one">Короткометражки</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Короткометражка</item> + <item quantity="few">Короткометражки</item> + <item quantity="many">Короткометражек</item> + <item quantity="other">Короткометражек</item> </plurals> <string name="ends_at">Конец в %1$s</string> <string name="enter_server_address">Введите адрес сервера</string> @@ -349,45 +347,45 @@ <string name="only_forced_subtitles">Только форсированные субтитры</string> <string name="voice_search">Голосовой поиск</string> <string name="voice_starting">Запуск</string> - <string name="voice_search_prompt">Говорите для поиска</string> + <string name="voice_search_prompt">Произнесите поисковый запрос</string> <string name="processing">Обработка</string> - <string name="press_back_to_cancel">Нажмите назад для отмены</string> + <string name="press_back_to_cancel">Нажмите «Назад» для отмены</string> <string name="voice_error_audio">Ошибка записи звука</string> <string name="voice_error_client">Ошибка распознавания голоса</string> <string name="voice_error_permissions">Требуется разрешение на использование микрофона</string> <string name="voice_error_network">Сетевая ошибка</string> - <string name="voice_error_network_timeout">Сетевой таймаут</string> + <string name="voice_error_network_timeout">Время ожидания сети истекло</string> <string name="voice_error_no_match">Речь не распознана</string> - <string name="voice_error_busy">Голосовое распознавание занято</string> + <string name="voice_error_busy">Служба распознавания речи занята</string> <string name="voice_error_server">Ошибка сервера</string> <string name="voice_error_speech_timeout">Речь не обнаружена</string> <string name="voice_error_unknown">Неизвестная ошибка</string> <string name="voice_error_start_failed">Не удалось запустить распознавание голоса</string> <string name="voice_error_timeout">Время ожидания распознавания голоса истекло</string> <string name="retry">Повторить</string> - <string name="media_information">О медиаданных</string> + <string name="media_information">Информация о медиафайле</string> <string name="refresh_rate_switching">Переключение частоты обновления</string> <string name="automatic">Автоматически</string> - <string name="username_or_password">Исп. имя пользователя/пароль</string> - <string name="login">Логин</string> + <string name="username_or_password">Войти с именем пользователя и паролем</string> + <string name="login">Войти</string> <string name="password">Пароль</string> <string name="username">Имя пользователя</string> <string name="language">Язык</string> <string name="general">Основное</string> <string name="container">Контейнер</string> - <string name="title">Заголовок</string> + <string name="title">Название</string> <string name="codec">Кодек</string> <string name="profile">Профиль</string> <string name="level">Уровень</string> <string name="resolution">Разрешение</string> <string name="anamorphic">Анаморфность</string> <string name="interlaced">Чересстрочность</string> - <string name="framerate">Ч-та кадров</string> + <string name="framerate">Частота кадров</string> <string name="bit_depth">Разрядность</string> <string name="video_range">Диапазон видео</string> <string name="video_range_type">Тип диапазона видео</string> <string name="color_space">Цветовое пространство</string> - <string name="color_transfer">Передача цвета</string> + <string name="color_transfer">Передаточная характеристика</string> <string name="color_primaries">Основные цвета</string> <string name="pixel_format">Формат пикселей</string> <string name="ref_frames">Опорные кадры</string> @@ -407,7 +405,7 @@ </plurals> <string name="channels">Каналы</string> <string name="sample_rate">Частота дискретизации</string> - <string name="contrast">Контраст</string> + <string name="contrast">Контрастность</string> <string name="saturation">Насыщенность</string> <string name="hue">Оттенок</string> <string name="red">Красный</string> @@ -424,10 +422,384 @@ <string name="photos">Фотографии</string> <string name="purple">Фиолетовый</string> <string name="orange">Оранжевый</string> - <string name="black">Черный</string> + <string name="black">Чёрный</string> <string name="skip_automatically">Пропускать автоматически</string> <string name="white">Белый</string> <string name="light_gray">Светло-серый</string> - <string name="dark_gray">Темно-серый</string> - <string name="yellow">Желтый</string> + <string name="dark_gray">Тёмно-серый</string> + <string name="yellow">Жёлтый</string> + <plurals name="movies"> + <item quantity="one">Фильм</item> + <item quantity="few">Фильма</item> + <item quantity="many">Фильмов</item> + <item quantity="other">Фильмов</item> + </plurals> + <plurals name="tv_shows"> + <item quantity="one">Сериал</item> + <item quantity="few">Сериала</item> + <item quantity="many">Сериалов</item> + <item quantity="other">Сериалов</item> + </plurals> + <plurals name="people"> + <item quantity="one">Человек</item> + <item quantity="few">Человека</item> + <item quantity="many">Человек</item> + <item quantity="other">Человек</item> + </plurals> + <plurals name="years_old"> + <item quantity="one">%1$d год</item> + <item quantity="few">%1$d года</item> + <item quantity="many">%1$d лет</item> + <item quantity="other">%1$d лет</item> + </plurals> + <string name="profile_specific_settings_from_server">Настройки профиля с сервера</string> + <string name="trailers_title">Трейлеры</string> + <string name="behind_the_scenes_title">За кадром</string> + <string name="theme_songs_title">Тематические песни</string> + <string name="theme_videos_title">Тематические видео</string> + <string name="clips_title">Клипы</string> + <string name="deleted_scenes_title">Удалённые сцены</string> + <string name="interviews_title">Интервью</string> + <string name="scenes_title">Сцены</string> + <string name="require_login">Требовать вход для доступа к профилю</string> + <string name="pin_digits_hint">[0–9]</string> + <string name="avc">AVC</string> + <string name="sdr">SDR</string> + <string name="hdr10">HDR10</string> + <string name="hdr10_plus">HDR10+</string> + <string name="hlg">HLG</string> + <string name="sample_rate_unit">Гц</string> + <string name="combined_search_results_on">Показать все результаты в одном списке</string> + <string name="combined_search_results_off">Показать результаты по категориям</string> + <string name="show_voice_search_button">Показать кнопку голосового ввода</string> + <string name="dts_x">DTS:X</string> + <string name="dts_hd">DTS:HD</string> + <string name="dts_hd_ma">DTS:HD MA</string> + <string name="truehd">TrueHD</string> + <string name="dolby_digital">DD</string> + <string name="dolby_digital_plus">DD+</string> + <string name="dts">DTS</string> + <string name="force_dovi_profile_7">Прямое воспроизведение Dolby Vision Profile 7</string> + <string name="server_version_not_supported">Версия сервера не поддерживается</string> + <string name="favorite_items_title">Избранное — %s</string> + <string name="disable_audio_offload_summary">Отключение передачи декодирования аудио специализированному аппаратному модулю может устранить зависания воспроизведения на некоторых устройствах</string> + <string name="nal">NAL</string> + <string name="show_titles">Показывать названия</string> + <string name="live_tv_repeat">Повтор</string> + <string name="favorite_channels_at_beginning">Сначала показывать избранные каналы</string> + <string name="sort_channels_recently_watched">Сортировка каналов по недавно просмотренным</string> + <string name="backdrop_display">Стиль фона</string> + <string name="combined_search_results">Комбинированные результаты поиска</string> + <string name="visible_ui">Видимый</string> + <string name="hidden_ui">Скрытый</string> + <string name="results">Результаты</string> + <string name="resolution_switching">Переключение разрешения</string> + <string name="local">Локальный</string> + <string name="clear_track_choices">Сбросить выбор дорожек</string> + <string name="force_dovi_profile_7_summary">Игнорировать проверки совместимости устройства</string> + <string name="discover">Обзор</string> + <string name="request">Запрос</string> + <string name="pending">В ожидании</string> + <string name="remove_seerr_server">Удалить сервер Seerr</string> + <string name="seerr_server_added">Сервер Seerr добавлен</string> + <string name="quick_connect">Быстрое подключение</string> + <string name="quick_connect_summary">Авторизовать другое устройство для подключения к вашей учётной записи</string> + <string name="quick_connect_code">Введите код быстрого подключения</string> + <string name="quick_connect_code_error">В коде должно быть 6 цифр</string> + <string name="quick_connect_success">Устройство авторизовано успешно</string> + <string name="url">URL</string> + <string name="trending">В тренде</string> + <string name="upcoming_tv">Предстоящие сериалы</string> + <string name="upcoming_movies">Предстоящие фильмы</string> + <string name="request_4k">Запросить в 4K</string> + <string name="software_decoding_av1">Программное декодирование AV1</string> + <string name="upgrade_mpv_toast">MPV теперь используется как плеер по умолчанию, кроме HDR.\nВы можете изменить это в настройках.</string> + <string name="play_slideshow">Воспроизвести слайдшоу</string> + <string name="stop_slideshow">Остановить слайдшоу</string> + <string name="no_more_images">Больше нет фото</string> + <string name="rotate_left">Повернуть влево</string> + <string name="rotate_right">Повернуть вправо</string> + <string name="zoom_in">Приблизить</string> + <string name="zoom_out">Отдалить</string> + <string name="slideshow_duration">Время показа слайда</string> + <string name="play_videos_during_slideshow">Воспроизводить видео во время слайдшоу</string> + <string name="send_media_info_log_to_server">Отправить журнал сведений о медиафайле на сервер</string> + <string name="no_limit">Без ограничения</string> + <string name="hdr">HDR</string> + <string name="blur">Размытие</string> + <string name="layout">Макет</string> + <string name="ffmpeg_never">Никогда не использовать FFmpeg</string> + <string name="ffmpeg_fallback">Использовать FFmpeg, только если нет встроенного декодера</string> + <string name="duration_hours_minutes_seconds">%1$d ч %2$d мин %3$d с</string> + <string name="duration_hours_minutes">%1$d ч %2$d мин</string> + <string name="duration_hours_seconds">%1$d ч %2$d с</string> + <string name="duration_minutes_seconds">%1$d мин %2$d с</string> + <string name="duration_hours">%1$d ч</string> + <string name="duration_minutes">%1$d мин</string> + <string name="duration_seconds">%1$d с</string> + <string name="time_left">Осталось %1$s</string> + <string name="season_episode_number">S%1$d E%2$d</string> + <string name="season_episode_number_range">S%1$d E%2$d-E%3$d</string> + <string name="season_episode_number_padded">S%1$02dE%2$02d</string> + <string name="season_episode_number_padded_range">S%1$02dE%2$02d-E%3$02d</string> + <string name="episode_number_short">E%1$d</string> + <string name="season_number">Сезон %1$d</string> + <string name="other_extras_title">Другое</string> + <string name="samples_title">Образцы</string> + <string name="featurettes_title">Дополнительные материалы</string> + <string name="shorts_title">Короткометражки</string> + <string name="disable_audio_offload">Отключить аудиоофлоад</string> + <string name="bit_unit">бит</string> + <string name="color_code_programs">Выделять программы цветом</string> + <string name="hdr_subtitle_style">Стиль субтитров для HDR</string> + <string name="image_subtitle_opacity">Непрозрачность графических субтитров</string> + <string name="save_for_album">Сохранить настройки для альбома</string> + <string name="slideshow_at_beginning">В начале</string> + <string name="max_days_next_up">Макс. дней в разделе «Далее»</string> + <string name="albums">Альбомы</string> + <string name="artists">Исполнители</string> + <string name="songs">Песни</string> + <string name="go_to_artist">Перейти к исполнителю</string> + <string name="now_playing">Сейчас играет</string> + <string name="instant_mix">Моментальный микс</string> + <string name="go_to_album">Перейти к альбому</string> + <string name="add_to_queue">Добавить в очередь</string> + <string name="add_row">Добавить ряд</string> + <string name="genres_in">Жанры раздела «%1$s»</string> + <string name="recently_released_in">Недавно вышедшее в разделе «%1$s»</string> + <string name="height">Высота</string> + <string name="apply_all_rows">Применить ко всем рядам</string> + <string name="customize_home">Настроить главный экран</string> + <string name="home_rows">Ряды на главном экране</string> + <string name="load_from_server">Загрузить из профиля пользователя на сервере</string> + <string name="save_to_server">Сохранить в профиль пользователя на сервере</string> + <string name="load_from_web_client">Загрузить из веб-клиента</string> + <string name="use_series">Использовать изображение сериала</string> + <string name="add_row_for">Добавить ряд: %1$s</string> + <string name="overwrite_server_settings">Перезаписать настройки на сервере?</string> + <string name="overwrite_local_settings">Перезаписать локальные настройки?</string> + <string name="for_episodes">Для эпизодов</string> + <string name="suggestions_for">Рекомендации для раздела «%1$s»</string> + <string name="increase_all_cards_size">Увеличить размер всех карточек</string> + <string name="decrease_all_cards_size">Уменьшить размер всех карточек</string> + <string name="use_thumb_images">Использовать миниатюры</string> + <string name="settings_saved">Настройки сохранены</string> + <string name="display_presets">Готовые варианты оформления</string> + <string name="display_presets_description">Готовые варианты для быстрой настройки оформления всех рядов</string> + <string name="customize_home_summary">Выбор рядов и изображений на главном экране</string> + <string name="prefer_ac3_for_surround_summary"><![CDATA[Перекодировать нестереофонический звук в AC3/Dolby Digital]]></string> + <string name="prefer_ac3_for_surround">Использовать AC3 для объёмного звука</string> + <string name="start_after">Запускать через</string> + <string name="animate">Анимировать</string> + <string name="max_age_rating">Макс. возрастной рейтинг</string> + <string name="no_max">Без ограничения</string> + <string name="for_all_ages">Для всех возрастов</string> + <string name="up_to_age">До %s лет</string> + <string name="screensaver">Заставка</string> + <string name="screensaver_settings">Настройки заставки</string> + <string name="start_screensaver">Запустить заставку</string> + <string name="include_types">Типы контента</string> + <string name="include_types_summary">Для каких типов элементов показывать изображения</string> + <string name="content_scale_fit">Вписать</string> + <string name="content_scale_crop">Обрезать</string> + <string name="content_scale_fill">Растянуть</string> + <string name="content_scale_fill_width">По ширине</string> + <string name="content_scale_fill_height">По высоте</string> + <string name="display_preset_default">Wolfskin по умолчанию</string> + <string name="display_preset_compact">Wolfskin компактный</string> + <string name="display_preset_series_thumb">Миниатюры сериалов</string> + <string name="display_preset_episode_thumbnails">Миниатюры эпизодов</string> + <string name="volume_lowest">Самая низкая</string> + <string name="volume_low">Низкая</string> + <string name="volume_medium">Средняя</string> + <string name="volume_high">Высокая</string> + <string name="volume_full">Полная</string> + <string name="bold_blue">Насыщенный синий</string> + <string name="brown">Коричневый</string> + <string name="skip_ignore">Игнорировать</string> + <string name="skip_ask">Спрашивать о пропуске</string> + <string name="ffmpeg_prefer">Предпочитать FFmpeg встроенным декодерам</string> + <string name="next_up_playback_end">В конце воспроизведения</string> + <string name="next_up_outro">Во время титров/концовки</string> + <string name="next_up_never">Никогда</string> + <string name="cyan">Голубой</string> + <string name="magenta">Пурпурный</string> + <string name="subtitle_edge_outline">Контур</string> + <string name="subtitle_edge_shadow">Тень</string> + <string name="background_style_wrap">Под каждой строкой</string> + <string name="background_style_boxed">Единый прямоугольник</string> + <string name="prefer_mpv">Предпочитать MPV</string> + <string name="external_player">Внешний плеер</string> + <string name="player_backend_options_subtitles_prefer_mpv">Использовать ExoPlayer для воспроизведения HDR</string> + <string name="aspect_ratios_poster">Постер (2:3)</string> + <string name="aspect_ratios_16_9">16:9</string> + <string name="aspect_ratios_4_3">4:3</string> + <string name="aspect_ratios_square">Квадрат (1:1)</string> + <string name="image_type_primary">Основное изображение</string> + <string name="image_type_thumb">Миниатюра</string> + <string name="view_options_type_grid">Сетка</string> + <string name="view_options_type_list">Список</string> + <string name="view_options_type_list_compact">Компактный список</string> + <string name="backdrop_style_dynamic">Изображение с динамическим цветом</string> + <string name="backdrop_style_image">Только изображение</string> + <string name="enter_url_api_key"><![CDATA[Введите URL и API-ключ]]></string> + <string name="api_key">API-ключ</string> + <string name="seerr_login">Вход на сервер Seerr</string> + <string name="seerr_jellyfin_user">Пользователь Jellyfin</string> + <string name="seerr_local_user">Локальный пользователь</string> + <string name="search_and_download_subtitles"><![CDATA[Поиск и загрузка субтитров]]></string> + <string name="no_subtitles_found">Субтитры для загрузки не найдены</string> + <string name="series_continueing">по настоящее время</string> + <string name="in_app_screensaver">Использовать встроенную заставку приложения</string> + <string name="delete_item">Вы уверены, что хотите удалить этот элемент?</string> + <string name="show_media_management">Показывать параметры управления медиатекой</string> + <string name="actor">Актёр</string> + <string name="composer">Композитор</string> + <string name="writer">Сценарист</string> + <string name="guest_star">Приглашённая звезда</string> + <string name="producer">Продюсер</string> + <string name="conductor">Дирижёр</string> + <string name="lyricist">Автор текста</string> + <string name="arranger">Аранжировщик</string> + <string name="engineer">Звукорежиссёр</string> + <string name="mixer">Сведение</string> + <string name="creator">Создатель</string> + <string name="artist">Исполнитель</string> + <string name="album_artist">Исполнитель альбома</string> + <string name="album">Альбом</string> + <string name="popular_songs">Популярные песни</string> + <string name="play_next">Воспроизвести следующим</string> + <string name="music_videos">Музыкальные клипы</string> + <string name="lyrics">Текст песни</string> + <string name="hide_lyrics">Скрыть текст песни</string> + <string name="show_lyrics">Показать текст песни</string> + <string name="song_has_lyrics">У песни есть текст</string> + <string name="remove_from_queue">Убрать из очереди</string> + <string name="show_album_cover">Показывать обложку альбома</string> + <string name="show_visualizer">Показывать визуализатор</string> + <string name="show_backdrop">Показывать фоновое изображение</string> + <string name="play_as_type">Воспроизвести как?</string> + <string name="visualizer_rationale">Для визуализации воспроизводимого звука требуется разрешение на запись аудио.</string> + <string name="continue_string">Продолжить</string> + <string name="separate_types">Разделять по типам</string> + <string name="prefer_logos">Показывать логотипы вместо текстовых названий</string> + <string name="updated_toast">Wolfskin обновлён до версии %s</string> + <string name="remove_continue_watching">Убрать из «Продолжить просмотр»</string> + <string name="remove_next_up">Убрать сериал из «Далее»</string> + <string name="view_removed_next_up">Показать сериалы, убранные из «Далее»</string> + <string name="view_more">Показать больше</string> + <string name="discover_tv">Обзор сериалов</string> + <string name="discover_movies">Обзор фильмов</string> + <string name="studios_in">Студии раздела «%1$s»</string> + <string name="last_played">Последнее воспроизведение</string> + <string name="system_default">Как в системе</string> + <string name="ass_subtitle_mode_libass">Прямое воспроизведение через libass</string> + <string name="ass_subtitle_mode_exoplayer">Прямое воспроизведение средствами ExoPlayer</string> + <string name="ass_subtitle_mode_transcode">Вшивать/перекодировать на сервере</string> + <string name="ass_subtitle_playback">Воспроизведение субтитров SSA/ASS</string> + <string name="cinema_mode">Режим кинотеатра</string> + <string name="show_more">Показать ещё %1$s</string> + <string name="skip_behavior">Настройки пропуска</string> + <string name="skip_behavior_summary">Для вступлений, титров и других сегментов</string> + <string name="play_with">Воспроизводить через</string> + <string name="transcoding">Перекодирование</string> + <string name="user_interface_language">Язык интерфейса</string> + <string name="display_toggles_title">Отображение рейтингов</string> + <string name="display_toggles_summary">Настройка того, что отображать</string> + <string name="profile_protection">Защита профиля</string> + <string name="continue_watching_click_behavior">Действие при нажатии в «Продолжить просмотр»/«Далее»</string> + <string name="continue_watching_click_summary_on">Воспроизводить по нажатию</string> + <string name="continue_watching_click_summary_off">Показывать подробности по нажатию</string> + <string name="stars">Звёзды</string> + <string name="tomatoes">Помидоры</string> + <string name="sort">Сортировка</string> + <string name="split_into_separate_rows">Разделять на отдельные ряды</string> + <string name="genre_movies">Фильмы: %1$s</string> + <string name="genre_tv_shows">Сериалы: %1$s</string> + <string name="quality_profile">Профиль качества</string> + <string name="is_default">По умолчанию</string> + <string name="root_folder">Корневая папка</string> + <string name="popularity">Популярность</string> + <string name="production_status">Статус производства</string> + <string name="different_for_hdr">Использовать другой стиль для HDR</string> + <string name="experimental_settings">Экспериментальные настройки</string> + <string name="confirm_enable_experimental_title">Включить экспериментальные настройки?</string> + <string name="confirm_enable_experimental_body">Экспериментальные настройки могут быть нестабильными!\n\nОни могут быть удалены или изменены в любой версии.</string> + <string name="video_tunneling">Туннелирование видео</string> + <string name="d_pad_seek_mode_title">Вид полосы перемотки при нажатии кнопок пульта</string> + <string name="dpad_seek_mode_minimal">Минимальный</string> + <string name="dpad_seek_mode_trickplay">Превью при перемотке</string> + <string name="dpad_seek_mode_minimal_summary">Небольшая полоса перемотки внизу экрана (по умолчанию)</string> + <string name="dpad_seek_mode_trickplay_summary">Полоса перемотки с миниатюрами кадров (Trickplay)</string> + <string name="dpad_seek_mode_none_summary">Без полосы перемотки</string> + <string name="blocklisted">В чёрном списке</string> + <string name="unavailable">Недоступно</string> + <string name="subtitle_mode">Режим субтитров</string> + <string name="use_user_profile">Использовать настройку профиля</string> + <string name="subtitle_mode_default">По умолчанию</string> + <string name="subtitle_mode_smart">Умный</string> + <string name="subtitle_mode_only_forced">Только форсированные</string> + <string name="subtitle_mode_always">Всегда</string> + <string name="subtitle_mode_none">Нет</string> + <string name="preferred_subtitle_language">Предпочитаемый язык субтитров</string> + <string name="preferred_audio_language">Предпочитаемый язык аудиодорожки</string> + <string name="override_user_profile_settings">Дополнительные настройки профиля пользователя</string> + <string name="override_user_profile_settings_summary">Переопределить настройки профиля пользователя с сервера</string> + <string name="any_language">Любой язык</string> + <string name="hash_matches">Совпадение по хешу</string> + <string name="subtitles_hearing_impaired">Для слабослышащих (SDH)</string> + <string name="programs">ТВ-программы</string> + <string name="recordings">Записи</string> + <string name="folders_title">Папки</string> + <string name="user_views">Разделы пользователя</string> + <string name="books">Книги</string> + <string name="audio_books">Аудиокниги</string> + <string name="appears_on">Альбомы с участием исполнителя</string> + <string name="dim_after_delay">Затемнять экран после задержки</string> + <string name="dim_percentage">Процент затемнения</string> + <string name="dim_screen">Затемнение экрана</string> + <string name="photo_albums">Фотоальбомы</string> + <string name="no_favorites_found">В избранном пока ничего нет!</string> + <string name="remove_from_playlist">Убрать из плейлиста</string> + <string name="login_credentials_expired">Срок действия учётных данных истёк. Пожалуйста, войдите снова</string> + <string name="login_not_authorized">У вас нет доступа к серверу в данный момент</string> + <string name="top_rated">С высоким рейтингом</string> + <string name="hdr_formats">Форматы HDR</string> + <string name="hdr10_override">HDR10</string> + <string name="hdr10_plus_override">HDR10+</string> + <string name="dovi_profile_5">Dolby Vision, профиль 5</string> + <string name="dovi_profile_7">Dolby Vision, профиль 7</string> + <string name="dovi_profile_8">Dolby Vision, профиль 8</string> + <string name="force_enable">Принудительно включить</string> + <string name="force_disable">Принудительно отключить</string> + <string name="prefer_dovi_over_hdr10_plus">Приоритет Dolby Vision над HDR10+</string> + <string name="prefer_dovi_over_hdr10_plus_summary">Удаляет метаданные HDR10+ из видео Dolby Vision для телевизоров, которые иначе воспроизводят такие видео в формате HDR10+</string> + <string name="server_version_not_supported_future">Поддержка этой версии сервера скоро прекратится</string> + <string name="server_version_required">Требуемая версия сервера: %1$s</string> + <string name="max_resolution">Максимальное разрешение без перекодирования</string> + <string name="resolution_8k">8K</string> + <string name="resolution_4k">4K</string> + <string name="resolution_1080">1080</string> + <string name="resolution_720">720</string> + <string name="resolution_576">576</string> + <string name="resolution_480">480</string> + <string name="resolution_360">360</string> + <plurals name="episodes_count"> + <item quantity="one">%1$d эпизод</item> + <item quantity="few">%1$d эпизода</item> + <item quantity="many">%1$d эпизодов</item> + <item quantity="other">%1$d эпизодов</item> + </plurals> + <string name="chosen_by">Источник выбора: %1$s</string> + <string name="user">Пользователь</string> + <string name="series">Сериал</string> + <string name="load_from_server_plugin">Загрузить настройки по умолчанию с сервера</string> + <string name="save_to_local">Сохранить только на этом устройстве</string> + <string name="home_settings_source_unset">По умолчанию</string> + <string name="home_settings_source_local">Настройки этого устройства</string> + <string name="home_settings_source_server_profile">Профиль пользователя на сервере</string> + <string name="home_settings_source_plugin">Настройки по умолчанию на сервере</string> + <string name="home_settings_source_web">Веб-клиент пользователя</string> + <string name="managed_by_server">Управляется сервером</string> + <string name="error">Ошибка</string> </resources> diff --git a/app/src/main/res/values-sk/strings.xml b/app/src/main/res/values-sk/strings.xml index ede412b5..50d2edd7 100644 --- a/app/src/main/res/values-sk/strings.xml +++ b/app/src/main/res/values-sk/strings.xml @@ -61,7 +61,7 @@ <string name="max_bitrate">Maximálny bitrate</string> <string name="more_like_this">Navrhované</string> <string name="more">Viac</string> - <string name="movies">Filmy</string> + <string name="movies_title">Filmy</string> <string name="name">Meno</string> <string name="next_up">Nasleduje</string> <string name="no_data">Žiadne dáta</string> @@ -71,7 +71,7 @@ <string name="none">Žiadne</string> <string name="outro">Outro</string> <string name="path">Cesta</string> - <string name="people">Ľudia</string> + <string name="people_title">Ľudia</string> <string name="play_count">Počet prehrávaní</string> <string name="play_from_here">Prehrať odtiaľto</string> <string name="play">Prehrať</string> @@ -143,22 +143,21 @@ <string name="switch_servers">Zmeniť servery</string> <string name="switch_user">Zmeniť</string> <string name="top_unwatched">Najlepšie hodnotené Nepozreté</string> - <string name="trailer">Trailer</string> <plurals name="trailers"> - <item quantity="one">Trailery</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Trailer</item> + <item quantity="few">Trailery</item> + <item quantity="many">Trailerov</item> + <item quantity="other">Trailerov</item> </plurals> <string name="tv_dvr_schedule">DVR rozvrh</string> <string name="tv_guide">Sprievodca</string> <string name="tv_season">Séria</string> <string name="tv_seasons">Série</string> <plurals name="tv_shows"> - <item quantity="one">Seriály</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Seriál</item> + <item quantity="few">Seriály</item> + <item quantity="many">Seriálov</item> + <item quantity="other">Seriálov</item> </plurals> <string name="ui_interface">Rozhranie</string> <string name="unknown">Neznámy</string> @@ -168,7 +167,12 @@ <string name="video">Video</string> <string name="videos">Videá</string> <string name="watch_live">Sledovať naživo</string> - <string name="years_old">%1$d rokov</string> + <plurals name="years_old"> + <item quantity="one">vo veku %1$d roka</item> + <item quantity="few">vo veku %1$d rokov</item> + <item quantity="many">vo veku %1$d rokov</item> + <item quantity="other">vo veku %1$d rokov</item> + </plurals> <string name="play_with_transcoding">Prehrávať s prekódovaním</string> <string name="media_information">Informácie o médiu</string> <string name="show_clock">Zobraziť hodiny</string> @@ -183,74 +187,74 @@ <string name="success">Úspech</string> <string name="official_rating">Rodičovské hodnotenie</string> <string name="runtime_sort">Dĺžka</string> - <string name="extras">Bonusový materiál</string> + <string name="extras_title">Bonusový materiál</string> <plurals name="other_extras"> - <item quantity="one">Ostatné</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Iné</item> + <item quantity="few">Iné</item> + <item quantity="many">Iných</item> + <item quantity="other">Iných</item> </plurals> <plurals name="behind_the_scenes"> <item quantity="one">Zo zákulisia</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="few">Zo zákulisia</item> + <item quantity="many">Zo zákulisia</item> + <item quantity="other">Zo zákulisia</item> </plurals> <plurals name="theme_songs"> - <item quantity="one">Tématické piesne</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Tématická pieseň</item> + <item quantity="few">Tématické piesne</item> + <item quantity="many">Tématických piesní</item> + <item quantity="other">Tématických piesní</item> </plurals> <plurals name="theme_videos"> - <item quantity="one">Tématické videá</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Tématické video</item> + <item quantity="few">Tématické videá</item> + <item quantity="many">Tématických videí</item> + <item quantity="other">Tématických videí</item> </plurals> <plurals name="clips"> - <item quantity="one">Klipy</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Klip</item> + <item quantity="few">Klipy</item> + <item quantity="many">Klipov</item> + <item quantity="other">Klipov</item> </plurals> <plurals name="deleted_scenes"> - <item quantity="one">Vymazané scény</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Vymazaná scéna</item> + <item quantity="few">Vymazané scény</item> + <item quantity="many">Vymazaných scén</item> + <item quantity="other">Vymazaných scén</item> </plurals> <plurals name="interviews"> - <item quantity="one">Rozhovory</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Rozhovor</item> + <item quantity="few">Rozhovory</item> + <item quantity="many">Rozhovorov</item> + <item quantity="other">Rozhovorov</item> </plurals> <plurals name="scenes"> - <item quantity="one">Scény</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Scéna</item> + <item quantity="few">Scény</item> + <item quantity="many">Scén</item> + <item quantity="other">Scén</item> </plurals> <plurals name="samples"> - <item quantity="one">Vzorky</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Vzorka</item> + <item quantity="few">Vzorky</item> + <item quantity="many">Vzoriek</item> + <item quantity="other">Vzoriek</item> </plurals> <plurals name="featurettes"> - <item quantity="one">Krátkometrážne filmy</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Krátkometrážny film</item> + <item quantity="few">Krátkometrážne filmy</item> + <item quantity="many">Krátkometrážnych filmov</item> + <item quantity="other">Krátkometrážnych filmov</item> </plurals> <plurals name="shorts"> - <item quantity="one">Krátke videá</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Krátke video</item> + <item quantity="few">Krátke videá</item> + <item quantity="many">Krátkych videí</item> + <item quantity="other">Krátkych videí</item> </plurals> - <string name="favorite_items">Obľúbené %s</string> + <string name="favorite_items_title">Obľúbené %s</string> <plurals name="downloads"> <item quantity="one">%s stiahnutý</item> <item quantity="few">%s stiahnuté</item> @@ -317,7 +321,7 @@ </plurals> <string name="auto_play_next_delay">Oneskorenie pred prehrávaním nasledujúceho</string> <string name="auto_play_next">Automatické prehrávanie nasledujúceho</string> - <string name="combine_continue_next"><![CDATA[Combine Continue Watching & Next Up]]></string> + <string name="combine_continue_next"><![CDATA[Spojiť sekcie Pokračovať v pozeraní a Nasleduje]]></string> <string name="playback_overrides">Prepísanie prehrávania</string> <string name="rewatch_next_up">Povoliť opätovné prehrávanie v nasledujúcich</string> <string name="skip_back_on_resume_preference">Pri obnovení prehrávania preskočiť späť</string> @@ -522,8 +526,8 @@ <string name="content_scale_fill">Vyplniť</string> <string name="content_scale_fill_width">Vyplniť na šírku</string> <string name="content_scale_fill_height">Vyplniť na výšku</string> - <string name="display_preset_default">Wholphin predvolené</string> - <string name="display_preset_compact">Wholphin kompaktné</string> + <string name="display_preset_default">Wolfskin predvolené</string> + <string name="display_preset_compact">Wolfskin kompaktné</string> <string name="display_preset_series_thumb">Náhľadové obrázky seriálov</string> <string name="display_preset_episode_thumbnails">Náhľadové obrázky epizód</string> <string name="image_type_thumb">Náhľad</string> @@ -609,4 +613,170 @@ <string name="play_next">Prehrať nasledujúce</string> <string name="music_videos">Hudobné videá</string> <string name="lyrics">Texty piesní</string> + <string name="trailers_title">Trailery</string> + <string name="tv_shows_title">Seriály</string> + <string name="other_extras_title">Ostatné</string> + <string name="behind_the_scenes_title">Zo zákulisia</string> + <string name="theme_songs_title">Úvodné piesne</string> + <string name="theme_videos_title">Úvodné videá</string> + <string name="clips_title">Klipy</string> + <string name="deleted_scenes_title">Vystrihnuté scény</string> + <string name="interviews_title">Rozhovory</string> + <string name="scenes_title">Scény</string> + <string name="samples_title">Ukážky</string> + <string name="featurettes_title">Bonusy</string> + <string name="shorts_title">Krátke filmy</string> + <plurals name="movies"> + <item quantity="one">Film</item> + <item quantity="few">Filmy</item> + <item quantity="many">Filmov</item> + <item quantity="other">Filmov</item> + </plurals> + <plurals name="people"> + <item quantity="one">Osoba</item> + <item quantity="few">Osoby</item> + <item quantity="many">Osôb</item> + <item quantity="other">Osôb</item> + </plurals> + <string name="require_login">Vyžadovať prihlásenie pre profil</string> + <string name="pin_digits_hint">[0–9]</string> + <string name="combined_search_results">Kombinované výsledky vyhľadávania</string> + <string name="combined_search_results_on">Zobraziť všetky výsledky v jednom zozname</string> + <string name="combined_search_results_off">Zobraziť výsledky podľa kategórie</string> + <string name="show_voice_search_button">Zobraziť tlačidlo hlasového vyhľadávania</string> + <string name="visible_ui">Viditeľné</string> + <string name="hidden_ui">Skryté</string> + <string name="results">Výsledky</string> + <string name="view_options_type_grid">Mriežka</string> + <string name="view_options_type_list">Zoznam</string> + <string name="view_options_type_list_compact">Kompaktný zoznam</string> + <string name="hide_lyrics">Skryť text piesne</string> + <string name="show_lyrics">Zobraziť text piesne</string> + <string name="song_has_lyrics">Skladba má text piesne</string> + <string name="remove_from_queue">Odstrániť z fronty</string> + <string name="show_album_cover">Zobraziť obal albumu</string> + <string name="show_visualizer">Zobraziť vizualizér</string> + <string name="show_backdrop">Zobraziť pozadie</string> + <string name="play_as_type">Prehrať ako?</string> + <string name="visualizer_rationale">Na vizualizáciu práve prehrávaného zvuku je potrebné povolenie na nahrávanie zvuku.</string> + <string name="continue_string">Pokračovať</string> + <string name="separate_types">Oddeliť typy</string> + <string name="prefer_logos">Uprednostniť zobrazovanie log namiesto názvov</string> + <string name="updated_toast">Wolfskin bol aktualizovaný na verziu %s</string> + <string name="remove_continue_watching">Odstrániť z Pokračovať v pozeraní</string> + <string name="remove_next_up">Odstrániť seriál zo sekcie Nasleduje</string> + <string name="view_removed_next_up">Zobraziť seriály odstránené zo sekcie Nasleduje</string> + <string name="view_more">Zobraziť viac</string> + <string name="discover_tv">Objavujte seriály</string> + <string name="discover_movies">Objavujte filmy</string> + <string name="studios_in">Štúdiá v %1$s</string> + <string name="last_played">Naposledy prehrané</string> + <string name="system_default">Predvolené nastavenie systému</string> + <string name="ass_subtitle_mode_libass">Priame prehrávanie s libass</string> + <string name="ass_subtitle_mode_exoplayer">Priame prehrávanie s vstavaným ExoPlayerom</string> + <string name="ass_subtitle_mode_transcode">Vypáliť titulky/prekódovať na serveri</string> + <string name="ass_subtitle_playback">Prehrávanie titulkov SSA/ASS</string> + <string name="cinema_mode">Kino režim</string> + <string name="show_more">Zobraziť ďalších %1$s</string> + <string name="skip_behavior">Správanie pri preskočení</string> + <string name="skip_behavior_summary">Pre intrá, titulky a podobne</string> + <string name="play_with">Prehrať pomocou</string> + <string name="transcoding">Prekódovanie</string> + <string name="user_interface_language">Jazyk používateľského rozhrania</string> + <string name="display_toggles_title">Zobrazenie hodnotení</string> + <string name="display_toggles_summary">Prispôsobte, čo sa má zobrazovať</string> + <string name="profile_protection">Ochrana profilu</string> + <string name="continue_watching_click_behavior">Správanie pri kliknutí v sekciách Pokračovať v pozeraní/Nasleduje</string> + <string name="continue_watching_click_summary_on">Prehrať po kliknutí</string> + <string name="continue_watching_click_summary_off">Zobraziť podrobnosti po kliknutí</string> + <string name="stars">Hviezdičky</string> + <string name="tomatoes">Paradajky</string> + <string name="sort">Zoradiť</string> + <string name="split_into_separate_rows">Rozdeliť do samostatných riadkov</string> + <string name="genre_movies">%1$s Filmov</string> + <string name="genre_tv_shows">%1$s Seriálov</string> + <string name="quality_profile">Profil kvality</string> + <string name="is_default">Predvolené</string> + <string name="root_folder">Koreňový priečinok</string> + <string name="popularity">Popularita</string> + <string name="production_status">Stav produkcie</string> + <string name="different_for_hdr">Použiť iný štýl pre HDR</string> + <string name="experimental_settings">Experimentálne nastavenia</string> + <string name="confirm_enable_experimental_title">Povoliť experimentálne nastavenia?</string> + <string name="confirm_enable_experimental_body">Experimentálne nastavenia môžu byť nestabilné!\n\n Môžu byť zmenené alebo odstránené v budúcich verziách.</string> + <string name="video_tunneling">Video tunneling</string> + <string name="duration_hours_minutes_seconds">%1$dh %2$dm %3$ds</string> + <string name="duration_hours_minutes">%1$dh %2$dm</string> + <string name="duration_minutes_seconds">%1$dm %2$ds</string> + <string name="duration_hours">%1$dh</string> + <string name="duration_minutes">%1$dm</string> + <string name="duration_seconds">%1$ds</string> + <string name="time_left">zostáva %1$s</string> + <string name="blocklisted">Blokované</string> + <string name="unavailable">Nedostupné</string> + <string name="prefer_ac3_for_surround_summary"><![CDATA[Prekódovať viackanálový zvuk na AC3/Dolby Digital]]></string> + <string name="prefer_ac3_for_surround">Použiť AC3 pre viackanálový zvuk</string> + <string name="profile_specific_settings_from_server">Nastavenia používateľského profilu zo servera</string> + <string name="subtitle_mode">Režim titulkov</string> + <string name="use_user_profile">Použiť nastavenie profilu</string> + <string name="subtitle_mode_default">Predvolené</string> + <string name="subtitle_mode_smart">Inteligentné</string> + <string name="subtitle_mode_only_forced">Len vynútené</string> + <string name="subtitle_mode_always">Vždy</string> + <string name="subtitle_mode_none">Žiadne</string> + <string name="preferred_subtitle_language">Preferovaný jazyk titulkov</string> + <string name="preferred_audio_language">Preferovaný jazyk zvuku</string> + <string name="override_user_profile_settings">Viac nastavení používateľského profilu</string> + <string name="override_user_profile_settings_summary">Prepísať nastavenia používateľského profilu zo servera</string> + <string name="any_language">Akýkoľvek jazyk</string> + <string name="d_pad_seek_mode_title">Režim panela pretáčania pomocou D-Padu</string> + <string name="dpad_seek_mode_trickplay">Trickplay</string> + <string name="hash_matches">Hash zhody</string> + <string name="subtitles_hearing_impaired">SDH</string> + <string name="programs">TV programy</string> + <string name="recordings">Nahrávky</string> + <string name="folders_title">Priečinky</string> + <string name="user_views">Používateľské zobrazenia</string> + <string name="books">Knihy</string> + <string name="audio_books">Audioknihy</string> + <string name="brown">Hnedá</string> + <string name="dpad_seek_mode_minimal">Minimálne</string> + <string name="dpad_seek_mode_minimal_summary">Malý panel pretáčania v dolnej časti obrazovky (predvolené)</string> + <string name="dpad_seek_mode_trickplay_summary">Panel pretáčania s náhľadmi Trickplay</string> + <string name="dpad_seek_mode_none_summary">Bez panela pretáčania</string> + <string name="server_version_not_supported">Verzia servera nie je podporovaná</string> + <string name="appears_on">Objavuje sa v</string> + <string name="disable_audio_offload">Zakázať hardvérové odovzdávanie zvuku</string> + <string name="disable_audio_offload_summary">Zakázanie odovzdávania dekódovania zvuku môže vyriešiť zamŕzanie prehrávania na niektorých zariadeniach</string> + <string name="dim_after_delay">Stlmiť obrazovku po uplynutí času</string> + <string name="dim_percentage">Úroveň stlmenia</string> + <string name="dim_screen">Stlmenie obrazovky</string> + <string name="photo_albums">Fotoalbumy</string> + <string name="no_favorites_found">Nenašli sa žiadne obľúbené položky!</string> + <string name="duration_hours_seconds">%1$dh %2$ds</string> + <string name="season_episode_number">S%1$d E%2$d</string> + <string name="season_episode_number_range">S%1$d E%2$d-E%3$d</string> + <string name="season_episode_number_padded">S%1$02dE%2$02d</string> + <string name="season_episode_number_padded_range">S%1$02dE%2$02d-E%3$02d</string> + <string name="episode_number_short">E%1$d</string> + <string name="season_number">Séria %1$d</string> + <string name="remove_from_playlist">Odstrániť z playlistu</string> + <string name="login_credentials_expired">Platnosť prihlasovacích údajov vypršala, prihláste sa znova</string> + <string name="login_not_authorized">Momentálne nemáte oprávnenie na prístup k serveru</string> + <string name="prefer_dovi_over_hdr10_plus">Preferovať Dolby Vision pred HDR10+</string> + <string name="prefer_dovi_over_hdr10_plus_summary">Odstraňuje HDR10+ metadáta z Dolby Vision videí, aby ich televízory namiesto HDR10+ prehrávali v režime Dolby Vision</string> + <string name="max_resolution">Maximálne rozlíšenie pred prekódovaním</string> + <string name="resolution_8k">8K</string> + <string name="resolution_4k">4K</string> + <string name="resolution_1080">1080</string> + <string name="resolution_720">720</string> + <string name="resolution_576">576</string> + <string name="resolution_480">480</string> + <string name="resolution_360">360</string> + <plurals name="episodes_count"> + <item quantity="one">%1$d epizóda</item> + <item quantity="few">%1$d epizódy</item> + <item quantity="many">%1$d epizód</item> + <item quantity="other">%1$d epizód</item> + </plurals> </resources> diff --git a/app/src/main/res/values-sl/strings.xml b/app/src/main/res/values-sl/strings.xml index 71e1bc71..5711dac1 100644 --- a/app/src/main/res/values-sl/strings.xml +++ b/app/src/main/res/values-sl/strings.xml @@ -31,7 +31,7 @@ <string name="director">Režiser</string> <string name="disabled">Onemogočeno</string> <string name="discovered_servers">Najdeni strežniki</string> - <string name="download_and_update"><![CDATA[Prenesi in posodobi]]></string> + <string name="download_and_update">Prenesi in posodobi</string> <string name="downloading">Prenos poteka…</string> <string name="enabled">Omogočeno</string> <string name="ends_at">Konča se ob %1$s</string> @@ -66,6 +66,7 @@ <string name="more">Več</string> <plurals name="movies"> <item quantity="one">Filmi</item> + <item quantity="other">Filmi</item> </plurals> <string name="name">Ime</string> <string name="next_up">Naslednjo</string> @@ -80,6 +81,7 @@ <string name="path">Pot</string> <plurals name="people"> <item quantity="one">Osebe</item> + <item quantity="other">Osebe</item> </plurals> <string name="play_count">Število predvajanj</string> <string name="play_from_here">Predvajaj od tu</string> @@ -104,7 +106,7 @@ <string name="restart">Zaženi znova</string> <string name="resume">Nadaljuj</string> <string name="save">Shrani</string> - <string name="search_and_download"><![CDATA[Poišči in prenesi]]></string> + <string name="search_and_download">Poišči in prenesi</string> <string name="search">Išči</string> <string name="searching">Iskanje…</string> <string name="voice_search">Glasovno iskanje</string> @@ -146,9 +148,9 @@ <string name="switch_servers">Zamenjaj strežnik</string> <string name="switch_user">Preklopi</string> <string name="top_unwatched">Najbolje ocenjeno, neogledano</string> - <string name="trailer">Napovednik</string> <plurals name="trailers"> <item quantity="one">Napovedniki</item> + <item quantity="other">Napovedniki</item> </plurals> <string name="tv_dvr_schedule">Program snemalnika</string> <string name="tv_guide">Vodnik</string> @@ -156,6 +158,7 @@ <string name="tv_seasons">Sezone</string> <plurals name="tv_shows"> <item quantity="one">TV oddaje</item> + <item quantity="other">TV oddaje</item> </plurals> <string name="ui_interface">Vmesnik</string> <string name="unknown">Neznano</string> @@ -165,7 +168,9 @@ <string name="video">Video</string> <string name="videos">Videji</string> <string name="watch_live">Glej v živo</string> - <string name="years_old">%1$d let staro</string> + <plurals name="years_old"> + <item quantity="other">%1$d let staro</item> + </plurals> <string name="play_with_transcoding">Igrajte s transkodiranjem</string> <string name="media_information">Informacije o mediji</string> <string name="show_clock">Prikaži uro</string> @@ -180,41 +185,52 @@ <string name="success">Uspeh</string> <string name="official_rating">Starševska ocena</string> <string name="runtime_sort">Trajanje izvajanja</string> - <string name="extras">Dodatki</string> + <string name="extras_title">Dodatki</string> <plurals name="other_extras"> <item quantity="one">Ostalo</item> + <item quantity="other">Ostalo</item> </plurals> <plurals name="behind_the_scenes"> <item quantity="one">V zakulisju</item> + <item quantity="other">V zakulisju</item> </plurals> <plurals name="theme_songs"> <item quantity="one">Tematske pesmi</item> + <item quantity="other">Tematske pesmi</item> </plurals> <plurals name="theme_videos"> <item quantity="one">Tematski videi</item> + <item quantity="other">Tematski videi</item> </plurals> <plurals name="clips"> <item quantity="one">Posnetki</item> + <item quantity="other">Posnetki</item> </plurals> <plurals name="deleted_scenes"> <item quantity="one">Izbrisane scene</item> + <item quantity="other">Izbrisane scene</item> </plurals> <plurals name="interviews"> <item quantity="one">Intervjuji</item> + <item quantity="other">Intervjuji</item> </plurals> <plurals name="scenes"> <item quantity="one">Prizori</item> + <item quantity="other">Prizori</item> </plurals> <plurals name="samples"> <item quantity="one">Vzorci</item> + <item quantity="other">Vzorci</item> </plurals> <plurals name="featurettes"> <item quantity="one">Kratki filmi</item> + <item quantity="other">Kratki filmi</item> </plurals> <plurals name="shorts"> <item quantity="one">Kratki filmi</item> + <item quantity="other">Kratki filmi</item> </plurals> - <string name="favorite_items">Dodaj %s med priljubljene</string> + <string name="favorite_items_title">Dodaj %s med priljubljene</string> <plurals name="downloads"> <item quantity="one">%s prenos</item> <item quantity="two">%s prenosa</item> @@ -260,7 +276,7 @@ <string name="auto_play_next">Samodejno predvajanje naslednjega</string> <string name="check_for_updates">Preveri za posodobitve</string> <string name="combine_continue_next_summary">Velja samo za televizijske serije</string> - <string name="combine_continue_next"><![CDATA[Združi »Nadaljuj z ogledom« in »Naslednje«]]></string> + <string name="combine_continue_next">Združi »Nadaljuj z ogledom« in »Naslednje«</string> <string name="direct_play_ass">Za podnapise v formatu ASS uporabite knjižnico libass</string> <string name="direct_play_pgs">Neposredno predvajanje podnapisov PGS</string> <string name="downmix_stereo">Vedno pretvori v stereo</string> @@ -344,7 +360,7 @@ <string name="aspect_ratio">Razmerje stranic</string> <string name="show_details">Pokaži podrobnosti</string> <string name="image_type">Vrsta slike</string> - <string name="cast_and_crew"><![CDATA[Igralska zasedba in ekipa]]></string> + <string name="cast_and_crew">Igralska zasedba in ekipa</string> <string name="guest_stars">Gostujoči igralci</string> <string name="edge_size">Velikost roba</string> <string name="refresh_rate_switching">Preklapljanje hitrosti osveževanja</string> @@ -541,12 +557,12 @@ <string name="image_type_thumb">Thumb</string> <string name="backdrop_style_dynamic">Slika z dinamičnimi barvami</string> <string name="backdrop_style_image">Samo slika</string> - <string name="enter_url_api_key"><![CDATA[Vnesite URL in API ključ]]></string> + <string name="enter_url_api_key">Vnesite URL in API ključ</string> <string name="api_key">API ključ</string> <string name="seerr_login">Prijava na strežnik Seerr</string> <string name="seerr_jellyfin_user">Jellyfin uporabnik</string> <string name="seerr_local_user">Lokalni uporabnik</string> - <string name="search_and_download_subtitles"><![CDATA[Išči in prenesi podnapise]]></string> + <string name="search_and_download_subtitles">Išči in prenesi podnapise</string> <string name="no_subtitles_found">Ni bilo najdenih podnapisov</string> <string name="series_continueing">danes</string> <string name="in_app_screensaver">Uporabite ohranjevalnik zaslona aplikacije</string> @@ -584,7 +600,7 @@ <string name="select_all">Izberi vse</string> <string name="separate_types">Ločene vrste</string> <string name="prefer_logos">Prikazuj logotipe za naslove</string> - <string name="updated_toast">Wholphin posodobljen na %s</string> + <string name="updated_toast">Wolfskin posodobljen na %s</string> <string name="remove_continue_watching">Odstrani iz nadaljevanja gledanja</string> <string name="remove_next_up">Odstrani serijo iz nadaljevanja gledanja</string> <string name="view_removed_next_up">Ogled serije, odstranjene iz naslednjega gledanja</string> diff --git a/app/src/main/res/values-sq/strings.xml b/app/src/main/res/values-sq/strings.xml new file mode 100644 index 00000000..2781d45b --- /dev/null +++ b/app/src/main/res/values-sq/strings.xml @@ -0,0 +1,36 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <string name="active_recordings">Rregjistrime aktive</string> + <string name="add_favorite">Shto si të preferuar</string> + <string name="add_server">Shto server</string> + <string name="add_user">Shto përdorues</string> + <string name="collection">Koleksion</string> + <string name="confirm">Konfirmo</string> + <string name="continue_watching">Vazhdo shikimin</string> + <string name="delete">Fshij</string> + <string name="download_and_update"><![CDATA[Shkarko & Përditëso]]></string> + <string name="decimal_seconds">%.2f sekonda</string> + <string name="duration_hours_minutes_seconds">%1$dh%2$dm%3$ds</string> + <string name="duration_hours_minutes">%1$dh%2$dm</string> + <string name="duration_minutes_seconds">%1$dm%2$ds</string> + <string name="duration_hours">%1$dh</string> + <string name="duration_minutes">%1$dm</string> + <string name="duration_seconds">%1$ds</string> + <string name="directed_by">Drejtuar nga %1$s</string> + <string name="downloading">Duke shkarkuar…</string> + <string name="enabled">Aktivizuar</string> + <string name="ends_at">Përfundon në %1$s</string> + <string name="enter_server_url">Vendos IP-në ose URL-në e serverit</string> + <string name="subtitles_hearing_impaired">SDH</string> + <string name="programs">Programe TV</string> + <string name="recordings">Rregjistrime</string> + <string name="books">Libra</string> + <string name="user_views">Shikimet e përdoruesit</string> + <string name="appears_on">Shfaqet në</string> + <string name="audio_books">Audiolibra</string> + <string name="server_version_not_supported">Versioni i serverit nuk suportohet</string> + <string name="folders_title">Dosje</string> + <string name="any_language">Çdo gjuhë</string> + <string name="hash_matches">Hash-i përputhet</string> + <string name="override_user_profile_settings_summary">Ndrysho cilësimet e profilit tënd të përdoruesit nga serveri</string> +</resources> diff --git a/app/src/main/res/values-sr/strings.xml b/app/src/main/res/values-sr/strings.xml new file mode 100644 index 00000000..4244a0df --- /dev/null +++ b/app/src/main/res/values-sr/strings.xml @@ -0,0 +1,134 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <string name="about">O aplikaciji</string> + <string name="active_recordings">Aktivni Snimci</string> + <string name="add_favorite">Omiljeni</string> + <string name="add_server">Dodaj Server</string> + <string name="add_user">Dodaj Korisnika</string> + <string name="audio">Audio</string> + <string name="birthplace">Mesto rođenja</string> + <string name="bitrate">Bitrate</string> + <string name="born">Rođen</string> + <string name="cancel_recording">Prekini Snimanje</string> + <string name="cancel_series_recording">Prekini Snimanje Serije</string> + <string name="cancel">Prekini</string> + <string name="chapters">Poglavlja</string> + <string name="choose_stream">Izaberi</string> + <string name="clear_image_cache">Očisti keš slika</string> + <string name="collection">Kolekcija</string> + <string name="collections">Kolekcije</string> + <string name="commercial">Reklama</string> + <string name="community_rating">Ocena Zajednice</string> + <string name="compose_error_message_title">Greška! Pritisni dugme da pošalješ logove serveru.</string> + <string name="confirm">Potvrdi</string> + <string name="continue_watching">Nastavi sa gledanjem</string> + <string name="critic_rating">Ocena Kritičara</string> + <string name="decimal_seconds">%.2f sekunde</string> + <string name="duration_hours_minutes_seconds">%1$dh %2$dm %3$ds</string> + <string name="duration_hours_minutes">%1$dh %2$dm</string> + <string name="duration_hours_seconds">%1$dh %2$ds</string> + <string name="duration_minutes_seconds">%1$dm %2$ds</string> + <string name="duration_hours">%1$dh</string> + <string name="duration_minutes">%1$dm</string> + <string name="duration_seconds">%1$ds</string> + <string name="default_track">Podrazumevano</string> + <string name="delete">Obriši</string> + <string name="died">Umro</string> + <string name="directed_by">Režirao %1$s</string> + <string name="director">Režiser</string> + <string name="disabled">Isključeno</string> + <string name="discovered_servers">Otkriveni Serveri</string> + <string name="download_and_update"><![CDATA[Preuzmi i ažuriraj]]></string> + <string name="downloading">Preuzimanje…</string> + <string name="enabled">Uključeno</string> + <string name="ends_at">Završava se u %1$s</string> + <string name="enter_server_url">Upiši Server IP ili URL</string> + <string name="enter_server_address">Upiši server adresu</string> + <string name="episodes">Epizode</string> + <string name="error_loading_collection">Greška pri učitavanju kolekcije %1$s</string> + <string name="external_track">Eksterni</string> + <string name="favorites">Omiljeni</string> + <string name="file_size">Veličina</string> + <string name="forced_track">Forsiran</string> + <string name="genres">Žanrovi</string> + <string name="go_to_series">Uđi u serije</string> + <string name="go_to">Idi do</string> + <string name="hide_controller_timeout">Sakrij kontrole za reprodukciju</string> + <string name="hide_debug_info">Sakrij debug informacije</string> + <string name="hide">Sakrij</string> + <string name="home">Kuća</string> + <string name="immediate">Odmah</string> + <string name="intro">Intro</string> + <string name="jump_letters">#ABCČĆDĐEFGHIJKLMNOPRSŠTUVZŽ</string> + <string name="library">Biblioteka</string> + <string name="license_info">Informacije o licenci</string> + <string name="live_tv">TV Uživo</string> + <string name="loading">Učitavanje…</string> + <string name="mark_entire_series_as_played">Obeleži celu seriju kao odgledanu?</string> + <string name="mark_entire_series_as_unplayed">Obeleži celu seriju kao neodgledanu?</string> + <string name="mark_unwatched">Obeleži kao neodgledano</string> + <string name="mark_watched">Obeleži kao odgledano</string> + <string name="max_bitrate">Maks bitrate</string> + <string name="more_like_this">Slično ovome</string> + <string name="more">Još</string> + <string name="movies_title">Filmovi</string> + <string name="name">Ime</string> + <string name="next_up">Sledeće</string> + <string name="no_data">Nema podataka</string> + <string name="no_results">Nema rezultata</string> + <string name="no_servers_found">Server nije pronađen</string> + <string name="no_scheduled_recordings">Nema zakazanih snimaka</string> + <string name="no_update_available">Nema dostupnih ažuriranja</string> + <string name="none">Nijedan</string> + <string name="only_forced_subtitles">Samo Forsirani Titlovi</string> + <string name="outro">Outro</string> + <string name="path">Putanja</string> + <string name="people_title">Ljudi</string> + <string name="play_count">Broj Prikaza</string> + <string name="play_from_here">Pusti odavde</string> + <string name="play">Pusti</string> + <string name="playback_debug_info">Prikaži debug info reprodukcije</string> + <string name="playback_speed">Brzina Reprodukcije</string> + <string name="playback">Reprodukcija</string> + <string name="playlist">Plejlista</string> + <string name="playlists">Plejliste</string> + <string name="preview">Pregled</string> + <string name="profile_specific_settings">Korisnička Podešavanja</string> + <string name="profile_specific_settings_from_server">Korisnička podešavanja sa servera</string> + <string name="queue">Red</string> + <string name="recap">Rekapitulacija</string> + <string name="recently_added_in">Nedavno dodato u %1$s</string> + <string name="recently_added">Nedavno dodato</string> + <string name="recently_recorded">Nedavno Snimljeno</string> + <string name="recently_released">Nedavno Objavljeno</string> + <string name="recommended">Preporučeno</string> + <string name="record_program">Snimi Program</string> + <string name="record_series">Snimi Seriju</string> + <string name="remove_favorite">Ukloni iz omiljenih</string> + <string name="restart">Restart</string> + <string name="resume">Nastavi</string> + <string name="save">Sačuvaj</string> + <string name="search_and_download"><![CDATA[Pretraži i Preuzmi]]></string> + <string name="search">Pretraži</string> + <string name="searching">Pretraga…</string> + <string name="voice_search">Glasovna pretraga</string> + <string name="voice_starting">Započinje</string> + <string name="voice_search_prompt">Pretraži govorom</string> + <string name="processing">Obrađuje se</string> + <string name="press_back_to_cancel">Pritisni nazad za prekid</string> + <string name="voice_error_audio">Greška u audio zapisu</string> + <string name="voice_error_client">Greška u prepoznavanju glasa</string> + <string name="voice_error_permissions">Potrebna je dozvola za mikrofon</string> + <string name="voice_error_network">Greška u mreži</string> + <string name="voice_error_network_timeout">Mreža izgubljena</string> + <string name="voice_error_no_match">Nije prepoznat govor</string> + <string name="voice_error_busy">Prepoznavanje govora zauzeto</string> + <string name="voice_error_server">Greška u Serveru</string> + <string name="voice_error_speech_timeout">Nije detektovan govor</string> + <string name="voice_error_unknown">Nepoznata greška</string> + <string name="voice_error_start_failed">Greška u pokretanju prepoznavanja glasa</string> + <string name="voice_error_timeout">Greška u prepoznavanju glasa</string> + <string name="retry">Pokušaj ponovo</string> + <string name="select_server">Odaberi Server</string> + <string name="select_user">Odaberi Korisnika</string> +</resources> diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index c84a46c3..002d5ece 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -31,7 +31,7 @@ <string name="no_results">Inga resultat</string> <string name="name">Namn</string> <string name="loading">Laddar…</string> - <string name="movies">Filmer</string> + <string name="movies_title">Filmer</string> <string name="play">Spela</string> <string name="playlist">Spellista</string> <string name="playlists">Spellistor</string> @@ -73,16 +73,15 @@ <string name="switch_servers">Byt server</string> <string name="switch_user">Byt</string> <string name="top_unwatched">Topprankade osedda</string> - <string name="trailer">Förhandsvisning</string> <plurals name="trailers"> <item quantity="one">Förhandsvisningar</item> - <item quantity="other"/> + <item quantity="other">Förhandsvisningar</item> </plurals> <string name="tv_dvr_schedule">DVR-schema</string> <string name="tv_guide">TV Guide</string> <string name="tv_season">Säsong</string> <string name="tv_seasons">Säsonger</string> - <string name="tv_shows">TV-serier</string> + <string name="tv_shows_title">TV-serier</string> <string name="ui_interface">Gränssnitt</string> <string name="unknown">Okänd</string> <string name="updates">Uppdateringar</string> @@ -91,7 +90,10 @@ <string name="video">Video</string> <string name="videos">Videor</string> <string name="watch_live">Titta live</string> - <string name="years_old">%1$d år gammal</string> + <plurals name="years_old"> + <item quantity="one">%1$d år gammal</item> + <item quantity="other">%1$d år gammal</item> + </plurals> <string name="play_with_transcoding">Spela med transkodning</string> <string name="show_clock">Visa klocka</string> <string name="aired_episode_order">Sändningsordning</string> @@ -105,50 +107,50 @@ <string name="success">Lyckades</string> <string name="official_rating">Åldersgräns</string> <string name="runtime_sort">Speltid</string> - <string name="extras">Extramaterial</string> + <string name="extras_title">Extramaterial</string> <plurals name="other_extras"> <item quantity="one">Annat</item> - <item quantity="other"/> + <item quantity="other">Annat</item> </plurals> <plurals name="behind_the_scenes"> <item quantity="one">Bakom kulisserna</item> - <item quantity="other"/> + <item quantity="other">Bakom kulisserna</item> </plurals> <plurals name="theme_songs"> <item quantity="one">Temalåtar</item> - <item quantity="other"/> + <item quantity="other">Temalåtar</item> </plurals> <plurals name="theme_videos"> <item quantity="one">Temavideor</item> - <item quantity="other"/> + <item quantity="other">Temavideor</item> </plurals> <plurals name="clips"> <item quantity="one">Klipp</item> - <item quantity="other"/> + <item quantity="other">Klipp</item> </plurals> <plurals name="deleted_scenes"> <item quantity="one">Borttagna scener</item> - <item quantity="other"/> + <item quantity="other">Borttagna scener</item> </plurals> <plurals name="interviews"> <item quantity="one">Intervjuer</item> - <item quantity="other"/> + <item quantity="other">Intervjuer</item> </plurals> <plurals name="scenes"> <item quantity="one">Scener</item> - <item quantity="other"/> + <item quantity="other">Scener</item> </plurals> <plurals name="samples"> <item quantity="one">Exempel</item> - <item quantity="other"/> + <item quantity="other">Exempel</item> </plurals> <plurals name="featurettes"> <item quantity="one">Specialinslag</item> - <item quantity="other"/> + <item quantity="other">Specialinslag</item> </plurals> <plurals name="shorts"> - <item quantity="one">Kortfilmer</item> - <item quantity="other"/> + <item quantity="one">Kortfilm</item> + <item quantity="other">Kortfilmer</item> </plurals> <plurals name="downloads"> <item quantity="one">%s nedladdning</item> @@ -171,14 +173,14 @@ <string name="confirm">Bekräfta</string> <string name="continue_watching">Fortsätt titta</string> <string name="critic_rating">Kritikerbetyg</string> - <string name="decimal_seconds">%.1f sekunder</string> + <string name="decimal_seconds">%.2f sekunder</string> <string name="default_track">Standard</string> <string name="delete">Radera</string> <string name="directed_by">Regisserad av %1$s</string> <string name="disabled">Inaktiverad</string> <string name="discovered_servers">Upptäckta servrar</string> <string name="enabled">Aktiverad</string> - <string name="enter_server_url">Ange serverns IP eller URL inklusive port</string> + <string name="enter_server_url">Ange serverns IP eller URL</string> <string name="error_loading_collection">Fel vid inläsning av samling %1$s</string> <string name="external_track">Extern</string> <string name="forced_track">Tvingad</string> @@ -198,14 +200,14 @@ <string name="mark_watched">Markera som spelad</string> <string name="max_bitrate">Maximal bithastighet</string> <string name="more_like_this">Liknande titlar</string> - <string name="next_up">Näst på tur</string> + <string name="next_up">Nästa på tur</string> <string name="no_data">Ingen data</string> <string name="no_scheduled_recordings">Inga schemalagda inspelningar</string> <string name="no_update_available">Ingen uppdatering tillgänglig</string> <string name="none">Ingen</string> <string name="outro">Slutsekvens</string> <string name="path">Sökväg</string> - <string name="people">Medverkande</string> + <string name="people_title">Medverkande</string> <string name="play_count">Spelningar</string> <string name="play_from_here">Spela härifrån</string> <string name="playback_debug_info">Visa felsökningsinfo för uppspelning</string> @@ -236,7 +238,7 @@ <string name="edge_style">Kantstil</string> <string name="edge_color">Kantfärg</string> <string name="background_opacity">Bakgrundsopacitet</string> - <string name="direct_play_ass">Direktuppspelning av ASS-undertexter</string> + <string name="direct_play_ass">Använd libass för ASS-undertexter</string> <string name="direct_play_pgs">Direktuppspelning av PGS-undertexter</string> <string name="downmix_stereo">Mixa alltid om till stereo</string> <string name="ffmpeg_extension_pref">Använd FFmpeg dekodermodul</string> @@ -244,7 +246,7 @@ <string name="disable_if_crash">Inaktivera om du upplever krascher</string> <string name="mpv_options">MPV inställningar</string> <string name="exoplayer_options">ExoPlayer inställningar</string> - <string name="download_and_update"><![CDATA[Ladda ner och uppdatera]]></string> + <string name="download_and_update">Ladda ner och uppdatera</string> <string name="skip_segment_intro">Hoppa över intro</string> <string name="skip_segment_outro">Hoppa över eftertexter</string> <string name="skip_segment_unknown">Hoppa över segment</string> @@ -344,7 +346,7 @@ <string name="refresh_rate_switching">Växling av uppdateringsfrekvens</string> <string name="ref_frames">Referensbilder</string> <string name="red">Röd</string> - <string name="resolution_switching">Växling av uppdateringsfrekvens</string> + <string name="resolution_switching">Växling av upplösning</string> <string name="retry">Försök igen</string> <string name="rewatch_next_up">Tillåt omvisning i “Nästa upp”</string> <string name="api_key">API nyckel</string> @@ -362,7 +364,7 @@ <string name="send_app_logs">Skicka apploggar till aktuell server</string> <string name="select_all">Markera alla</string> <string name="seerr_server_added">Seerr-server tillagd</string> - <string name="seerr_login">Logga in på Seerr-server</string> + <string name="seerr_login">Logga in på Seerr</string> <string name="seerr_local_user">Lokal användare</string> <string name="seerr_jellyfin_user">Jellyfin användare</string> <string name="seek_bar_steps">Steg för sökreglage</string> @@ -375,8 +377,8 @@ <string name="skip_ask">Fråga om att hoppa över</string> <string name="skip_automatically">Hoppa över automatiskt</string> <string name="send_app_logs_summary">Användbart för felsökning</string> - <string name="search_and_download_subtitles"><![CDATA[Sök och ladda ner undertexter]]></string> - <string name="search_and_download"><![CDATA[Sök och ladda ner]]></string> + <string name="search_and_download_subtitles">Sök och ladda ner undertexter</string> + <string name="search_and_download">Sök och ladda ner</string> <string name="saturation">Mättnad</string> <string name="save_for_album">Spara till album</string> <string name="save_to_server">Spara till serverns användarprofil</string> @@ -420,11 +422,273 @@ <string name="content_scale_fill">Fyll</string> <string name="customize_home">Anpassa startsidan</string> <string name="customize_home_summary">Välj rader och bilder på startsidan</string> - <string name="delete_item">Är du säker på att du vill ta bort detta objekt?</string> + <string name="delete_item">Är du säker på att du vill radera detta objekt?</string> <string name="discover">Upptäck</string> - <string name="display_preset_compact">Wholphin Kompakt</string> - <string name="display_preset_default">Wholphin Standard</string> + <string name="display_preset_compact">Wolfskin Kompakt</string> + <string name="display_preset_default">Wolfskin Standard</string> <string name="display_presets">Visningsförinställningar</string> <string name="display_presets_description">Inbyggda förinställningar för att snabbt styla alla rader</string> <string name="display_preset_episode_thumbnails">Avsnittsminiatyrer</string> + <string name="continue_string">Fortsätt</string> + <string name="cyan">Turkos</string> + <string name="magenta">Purpurröd</string> + <string name="stars">Stjärnor</string> + <string name="subtitle_edge_shadow">Skugga</string> + <string name="avc">AVC</string> + <string name="volume_lowest">Lägsta</string> + <string name="volume_low">Låg</string> + <string name="volume_medium">Medel</string> + <string name="volume_high">Hög</string> + <string name="volume_full">Full</string> + <string name="bold_blue">Åskblå</string> + <string name="ffmpeg_fallback">Använd endast FFmpeg om det inte finns någon inbyggd avkodare</string> + <string name="ffmpeg_prefer">Föredra att använda FFmpeg framför inbyggda avkodare</string> + <string name="ffmpeg_never">Använd aldrig FFmpeg-avkodare</string> + <string name="next_up_never">Aldrig</string> + <string name="subtitle_edge_outline">Kontur</string> + <string name="background_style_wrap">Radbryt</string> + <string name="background_style_boxed">Inramad</string> + <string name="prefer_mpv">Föredra MPV</string> + <string name="external_player">Extern spelare</string> + <string name="player_backend_options_subtitles_prefer_mpv">Använd ExoPlayer för HDR-uppspelning</string> + <string name="aspect_ratios_poster">Affisch (2:3)</string> + <string name="aspect_ratios_16_9">16:9</string> + <string name="aspect_ratios_4_3">4:3</string> + <string name="aspect_ratios_square">Kvadratisk (1:1)</string> + <string name="view_options_type_grid">Rutnät</string> + <string name="view_options_type_list">Lista</string> + <string name="view_options_type_list_compact">Kompakt lista</string> + <string name="enter_url_api_key">Ange URL och API-nyckel</string> + <string name="no_subtitles_found">Inga externa undertexter hittades</string> + <string name="in_app_screensaver">Använd skärmsläckare i appen</string> + <string name="lyricist">Textförfattare</string> + <string name="arranger">Arrangör</string> + <string name="mixer">Mixare</string> + <string name="artist">Artist</string> + <string name="album_artist">Albumartist</string> + <string name="album">Album</string> + <string name="popular_songs">Populära låtar</string> + <string name="play_next">Spela nästa</string> + <string name="music_videos">Musikvideor</string> + <string name="lyrics">Låttexter</string> + <string name="hide_lyrics">Döjt låttexter</string> + <string name="show_lyrics">Visa låttexter</string> + <string name="sort">Sortera</string> + <string name="subtitle_delay">Undertextfördröjning</string> + <string name="artists">Artister</string> + <string name="start_after">Starta efter</string> + <string name="apply_all_rows">Tillämpa på alla rader</string> + <string name="continue_watching_click_summary_on">Spela vid klick</string> + <string name="continue_watching_click_summary_off">Visa detaljer vid klick</string> + <string name="tomatoes">Tomater</string> + <string name="split_into_separate_rows">Dela upp i separata rader</string> + <string name="contrast">Kontrast</string> + <string name="sdr">SDR</string> + <string name="go_to_album">Gå till album</string> + <string name="add_to_queue">Lägg till i kön</string> + <string name="albums">Album</string> + <string name="add_row_for">Lägg till rad för %1$s</string> + <string name="now_playing">Spelas nu</string> + <string name="hidden_ui">Dold</string> + <string name="remove_from_queue">Ta bort från kön</string> + <string name="require_login">Kräv inloggning för profil</string> + <string name="remove_next_up">Ta bort serien från Fortsätt titta</string> + <string name="view_removed_next_up">Visa serier som tagits bort från Fortsätt titta</string> + <string name="view_more">Visa mer</string> + <string name="discover_tv">Upptäck TV-serier</string> + <string name="discover_movies">Upptäck filmer</string> + <plurals name="days"> + <item quantity="one">%s dag</item> + <item quantity="other">%s dagar</item> + </plurals> + <string name="layout">Layout</string> + <string name="channels">Kanaler</string> + <string name="results">Resultat</string> + <string name="force_dovi_profile_7_summary">Ignorerar kontroller av enhetskompatibilitet</string> + <string name="pending">Pendlar</string> + <string name="play_trailer">Spela trailer</string> + <string name="visible_ui">Synlig</string> + <string name="combined_search_results">Kombinerade sökresultat</string> + <string name="combined_search_results_on">Visa alla resultat i en enda lista</string> + <string name="combined_search_results_off">Visa resultat efter kategori</string> + <string name="show_voice_search_button">Visa knapp för röstsökning</string> + <string name="ends_at">Slutar %1$s</string> + <string name="no_servers_found">Inga servrar hittades</string> + <string name="only_forced_subtitles">Endast tvingade undertexter</string> + <string name="no_trailers">Inga trailrar</string> + <string name="combine_continue_next">Kombinera Fortsätt titta och kommande avsnitt</string> + <string name="framerate">Bildfrekvens</string> + <string name="bit_depth">Bitdjup</string> + <string name="video_range">Videoomfång</string> + <string name="video_range_type">Typ av videoomfång</string> + <string name="color_space">Färgrymd</string> + <string name="cast_and_crew">Rollista och team</string> + <string name="genres_in">Genrer i %1$s</string> + <string name="remove_continue_watching">Ta bort från Fortsätt titta</string> + <string name="local">Lokal</string> + <string name="play_with">Spela med</string> + <string name="transcoding">Omkodning</string> + <string name="user_interface_language">Språk för användargränssnitt</string> + <string name="display_toggles_title">Visning av betyg</string> + <string name="display_toggles_summary">Anpassa vilka som ska visas</string> + <string name="profile_protection">Profilskydd</string> + <string name="continue_watching_click_behavior">Klickbeteende för Fortsätt titta/kommande avsnitt</string> + <string name="voice_search">Röstsökning</string> + <string name="voice_starting">Startar</string> + <string name="voice_search_prompt">Tala för att söka</string> + <string name="voice_error_network_timeout">Tidsgräns för nätverk överskreds</string> + <string name="voice_error_no_match">Inget tal kändes igen</string> + <string name="voice_error_busy">Röstigenkänning upptagen</string> + <string name="will_remove_pin">Kommer att ta bort PIN-koden</string> + <string name="play_as_type">Spela som?</string> + <string name="favorite_channels_at_beginning">Visa favoritkanaler först</string> + <string name="sort_channels_recently_watched">Sortera kanaler efter senaste visade</string> + <string name="favorite_items_title">Favoritmarkera %s</string> + <string name="play_slideshow">Spela bildspel</string> + <string name="songs">Sånger</string> + <string name="prefer_logos">Föredra att visa logotyper för titlar</string> + <string name="brightness">Ljusstyrka</string> + <string name="cinema_mode">Bioläge</string> + <string name="suggestions_for">Förslag för %1$s</string> + <string name="system_default">Systemstandard</string> + <string name="playback_overrides">Överskrid uppspelning</string> + <string name="processing">Processering</string> + <string name="voice_error_audio">Fel på ljudinspelning</string> + <string name="voice_error_client">Fel på röstigenkänning</string> + <string name="voice_error_permissions">Mikrofonbehörighet krävs</string> + <string name="media_information">Information om Media</string> + <string name="other_extras_title">Annat</string> + <string name="behind_the_scenes_title">Bakom kulisserna</string> + <string name="theme_songs_title">Temasånger</string> + <string name="theme_videos_title">Temavideor</string> + <string name="clips_title">Klipp</string> + <string name="deleted_scenes_title">Borttagna scener</string> + <string name="interviews_title">Intervjuer</string> + <string name="scenes_title">Scener</string> + <string name="featurettes_title">Extramaterial</string> + <string name="shorts_title">Korta klipp</string> + <plurals name="movies"> + <item quantity="one">Film</item> + <item quantity="other">FIlmer</item> + </plurals> + <plurals name="tv_shows"> + <item quantity="one">TV Serie</item> + <item quantity="other">TV Serier</item> + </plurals> + <plurals name="people"> + <item quantity="one">Person</item> + <item quantity="other">Personer</item> + </plurals> + <string name="incorrect">Felaktigt</string> + <string name="title">Titel</string> + <string name="trending">Trendande</string> + <string name="stop_slideshow">Stoppa bildspelet</string> + <string name="slideshow_at_beginning">I början</string> + <string name="no_more_images">Inga fler bilder</string> + <string name="go_to_artist">Gå till artist</string> + <string name="load_from_web_client">Ladda från webbklient</string> + <string name="for_episodes">För episoder</string> + <string name="increase_all_cards_size">Öka storleken för alla kort</string> + <string name="decrease_all_cards_size">Minska storleken för alla kort</string> + <string name="animate">Animera</string> + <string name="max_age_rating">Maximal åldersgräns</string> + <string name="include_types_summary">Vilka typer av objekt ska bilder visas för</string> + <string name="song_has_lyrics">Låten har text</string> + <string name="show_album_cover">Visa omslag till album</string> + <string name="updated_toast">Wolfskin har uppdaterats till %s</string> + <string name="last_played">Senast visat</string> + <string name="root_folder">Huvudkatalog</string> + <string name="popularity">Populäritet</string> + <string name="production_status">Produktionsstatus</string> + <string name="experimental_settings">Experimentella inställningar</string> + <string name="confirm_enable_experimental_title">Slå på experimentella inställningar?</string> + <string name="video_tunneling">Videotunnling</string> + <string name="blocklisted">Svartlistad</string> + <string name="unavailable">Otillgänglig</string> + <string name="books">Böcker</string> + <string name="audio_books">Ljudböcker</string> + <string name="duration_hours_minutes_seconds">%1$dt %2$dm %3$ds</string> + <string name="duration_hours_minutes">%1$dt %2$dm</string> + <string name="duration_minutes_seconds">%1$dm %2$ds</string> + <string name="duration_hours">%1$dt</string> + <string name="duration_minutes">%1$dm</string> + <string name="duration_seconds">%1$ds</string> + <string name="time_left">%1$s kvar</string> + <string name="mpv_hardware_decoding">MPV: Använd hårdvaruavkodning</string> + <string name="interlaced">Sammanflätad</string> + <string name="level">Nivå</string> + <string name="mpv_conf">Redigera mpv.conf</string> + <string name="profile_specific_settings_from_server">Profilinställningar från server</string> + <string name="no_max">Ingen övre gräns</string> + <string name="include_types">Inkludera typer</string> + <string name="load_from_server">Ladda från serverns användarprofil</string> + <string name="pin_digits_hint">[0–9]</string> + <string name="disable_audio_offload">Stäng av hårdvaruavlastning</string> + <string name="disable_audio_offload_summary">Stänga av ljudavlastning kan hjälpa uppspelning som fryser på vissa enheter</string> + <string name="codec">Codec</string> + <string name="anamorphic">Anamorfisk</string> + <string name="nal">NAL</string> + <string name="hdr">HDR</string> + <string name="hdr10">HDR10</string> + <string name="hdr10_plus">HDR10+</string> + <string name="hlg">HLG</string> + <string name="bit_unit">bit</string> + <string name="dts_x">DTS:X</string> + <string name="dts_hd">DTS:HD</string> + <string name="dts_hd_ma">DTS:HD MA</string> + <string name="truehd">TrueHD</string> + <string name="dolby_digital">DD</string> + <string name="dolby_digital_plus">DD+</string> + <string name="dts">DTS</string> + <string name="force_dovi_profile_7">Direct play Dolby Vision Profil 7</string> + <string name="software_decoding_av1">AV1 mjukvaruavkodning</string> + <string name="slideshow_duration">Bildspelslängd</string> + <string name="play_videos_during_slideshow">Spela videor under bildspel</string> + <string name="prefer_ac3_for_surround">Använd AC3 för surroundljud</string> + <string name="brown">Brun</string> + <string name="show_visualizer">Visa visualizer</string> + <string name="show_backdrop">Visa bakgrund</string> + <string name="player_backend">Uppspelning backend</string> + <string name="season_number">Säsong %1$d</string> + <string name="season_episode_number">S%1$d E%2$d</string> + <string name="season_episode_number_range">S%1$d E%2$d-E%3$d</string> + <string name="season_episode_number_padded">S%1$02dE%2$02d</string> + <string name="duration_hours_seconds">%1$dt %2$ds</string> + <string name="blur">Oskärpa</string> + <string name="season_episode_number_padded_range">S%1$02dE%2$02d-E%3$02d</string> + <string name="episode_number_short">E%1$d</string> + <string name="show_more">Visa %1$s till</string> + <string name="is_default">Standard</string> + <string name="preferred_audio_language">Föredraget ljudspråk</string> + <string name="override_user_profile_settings">Fler profilinställningar</string> + <string name="use_user_profile">Använd profilinställningar</string> + <string name="override_user_profile_settings_summary">Ignorera serverns profilinställningar</string> + <string name="server_version_not_supported">Serverns version stöds inte</string> + <string name="appears_on">Medverkar i</string> + <string name="photo_albums">Fotoalbum</string> + <string name="no_favorites_found">Inga favoriter hittades!</string> + <string name="confirm_enable_experimental_body">Experimentella inställningar kan vara instabila!\n\n Dessa kan tas bort eller ändras i framtida versioner.</string> + <string name="remove_from_playlist">Ta bort från spellista</string> + <string name="trailers_title">Trailers</string> + <string name="visualizer_rationale">Visualisering av ljuduppspelning kräver behörighet att spela in ljud.</string> + <string name="login_credentials_expired">Inloggningsuppgifter har löpt ut. Vänligen logga in på nytt.</string> + <string name="subtitle_mode">Undertextläge</string> + <string name="subtitle_mode_default">Standard</string> + <string name="subtitle_mode_smart">Smart</string> + <string name="subtitle_mode_only_forced">Endast tvingande</string> + <string name="subtitle_mode_always">Alltid</string> + <string name="subtitle_mode_none">Inga</string> + <string name="preferred_subtitle_language">Fördraget undertextspråk</string> + <string name="subtitles_hearing_impaired">För hörselskadade</string> + <string name="programs">TV-program</string> + <string name="recordings">Inspelningar</string> + <string name="folders_title">Mappar</string> + <string name="user_views">Användarvisningar</string> + <string name="genre_movies">%1$sfilmer</string> + <string name="quality_profile">Kvalitetsprofil</string> + <string name="hdr_formats">HDR-format</string> + <string name="hdr10_override">HDR10</string> + <string name="hdr10_plus_override">HDR10+</string> + <string name="force_enable">Tvinga aktivering</string> + <string name="force_disable">Tvinga avaktivering</string> </resources> diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index c1f2fd7a..2ccccbae 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -25,7 +25,7 @@ <string name="critic_rating">การจัดอันดับนักวิจารณ์</string> <string name="decimal_seconds">%.2f วินาที</string> <string name="default_track">ค่าเริ่มต้น</string> - <string name="delete">ลบ\"</string> + <string name="delete">ลบ</string> <string name="died">เสียชีวิต</string> <string name="directed_by">กำกับโดย %1$s</string> <string name="director">ผู้กำกับ</string> @@ -34,7 +34,7 @@ <string name="download_and_update"><![CDATA[ดาวน์โหลดและอัปเดต]]></string> <string name="downloading">กำลังดาวน์โหลด…</string> <string name="enabled">ใช้งาน</string> - <string name="ends_at">จบเมื่อ %1$s</string> + <string name="ends_at">จบที่เวลา %1$s</string> <string name="enter_server_url">ป้อนที่อยู่ IP หรือ URL ของเซิร์ฟเวอร์</string> <string name="enter_server_address">ป้อนที่อยู่เซิร์ฟเวอร์</string> <string name="episodes">ตอน</string> @@ -44,7 +44,7 @@ <string name="file_size">ขนาด</string> <string name="forced_track">บังคับ</string> <string name="genres">แนวเนื้อหา</string> - <string name="go_to_series">ไปซีรีย์</string> + <string name="go_to_series">ไปที่ซีรีส์</string> <string name="go_to">ไปที่</string> <string name="hide_controller_timeout">ซ่อนการควบคุมการเล่น</string> <string name="hide_debug_info">ซ่อนข้อมูลการแก้ไขข้อบกพร่อง</string> @@ -132,7 +132,7 @@ <string name="show">แสดง</string> <string name="shuffle">สุ่ม</string> <string name="skip">ข้าม</string> - <string name="sort_by_date_added">บันทึกวันที่แล้ว</string> + <string name="sort_by_date_added">วันที่เพิ่มลงในคลัง</string> <string name="sort_by_date_episode_added">วันที่เพิ่มตอน</string> <string name="sort_by_date_played">วันที่เล่น</string> <string name="sort_by_date_released">วันที่เผยแพร่</string> @@ -147,7 +147,6 @@ <string name="switch_servers">สลับเซิร์ฟเวอร์</string> <string name="switch_user">สวิตช์</string> <string name="top_unwatched">รายการที่ได้คะแนนสูงที่ยังไม่ได้ดู</string> - <string name="trailer">ตัวอย่าง</string> <plurals name="trailers"> <item quantity="other">ตัวอย่าง</item> </plurals> @@ -166,22 +165,24 @@ <string name="video">วิดีโอ</string> <string name="videos">วิดีโอ</string> <string name="watch_live">ชมสด</string> - <string name="years_old">%1$d ปี</string> + <plurals name="years_old"> + <item quantity="other">%1$d ปี</item> + </plurals> <string name="play_with_transcoding">เล่นโดยใช้การแปลงรหัส</string> <string name="media_information">ข้อมูลสื่อ</string> <string name="show_clock">แสดงนาฬิกา</string> <string name="aired_episode_order">ลำดับตอนที่ออกอากาศ</string> <string name="create_playlist">สร้างเพลย์ลิสต์ใหม่</string> <string name="add_to_playlist">เพิ่มชื่อเพลย์ลิสต์</string> - <string name="one_click_pause">หยุดเกมด้วยการคลิกเพียงครั้งเดียว</string> + <string name="one_click_pause">หยุดเล่นด้วยการคลิกเพียงครั้งเดียว</string> <string name="one_click_pause_summary_on">กดปุ่ม D-Pad ตรงกลางเพื่อหยุด/เล่น</string> - <string name="italic_font">ทำตัวเอียง</string> - <string name="font">ฟ้อนต์</string> + <string name="italic_font">ตัวเอียง</string> + <string name="font">ฟอนต์</string> <string name="background">พื้นหลัง</string> <string name="success">สำเร็จ</string> <string name="official_rating">การให้คะแนนโดยผู้ปกครอง</string> <string name="runtime_sort">รันไทม์</string> - <string name="extras">บริการพิเศษ</string> + <string name="extras_title">เพิ่มเติม</string> <plurals name="other_extras"> <item quantity="other">อื่นๆ</item> </plurals> @@ -213,9 +214,9 @@ <item quantity="other">สารคดี</item> </plurals> <plurals name="shorts"> - <item quantity="other">กางเกงขาสั้น</item> + <item quantity="other">หนังสั้น</item> </plurals> - <string name="favorite_items">ชื่นชอบ %s</string> + <string name="favorite_items_title">ชื่นชอบ %s</string> <string name="ac3_supported">อุปกรณ์รองรับระบบเสียง AC3/Dolby Digital</string> <string name="advanced_settings">การตั้งค่าขั้นสูง</string> <string name="advanced_ui">UI ขั้นสูง</string> @@ -226,9 +227,9 @@ <string name="check_for_updates">ตรวจสอบการอัปเดต</string> <string name="combine_continue_next_summary">ใช้กับรายการโทรทัศน์เท่านั้น</string> <string name="combine_continue_next"><![CDATA[รวมตัวเลือก \"ดูต่อ\" และ \"รายการถัดไป\" เข้าด้วยกัน]]></string> - <string name="direct_play_ass">เล่นคำบรรยาย ASS โดยตรง</string> + <string name="direct_play_ass">เล่นคำบรรยาย ASS โดยตรงด้วย libass</string> <string name="direct_play_pgs">เล่นคำบรรยาย PGS โดยตรง</string> - <string name="downmix_stereo">ลดระดับเสียงลงเป็นสเตอริโอเสมอ</string> + <string name="downmix_stereo">แปลงเสียงเป็นสเตอริโอเสมอ</string> <string name="ffmpeg_extension_pref">ใช้โมดูลถอดรหัส FFmpeg</string> <string name="global_content_scale">ขนาดเนื้อหาเริ่มต้น</string> <string name="install_update">ติดตั้งอัปเดต</string> @@ -238,7 +239,7 @@ <string name="nav_drawer_pins">ปรับแต่งรายการเมนูนำทาง</string> <string name="nav_drawer_switch_on_focus_summary_off">คลิกเพื่อสลับหน้า</string> <string name="nav_drawer_switch_on_focus">สลับหน้าเมนูนำทางเมื่อโฟกัส</string> - <string name="pass_out_protection">การป้องกันการส่งผ่าน</string> + <string name="pass_out_protection">การป้องกันการเผลอหลับ</string> <string name="play_theme_music">เล่นเพลงธีม</string> <string name="playback_overrides">การเล่นจะแทนที่</string> <string name="remember_selected_tab">จำแท็บที่เลือก</string> @@ -253,24 +254,24 @@ <string name="skip_back_preference">ข้ามกลับ</string> <string name="skip_commercials_behavior">พฤติกรรมการข้ามโฆษณา</string> <string name="skip_forward_preference">ข้ามไปข้างหน้า</string> - <string name="skip_intro_behavior">ข้ามพฤติกรรมการแนะนำ</string> - <string name="skip_outro_behavior">ข้ามพฤติกรรมตอนจบ</string> - <string name="skip_previews_behavior">ข้ามพฤติกรรมการแสดงตัวอย่าง</string> - <string name="skip_recap_behavior">ข้ามพฤติกรรมการสรุป</string> + <string name="skip_intro_behavior">พฤติกรรมการข้ามช่วงเริ่มเรื่อง</string> + <string name="skip_outro_behavior">พฤติกรรมการข้ามช่วงท้ายเรื่อง</string> + <string name="skip_previews_behavior">พฤติกรรมการข้ามช่วงตัวอย่าง</string> + <string name="skip_recap_behavior">พฤติกรรมการข้ามช่วงสรุปตอนก่อนหน้า</string> <string name="update_available">มีอัปเดตใหม่</string> <string name="update_url_summary">URL ที่ใช้ตรวจสอบการอัปเดตแอป</string> <string name="update_url">อัปเดต URL</string> <string name="font_size">ขนาดตัวอักษร</string> <string name="font_color">สีตัวอักษร</string> <string name="font_opacity">ความทึบของตัวอักษร</string> - <string name="edge_style">สไตล์ขอบ</string> + <string name="edge_style">รูปแบบขอบ</string> <string name="edge_color">สีขอบ</string> <string name="background_opacity">ความเข้มจางของพื้นหลัง</string> - <string name="background_style">สไตล์พื้นหลัง</string> - <string name="subtitle_style">สไตล์คำบรรยาย</string> + <string name="background_style">รูปแบบพื้นหลัง</string> + <string name="subtitle_style">รูปแบบคำบรรยาย</string> <string name="background_color">สีพื้นหลัง</string> <string name="reset">รีเซ็ต</string> - <string name="bold_font">ตัวอักษรตัวหนา</string> + <string name="bold_font">ตัวหนา</string> <string name="player_backend">การเล่นแบ็กเอนด์</string> <string name="mpv_hardware_decoding">MPV: ใช้การถอดรหัสฮาร์ดแวร์</string> <string name="disable_if_crash">ปิดใช้งานหากพบปัญหาโปรแกรมหยุดทำงาน</string> @@ -287,9 +288,9 @@ <string name="year">ปี</string> <string name="decade">ทศวรรษ</string> <string name="remove">ลบ</string> - <string name="dolby_vision">Dolby Vision</string> + <string name="dolby_vision">ดอลบี้ วิชั่น</string> <string name="discard_change">ทิ้งการเปลี่ยนแปลง?</string> - <string name="subtitle_margin">ขอบ</string> + <string name="subtitle_margin">ระยะห่างจากด้านล่าง</string> <string name="verbose_logging">การบันทึกข้อมูลแบบละเอียด</string> <string name="enter_pin">ป้อนรหัส PIN</string> <string name="sign_in_auto">ลงชื่อเข้าใช้โดยอัตโนมัติ</string> @@ -353,7 +354,7 @@ <string name="sort_channels_recently_watched">เรียงลำดับช่องตามลำดับการรับชมล่าสุด</string> <string name="color_code_programs">โปรแกรมรหัสสี</string> <string name="subtitle_delay">คำบรรยายล่าช้า</string> - <string name="backdrop_display">สไตล์ฉากหลัง</string> + <string name="backdrop_display">รูปแบบฉากหลัง</string> <string name="resolution_switching">การสลับความละเอียด</string> <string name="local">ท้องถิ่น</string> <string name="play_trailer">เล่นตัวอย่าง</string> @@ -362,11 +363,11 @@ <string name="dts_x">DTS:X</string> <string name="dts_hd">DTS:HD</string> <string name="dts_hd_ma">DTS:HD MA</string> - <string name="truehd">TrueHD</string> + <string name="truehd">ทรูเอชดี</string> <string name="dolby_digital">DD</string> <string name="dolby_digital_plus">DD+</string> <string name="dts">DTS</string> - <string name="force_dovi_profile_7">Direct play Dolby Vision Profile 7</string> + <string name="force_dovi_profile_7">เล่น Dolby Vision Profile 7 โดยตรง</string> <string name="force_dovi_profile_7_summary">ไม่สนใจการตรวจสอบความเข้ากันได้ของอุปกรณ์</string> <string name="discover">ค้นพบ</string> <string name="request">คำขอ</string> @@ -386,7 +387,7 @@ <string name="upcoming_movies">ภาพยนตร์ที่จะเกิดขึ้น</string> <string name="upcoming_tv">รายการทีวีที่จะออกอากาศเร็วๆ นี้</string> <string name="request_4k">ขอในความละเอียด 4K</string> - <string name="software_decoding_av1">AV1 software decoding</string> + <string name="software_decoding_av1">ถอดรหัส AV1 ด้วยซอฟต์แวร์</string> <string name="upgrade_mpv_toast">ขณะนี้ MPV เป็นโปรแกรมเล่นวิดีโอเริ่มต้น ยกเว้นวิดีโอ HDR\nคุณสามารถเปลี่ยนการตั้งค่านี้ได้ในเมนูการตั้งค่า</string> <string name="hdr_subtitle_style">รูปแบบคำบรรยาย HDR</string> <string name="image_subtitle_opacity">ความทึบของคำบรรยายภาพ</string> @@ -405,7 +406,7 @@ <string name="no_more_images">ไม่มีรูปถ่ายอีกต่อไป</string> <string name="rotate_left">หมุนไปทางซ้าย</string> <string name="rotate_right">หมุนไปทางขวา</string> - <string name="zoom_in">ขยายเข้า</string> + <string name="zoom_in">ซูมเข้า</string> <string name="zoom_out">ซูมออก</string> <string name="slideshow_duration">ระยะเวลาการแสดงภาพสไลด์</string> <string name="play_videos_during_slideshow">เล่นวิดีโอระหว่างการนำเสนอสไลด์</string> @@ -421,7 +422,7 @@ <string name="go_to_album">ไปยังอัลบัม</string> <string name="add_to_queue">เพิ่มเข้าคิวเพลง</string> <string name="add_row">เพิ่มแถว</string> - <string name="dolby_atmos">Dolby Atmos</string> + <string name="dolby_atmos">ดอลบี้ แอทมอส</string> <string name="mpv_use_gpu_next">MPV: ใช้ gpu-next</string> <string name="mpv_conf">แก้ไข mpv.conf</string> <string name="genres_in">ประเภทใน %1$s</string> @@ -429,11 +430,11 @@ <string name="height">ความสูง</string> <string name="apply_all_rows">นำไปใช้กับทุกแถว</string> <string name="customize_home">ปรับแต่งหน้าแรก</string> - <string name="home_rows">แถวบ้าน</string> + <string name="home_rows">แถวที่หน้าแรก</string> <string name="load_from_server">โหลดจากโปรไฟล์ผู้ใช้เซิร์ฟเวอร์</string> <string name="save_to_server">ดาวน์โหลดจากโปรไฟล์ผู้ใช้คืนนี้</string> <string name="load_from_web_client">โหลดจากเว็บไคลเอ็นต์</string> - <string name="use_series">ใช้ภาพชุด</string> + <string name="use_series">ใช้ภาพของซีรีส์</string> <string name="add_row_for">เพิ่มแถวสำหรับ %1$s</string> <string name="overwrite_server_settings">ตั้งค่าทับบนเซิร์ฟเวอร์หรือไม่?</string> <string name="overwrite_local_settings">แทนที่การตั้งค่าในเครื่องหรือไม่?</string> @@ -444,9 +445,9 @@ <string name="use_thumb_images">ใช้ภาพขนาดย่อ</string> <string name="settings_saved">บันทึกการตั้งค่าแล้ว</string> <string name="display_presets">แสดงค่าที่ตั้งไว้ล่วงหน้า</string> - <string name="display_presets_description">มีค่าที่ตั้งไว้ล่วงหน้าเพื่อจัดสไตล์ทุกแถวได้อย่างรวดเร็ว</string> + <string name="display_presets_description">รูปแบบที่ตั้งไว้ล่วงหน้าเพื่อจัดรูปแบบได้อย่างรวดเร็ว</string> <string name="customize_home_summary">เลือกแถวและรูปภาพบนหน้าแรก</string> - <string name="start_after">เริ่มทีหลัง</string> + <string name="start_after">เริ่มหลังจาก</string> <string name="animate">เคลื่อนไหว</string> <string name="max_age_rating">ระดับอายุสูงสุด</string> <string name="no_max">ไม่มีสูงสุด</string> @@ -464,8 +465,8 @@ <string name="content_scale_fill">ยืดเต็มจอ</string> <string name="content_scale_fill_width">ยืดตามความกว้าง</string> <string name="content_scale_fill_height">ยืดตามความสูง</string> - <string name="display_preset_default">Wholphin ค่าเริ่มต้น</string> - <string name="display_preset_compact">Wholphin คอมแพค</string> + <string name="display_preset_default">Wolfskin ค่าเริ่มต้น</string> + <string name="display_preset_compact">Wolfskin คอมแพค</string> <string name="display_preset_series_thumb">ภาพขนาดย่อของซีรี่ส์</string> <string name="display_preset_episode_thumbnails">ภาพขนาดย่อของตอน</string> <string name="volume_lowest">เบาสุด</string> @@ -479,17 +480,17 @@ <string name="black">ดำ</string> <string name="skip_ignore">ไม่สนใจ</string> <string name="skip_automatically">ข้ามโดยอัตโนมัติ</string> - <string name="skip_ask">ขอให้ข้ามไป</string> + <string name="skip_ask">กดปุมเพื่อข้าม</string> <string name="ffmpeg_fallback">ใช้ FFmpeg เฉพาะในกรณีที่ไม่มีตัวถอดรหัสในตัวเท่านั้น</string> <string name="ffmpeg_prefer">ควรเลือกใช้ FFmpeg มากกว่าตัวถอดรหัสในตัว</string> <string name="ffmpeg_never">ห้ามใช้ตัวถอดรหัส FFmpeg เด็ดขาด</string> <string name="next_up_playback_end">เมื่อการเล่นจบลง</string> <string name="next_up_outro">ระหว่างเครดิตท้ายเรื่อง/ฉากจบ</string> <string name="white">ขาว</string> - <string name="light_gray">สีเทาอ่อน</string> - <string name="dark_gray">สีเทาเข้ม</string> + <string name="light_gray">เทาอ่อน</string> + <string name="dark_gray">เทาเข้ม</string> <string name="yellow">เหลือง</string> - <string name="cyan">น้ำเงินเขียว</string> + <string name="cyan">ฟ้าอมเขียว</string> <string name="magenta">บานเย็น</string> <string name="subtitle_edge_outline">โครงร่าง</string> <string name="subtitle_edge_shadow">เงา</string> @@ -501,8 +502,8 @@ <string name="aspect_ratios_16_9">16:9</string> <string name="aspect_ratios_4_3">4:3</string> <string name="aspect_ratios_square">สี่เหลี่ยมจัตุรัส (1:1)</string> - <string name="image_type_primary">หลัก</string> - <string name="image_type_thumb">นิ้วหัวแม่มือ</string> + <string name="image_type_primary">รูปภาพหลัก</string> + <string name="image_type_thumb">รูปภาพย่อ</string> <string name="backdrop_style_dynamic">ภาพที่มีสีแบบไดนามิก</string> <string name="backdrop_style_image">รูปภาพเท่านั้น</string> <string name="enter_url_api_key"><![CDATA[ป้อน URL และ API Key]]></string> @@ -517,7 +518,7 @@ <string name="delete_item">คุณแน่ใจหรือไม่ว่าต้องการลบรายการนี้?</string> <string name="show_media_management">แสดงตัวเลือกการจัดการสื่อ</string> <string name="search_for">ค้นหา %s</string> - <string name="actor">ผู้ดำเนินการ</string> + <string name="actor">นักแสดง</string> <string name="composer">ผู้แต่ง</string> <string name="writer">นักเขียน</string> <string name="guest_star">ดารารับเชิญ</string> @@ -547,8 +548,50 @@ <string name="continue_string">ต่อไป</string> <string name="select_all">เลือกทั้งหมด</string> <string name="separate_types">แยกประเภท</string> - <string name="prefer_logos">ควรแสดงโลโก้แทนชื่อเรื่อง</string> + <string name="prefer_logos">แสดงโลโก้แทนชื่อเรื่อง</string> <string name="updated_toast">Wolphin อัปเดตเวอร์ชั่นเป็น %s แล้ว</string> <string name="remove_continue_watching">นำออกจากรายการดูต่อ</string> <string name="remove_next_up">นำซีรีส์ออกจากรายการถัดไป</string> + <string name="play_with">เล่นโดย</string> + <string name="ass_subtitle_mode_libass">เล่นโดยตรงด้วย libass</string> + <string name="ass_subtitle_mode_exoplayer">เล่นโดยตรงด้วย ExoPlayer ในแอป</string> + <string name="next_up_never">ไม่เลย</string> + <string name="skip_behavior">พฤติกรรมการข้าม</string> + <string name="skip_behavior_summary">สำหรับช่วงเริ่มเรื่อง, เครดิต</string> + <string name="external_player">เครื่องเล่นภายนอก</string> + <string name="combined_search_results_off">แสดงผลลัพธ์ตามหมวดหมู่</string> + <string name="results">ผลลัพธ์</string> + <string name="combined_search_results_on">แสดงผลลัพธ์ในรายการเดียว</string> + <string name="pin_digits_hint">[0-9]</string> + <string name="system_default">ค่าเริ่มต้นของระบบ</string> + <string name="ass_subtitle_mode_transcode">ฝังคำบรรยายลงในวิดีโอโดยการแปลงบนเซิร์ฟเวอร์</string> + <string name="ass_subtitle_playback">การแสดงคำบรรยาย SSA/ASS</string> + <string name="cinema_mode">รูปแบบโรงภาพยนตร์</string> + <string name="transcoding">แปลงไฟล์</string> + <plurals name="downloads"> + <item quantity="other">%s ดาวน์โหลด</item> + </plurals> + <plurals name="hours"> + <item quantity="other">%d ชั่วโมง</item> + </plurals> + <plurals name="days"> + <item quantity="other">%s วัน</item> + </plurals> + <plurals name="items"> + <item quantity="other">%d รายการ</item> + </plurals> + <plurals name="seconds"> + <item quantity="other">%d วินาที</item> + </plurals> + <plurals name="minutes"> + <item quantity="other">%s นาที</item> + </plurals> + <string name="combined_search_results">ผสานผลลัพธ์การค้นหา</string> + <string name="view_more">ดูเพิ่ม</string> + <string name="view_removed_next_up">ดูซีรี่ส์ที่ลบออกจาก ตอนถัดไป</string> + <string name="show_more">ดู %1$s เพิ่มเติม</string> + <string name="last_played">เล่นล่าสุด</string> + <string name="discover_tv">รายการทีวีที่ค้นพบ</string> + <string name="discover_movies">ภาพยนตร์ที่ค้นพบ</string> + <string name="studios_in">สตูดิโอของเรื่อง %1$s</string> </resources> diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 4dd8212a..70804da5 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -13,7 +13,7 @@ <string name="cancel_series_recording">Dizi kaydını iptal et</string> <string name="cancel">İptal</string> <string name="chapters">Bölümler</string> - <string name="choose_stream">%1$s seç</string> + <string name="choose_stream">%1$s ögesini seçin</string> <string name="clear_image_cache">Resim önbelleğini temizle</string> <string name="collection">Koleksiyon</string> <string name="collections">Koleksiyonlar</string> @@ -63,7 +63,7 @@ <string name="mark_watched">İzlendi olarak işaretle</string> <string name="max_bitrate">Maksimum bit hızı</string> <string name="more_like_this">Benzerleri</string> - <string name="more">Diğer</string> + <string name="more">Daha Fazla</string> <plurals name="movies"> <item quantity="one">Film</item> <item quantity="other">Filmler</item> @@ -149,7 +149,6 @@ <string name="switch_servers">Sunucu değiştir</string> <string name="switch_user">Değiştir</string> <string name="top_unwatched">İzlenmemiş En İyiler</string> - <string name="trailer">Fragman</string> <plurals name="trailers"> <item quantity="one">Fragman</item> <item quantity="other">Fragmanlar</item> @@ -170,7 +169,10 @@ <string name="video">Video</string> <string name="videos">Videolar</string> <string name="watch_live">Canlı izle</string> - <string name="years_old">%1$d yaşında</string> + <plurals name="years_old"> + <item quantity="one">%1$d yaşında</item> + <item quantity="other">%1$d yaşında</item> + </plurals> <string name="play_with_transcoding">Kod dönüştürerek oynat</string> <string name="media_information">Medya Bilgisi</string> <string name="show_clock">Saati göster</string> @@ -185,10 +187,10 @@ <string name="success">Başarılı</string> <string name="official_rating">Yaş Derecelendirmesi</string> <string name="runtime_sort">Süre</string> - <string name="extras">Ekstralar</string> + <string name="extras_title">Özel Özellikler</string> <plurals name="other_extras"> - <item quantity="one">Diğer Ekstra</item> - <item quantity="other">Diğer Ekstralar</item> + <item quantity="one">Diğer</item> + <item quantity="other">Diğer</item> </plurals> <plurals name="behind_the_scenes"> <item quantity="one">Sahne Arkası</item> @@ -223,8 +225,8 @@ <item quantity="other">Örnekler</item> </plurals> <plurals name="featurettes"> - <item quantity="one">Tanıtım</item> - <item quantity="other">Tanıtımlar</item> + <item quantity="one">Özel Seçenek</item> + <item quantity="other">Özel Seçenekler</item> </plurals> <plurals name="shorts"> <item quantity="one">Kısa Video</item> @@ -471,15 +473,15 @@ <string name="settings_saved">Ayarlar kaydedildi</string> <string name="display_presets">Görüntü ön ayarları</string> <string name="display_presets_description">Tüm satırları hızlıca stilize etmek için hazır ayarlar</string> - <string name="favorite_items">Favori %s</string> + <string name="favorite_items_title">Favori %s</string> <string name="customize_home_summary">Ana sayfadaki satırları ve görselleri seçin</string> <string name="content_scale_fit">Sığdır</string> <string name="content_scale_crop">Kırp</string> <string name="content_scale_fill">Doldur</string> <string name="content_scale_fill_width">Genişliği Doldur</string> <string name="content_scale_fill_height">Yüksekliği Doldur</string> - <string name="display_preset_default">Wholphin Varsayılan</string> - <string name="display_preset_compact">Wholphin Kompakt</string> + <string name="display_preset_default">Wolfskin Varsayılan</string> + <string name="display_preset_compact">Wolfskin Kompakt</string> <string name="display_preset_series_thumb">Dizi Küçük Resimleri</string> <string name="display_preset_episode_thumbnails">Bölüm Küçük Resimleri</string> <string name="volume_lowest">En Düşük</string> @@ -587,7 +589,7 @@ <string name="continue_string">Devam et</string> <string name="separate_types">Farklı türler</string> <string name="prefer_logos">Başlık yerine logo göster</string> - <string name="updated_toast">Wholphin %s sürümüne güncellendi</string> + <string name="updated_toast">Wolfskin %s sürümüne güncellendi</string> <string name="view_more">Daha fazla göster</string> <string name="discover_tv">Dizileri Keşfet</string> <string name="discover_movies">Filmleri Keşfet</string> @@ -609,4 +611,152 @@ <string name="cinema_mode">Sinema Modu</string> <string name="transcoding">Kod dönüştürme</string> <string name="play_with">Şununla Oynat</string> + <string name="pin_digits_hint">[0-9]</string> + <string name="combined_search_results">Birleştirilmiş arama sonuçları</string> + <string name="combined_search_results_on">Tüm sonuçları tek listede göster</string> + <string name="combined_search_results_off">Sonuçları kategoriye göre göster</string> + <string name="results">Sonuçlar</string> + <string name="require_login">Profil için giriş zorunlu olsun</string> + <string name="display_toggles_title">Puanlama gösterimi</string> + <string name="display_toggles_summary">Nelerin görüntüleneceğini özelleştir</string> + <string name="profile_protection">Profil koruması</string> + <string name="continue_watching_click_behavior">İzlemeye Devam Et/Sıradaki Bölümler tıklama davranışı</string> + <string name="continue_watching_click_summary_on">Tıklayınca oynat</string> + <string name="continue_watching_click_summary_off">Tıklayınca detayları göster</string> + <string name="stars">Yıldızlar</string> + <string name="tomatoes">Domatesler</string> + <string name="show_voice_search_button">Sesli arama butonunu göster</string> + <string name="visible_ui">Görünür</string> + <string name="hidden_ui">Gizli</string> + <string name="user_interface_language">Kullanıcı arayüzü dili</string> + <string name="view_options_type_grid">Izgara</string> + <string name="view_options_type_list">Liste</string> + <string name="view_options_type_list_compact">Sıkışık Liste</string> + <string name="sort">Sırala</string> + <string name="split_into_separate_rows">Ayrı sıralara ayır</string> + <string name="movies_title">Filmler</string> + <string name="trailers_title">Fragmanlar</string> + <string name="tv_shows_title">Diziler</string> + <string name="other_extras_title">Diğer</string> + <string name="behind_the_scenes_title">Kamera Arkası</string> + <string name="theme_songs_title">Tema Müzikleri</string> + <string name="theme_videos_title">Tema Videoları</string> + <string name="clips_title">Klipler</string> + <string name="deleted_scenes_title">Silinmiş Sahneler</string> + <string name="interviews_title">Röportajlar</string> + <string name="scenes_title">Sahneler</string> + <string name="samples_title">Örnekler</string> + <string name="featurettes_title">Özel Seçenekler</string> + <string name="shorts_title">Kısa Videolar</string> + <string name="people_title">Oyuncular</string> + <string name="genre_movies">%1$s Filmleri</string> + <string name="genre_tv_shows">%1$s Dizileri</string> + <string name="quality_profile">Kalite Profili</string> + <string name="is_default">Varsayılan</string> + <string name="root_folder">Kök Klasör</string> + <string name="popularity">Popülerlik</string> + <string name="production_status">Yapım Durumu</string> + <string name="different_for_hdr">HDR için farklı stil kullan</string> + <string name="experimental_settings">Deneysel Ayarlar</string> + <string name="confirm_enable_experimental_title">Deneysel ayarları açmak istiyor musun?</string> + <string name="confirm_enable_experimental_body">Deneysel ayarlar kararsız çalışabilir! \n\nBu ayarlar sonraki sürümlerde kaldırılabilir veya değiştirilebilir.</string> + <string name="video_tunneling">Video tünelleme</string> + <string name="duration_hours_minutes_seconds">%1$dsa %2$ddk %3$dsn</string> + <string name="duration_hours_minutes">%1$dsa %2$ddk</string> + <string name="duration_minutes_seconds">%1$ddk %2$dsn</string> + <string name="duration_hours">%1$dsa</string> + <string name="duration_minutes">%1$ddk</string> + <string name="duration_seconds">%1$dsn</string> + <string name="time_left">%1$s kaldı</string> + <string name="blocklisted">Engellenenler</string> + <string name="unavailable">Mevcut Değil</string> + <string name="prefer_ac3_for_surround_summary"><![CDATA[Stereo olmayan sesleri AC3/Dolby Digital\'e dönüştür]]></string> + <string name="prefer_ac3_for_surround">Çevresel ses için AC3 kullan</string> + <string name="profile_specific_settings_from_server">Sunucudaki kullanıcı profili ayarları</string> + <string name="subtitle_mode">Altyazı Modu</string> + <string name="use_user_profile">Profil ayarlarını kullan</string> + <string name="subtitle_mode_default">Varsayılan</string> + <string name="subtitle_mode_smart">Akıllı</string> + <string name="subtitle_mode_only_forced">Sadece Zorunlu</string> + <string name="subtitle_mode_always">Her zaman</string> + <string name="subtitle_mode_none">Hiçbiri</string> + <string name="preferred_subtitle_language">Tercih edilen altyazı dili</string> + <string name="preferred_audio_language">Tercih edilen ses dili</string> + <string name="override_user_profile_settings">Daha fazla kullanıcı profili ayarı</string> + <string name="override_user_profile_settings_summary">Sunucudaki kullanıcı profili ayarlarını geçersiz kıl</string> + <string name="any_language">Herhangi bir dil</string> + <string name="d_pad_seek_mode_title">Yön Tuşu Arama Çubuğu Modu</string> + <string name="dpad_seek_mode_trickplay">Trickplay</string> + <string name="hash_matches">Hash Eşleşmeleri</string> + <string name="subtitles_hearing_impaired">SDH</string> + <string name="programs">TV Programları</string> + <string name="recordings">Kayıtlar</string> + <string name="folders_title">Klasörler</string> + <string name="user_views">Kullanıcı Görünümleri</string> + <string name="books">Kitaplar</string> + <string name="audio_books">Sesli Kitaplar</string> + <string name="dpad_seek_mode_minimal">Minimal</string> + <string name="dpad_seek_mode_minimal_summary">Ekranın alt kısmında küçük ilerleme çubuğu (varsayılan)</string> + <string name="dpad_seek_mode_trickplay_summary">Trickplay önizleme görsellerine sahip arama çubuğu</string> + <string name="dpad_seek_mode_none_summary">İlerleme çubuğu yok</string> + <string name="brown">Kahverengi</string> + <string name="server_version_not_supported">Sunucu sürümü desteklenmiyor</string> + <string name="disable_audio_offload">Ses Donanım Hızlandırmasını Devre Dışı Bırak</string> + <string name="disable_audio_offload_summary">Ses kod çözme işlemlerinin donanıma devredilmesini (offload) devre dışı bırakmak, bazı cihazlardaki oynatma donmalarını düzeltebilir</string> + <string name="dim_after_delay">Belirli bir süre sonra ekranı karart</string> + <string name="dim_percentage">Karartma yüzdesi</string> + <string name="dim_screen">Ekranı karart</string> + <string name="duration_hours_seconds">%1$dsa %2$dsn</string> + <string name="season_episode_number">S%1$d E%2$d</string> + <string name="season_episode_number_range">S%1$d E%2$d-E%3$d</string> + <string name="season_episode_number_padded">S%1$02dE%2$02d</string> + <string name="season_episode_number_padded_range">S%1$02dE%2$02d-E%3$02d</string> + <string name="episode_number_short">E%1$d</string> + <string name="season_number">Sezon %1$d</string> + <string name="photo_albums">Fotoğraf Albümleri</string> + <string name="no_favorites_found">Favori bulunamadı!</string> + <string name="remove_from_playlist">Oynatma listesinden kaldır</string> + <string name="login_credentials_expired">Oturum süresi doldu, lütfen tekrar giriş yapın</string> + <string name="login_not_authorized">Şu anda sunucuya erişim yetkiniz bulunmuyor</string> + <string name="prefer_dovi_over_hdr10_plus">HDR10+ yerine Dolby Vision\'ı tercih et</string> + <string name="prefer_dovi_over_hdr10_plus_summary">Aksi takdirde bu videoları HDR10+ olarak oynatacak TV\'ler için, Dolby Vision videolardan HDR10+ meta verilerini kaldırır</string> + <string name="max_resolution">Dönüştürme öncesi maksimum çözünürlük</string> + <string name="resolution_8k">8K</string> + <string name="resolution_4k">4K</string> + <string name="resolution_1080">1080</string> + <string name="resolution_720">720</string> + <string name="resolution_576">576</string> + <string name="resolution_480">480</string> + <string name="resolution_360">360</string> + <plurals name="episodes_count"> + <item quantity="one">%1$d Bölüm</item> + <item quantity="other">%1$d Bölüm</item> + </plurals> + <string name="appears_on">Yer Aldığı</string> + <string name="top_rated">En Yüksek Puanlı</string> + <string name="hdr_formats">HDR Formatları</string> + <string name="hdr10_override">HDR10</string> + <string name="hdr10_plus_override">HDR10+</string> + <string name="dovi_profile_5">Dolby Vision Profile 5</string> + <string name="dovi_profile_7">Dolby Vision Profile 7</string> + <string name="dovi_profile_8">Dolby Vision Profile 8</string> + <string name="force_enable">Zorla Etkinleştir</string> + <string name="force_disable">Zorla Devre Dışı Bırak</string> + <string name="server_version_not_supported_future">Sunucu sürümü yakında desteklenmeyecek</string> + <string name="server_version_required">Gerekli sunucu sürümü: %1$s</string> + <string name="chosen_by">Seçen: %1$s</string> + <string name="user">Kullanıcı</string> + <string name="series">Diziler</string> + <string name="load_from_server_plugin">Sunucudan varsayılanları yükle</string> + <string name="save_to_local">Yalnızca bu cihaza kaydet</string> + <string name="home_settings_source_unset">Varsayılan</string> + <string name="home_settings_source_local">Cihaza özel</string> + <string name="home_settings_source_server_profile">Sunucu kullanıcı profili</string> + <string name="home_settings_source_plugin">Sunucu varsayılanı</string> + <string name="home_settings_source_web">Kullanıcı web istemcisi</string> + <string name="managed_by_server">Sunucu tarafından yönetiliyor</string> + <string name="error">Hata</string> + <string name="convert_dovi_profile_7">Dolby Vision Profile 7\'yi 8.1\'e Dönüştür</string> + <string name="convert_dovi_profile_7_summary">Profil 8\'i destekleyen ancak Profil 7\'yi desteklemeyen çözücülere sahip cihazlar için; çift katmanlı Profil 7\'yi çözücüye ulaşmadan önce tek katmanlı Profil 8.1\'e dönüştürür ve doğrudan oynatır</string> + <string name="artist_source">Sanatçı Kaynağı</string> </resources> diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index f7835121..0c0c9727 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -8,7 +8,7 @@ <string name="audio">Аудіо</string> <string name="birthplace">Місце народження</string> <string name="bitrate">Бітрейт</string> - <string name="born">Місце народженя</string> + <string name="born">Дата народження</string> <string name="cancel_recording">Скасувати запис</string> <string name="cancel_series_recording">Скасувати запис серіалу</string> <string name="cancel">Скасувати</string> @@ -23,7 +23,7 @@ <string name="confirm">Підтвердити</string> <string name="continue_watching">Продовжити перегляд</string> <string name="critic_rating">Оцінка критиків</string> - <string name="decimal_seconds">%.1f секунд</string> + <string name="decimal_seconds">%.2f секунд</string> <string name="default_track">За замовчуванням</string> <string name="delete">Видалити</string> <string name="died">Помер</string> @@ -31,7 +31,7 @@ <string name="director">Режисер</string> <string name="disabled">Вимкнутий</string> <string name="discovered_servers">Виявлені сервери</string> - <string name="download_and_update"><![CDATA[Download & Update]]></string> + <string name="download_and_update"><![CDATA[Завантажити і оновити]]></string> <string name="downloading">Завантаження…</string> <string name="enabled">Увімкнено</string> <string name="ends_at">Закінчується о %1$s</string> @@ -42,7 +42,7 @@ <string name="external_track">Зовнішній</string> <string name="favorites">Улюблені</string> <string name="file_size">Розмір</string> - <string name="forced_track">Forced</string> + <string name="forced_track">Примусові</string> <string name="genres">Жанри</string> <string name="go_to_series">Перейти до серії</string> <string name="go_to">Перейти до</string> @@ -65,15 +65,15 @@ <string name="more_like_this">Більше подібного</string> <string name="more">Більше</string> <plurals name="movies"> - <item quantity="one">Фільми</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Фільм</item> + <item quantity="few">Фільми</item> + <item quantity="many">Фільмів</item> + <item quantity="other">Фільмів</item> </plurals> <string name="name">Ім\'я</string> <string name="next_up">Наступний</string> <string name="no_data">Дані відсутні</string> - <string name="no_results">Відсутні результати</string> + <string name="no_results">Результати відсутні</string> <string name="no_servers_found">Сервери не знайдені</string> <string name="no_scheduled_recordings">Заплановані записи відсутні</string> <string name="no_update_available">Немає доступних оновлень</string> @@ -82,10 +82,10 @@ <string name="outro">Завершення</string> <string name="path">Шлях</string> <plurals name="people"> - <item quantity="one">Люди</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Людина</item> + <item quantity="few">Людей</item> + <item quantity="many">Людей</item> + <item quantity="other">Людей</item> </plurals> <string name="play_count">Кількість відтворень</string> <string name="play_from_here">Відтворити звідси</string> @@ -110,7 +110,7 @@ <string name="restart">Перезавантаження</string> <string name="resume">Відновити</string> <string name="save">Зберегти</string> - <string name="search_and_download"><![CDATA[Search & Download]]></string> + <string name="search_and_download"><![CDATA[Знайти і завантажити]]></string> <string name="search">Пошук</string> <string name="searching">Шукаю…</string> <string name="voice_search">Голосовий пошук</string> @@ -153,22 +153,21 @@ <string name="switch_servers">Перемикнути сервер</string> <string name="switch_user">Перемкнути</string> <string name="top_unwatched">Топ непереглянутих</string> - <string name="trailer">Трейлер</string> <plurals name="trailers"> - <item quantity="one">Трейлери</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Трейлер</item> + <item quantity="few">Трейлери</item> + <item quantity="many">Трейлерів</item> + <item quantity="other">Трейлерів</item> </plurals> <string name="tv_dvr_schedule">Розклад DVR</string> <string name="tv_guide">Посібник</string> <string name="tv_season">Сезон</string> <string name="tv_seasons">Сезони</string> <plurals name="tv_shows"> - <item quantity="one">Серіали</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">ТВ-шоу</item> + <item quantity="few">ТВ-шоу</item> + <item quantity="many">ТВ-шоу</item> + <item quantity="other">ТВ-шоу</item> </plurals> <string name="ui_interface">Інтерфейс</string> <string name="unknown">Невідомо</string> @@ -178,7 +177,6 @@ <string name="video">Відео</string> <string name="videos">Відоси</string> <string name="watch_live">Дивитись онлайн</string> - <string name="years_old">%1$d років</string> <string name="play_with_transcoding">Відтворити з перекодуванням</string> <string name="media_information">Інформація</string> <string name="show_clock">Показати годиник</string> @@ -193,72 +191,72 @@ <string name="success">Успіх</string> <string name="official_rating">Вікова оцінка</string> <string name="runtime_sort">Час виконання</string> - <string name="extras">Додатково</string> + <string name="extras_title">Додатково</string> <plurals name="other_extras"> <item quantity="one">Інше</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="few">Іншого</item> + <item quantity="many">Іншого</item> + <item quantity="other">Іншого</item> </plurals> <plurals name="behind_the_scenes"> <item quantity="one">Позакадром</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="few">Позакадром</item> + <item quantity="many">Позакадром</item> + <item quantity="other">Позакадром</item> </plurals> <plurals name="theme_songs"> - <item quantity="one">Тематичні пісні</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Музична тема</item> + <item quantity="few">Музичні теми</item> + <item quantity="many">Музичних тем</item> + <item quantity="other">Музичних тем</item> </plurals> <plurals name="theme_videos"> - <item quantity="one">Тематичні відео</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Тематичне відео</item> + <item quantity="few">Тематичні відео</item> + <item quantity="many">Тематичних відео</item> + <item quantity="other">Тематичних відео</item> </plurals> <plurals name="clips"> - <item quantity="one">Кліпи</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Кліп</item> + <item quantity="few">Кліпи</item> + <item quantity="many">Кліпів</item> + <item quantity="other">Кліпів</item> </plurals> <plurals name="deleted_scenes"> - <item quantity="one">Видалені сцени</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Видалена сцена</item> + <item quantity="few">Видалені сцени</item> + <item quantity="many">Видалених сцен</item> + <item quantity="other">Видалених сцен</item> </plurals> <plurals name="interviews"> <item quantity="one">Інтерв\'ю</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="few">Інтерв\'ю</item> + <item quantity="many">Інтерв\'ю</item> + <item quantity="other">Інтерв\'ю</item> </plurals> <plurals name="scenes"> - <item quantity="one">Сцени</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Сцена</item> + <item quantity="few">Сцени</item> + <item quantity="many">Сцен</item> + <item quantity="other">Сцен</item> </plurals> <plurals name="samples"> - <item quantity="one">Зразки</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Зразок</item> + <item quantity="few">Зразка</item> + <item quantity="many">Зразків</item> + <item quantity="other">Зразків</item> </plurals> <plurals name="featurettes"> - <item quantity="one">Короткометражні фільми</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Короткометражний фільм</item> + <item quantity="few">Короткометражні фільми</item> + <item quantity="many">Короткометражних фільмім</item> + <item quantity="other">Короткометражних фільмім</item> </plurals> <plurals name="shorts"> - <item quantity="one">Шорти</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Коротке відео</item> + <item quantity="few">Короткі відео</item> + <item quantity="many">Коротких відео</item> + <item quantity="other">Коротких відео</item> </plurals> <plurals name="downloads"> <item quantity="one">%s завантаження</item> @@ -293,7 +291,7 @@ <string name="auto_play_next">Автоматичне відтворення наступного</string> <string name="check_for_updates">Перевірити оноалення</string> <string name="combine_continue_next_summary">Застосовується тільки до серіалів</string> - <string name="direct_play_ass">Пряме відтворення субтитрів ASS</string> + <string name="direct_play_ass">Використовувати libass для субтитрів ASS</string> <string name="direct_play_pgs">Пряме відтворення субтитрів PGS</string> <string name="downmix_stereo">Завжди змішувати в стерео</string> <string name="ffmpeg_extension_pref">Використовувати модуль декодера FFmpeg</string> @@ -338,7 +336,7 @@ <string name="background_color">Колір фону</string> <string name="reset">Скинути</string> <string name="bold_font">Жирний шрифт</string> - <string name="player_backend">Playback Backend</string> + <string name="player_backend">Компонент відтворення</string> <string name="mpv_hardware_decoding">MPV: Використовувати апаратне декодування</string> <string name="disable_if_crash">Вимкніть, якщо ви стикаєтеся з проблемами</string> <string name="mpv_options">Варіанти MPV</string> @@ -376,9 +374,9 @@ <string name="spacing">Відступ(Інтервал)</string> <string name="aspect_ratio">Співвідношення сторін</string> <string name="show_details">Показати деталі</string> - <string name="combine_continue_next"><![CDATA[Combine Continue Watching & Next Up]]></string> + <string name="combine_continue_next"><![CDATA[Об’єднати функції «Продовжити перегляд» та «Наступне»]]></string> <string name="image_type">Тип зображення</string> - <string name="cast_and_crew"><![CDATA[Cast & Crew]]></string> + <string name="cast_and_crew"><![CDATA[Актори та знімальна група]]></string> <string name="guest_stars">Запрошені зірки</string> <string name="edge_size">Розмір контуру</string> <string name="refresh_rate_switching">Перемикання частоти оновлення</string> @@ -475,12 +473,12 @@ <string name="zoom_out">Зменшити</string> <string name="slideshow_duration">Тривалість слайд-шоу</string> <string name="play_videos_during_slideshow">Відтворення відео під час слайд-шоу</string> - <string name="favorite_items">Улюблені %s</string> + <string name="favorite_items_title">Улюблені %s</string> <plurals name="days"> - <item quantity="one">день</item> - <item quantity="few">дні</item> - <item quantity="many">днів</item> - <item quantity="other">днів</item> + <item quantity="one">%s день</item> + <item quantity="few">%s дні</item> + <item quantity="many">%s днів</item> + <item quantity="other">%s днів</item> </plurals> <string name="quick_connect">Швидке підключення</string> <string name="quick_connect_summary">Дозвольте іншому пристрою увійти у ваш обліковий запис</string> @@ -513,4 +511,180 @@ <string name="display_presets">Налаштування дисплея</string> <string name="display_presets_description">Вбудовані налаштування для швидкого оформлення всіх рядків</string> <string name="customize_home_summary">Обери рядки і зображення для головної сторінки</string> + <string name="people_title">Люди</string> + <string name="trailers_title">Трейлери</string> + <string name="tv_shows_title">Телешоу</string> + <string name="other_extras_title">Інше</string> + <string name="behind_the_scenes_title">За лаштунками</string> + <string name="movies_title">Фільми</string> + <string name="theme_songs_title">Музичні теми</string> + <string name="theme_videos_title">Тематичні відео</string> + <string name="clips_title">Кліпи</string> + <string name="deleted_scenes_title">Видалені сцени</string> + <string name="interviews_title">Інтерв\'ю</string> + <string name="scenes_title">Сцени</string> + <string name="samples_title">Зразки</string> + <string name="featurettes_title">Короткометражні фільми</string> + <string name="shorts_title">Короткі відео</string> + <plurals name="minutes"> + <item quantity="one">%s хвилина</item> + <item quantity="few">%s хвилини</item> + <item quantity="many">%s хвилин</item> + <item quantity="other">%s хвилин</item> + </plurals> + <string name="require_login">Запитувати пароль для профілю</string> + <string name="pin_digits_hint">[0-9]</string> + <string name="combined_search_results">Спільні результати пошуку</string> + <string name="combined_search_results_on">Показувати всі результати одним списком</string> + <string name="combined_search_results_off">Показувати результати за категоріями</string> + <string name="show_voice_search_button">Показувати кнопку голосового пошуку</string> + <string name="visible_ui">Видимий</string> + <string name="hidden_ui">Прихований</string> + <string name="results">Результати</string> + <string name="albums">Альбоми</string> + <string name="artists">Виконавці</string> + <string name="songs">Пісні</string> + <string name="go_to_artist">До виконавця</string> + <string name="now_playing">Зараз грає</string> + <string name="instant_mix">Швидкий мікс</string> + <string name="go_to_album">До альбому</string> + <string name="add_to_queue">Додати до черги</string> + <string name="start_after">Почати після</string> + <string name="animate">Анімація</string> + <string name="max_age_rating">Обмеження за віком</string> + <string name="no_max">Без обмежень</string> + <string name="for_all_ages">Для будь-якого віку</string> + <string name="up_to_age">До %s років</string> + <string name="screensaver">Заставка</string> + <string name="screensaver_settings">Налаштування заставки</string> + <string name="start_screensaver">Запустити заставку</string> + <string name="duration">Тривалість</string> + <string name="photos">Фото</string> + <string name="include_types">Включити типи</string> + <string name="include_types_summary">Для яких типів елементів показувати зображення</string> + <string name="content_scale_fit">Вписати</string> + <string name="content_scale_crop">Обрізати</string> + <string name="content_scale_fill">Заповнити</string> + <string name="content_scale_fill_width">Заповнити по ширині</string> + <string name="content_scale_fill_height">Заповнити по висоті</string> + <string name="display_preset_default">Wolfskin за замовчуванням</string> + <string name="display_preset_series_thumb">Мініатюри серіалів</string> + <string name="display_preset_episode_thumbnails">Мініатюри серій</string> + <string name="volume_lowest">Найнижча</string> + <string name="volume_low">Низька</string> + <string name="volume_medium">Середня</string> + <string name="volume_high">Висока</string> + <string name="volume_full">Максимальна</string> + <string name="purple">Пурпуровий</string> + <string name="orange">Помаранчевий</string> + <string name="bold_blue">Яскраво-синій</string> + <string name="black">Чорний</string> + <string name="skip_ignore">Ігнорувати</string> + <string name="skip_automatically">Пропускати автоматично</string> + <string name="skip_ask">Запитувати про пропуск</string> + <string name="ffmpeg_fallback">Використовувати FFmpeg, лише якщо немає вбудованого декодера</string> + <string name="ffmpeg_prefer">Надавати перевагу FFmpeg перед вбудованими декодерами</string> + <string name="ffmpeg_never">Ніколи не використовувати декодери FFmpeg</string> + <string name="next_up_playback_end">Після завершення відтворення</string> + <string name="next_up_outro">Під час фінальних титрів</string> + <string name="next_up_never">Ніколи</string> + <string name="white">Білий</string> + <string name="light_gray">Світло-сірий</string> + <string name="dark_gray">Темно-сірий</string> + <string name="yellow">Жовтий</string> + <string name="cyan">Блакитний</string> + <string name="magenta">Маджента</string> + <string name="subtitle_edge_outline">Контур</string> + <string name="subtitle_edge_shadow">Тінь</string> + <string name="background_style_wrap">Обтікання</string> + <string name="background_style_boxed">У рамці</string> + <string name="prefer_mpv">Надавати перевагу MPV</string> + <string name="external_player">Зовнішній плеєр</string> + <string name="player_backend_options_subtitles_prefer_mpv">Використовувати ExoPlayer для відтворення HDR</string> + <string name="aspect_ratios_poster">Постер (2:3)</string> + <string name="aspect_ratios_16_9">16:9</string> + <string name="aspect_ratios_4_3">4:3</string> + <string name="aspect_ratios_square">Квадрат (1:1)</string> + <string name="image_type_primary">Основне</string> + <string name="image_type_thumb">Мініатюра</string> + <string name="view_options_type_grid">Сітка</string> + <string name="view_options_type_list">Список</string> + <string name="view_options_type_list_compact">Компактний список</string> + <string name="backdrop_style_dynamic">Зображення з динамічним кольором</string> + <string name="backdrop_style_image">Лише зображення</string> + <string name="enter_url_api_key"><![CDATA[Введіть URL та API-ключ]]></string> + <string name="api_key">API-ключ</string> + <string name="seerr_login">Увійти до сервера Seerr</string> + <string name="seerr_jellyfin_user">Користувач Jellyfin</string> + <string name="seerr_local_user">Локальний користувач</string> + <string name="search_and_download_subtitles"><![CDATA[Пошук та завантаження субтитрів]]></string> + <string name="no_subtitles_found">Віддалених субтитрів не знайдено</string> + <string name="series_continueing">Дотепер</string> + <string name="in_app_screensaver">Використовувати вбудовану заставку</string> + <string name="delete_item">Ви впевнені, що хочете видалити цей елемент?</string> + <string name="show_media_management">Показувати параметри керування медіа</string> + <string name="search_for">Шукати %s</string> + <string name="actor">Актор</string> + <string name="composer">Композитор</string> + <string name="writer">Сценарист</string> + <string name="guest_star">Запрошена зірка</string> + <string name="producer">Продюсер</string> + <string name="conductor">Диригент</string> + <string name="lyricist">Автор тексту</string> + <string name="arranger">Аранжувальник</string> + <string name="engineer">Звукорежисер</string> + <string name="mixer">Звукорежисер зведення</string> + <string name="creator">Автор</string> + <string name="artist">Виконавець</string> + <string name="album_artist">Виконавець альбому</string> + <string name="album">Альбом</string> + <string name="popular_songs">Популярні пісні</string> + <string name="play_next">Відтворити наступним</string> + <string name="music_videos">Музичні відео</string> + <string name="lyrics">Текст пісн</string> + <string name="hide_lyrics">Приховати текст пісні</string> + <string name="show_lyrics">Показати текст пісні</string> + <string name="song_has_lyrics">Пісня має текст</string> + <string name="remove_from_queue">Видалити з черги</string> + <string name="show_album_cover">Показувати обкладинку альбому</string> + <string name="show_visualizer">Показувати візуалізатор</string> + <string name="show_backdrop">Показувати фон</string> + <string name="play_as_type">Відтворити як?</string> + <string name="visualizer_rationale">Для візуалізації аудіо, що зараз відтворюється, потрібен дозвіл на запис звуку.</string> + <string name="continue_string">Продовжити</string> + <string name="select_all">Вибрати все</string> + <string name="separate_types">Розділяти типи</string> + <string name="prefer_logos">Надавати перевагу логотипам для назв</string> + <string name="updated_toast">Wolfskin оновлено до %s</string> + <string name="remove_continue_watching">Видалити з «Продовжити перегляд»</string> + <string name="remove_next_up">Видалити серіал з «Далі»</string> + <string name="view_removed_next_up">Переглянути серіали, видалені з «Далі»</string> + <string name="view_more">Переглянути більше</string> + <string name="discover_tv">Знайти ТВ-шоу</string> + <string name="discover_movies">Знайти фільми</string> + <string name="studios_in">Студії у %1$s</string> + <string name="last_played">Останні відтворені</string> + <string name="system_default">Системне за замовчуванням</string> + <string name="ass_subtitle_mode_libass">Пряме відтворення через libass</string> + <string name="ass_subtitle_mode_exoplayer">Пряме відтворення через вбудований ExoPlayer</string> + <string name="ass_subtitle_mode_transcode">Вшивати/транскодувати на сервері</string> + <string name="ass_subtitle_playback">Відтворення субтитрів SSA/ASS</string> + <string name="cinema_mode">Режим кінотеатру</string> + <string name="show_more">Показати ще %1$s</string> + <string name="skip_behavior">Поведінка пропуску</string> + <string name="skip_behavior_summary">Для інтро, титрів тощо</string> + <string name="play_with">Відтворити за допомогою</string> + <string name="transcoding">Транскодування</string> + <string name="user_interface_language">Мова інтерфейсу</string> + <string name="display_toggles_title">Відображення рейтингів</string> + <string name="display_toggles_summary">Налаштуйте, що відображати</string> + <string name="profile_protection">Захист профілю</string> + <string name="continue_watching_click_behavior">Дія при натисканні в «Продовжити перегляд»</string> + <string name="continue_watching_click_summary_on">Відтворювати при натисканні</string> + <string name="continue_watching_click_summary_off">Показувати деталі при натисканні</string> + <string name="stars">Зірки</string> + <string name="tomatoes">Томати</string> + <string name="sort">Сортування</string> + <string name="split_into_separate_rows">Розділити на окремі рядки</string> + <string name="display_preset_compact">Wolfskin компактний</string> </resources> diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml new file mode 100644 index 00000000..045e125f --- /dev/null +++ b/app/src/main/res/values-vi/strings.xml @@ -0,0 +1,3 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> +</resources> diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 05e646cf..07b57d8e 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -62,7 +62,7 @@ <string name="max_bitrate">最大比特率</string> <string name="more_like_this">类似推荐</string> <string name="more">更多</string> - <string name="movies">电影</string> + <string name="movies_title">电影</string> <string name="name">名称</string> <string name="next_up">即将播放</string> <string name="no_data">无数据</string> @@ -72,7 +72,7 @@ <string name="none">无</string> <string name="outro">片尾</string> <string name="path">路径</string> - <string name="people">演职人员</string> + <string name="people_title">演职人员</string> <string name="play_count">播放次数</string> <string name="play_from_here">从此处播放</string> <string name="play">播放</string> @@ -121,7 +121,6 @@ <string name="switch_servers">切换服务器</string> <string name="switch_user">切换</string> <string name="top_unwatched">高分未看</string> - <string name="trailer">预告片</string> <plurals name="trailers"> <item quantity="other">预告片</item> </plurals> @@ -129,7 +128,7 @@ <string name="tv_guide">指南</string> <string name="tv_season">季</string> <string name="tv_seasons">季</string> - <string name="tv_shows">电视节目</string> + <string name="tv_shows_title">电视节目</string> <string name="ui_interface">界面</string> <string name="unknown">未知</string> <string name="updates">更新</string> @@ -138,7 +137,9 @@ <string name="video">视频</string> <string name="videos">视频</string> <string name="watch_live">观看直播</string> - <string name="years_old">%1$d 岁</string> + <plurals name="years_old"> + <item quantity="other">%1$d 岁</item> + </plurals> <string name="play_with_transcoding">转码播放</string> <string name="show_clock">显示时钟</string> <string name="aired_episode_order">剧集播出顺序</string> @@ -152,7 +153,7 @@ <string name="success">成功</string> <string name="official_rating">内容分级</string> <string name="runtime_sort">播放时长</string> - <string name="extras">附加内容</string> + <string name="extras_title">附加内容</string> <plurals name="other_extras"> <item quantity="other">其他</item> </plurals> @@ -254,9 +255,9 @@ <string name="reset">重置</string> <string name="bold_font">粗体</string> <string name="player_backend">播放后端</string> - <string name="mpv_hardware_decoding">mpv:使用硬件解码</string> + <string name="mpv_hardware_decoding">MPV:使用硬件解码</string> <string name="disable_if_crash">如果遇到崩溃,请禁用</string> - <string name="mpv_options">mpv 选项</string> + <string name="mpv_options">MPV 选项</string> <string name="exoplayer_options">ExoPlayer 选项</string> <string name="skip_segment_unknown">跳过片段</string> <string name="skip_segment_commercial">跳过广告</string> @@ -271,7 +272,7 @@ <string name="remove">移除</string> <string name="dolby_vision">杜比视界</string> <string name="dolby_atmos">杜比全景声</string> - <string name="mpv_use_gpu_next">mpv:使用 gpu-next</string> + <string name="mpv_use_gpu_next">MPV:使用 gpu-next</string> <string name="mpv_conf">编辑 mpv.conf</string> <string name="discard_change">舍弃更改?</string> <string name="subtitle_margin">边距</string> @@ -390,7 +391,7 @@ <string name="voice_error_timeout">语音识别超时</string> <string name="retry">重试</string> <string name="software_decoding_av1">AV1 软件解码</string> - <string name="upgrade_mpv_toast">除 HDR 视频外,mpv 现在是默认播放器。\n您可以在设置中更改此设置。</string> + <string name="upgrade_mpv_toast">除 HDR 视频外,MPV 现在是默认播放器。\n您可以在设置中更改此设置。</string> <string name="hdr_subtitle_style">HDR 字幕样式</string> <string name="image_subtitle_opacity">图像字幕不透明度</string> <string name="brightness">亮度</string> @@ -446,14 +447,14 @@ <string name="display_presets">显示预设</string> <string name="display_presets_description">可快速设置所有行样式的内置预设</string> <string name="customize_home_summary">选择首页上的行和图片</string> - <string name="favorite_items">收藏的 %s</string> + <string name="favorite_items_title">收藏的 %s</string> <string name="content_scale_fit">适应</string> <string name="content_scale_crop">裁剪</string> <string name="content_scale_fill">填充</string> <string name="content_scale_fill_width">填充宽度</string> <string name="content_scale_fill_height">填充高度</string> - <string name="display_preset_default">Wholphin 默认</string> - <string name="display_preset_compact">Wholphin 紧凑</string> + <string name="display_preset_default">Wolfskin 默认</string> + <string name="display_preset_compact">Wolfskin 紧凑</string> <string name="display_preset_series_thumb">剧集缩略图</string> <string name="display_preset_episode_thumbnails">分集缩略图</string> <string name="purple">紫色</string> @@ -476,8 +477,8 @@ <string name="magenta">紫红色</string> <string name="subtitle_edge_outline">轮廓</string> <string name="subtitle_edge_shadow">阴影</string> - <string name="prefer_mpv">优先使用 mpv</string> - <string name="player_backend_options_subtitles_prefer_mpv">HDR 播放使用 ExoPlayer</string> + <string name="prefer_mpv">优先使用 MPV</string> + <string name="player_backend_options_subtitles_prefer_mpv">对于 HDR 播放使用 ExoPlayer</string> <string name="aspect_ratios_poster">海报 (2:3)</string> <string name="aspect_ratios_16_9">16:9</string> <string name="aspect_ratios_4_3">4:3</string> @@ -535,15 +536,15 @@ <string name="search_for">搜索 %s</string> <string name="select_all">全选</string> <string name="albums">专辑</string> - <string name="artists">音乐人</string> + <string name="artists">艺人</string> <string name="songs">歌曲</string> - <string name="go_to_artist">前往音乐人</string> + <string name="go_to_artist">前往艺人</string> <string name="now_playing">正在播放</string> <string name="instant_mix">即时混音</string> <string name="go_to_album">前往专辑</string> <string name="add_to_queue">添加到待播列表</string> <string name="remove_from_queue">从待播列表中移除</string> - <string name="album_artist">专辑音乐人</string> + <string name="album_artist">专辑艺人</string> <string name="album">专辑</string> <string name="popular_songs">热门歌曲</string> <string name="play_next">接下来播放</string> @@ -558,9 +559,9 @@ <string name="play_as_type">播放方式?</string> <string name="visualizer_rationale">可视化当前播放的音频需要录音权限。</string> <string name="continue_string">继续</string> - <string name="separate_types">分开类型</string> + <string name="separate_types">分离类型</string> <string name="prefer_logos">优先显示标题图标</string> - <string name="updated_toast">Wholphin 已更新至 %s</string> + <string name="updated_toast">Wolfskin 已更新至 %s</string> <string name="view_more">查看更多</string> <string name="discover_tv">发现电视节目</string> <string name="discover_movies">发现电影</string> @@ -582,4 +583,118 @@ <string name="cinema_mode">影院模式</string> <string name="play_with">播放方式</string> <string name="transcoding">转码</string> + <string name="pin_digits_hint">[0–9]</string> + <string name="combined_search_results">综合搜索结果</string> + <string name="combined_search_results_on">在单个列表中显示所有结果</string> + <string name="combined_search_results_off">按类别显示结果</string> + <string name="results">结果</string> + <string name="continue_watching_click_behavior">点击继续观看/即将播放时的行为</string> + <string name="continue_watching_click_summary_on">点击时播放</string> + <string name="continue_watching_click_summary_off">点击时显示详情</string> + <string name="require_login">配置需要登录</string> + <string name="display_toggles_title">评分显示</string> + <string name="display_toggles_summary">自定义显示内容</string> + <string name="profile_protection">配置保护</string> + <string name="stars">星级评分</string> + <string name="tomatoes">番茄评分</string> + <string name="show_voice_search_button">显示语音搜索按钮</string> + <string name="visible_ui">可见</string> + <string name="hidden_ui">隐藏</string> + <string name="view_options_type_grid">网格</string> + <string name="view_options_type_list">列表</string> + <string name="view_options_type_list_compact">紧凑列表</string> + <string name="user_interface_language">用户界面语言</string> + <string name="sort">排序</string> + <string name="split_into_separate_rows">拆分为单独的行</string> + <string name="trailers_title">预告片</string> + <string name="other_extras_title">其他</string> + <string name="behind_the_scenes_title">幕后花絮</string> + <string name="theme_songs_title">主题曲</string> + <string name="theme_videos_title">主题视频</string> + <string name="clips_title">片段</string> + <string name="deleted_scenes_title">删减片段</string> + <string name="interviews_title">访谈</string> + <string name="scenes_title">场景</string> + <string name="samples_title">样片</string> + <string name="featurettes_title">特辑</string> + <string name="shorts_title">短片</string> + <plurals name="movies"> + <item quantity="other">电影</item> + </plurals> + <plurals name="tv_shows"> + <item quantity="other">电视节目</item> + </plurals> + <plurals name="people"> + <item quantity="other">演职人员</item> + </plurals> + <string name="genre_movies">%1$s 电影</string> + <string name="genre_tv_shows">%1$s 电视节目</string> + <string name="quality_profile">质量配置</string> + <string name="is_default">默认</string> + <string name="root_folder">根文件夹</string> + <string name="popularity">热度</string> + <string name="production_status">制作状态</string> + <string name="different_for_hdr">为 HDR 使用不同的样式</string> + <string name="experimental_settings">实验性设置</string> + <string name="confirm_enable_experimental_title">启用实验性设置?</string> + <string name="confirm_enable_experimental_body">实验性设置可能不稳定!\n\n 这些设置可能会在任何版本中被移除或更改。</string> + <string name="video_tunneling">隧道播放模式</string> + <string name="duration_hours_minutes_seconds">%1$d小时%2$d分%3$d秒</string> + <string name="duration_hours_minutes">%1$d小时%2$d分</string> + <string name="duration_minutes_seconds">%1$d分%2$d秒</string> + <string name="duration_hours">%1$d小时</string> + <string name="duration_minutes">%1$d分</string> + <string name="duration_seconds">%1$d秒</string> + <string name="time_left">还剩%1$s</string> + <string name="blocklisted">被列入黑名单</string> + <string name="unavailable">不可用</string> + <string name="prefer_ac3_for_surround_summary"><![CDATA[将非立体声音频转码为 AC3/杜比数字]]></string> + <string name="prefer_ac3_for_surround">使用 AC3 处理环绕声音频</string> + <string name="profile_specific_settings_from_server">来自服务器的用户配置设置</string> + <string name="subtitle_mode">字幕模式</string> + <string name="use_user_profile">使用您的配置设置</string> + <string name="subtitle_mode_default">默认</string> + <string name="subtitle_mode_smart">智能</string> + <string name="subtitle_mode_only_forced">仅强制</string> + <string name="subtitle_mode_always">始终</string> + <string name="subtitle_mode_none">无</string> + <string name="preferred_subtitle_language">首选字幕语言</string> + <string name="preferred_audio_language">首选音频语言</string> + <string name="override_user_profile_settings">更多用户配置设置</string> + <string name="override_user_profile_settings_summary">从服务器覆盖您的用户配置设置</string> + <string name="any_language">任何语言</string> + <string name="d_pad_seek_mode_title">方向键进度条模式</string> + <string name="dpad_seek_mode_trickplay">特技播放</string> + <string name="hash_matches">哈希匹配</string> + <string name="subtitles_hearing_impaired">SDH</string> + <string name="programs">电视节目</string> + <string name="recordings">录制内容</string> + <string name="folders_title">文件夹</string> + <string name="user_views">用户视图</string> + <string name="books">书籍</string> + <string name="audio_books">有声读物</string> + <string name="brown">棕色</string> + <string name="dpad_seek_mode_minimal">极简</string> + <string name="dpad_seek_mode_minimal_summary">屏幕底部的小型进度条(默认)</string> + <string name="dpad_seek_mode_trickplay_summary">带有特技播放预览图的进度条</string> + <string name="dpad_seek_mode_none_summary">无进度条</string> + <string name="server_version_not_supported">服务器版本不支持</string> + <string name="appears_on">出现在</string> + <string name="disable_audio_offload">禁用音频硬件分流</string> + <string name="disable_audio_offload_summary">禁用分流音频解码可能会解决某些设备上的播放卡顿问题</string> + <string name="dim_after_delay">延迟后调暗屏幕</string> + <string name="dim_percentage">调暗百分比</string> + <string name="dim_screen">调暗屏幕</string> + <string name="photo_albums">相册</string> + <string name="no_favorites_found">未找到收藏!</string> + <string name="duration_hours_seconds">%1$d小时%2$d秒</string> + <string name="season_episode_number">第%1$d季 第%2$d集</string> + <string name="season_episode_number_range">第%1$d季 第%2$d-%3$d集</string> + <string name="season_episode_number_padded">第%1$02d季第%2$02d集</string> + <string name="season_episode_number_padded_range">第%1$02d季第%2$02d-%3$02d集</string> + <string name="episode_number_short">第%1$d集</string> + <string name="season_number">第%1$d季</string> + <string name="remove_from_playlist">从播放列表中移除</string> + <string name="login_credentials_expired">凭据已过期,请重新登录</string> + <string name="login_not_authorized">您目前无权访问该服务器</string> </resources> diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 4515eb94..c82ff0d8 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -9,7 +9,7 @@ <string name="loading">載入中…</string> <string name="more_like_this">類似作品</string> <string name="more">更多</string> - <string name="movies">電影</string> + <string name="movies_title">電影</string> <string name="name">名稱</string> <string name="next_up">接下來播放</string> <string name="no_data">無資料</string> @@ -31,7 +31,7 @@ <string name="recently_released">最近發行</string> <string name="recommended">推薦</string> <string name="record_program">錄製節目</string> - <string name="restart">重頭播放</string> + <string name="restart">從頭播放</string> <string name="save">儲存</string> <string name="search">搜尋</string> <string name="searching">搜尋中…</string> @@ -52,7 +52,6 @@ <string name="suggestions">推薦</string> <string name="switch_servers">切換伺服器</string> <string name="switch_user">切換</string> - <string name="trailer">預告片</string> <plurals name="trailers"> <item quantity="other">預告片</item> </plurals> @@ -189,7 +188,7 @@ <string name="mark_watched">標記為已觀看</string> <string name="max_bitrate">最大位元率</string> <string name="outro">片尾</string> - <string name="people">演職人員</string> + <string name="people_title">演職人員</string> <string name="play_from_here">從此處播放</string> <string name="playback_debug_info">顯示播放偵錯資訊</string> <string name="playback">播放</string> @@ -202,9 +201,11 @@ <string name="studios">製作公司</string> <string name="top_unwatched">高評分未觀看</string> <string name="tv_dvr_schedule">錄影排程</string> - <string name="tv_shows">電視劇</string> + <string name="tv_shows_title">電視劇</string> <string name="ui_interface">介面</string> - <string name="years_old">%1$d 歲</string> + <plurals name="years_old"> + <item quantity="other">%1$d 歲</item> + </plurals> <string name="play_with_transcoding">轉碼播放</string> <string name="one_click_pause_summary_on">按下方向鍵中央以暫停/播放</string> <plurals name="deleted_scenes"> @@ -243,7 +244,7 @@ <plurals name="scenes"> <item quantity="other">精選片段</item> </plurals> - <string name="extras">特別收錄</string> + <string name="extras_title">特別收錄</string> <plurals name="samples"> <item quantity="other">試看片段</item> </plurals> @@ -332,7 +333,7 @@ <string name="nal">NAL</string> <string name="bit_depth">位元深度</string> <string name="avc">AVC</string> - <string name="layout">聲道配置</string> + <string name="layout">配置</string> <string name="channels">聲道數</string> <string name="video_range">動態範圍</string> <string name="video_range_type">動態範圍類型</string> @@ -451,10 +452,10 @@ <string name="content_scale_fill">拉伸填滿</string> <string name="content_scale_fill_width">填滿寬度</string> <string name="content_scale_fill_height">填滿高度</string> - <string name="volume_lowest">最低音量</string> - <string name="volume_low">低音量</string> + <string name="volume_lowest">最小音量</string> + <string name="volume_low">小音量</string> <string name="volume_medium">中等音量</string> - <string name="volume_high">高音量</string> + <string name="volume_high">大音量</string> <string name="volume_full">最大音量</string> <string name="purple">紫色</string> <string name="orange">橘色</string> @@ -480,9 +481,9 @@ <string name="background_style_boxed">長方底框</string> <string name="prefer_mpv">優先使用 MPV</string> <string name="player_backend_options_subtitles_prefer_mpv">僅 HDR 使用 ExoPlayer 播放</string> - <string name="favorite_items">我的最愛的 %s</string> - <string name="display_preset_default">Wholphin 預設樣式</string> - <string name="display_preset_compact">Wholphin 緊湊樣式</string> + <string name="favorite_items_title">我的最愛的 %s</string> + <string name="display_preset_default">Wolfskin 預設樣式</string> + <string name="display_preset_compact">Wolfskin 緊湊樣式</string> <string name="display_preset_series_thumb">劇集縮圖樣式</string> <string name="display_preset_episode_thumbnails">單集縮圖樣式</string> <string name="aspect_ratios_poster">海報 (2:3)</string> @@ -560,7 +561,7 @@ <string name="instant_mix">即時混播</string> <string name="separate_types">依類型分開顯示</string> <string name="prefer_logos">優先顯示圖像標題</string> - <string name="updated_toast">Wholphin 已更新至 %s</string> + <string name="updated_toast">Wolfskin 已更新至 %s</string> <string name="view_more">查看更多</string> <string name="discover_tv">探索電視劇</string> <string name="discover_movies">探索電影</string> @@ -582,4 +583,150 @@ <string name="cinema_mode">影院模式</string> <string name="play_with">播放方式</string> <string name="transcoding">轉碼</string> + <string name="pin_digits_hint">[0–9]</string> + <string name="combined_search_results">合併搜尋結果</string> + <string name="combined_search_results_on">在單一列表中顯示所有結果</string> + <string name="combined_search_results_off">按類別顯示結果</string> + <string name="results">結果</string> + <string name="require_login">使用密碼登入</string> + <string name="profile_protection">登入保護</string> + <string name="continue_watching_click_behavior">點擊 繼續觀看/接下來播放 時的行為</string> + <string name="continue_watching_click_summary_on">點擊時播放</string> + <string name="continue_watching_click_summary_off">點擊時顯示詳情</string> + <string name="display_toggles_title">評分顯示</string> + <string name="display_toggles_summary">自訂要顯示的項目</string> + <string name="stars">星級評分</string> + <string name="tomatoes">番茄評分</string> + <string name="show_voice_search_button">顯示語音搜尋按鈕</string> + <string name="visible_ui">顯示</string> + <string name="hidden_ui">隱藏</string> + <string name="view_options_type_grid">網格</string> + <string name="view_options_type_list">列表</string> + <string name="view_options_type_list_compact">緊湊列表</string> + <string name="user_interface_language">介面語言</string> + <string name="sort">排序</string> + <string name="split_into_separate_rows">分開為不同列</string> + <string name="trailers_title">預告片</string> + <string name="other_extras_title">其他</string> + <string name="behind_the_scenes_title">幕後花絮</string> + <string name="theme_songs_title">主題曲</string> + <string name="theme_videos_title">主題影片</string> + <string name="clips_title">片段</string> + <string name="deleted_scenes_title">刪剪片段</string> + <string name="interviews_title">訪談</string> + <string name="scenes_title">精選片段</string> + <string name="samples_title">試看片段</string> + <string name="featurettes_title">幕後特輯</string> + <string name="shorts_title">短片</string> + <plurals name="movies"> + <item quantity="other">電影</item> + </plurals> + <plurals name="tv_shows"> + <item quantity="other">電視劇</item> + </plurals> + <plurals name="people"> + <item quantity="other">演職人員</item> + </plurals> + <string name="genre_movies">%1$s 電影</string> + <string name="genre_tv_shows">%1$s 電視劇</string> + <string name="quality_profile">品質設定</string> + <string name="is_default">預設</string> + <string name="root_folder">根目錄</string> + <string name="popularity">熱門程度</string> + <string name="production_status">製作狀態</string> + <string name="different_for_hdr">HDR 使用不同的樣式</string> + <string name="video_tunneling">影片直通模式</string> + <string name="experimental_settings">實驗性設定</string> + <string name="confirm_enable_experimental_title">啟用實驗性設定?</string> + <string name="confirm_enable_experimental_body">實驗性設定可能不穩定!\n在後續版本中可能會被移除或變更。</string> + <string name="duration_hours_minutes_seconds">%1$d小時%2$d分%3$d秒</string> + <string name="duration_hours_minutes">%1$d小時%2$d分</string> + <string name="duration_minutes_seconds">%1$d分%2$d秒</string> + <string name="duration_hours">%1$d小時</string> + <string name="duration_minutes">%1$d分</string> + <string name="duration_seconds">%1$d秒</string> + <string name="time_left">還剩 %1$s</string> + <string name="blocklisted">被列入黑名單</string> + <string name="unavailable">無法請求</string> + <string name="prefer_ac3_for_surround_summary"><![CDATA[將非立體聲音訊轉碼為 AC3/杜比數位格式]]></string> + <string name="prefer_ac3_for_surround">使用 AC3 處理環繞音效</string> + <string name="profile_specific_settings_from_server">由伺服器提供的個人化設定</string> + <string name="subtitle_mode">字幕模式</string> + <string name="use_user_profile">使用伺服器設定</string> + <string name="subtitle_mode_default">預設</string> + <string name="subtitle_mode_smart">智慧模式</string> + <string name="subtitle_mode_only_forced">僅強制字幕</string> + <string name="subtitle_mode_always">總是顯示</string> + <string name="subtitle_mode_none">不顯示</string> + <string name="preferred_subtitle_language">偏好字幕語言</string> + <string name="preferred_audio_language">偏好音軌語言</string> + <string name="override_user_profile_settings">更多個人化設定</string> + <string name="override_user_profile_settings_summary">覆寫伺服器提供的個人化設定</string> + <string name="any_language">任何語言</string> + <string name="brown">棕色</string> + <string name="d_pad_seek_mode_title">方向鍵進度條模式</string> + <string name="dpad_seek_mode_minimal">精簡</string> + <string name="dpad_seek_mode_trickplay">縮圖預覽</string> + <string name="dpad_seek_mode_minimal_summary">在畫面底部顯示小進度條 (預設)</string> + <string name="dpad_seek_mode_trickplay_summary">顯示有縮圖預覽的進度條</string> + <string name="dpad_seek_mode_none_summary">不顯示進度條</string> + <string name="hash_matches">雜湊值相符</string> + <string name="subtitles_hearing_impaired">SDH</string> + <string name="programs">電視劇</string> + <string name="recordings">錄影節目</string> + <string name="folders_title">資料夾</string> + <string name="books">書籍</string> + <string name="audio_books">有聲書</string> + <string name="server_version_not_supported">不支援此伺服器版本</string> + <string name="user_views">使用者檢視方式</string> + <string name="appears_on">出現於</string> + <string name="disable_audio_offload">停用音訊硬體卸載</string> + <string name="disable_audio_offload_summary">停用音訊解碼卸載或許可以解決某些裝置的播放卡住問題</string> + <string name="dim_after_delay">閒置後調暗螢幕</string> + <string name="dim_percentage">調暗程度</string> + <string name="dim_screen">螢幕調暗</string> + <string name="no_favorites_found">無任何最愛項目!</string> + <string name="photo_albums">相簿</string> + <string name="duration_hours_seconds">%1$d小時%2$d分</string> + <string name="season_episode_number">第%1$d季 第%2$d集</string> + <string name="season_episode_number_range">第%1$d季 第%2$d-%3$d集</string> + <string name="season_episode_number_padded">第%1$02d季 第%2$02d集</string> + <string name="season_episode_number_padded_range">第%1$02d季 第%2$02d-%3$02d集</string> + <string name="episode_number_short">第 %1$d 集</string> + <string name="season_number">第 %1$d 季</string> + <string name="remove_from_playlist">從播放清單中移除</string> + <string name="login_not_authorized">您目前無存取此伺服器的權限</string> + <string name="login_credentials_expired">登入憑證已過期,請重新登入</string> + <plurals name="episodes_count"> + <item quantity="other">%1$d 集</item> + </plurals> + <string name="prefer_dovi_over_hdr10_plus">Dolby Vision 優先於 HDR10+</string> + <string name="prefer_dovi_over_hdr10_plus_summary">移除 Dolby Vision 影片中的 HDR10+ 中介資料,以解決部分電視誤播為 HDR10+</string> + <string name="max_resolution">最高解析度,超過將轉碼</string> + <string name="resolution_8k">8K</string> + <string name="resolution_4k">4K</string> + <string name="resolution_1080">1080</string> + <string name="resolution_720">720</string> + <string name="resolution_576">576</string> + <string name="resolution_480">480</string> + <string name="resolution_360">360</string> + <string name="hdr10_override">HDR10</string> + <string name="hdr10_plus_override">HDR10+</string> + <string name="dovi_profile_5">杜比視界 Profile 5</string> + <string name="dovi_profile_7">杜比視界 Profile 7</string> + <string name="dovi_profile_8">杜比視界 Profile 8</string> + <string name="force_enable">強制啟用</string> + <string name="force_disable">強制停用</string> + <string name="server_version_not_supported_future">伺服器版本即將停止支援</string> + <string name="server_version_required">伺服器版本需為:%1$s</string> + <string name="user">使用者</string> + <string name="series">劇集</string> + <string name="load_from_server_plugin">從伺服器載入預設值</string> + <string name="save_to_local">僅儲存於此裝置</string> + <string name="home_settings_source_unset">預設</string> + <string name="top_rated">高評分</string> + <string name="hdr_formats">HDR 格式</string> + <string name="chosen_by">由 %1$s 精選</string> + <string name="convert_dovi_profile_7">將杜比視界 Profile 7 轉換為 8.1</string> + <string name="convert_dovi_profile_7_summary">在解碼器處理前,將雙層 Profile 7 重新改寫為單層 Profile 8.1,讓不支援 Profile 7 但支援 Profile 8 的裝置能夠直接播放影片</string> </resources> diff --git a/app/src/main/res/values/fa_strings.xml b/app/src/main/res/values/fa_strings.xml index 770a6b72..45a6f7b2 100644 --- a/app/src/main/res/values/fa_strings.xml +++ b/app/src/main/res/values/fa_strings.xml @@ -1,5 +1,8 @@ <?xml version="1.0" encoding="utf-8"?> <resources> + <!-- Names should be prefixed with 'fa_' and use the icon name from FontAwesome --> + <!-- Uses v6 of FontAwesome: https://fontawesome.com/v6/icons/classic/solid/<name> --> + <!-- Value is '&#x' then the unicode value, then semicolon --> <string name="fa_tag" translatable="false"></string> <string name="fa_user" translatable="false"></string> <string name="fa_location_dot" translatable="false"></string> @@ -56,4 +59,7 @@ <string name="fa_compass" translatable="false"></string> <string name="fa_repeat" translatable="false"></string> <string name="fa_compact_disc" translatable="false"></string> + <string name="fa_xmark" translatable="false"></string> + <string name="fa_dice" translatable="false"></string> + <string name="fa_circle_xmark" translatable="false"></string> </resources> diff --git a/app/src/main/res/values/ic_launcher_background.xml b/app/src/main/res/values/ic_launcher_background.xml index ffea1b18..933396c0 100644 --- a/app/src/main/res/values/ic_launcher_background.xml +++ b/app/src/main/res/values/ic_launcher_background.xml @@ -1,4 +1,4 @@ <?xml version="1.0" encoding="utf-8"?> <resources> - <color name="ic_launcher_background">#101010</color> + <color name="ic_launcher_background">#07070a</color> </resources> diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b9b56865..1fadeac7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -26,6 +26,13 @@ <string name="continue_watching">Continue watching</string> <string name="critic_rating">Critic Rating</string> <string name="decimal_seconds">%.2f seconds</string> + <string name="duration_hours_minutes_seconds">%1$dh %2$dm %3$ds</string> + <string name="duration_hours_minutes">%1$dh %2$dm</string> + <string name="duration_hours_seconds">%1$dh %2$ds</string> + <string name="duration_minutes_seconds">%1$dm %2$ds</string> + <string name="duration_hours">%1$dh</string> + <string name="duration_minutes">%1$dm</string> + <string name="duration_seconds">%1$ds</string> <string name="default_track">Default</string> <string name="delete">Delete</string> <string name="died">Died</string> @@ -66,7 +73,7 @@ <string name="max_bitrate">Max bitrate</string> <string name="more_like_this">More like this</string> <string name="more">More</string> - <string name="movies">Movies</string> + <string name="movies_title">Movies</string> <string name="name">Name</string> <string name="next_up">Next Up</string> <string name="no_data">No data</string> @@ -78,7 +85,7 @@ <string name="only_forced_subtitles">Only Forced Subtitles</string> <string name="outro">Outro</string> <string name="path">Path</string> - <string name="people">People</string> + <string name="people_title">People</string> <string name="play_count">Play Count</string> <string name="play_from_here">Play from here</string> <string name="play">Play</string> @@ -89,6 +96,7 @@ <string name="playlists">Playlists</string> <string name="preview">Preview</string> <string name="profile_specific_settings">User Profile Settings</string> + <string name="profile_specific_settings_from_server">User profile settings from server</string> <string name="queue">Queue</string> <string name="recap">Recap</string> <string name="recently_added_in">Recently added in %1$s</string> @@ -144,14 +152,22 @@ <string name="suggestions">Suggestions</string> <string name="switch_servers">Switch servers</string> <string name="switch_user">Switch</string> + <string name="time_left">%1$s left</string> <string name="top_unwatched">Top Rated Unwatched</string> - <string name="trailer">Trailer</string> - <string name="trailers">Trailers</string> + <string name="top_rated">Top Rated</string> + <string name="trailers_title">Trailers</string> <string name="tv_dvr_schedule">DVR Schedule</string> <string name="tv_guide">Guide</string> <string name="tv_season">Season</string> <string name="tv_seasons">Seasons</string> - <string name="tv_shows">TV Shows</string> + <string name="season_episode_number">S%1$d E%2$d</string> + <string name="season_episode_number_range">S%1$d E%2$d-E%3$d</string> + <string name="season_episode_number_padded">S%1$02dE%2$02d</string> + <string name="season_episode_number_padded_range">S%1$02dE%2$02d-E%3$02d</string> + <string name="episode_number_short">E%1$d</string> + <string name="rating_episode_label">Ep</string> + <string name="season_number">Season %1$d</string> + <string name="tv_shows_title">TV Shows</string> <string name="ui_interface">Interface</string> <string name="unknown">Unknown</string> <string name="updates">Updates</string> @@ -160,7 +176,11 @@ <string name="video">Video</string> <string name="videos">Videos</string> <string name="watch_live">Watch live</string> - <string name="years_old">%1$d years old</string> + <plurals name="years_old"> + <item quantity="zero">%1$d years old</item> + <item quantity="one">%1$d year old</item> + <item quantity="other">%1$d years old</item> + </plurals> <string name="play_with_transcoding">Play with transcoding</string> <string name="media_information">Media Information</string> <string name="show_clock">Show Clock</string> @@ -176,19 +196,19 @@ <string name="official_rating">Parental Rating</string> <string name="runtime_sort">Runtime</string> - <string name="extras">Extras</string> - <string name="other_extras">Other</string> - <string name="behind_the_scenes">Behind the Scenes</string> - <string name="theme_songs">Theme Songs</string> - <string name="theme_videos">Theme Videos</string> - <string name="clips">Clips</string> - <string name="deleted_scenes">Deleted Scenes</string> - <string name="interviews">Interviews</string> - <string name="scenes">Scenes</string> - <string name="samples">Samples</string> - <string name="featurettes">Featurettes</string> - <string name="shorts">Shorts</string> - <string name="favorite_items">Favorite %s</string> + <string name="extras_title">Extras</string> + <string name="other_extras_title">Other</string> + <string name="behind_the_scenes_title">Behind the Scenes</string> + <string name="theme_songs_title">Theme Songs</string> + <string name="theme_videos_title">Theme Videos</string> + <string name="clips_title">Clips</string> + <string name="deleted_scenes_title">Deleted Scenes</string> + <string name="interviews_title">Interviews</string> + <string name="scenes_title">Scenes</string> + <string name="samples_title">Samples</string> + <string name="featurettes_title">Featurettes</string> + <string name="shorts_title">Shorts</string> + <string name="favorite_items_title">Favorite %s</string> <plurals name="trailers"> <item quantity="zero">Trailers</item> @@ -386,12 +406,28 @@ <string name="use_server_credentials">Login via server</string> <string name="press_enter_to_confirm">Press center to confirm</string> <string name="require_pin_code">Require PIN for profile</string> + <string name="require_login">Require login for profile</string> <string name="confirm_pin">Confirm PIN</string> <string name="incorrect">Incorrect</string> <string name="pin_too_short">PIN must be 4 keys or longer</string> <string name="will_remove_pin">Will remove PIN</string> - <string name="pin_digits_hint">[0-9]</string> + <string name="pin_digits_hint">[0–9]</string> <string name="image_cache_size">Image disk cache size (MB)</string> + <string name="disable_audio_offload">Disable audio hardware offload</string> + <string name="disable_audio_offload_summary">Disabling offloading audio decoding may fix playback freezes on some devices</string> + <string name="hdr_formats">HDR formats</string> + <string name="hdr10_override">HDR10</string> + <string name="hdr10_plus_override">HDR10+</string> + <string name="dovi_profile_5">Dolby Vision Profile 5</string> + <string name="dovi_profile_7">Dolby Vision Profile 7</string> + <string name="dovi_profile_8">Dolby Vision Profile 8</string> + <string name="force_enable">Force enable</string> + <string name="force_disable">Force disable</string> + <string-array name="hdr_override_modes"> + <item>@string/automatic</item> + <item>@string/force_enable</item> + <item>@string/force_disable</item> + </string-array> <string name="view_options">View options</string> <string name="columns">Columns</string> <string name="spacing">Spacing</string> @@ -445,6 +481,13 @@ <string name="color_code_programs">Color-code programs</string> <string name="subtitle_delay">Subtitle delay</string> <string name="backdrop_display">Backdrop style</string> + <string name="combined_search_results">Combined search results</string> + <string name="combined_search_results_on">Show all results in a single list</string> + <string name="combined_search_results_off">Show results by category</string> + <string name="show_voice_search_button">Show voice search button</string> + <string name="visible_ui">Visible</string> + <string name="hidden_ui">Hidden</string> + <string name="results">Results</string> <string name="resolution_switching">Resolution switching</string> <string name="local">Local</string> <string name="play_trailer">Play trailer</string> @@ -459,6 +502,10 @@ <string name="dts">DTS</string> <string name="force_dovi_profile_7">Direct play Dolby Vision Profile 7</string> <string name="force_dovi_profile_7_summary">Ignores device compatibility checks</string> + <string name="prefer_dovi_over_hdr10_plus">Prefer Dolby Vision over HDR10+</string> + <string name="prefer_dovi_over_hdr10_plus_summary">Removes HDR10+ metadata from Dolby Vision videos, for TVs that otherwise play them as HDR10+</string> + <string name="convert_dovi_profile_7">Convert Dolby Vision Profile 7 to 8.1</string> + <string name="convert_dovi_profile_7_summary">Rewrites dual-layer Profile 7 into single-layer Profile 8.1 before the decoder and direct plays it, for devices whose decoder lists Profile 8 but not Profile 7</string> <string name="discover">Discover</string> <string name="request">Request</string> @@ -484,7 +531,7 @@ <string name="image_subtitle_opacity">Image subtitle opacity</string> <string name="brightness">Brightness</string> - <string name="contrast">Constrast</string> + <string name="contrast">Contrast</string> <string name="saturation">Saturation</string> <string name="hue">Hue</string> <string name="red">Red</string> @@ -537,6 +584,8 @@ <string name="display_presets">Display presets</string> <string name="display_presets_description">Built-in presets to quickly style all rows</string> <string name="customize_home_summary">Choose rows and images on the home page</string> + <string name="prefer_ac3_for_surround_summary"><![CDATA[Transcode non-stereo audio to AC3/Dolby Digital]]></string> + <string name="prefer_ac3_for_surround">Use AC3 for surround sound audio</string> <string name="start_after">Start after</string> <string name="animate">Animate</string> <string name="max_age_rating">Max age rating</string> @@ -578,6 +627,7 @@ <string name="orange">Orange</string> <string name="bold_blue">Bold Blue</string> <string name="black">Black</string> + <string name="brown">Brown</string> <string-array name="app_theme_colors"> <item>@string/purple</item> <item>@string/blue</item> @@ -585,6 +635,8 @@ <item>@string/orange</item> <item>@string/bold_blue</item> <item>@string/black</item> + <item>@string/red</item> + <item>@string/brown</item> </string-array> <string name="skip_ignore">Ignore</string> @@ -695,6 +747,14 @@ <item>@string/image_type_thumb</item> <!-- <item>Banner</item>--> </string-array> + <string name="view_options_type_grid">Grid</string> + <string name="view_options_type_list">List</string> + <string name="view_options_type_list_compact">Compact List</string> + <string-array name="view_options_types"> + <item>@string/view_options_type_grid</item> + <item>@string/view_options_type_list</item> + <item>@string/view_options_type_list_compact</item> + </string-array> <string name="backdrop_style_dynamic">Image with dynamic color</string> <string name="backdrop_style_image">Image only</string> @@ -716,11 +776,22 @@ </string-array> <string-array name="screensaver_item_types"> - <item>@string/movies</item> - <item>@string/tv_shows</item> + <item>@string/movies_title</item> + <item>@string/tv_shows_title</item> <item>@string/photos</item> </string-array> + <string-array name="display_toggle_types"> + <item>@string/official_rating</item> + <item>@string/critic_rating</item> + <item>@string/community_rating</item> + </string-array> + <string-array name="display_toggle_types_subtitles"> + <item></item><!-- Intentionally blank --> + <item>@string/tomatoes</item> + <item>@string/stars</item> + </string-array> + <string name="search_for">Search %s</string> <string name="actor">Actor</string> @@ -736,6 +807,11 @@ <string name="creator">Creator</string> <string name="artist">Artist</string> <string name="album_artist">Album artist</string> + <string name="artist_source">Artist source</string> + <string-array name="artist_sources"> + <item>@string/artist</item> + <item>@string/album_artist</item> + </string-array> <string name="album">Album</string> <string name="popular_songs">Popular songs</string> <string name="play_next">Play next</string> @@ -774,6 +850,13 @@ <string name="skip_behavior_summary">For intros, credits, etc</string> <string name="play_with">Play with</string> <string name="transcoding">Transcoding</string> + <string name="user_interface_language">User interface language</string> + <string name="display_toggles_title">Ratings display</string> + <string name="display_toggles_summary">Customize which to display</string> + <string name="profile_protection">Profile protection</string> + <string name="continue_watching_click_behavior">Continue watching/Next up click behavior</string> + <string name="continue_watching_click_summary_on">Play on click</string> + <string name="continue_watching_click_summary_off">Show details on click</string> <array name="ass_subtitle_modes"> <item>@string/ass_subtitle_mode_libass</item> <item>@string/ass_subtitle_mode_exoplayer</item> @@ -784,4 +867,124 @@ <item></item><!-- Intentionally blank --> <item></item><!-- Intentionally blank --> </array> + <string name="stars">Stars</string> + <string name="tomatoes">Tomatoes</string> + <string name="sort">Sort</string> + <string name="split_into_separate_rows">Split into separate rows</string> + <string name="genre_movies">%1$s Movies</string> + <string name="genre_tv_shows">%1$s TV Shows</string> + <string name="quality_profile">Quality profile</string> + <string name="is_default">Default</string> + <string name="root_folder">Root folder</string> + <string name="popularity">Popularity</string> + <string name="production_status">Production status</string> + <string name="different_for_hdr">Use different style for HDR</string> + <string name="experimental_settings">Experimental settings</string> + <string name="confirm_enable_experimental_title">Enable experimental settings?</string> + <string name="confirm_enable_experimental_body">Experimental settings may be unstable!\n\n These may removed or changed in any release.</string> + <string name="video_tunneling">Video tunneling</string> + + <string name="d_pad_seek_mode_title">D-Pad seek bar mode</string> + <string name="dpad_seek_mode_minimal">Minimal</string> + <string name="dpad_seek_mode_trickplay">Trickplay</string> + <string-array name="dpad_seek_mode_options"> + <item>@string/dpad_seek_mode_minimal</item> + <item>@string/dpad_seek_mode_trickplay</item> + <item>@string/none</item> + </string-array> + <string name="dpad_seek_mode_minimal_summary">Small seek bar along the bottom of the screen (default)</string> + <string name="dpad_seek_mode_trickplay_summary">Seek bar with trickplay preview images</string> + <string name="dpad_seek_mode_none_summary">No seek bar</string> + <string-array name="dpad_seek_mode_summaries"> + <item>@string/dpad_seek_mode_minimal_summary</item> + <item>@string/dpad_seek_mode_trickplay_summary</item> + <item>@string/dpad_seek_mode_none_summary</item> + </string-array> + + <string name="blocklisted">Blocklisted</string> + <string name="unavailable">Unavailable</string> + <string name="subtitle_mode">Subtitle mode</string> + + <string name="use_user_profile">Use your profile setting</string> + + <string name="subtitle_mode_default">Default</string> + <string name="subtitle_mode_smart">Smart</string> + <string name="subtitle_mode_only_forced">Only Forced</string> + <string name="subtitle_mode_always">Always</string> + <string name="subtitle_mode_none">None</string> + <string-array name="subtitle_mode_options"> + <item>@string/use_user_profile</item> + <item>@string/subtitle_mode_default</item> + <item>@string/subtitle_mode_smart</item> + <item>@string/subtitle_mode_only_forced</item> + <item>@string/subtitle_mode_always</item> + <item>@string/subtitle_mode_none</item> + </string-array> + + <string-array name="home_click_to_play_options"> + <item>@string/continue_watching_click_summary_off</item> + <item>@string/continue_watching_click_summary_on</item> + </string-array> + + <string name="preferred_subtitle_language">Preferred subtitle language</string> + <string name="preferred_audio_language">Preferred audio language</string> + <string name="override_user_profile_settings">More user profile settings</string> + <string name="override_user_profile_settings_summary">Override your user profile settings from the server</string> + <string name="any_language">Any language</string> + <string name="hash_matches">Hash matches</string> + <string name="subtitles_hearing_impaired">SDH</string> + <string name="programs">TV programs</string> + <string name="recordings">Recordings</string> + <string name="folders_title">Folders</string> + <string name="user_views">User views</string> + <string name="books">Books</string> + <string name="audio_books">Audio books</string> + <string name="server_version_not_supported">Server version not supported</string> + <string name="server_version_not_supported_future">Server version will not be supported soon</string> + <string name="server_version_required">Required server version: %1$s</string> + <string name="appears_on">Appears on</string> + <string name="dim_after_delay">Dim screen after delay</string> + <string name="dim_percentage">Dim percentage</string> + <string name="dim_screen">Dim screen</string> + <string name="photo_albums">Photo albums</string> + <string name="no_favorites_found">No favorites found!</string> + <string name="remove_from_playlist">Remove from playlist</string> + <string name="login_credentials_expired">Credentials expired, please login in again</string> + <string name="login_not_authorized">You are not authorized to access the server at this time</string> + <string name="max_resolution">Max resolution before transcoding</string> + + <string name="resolution_8k">8K</string> + <string name="resolution_4k">4K</string> + <string name="resolution_1080">1080</string> + <string name="resolution_720">720</string> + <string name="resolution_576">576</string> + <string name="resolution_480">480</string> + <string name="resolution_360">360</string> + <string-array name="max_resolution_options"> + <item>@string/no_limit</item> + <item>@string/resolution_8k</item> + <item>@string/resolution_4k</item> + <item>@string/resolution_1080</item> + <item>@string/resolution_720</item> + <item>@string/resolution_576</item> + <item>@string/resolution_480</item> + <item>@string/resolution_360</item> + </string-array> + <plurals name="episodes_count"> + <item quantity="zero">%1$d Episodes</item> + <item quantity="one">%1$d Episode</item> + <item quantity="other">%1$d Episodes</item> + </plurals> + <string name="chosen_by">Chosen by %1$s</string> + <string name="user">User</string> + <string name="series">Series</string> + <string name="load_from_server_plugin">Load default from server</string> + <string name="save_to_local">Save only to this device</string> + <string name="home_settings_source_unset">Default</string> + <string name="home_settings_source_local">Device specific</string> + <string name="home_settings_source_server_profile">Server user profile</string> + <string name="home_settings_source_plugin">Server default</string> + <string name="home_settings_source_web">User web client</string> + <string name="managed_by_server">Managed by server</string> + <string name="error">Error</string> </resources> diff --git a/app/src/main/res/values/wolfskin.xml b/app/src/main/res/values/wolfskin.xml new file mode 100644 index 00000000..0308ae9e --- /dev/null +++ b/app/src/main/res/values/wolfskin.xml @@ -0,0 +1,6 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <string name="wolfskin_screens" translatable="false">Wolfskin screens</string> + <string name="wolfskin_upstream_credit" translatable="false">based on Wholphin by damontecres</string> + <string name="wolfskin_upstream_url" translatable="false">https://github.com/damontecres/Wholphin</string> +</resources> diff --git a/app/src/main/seerr/seerr-api.yml b/app/src/main/seerr/seerr-api.yml index 4c21b699..dc493638 100644 --- a/app/src/main/seerr/seerr-api.yml +++ b/app/src/main/seerr/seerr-api.yml @@ -603,15 +603,14 @@ components: example: false required: - name - - hostname - - port - - apiKey - - useSsl + # - hostname + # - port + # - useSsl - activeProfileId - - activeProfileName + # - activeProfileName - activeDirectory - is4k - - minimumAvailability + # - minimumAvailability - isDefault SonarrSettings: type: object @@ -682,15 +681,15 @@ components: example: false required: - name - - hostname - - port - - apiKey - - useSsl + # - hostname + # - port + # - apiKey + # - useSsl - activeProfileId - - activeProfileName + # - activeProfileName - activeDirectory - is4k - - enableSeasonFolders + # - enableSeasonFolders - isDefault ServarrTag: type: object @@ -1091,8 +1090,71 @@ components: status: type: integer example: 0 - description: Status of the request. 1 = PENDING APPROVAL, 2 = APPROVED, 3 = DECLINED + description: Availability of the season readOnly: true + status4k: + type: integer + 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"] TvDetails: type: object properties: @@ -1198,8 +1260,6 @@ components: type: array items: $ref: '#/components/schemas/Season' - status: - type: string tagline: type: string type: @@ -1299,6 +1359,12 @@ components: type: integer example: 0 description: Availability of the media. 1 = `UNKNOWN`, 2 = `PENDING`, 3 = `PROCESSING`, 4 = `PARTIALLY_AVAILABLE`, 5 = `AVAILABLE`, 6 = `DELETED` + seasons: + type: array + readOnly: true + nullable: true + items: + $ref: '#/components/schemas/Season' requests: type: array readOnly: true @@ -1396,6 +1462,17 @@ components: name: type: string example: 720p/1080p + RootFolder: + type: object + properties: + id: + type: integer + example: 1 + freeSpace: + type: long + path: + type: string + example: /movies PageInfo: type: object properties: @@ -6154,9 +6231,9 @@ paths: is4k: type: boolean example: false - serverid: + serverId: type: integer - profileid: + profileId: type: integer rootFolder: type: string @@ -6166,6 +6243,10 @@ paths: userid: type: integer nullable: true + tags: + type: array + items: + type: integer required: - mediaType - mediaId @@ -6261,9 +6342,9 @@ paths: is4k: type: boolean example: false - serverid: + serverId: type: integer - profileid: + profileId: type: integer rootFolder: type: string @@ -6502,13 +6583,13 @@ paths: example: 85 criticsRating: type: string - enum: ['Rotten', 'Fresh', 'Certified Fresh'] + example: 'Certified Fresh' audienceScore: type: integer example: 65 audienceRating: type: string - enum: ['Spilled', 'Upright'] + example: 'Upright' /movie/{movieId}/ratingscombined: get: summary: Get RT and IMDB movie ratings combined @@ -6547,13 +6628,13 @@ paths: example: 85 criticsRating: type: string - enum: ['Rotten', 'Fresh', 'Certified Fresh'] + example: 'Certified Fresh' audienceScore: type: integer example: 65 audienceRating: type: string - enum: ['Spilled', 'Upright'] + example: 'Upright' imdb: type: object properties: @@ -6564,8 +6645,12 @@ paths: type: string example: 'https://www.imdb.com/title/tt0480249' criticsScore: + type: number + example: 8.8 + criticsScoreCount: type: integer - example: 6.5 + format: int64 + example: 2500000 /tv/{tvId}: get: summary: Get TV details @@ -6621,7 +6706,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/Season' + $ref: '#/components/schemas/SeasonDetails' /tv/{tvId}/recommendations: get: summary: Get recommended TV series @@ -6747,7 +6832,13 @@ paths: example: 85 criticsRating: type: string - enum: ['Rotten', 'Fresh'] + example: 'Certified Fresh' + audienceScore: + type: integer + example: 65 + audienceRating: + type: string + example: 'Upright' /person/{personId}: get: summary: Get person details @@ -7061,7 +7152,13 @@ paths: server: $ref: '#/components/schemas/RadarrSettings' profiles: - $ref: '#/components/schemas/ServiceProfile' + type: array + items: + $ref: '#/components/schemas/ServiceProfile' + rootFolders: + type: array + items: + $ref: '#/components/schemas/RootFolder' /service/sonarr: get: summary: Get non-sensitive Sonarr server list @@ -7101,7 +7198,13 @@ paths: server: $ref: '#/components/schemas/SonarrSettings' profiles: - $ref: '#/components/schemas/ServiceProfile' + type: array + items: + $ref: '#/components/schemas/ServiceProfile' + rootFolders: + type: array + items: + $ref: '#/components/schemas/RootFolder' /service/sonarr/lookup/{tmdbId}: get: summary: Get series from Sonarr diff --git a/app/src/optimized/baselineProfiles/baseline-prof.txt b/app/src/optimized/baselineProfiles/baseline-prof.txt new file mode 100644 index 00000000..ad4df0ae --- /dev/null +++ b/app/src/optimized/baselineProfiles/baseline-prof.txt @@ -0,0 +1,8 @@ +# Hand-maintained startup/shell seed; supplement with device-recorded navigation profiles. +Lcom/github/damontecres/wholphin/WholphinApplication; +HSPLcom/github/damontecres/wholphin/WholphinApplication;->onCreate()V +Lcom/github/damontecres/wholphin/MainActivity; +HSPLcom/github/damontecres/wholphin/MainActivity;->onCreate(Landroid/os/Bundle;)V +Lorg/justinvisser/wolfskin/tv/WolfskinAppKt; +Lorg/justinvisser/wolfskin/tv/WolfskinScreenPreference; +Lorg/justinvisser/wolfskin/tv/ConductarrPreference; diff --git a/app/src/optimized/res/values/strings.xml b/app/src/optimized/res/values/strings.xml new file mode 100644 index 00000000..fa4e585f --- /dev/null +++ b/app/src/optimized/res/values/strings.xml @@ -0,0 +1,4 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <string name="app_name" translatable="false">Wolfskin (Opt)</string> +</resources> diff --git a/app/src/release/java/org/justinvisser/wolfskin/tv/DebugHome.kt b/app/src/release/java/org/justinvisser/wolfskin/tv/DebugHome.kt new file mode 100644 index 00000000..b49e7828 --- /dev/null +++ b/app/src/release/java/org/justinvisser/wolfskin/tv/DebugHome.kt @@ -0,0 +1,8 @@ +package org.justinvisser.wolfskin.tv + +import android.content.Context +import org.justinvisser.wolfskin.design.HomeSnapshot + +/** Release Home always reads the selected Jellyfin identity, never prototype data. */ +@Suppress("UNUSED_PARAMETER") +fun debugHomeSnapshot(context: Context, person: String): HomeSnapshot? = null diff --git a/app/src/test/java/com/github/damontecres/wholphin/TestMainActivityViewModel.kt b/app/src/test/java/com/github/damontecres/wholphin/TestMainActivityViewModel.kt new file mode 100644 index 00000000..1de5becd --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/TestMainActivityViewModel.kt @@ -0,0 +1,432 @@ +package com.github.damontecres.wholphin + +import android.content.Context +import android.content.Intent +import android.widget.Toast +import androidx.datastore.core.DataStore +import com.github.damontecres.wholphin.data.CurrentUser +import com.github.damontecres.wholphin.data.RestoredSession +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.update +import com.github.damontecres.wholphin.services.AppUpgradeHandler +import com.github.damontecres.wholphin.services.BackdropService +import com.github.damontecres.wholphin.services.DeviceProfileService +import com.github.damontecres.wholphin.services.IntentResult +import com.github.damontecres.wholphin.services.IntentService +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.SetupDestination +import com.github.damontecres.wholphin.services.SetupNavigationManager +import com.github.damontecres.wholphin.test.currentUser +import com.github.damontecres.wholphin.test.server +import com.github.damontecres.wholphin.test.user +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.util.WholphinDispatchers +import com.github.damontecres.wholphin.util.configure +import com.github.damontecres.wholphin.util.reset +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.slot +import io.mockk.unmockkStatic +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import org.jellyfin.sdk.model.UUID +import org.junit.After +import org.junit.Assert +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class TestMainActivityViewModel { + private val testDispatcher = StandardTestDispatcher() + + private val context: Context = mockk() + private val preferences: DataStore<AppPreferences> = mockk() + private val serverRepository: ServerRepository = mockk() + private val setupNavigationManager: SetupNavigationManager = mockk(relaxed = true) + private val navigationManager: NavigationManager = mockk(relaxed = true) + private val deviceProfileService: DeviceProfileService = mockk() + private val backdropService: BackdropService = mockk(relaxed = true) + private val appUpgradeHandler: AppUpgradeHandler = mockk() + private val intentService: IntentService = mockk() + + val viewModel = + MainActivityViewModel( + context, + preferences, + serverRepository, + setupNavigationManager, + navigationManager, + deviceProfileService, + backdropService, + appUpgradeHandler, + intentService, + ) + + private val serverId = UUID.randomUUID() + private val server = server(serverId) + private val userId = UUID.randomUUID() + private val currentUser = currentUser(serverId, userId) + + private val protectedCurrentUser = + CurrentUser( + server(serverId), + user(serverId, userId).copy(pin = "1234"), + ) + + @Before + fun setUp() { + WholphinDispatchers.configure(testDispatcher) + every { appUpgradeHandler.needUpgrade() } returns false + every { appUpgradeHandler.copySubfont(any()) } returns Unit + mockkStatic(Toast::class) + val mockToast = mockk<Toast>() + every { Toast.makeText(any(), any<CharSequence>(), any()) } returns mockToast + every { mockToast.show() } returns Unit + } + + @After + fun tearDown() { + WholphinDispatchers.reset() + unmockkStatic(Toast::class) + } + + private fun setupPreferences(block: AppPreferences.Builder.() -> Unit) { + every { preferences.data } returns + flow { + emit( + AppPreferences.getDefaultInstance().update(block), + ) + } + } + + @Test + fun `Test auto sign in with unprotected profile`() = + runTest(testDispatcher) { + setupPreferences { + signInAutomatically = true + } + every { serverRepository.current } returns MutableStateFlow(currentUser) +// coEvery { serverRepository.tryRestoreSession(serverId, userId) } returns currentUser +// coEvery { serverRepository.restoreLastSession() } returns +// RestoredSession.Success(currentUser) + + viewModel.handleAppStart() + + val slot = slot<SetupDestination>() + verify { setupNavigationManager.navigateTo(capture(slot)) } + slot.captured.let { destination -> + Assert.assertTrue( + "destination is ${destination::class}", + destination is SetupDestination.AppContent, + ) + destination as SetupDestination.AppContent + Assert.assertEquals(currentUser, destination.current) + } + coVerify(exactly = 0) { serverRepository.restoreLastSession() } + coVerify(exactly = 0) { serverRepository.getMostRecentServer() } + } + + @Test + fun `Test auto sign disabled in with current server`() = + runTest { + setupPreferences { + signInAutomatically = false + } + every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(currentUser) +// coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser + coEvery { serverRepository.getMostRecentServer() } returns + RestoredSession.ServerOnly(server) + + viewModel.handleAppStart() + + coVerify(exactly = 0) { serverRepository.tryChangeUser(any(), any()) } + coVerify(exactly = 0) { serverRepository.restoreLastSession() } + coVerify(exactly = 1) { serverRepository.getMostRecentServer() } + val args = mutableListOf<SetupDestination>() + verify { setupNavigationManager.navigateTo(capture(args)) } + Assert.assertEquals(1, args.size) + args[0].let { destination -> + Assert.assertTrue( + "destination is ${destination::class}", + destination is SetupDestination.UserList, + ) + destination as SetupDestination.UserList + Assert.assertEquals(currentUser.server, destination.server) + } + } + + @Test + fun `Test auto sign in, hot load, with protected profile`() = + runTest { + setupPreferences { + signInAutomatically = true + } + every { serverRepository.current } returns + MutableStateFlow<CurrentUser?>( + protectedCurrentUser, + ) + coEvery { + serverRepository.tryChangeUser(serverId, userId) + } returns null + coEvery { serverRepository.restoreLastSession() } returns + RestoredSession.ServerOnly(server) + + viewModel.handleAppStart() + + coVerify(exactly = 0) { serverRepository.tryChangeUser(serverId, userId) } + val args = mutableListOf<SetupDestination>() + verify { setupNavigationManager.navigateTo(capture(args)) } + Assert.assertEquals(1, args.size) + args[0].let { destination -> + Assert.assertTrue( + "destination is ${destination::class}", + destination is SetupDestination.UserList, + ) + destination as SetupDestination.UserList + Assert.assertEquals(currentUser.server, destination.server) + } + } + + @Test + fun `Test auto sign in, cold load, with protected profile`() = + runTest { + setupPreferences { + signInAutomatically = true + } + every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(null) + coEvery { serverRepository.restoreLastSession() } returns + RestoredSession.ServerOnly(server) + coEvery { + serverRepository.tryChangeUser(serverId, userId) + } returns null + + viewModel.handleAppStart() + + coVerify(exactly = 1) { serverRepository.restoreLastSession() } + val slot = slot<SetupDestination>() + verify { setupNavigationManager.navigateTo(capture(slot)) } + slot.captured.let { destination -> + Assert.assertTrue( + "destination is ${destination::class}", + destination is SetupDestination.UserList, + ) + destination as SetupDestination.UserList + Assert.assertEquals(currentUser.server, destination.server) + } + } + + @Test + fun `Test auto sign in, no current user`() = + runTest { + setupPreferences { + signInAutomatically = true + } + every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(null) + coEvery { serverRepository.restoreLastSession() } returns RestoredSession.None + + viewModel.handleAppStart() + + coVerify(exactly = 1) { serverRepository.restoreLastSession() } + val slot = slot<SetupDestination>() + verify { setupNavigationManager.navigateTo(capture(slot)) } + slot.captured.let { destination -> + Assert.assertTrue( + "destination is ${destination::class}", + destination is SetupDestination.ServerList, + ) + } + } + + @Test + fun `Test intent result is noop, hot reload`() = + runTest(testDispatcher) { + setupPreferences { + signInAutomatically = true + } + every { serverRepository.current } returns MutableStateFlow(currentUser) + coEvery { serverRepository.restoreLastSession() } returns + RestoredSession.Success(currentUser) + coEvery { intentService.parseIntent(any()) } returns IntentResult.NoOp + + val result = viewModel.handleIntent(Intent()) + assertFalse(result) + +// val slot = slot<SetupDestination>() +// verify { setupNavigationManager.navigateTo(capture(slot)) } +// slot.captured.let { destination -> +// Assert.assertTrue( +// "destination is ${destination::class}", +// destination is SetupDestination.AppContent, +// ) +// destination as SetupDestination.AppContent +// Assert.assertEquals(currentUser, destination.current) +// } + coVerify(exactly = 0) { serverRepository.restoreLastSession() } + coVerify(exactly = 0) { serverRepository.getMostRecentServer() } + } + + @Test + fun `Test intent result is error, has server`() = + runTest(testDispatcher) { + setupPreferences { + signInAutomatically = true + } + every { serverRepository.current } returns MutableStateFlow(currentUser) + coEvery { intentService.parseIntent(any()) } returns IntentResult.Error("Error") + + val result = viewModel.handleIntent(Intent()) + assertTrue(result) + + val slot = slot<SetupDestination>() + verify { setupNavigationManager.navigateTo(capture(slot)) } + slot.captured.let { destination -> + Assert.assertTrue( + "destination is ${destination::class}", + destination is SetupDestination.UserList, + ) + destination as SetupDestination.UserList + Assert.assertEquals(currentUser.server, destination.server) + } + } + + @Test + fun `Test intent result is error, does not have server`() = + runTest(testDispatcher) { + setupPreferences { + signInAutomatically = true + } + every { serverRepository.current } returns MutableStateFlow(null) + coEvery { intentService.parseIntent(any()) } returns IntentResult.Error("Error") + + val result = viewModel.handleIntent(Intent()) + assertTrue(result) + + val slot = slot<SetupDestination>() + verify { setupNavigationManager.navigateTo(capture(slot)) } + slot.captured.let { destination -> + Assert.assertTrue( + "destination is ${destination::class}", + destination is SetupDestination.ServerList, + ) + } + } + + @Test + fun `Test intent result is target, has server`() = + runTest(testDispatcher) { + setupPreferences { + signInAutomatically = true + } + every { serverRepository.current } returns MutableStateFlow(currentUser) +// coEvery { serverRepository.tryRestoreSession(serverId, userId) } returns currentUser + coEvery { intentService.parseIntent(any()) } returns + IntentResult.Target( + destinations = listOf(Destination.Favorites), + addHomeToBackStack = true, + ) + val backstack = mutableListOf<Destination>() + every { navigationManager.backStack } returns backstack + + val result = viewModel.handleIntent(Intent()) + assertTrue(result) + + val slot = slot<SetupDestination>() + verify { setupNavigationManager.navigateTo(capture(slot)) } + verify { navigationManager.reloadHome() } + slot.captured.let { destination -> + Assert.assertTrue( + "destination is ${destination::class}", + destination is SetupDestination.AppContent, + ) + destination as SetupDestination.AppContent + Assert.assertEquals(currentUser, destination.current) + } + // Note: navigation manager internally handles Home destination, so it won't be in this list + Assert.assertEquals(1, backstack.size) + backstack[0].let { destination -> + Assert.assertTrue( + "destination is ${destination::class}", + destination is Destination.Favorites, + ) + } + } + + @Test + fun `Test intent result is target, has server, no home backstack`() = + runTest(testDispatcher) { + setupPreferences { + signInAutomatically = true + } + every { serverRepository.current } returns MutableStateFlow(currentUser) +// coEvery { serverRepository.tryRestoreSession(serverId, userId) } returns currentUser + coEvery { intentService.parseIntent(any()) } returns + IntentResult.Target( + destinations = listOf(Destination.Favorites), + addHomeToBackStack = false, + ) + val backstack = mutableListOf<Destination>() + every { navigationManager.backStack } returns backstack + + val result = viewModel.handleIntent(Intent()) + assertTrue(result) + + val slot = slot<SetupDestination>() + verify { setupNavigationManager.navigateTo(capture(slot)) } + verify(exactly = 0) { navigationManager.reloadHome() } + slot.captured.let { destination -> + Assert.assertTrue( + "destination is ${destination::class}", + destination is SetupDestination.AppContent, + ) + destination as SetupDestination.AppContent + Assert.assertEquals(currentUser, destination.current) + } + Assert.assertEquals(1, backstack.size) + backstack[0].let { destination -> + Assert.assertTrue( + "destination is ${destination::class}", + destination is Destination.Favorites, + ) + } + } + + @Test + fun `Test intent result is target, has no server`() = + runTest(testDispatcher) { + setupPreferences { + signInAutomatically = true + } + every { serverRepository.current } returns MutableStateFlow(null) +// coEvery { serverRepository.tryRestoreSession(serverId, userId) } returns currentUser + coEvery { intentService.parseIntent(any()) } returns + IntentResult.Target( + destinations = listOf(Destination.Favorites), + addHomeToBackStack = true, + ) + val backstack = mutableListOf<Destination>() + every { navigationManager.backStack } returns backstack + + val result = viewModel.handleIntent(Intent()) + assertTrue(result) + + val slot = slot<SetupDestination>() + verify { setupNavigationManager.navigateTo(capture(slot)) } + verify(exactly = 0) { navigationManager.reloadHome() } + slot.captured.let { destination -> + Assert.assertTrue( + "destination is ${destination::class}", + destination is SetupDestination.ServerList, + ) + } + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/WolfskinDefaultsTest.kt b/app/src/test/java/com/github/damontecres/wholphin/WolfskinDefaultsTest.kt new file mode 100644 index 00000000..14d59cf2 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/WolfskinDefaultsTest.kt @@ -0,0 +1,17 @@ +package com.github.damontecres.wholphin + +import org.junit.Assert.assertEquals +import org.junit.Test + +class WolfskinDefaultsTest { + @Test + fun emptyUrlIsPrefilledWithWolfskinSeerrUrl() { + assertEquals(WolfskinDefaults.SEERR_URL, seerrUrlPrefill()) + assertEquals(WolfskinDefaults.SEERR_URL, seerrUrlPrefill(" ")) + } + + @Test + fun enteredUrlIsKept() { + assertEquals("https://seerr.example.org", seerrUrlPrefill("https://seerr.example.org")) + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/data/model/AlphabetSortNameTest.kt b/app/src/test/java/com/github/damontecres/wholphin/data/model/AlphabetSortNameTest.kt new file mode 100644 index 00000000..0b18cc8e --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/data/model/AlphabetSortNameTest.kt @@ -0,0 +1,36 @@ +package com.github.damontecres.wholphin.data.model + +import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.BaseItemKind +import org.junit.Assert.assertEquals +import org.junit.Test +import java.util.UUID + +class AlphabetSortNameTest { + @Test + fun prefersSortNameOverName() { + val dto = + BaseItemDto( + id = UUID.randomUUID(), + type = BaseItemKind.MUSIC_ARTIST, + name = "The Beatles", + sortName = "beatles", + ) + + assertEquals("beatles", dto.alphabetSortName) + assertEquals("beatles", BaseItem(dto).sortName) + } + + @Test + fun fallsBackToNameWhenSortNameMissing() { + val dto = + BaseItemDto( + id = UUID.randomUUID(), + type = BaseItemKind.STUDIO, + name = "The Walt Disney Company", + sortName = null, + ) + + assertEquals("The Walt Disney Company", dto.alphabetSortName) + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/data/model/BaseItemSubtitleTest.kt b/app/src/test/java/com/github/damontecres/wholphin/data/model/BaseItemSubtitleTest.kt new file mode 100644 index 00000000..11006322 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/data/model/BaseItemSubtitleTest.kt @@ -0,0 +1,48 @@ +package com.github.damontecres.wholphin.data.model + +import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.BaseItemKind +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import java.util.UUID + +class BaseItemSubtitleTest { + private fun album( + albumArtist: String? = null, + artists: List<String>? = null, + productionYear: Int? = null, + ) = BaseItem( + BaseItemDto( + id = UUID.randomUUID(), + type = BaseItemKind.MUSIC_ALBUM, + name = "Test Album", + albumArtist = albumArtist, + artists = artists, + productionYear = productionYear, + ), + ) + + @Test + fun albumPrefersAlbumArtist() { + val item = album(albumArtist = "Album Artist", artists = listOf("A", "B"), productionYear = 2021) + assertEquals("Album Artist - 2021", item.subtitle) + } + + @Test + fun albumFallsBackToArtists() { + val item = album(artists = listOf("A", "B"), productionYear = 2021) + assertEquals("A, B - 2021", item.subtitle) + } + + @Test + fun albumWithoutYear() { + assertEquals("Album Artist", album(albumArtist = "Album Artist").subtitle) + } + + @Test + fun albumWithoutArtist() { + assertEquals("2021", album(productionYear = 2021).subtitle) + assertNull(album(artists = emptyList()).subtitle) + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/data/model/RelativeMediaStreamTest.kt b/app/src/test/java/com/github/damontecres/wholphin/data/model/RelativeMediaStreamTest.kt new file mode 100644 index 00000000..283ee8ca --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/data/model/RelativeMediaStreamTest.kt @@ -0,0 +1,149 @@ +package com.github.damontecres.wholphin.data.model + +import com.github.damontecres.wholphin.test.TestTracks +import com.github.damontecres.wholphin.test.assertNotNull +import org.junit.Assert.assertEquals +import org.junit.Test + +class RelativeMediaStreamTest { + @Test + fun `Test getRelativeIndex`() { + val source = + TestTracks + .Builder() + .addSubtitle(2) + .addAudio(2) + .addVideo() + .buildMediaSourceInfo() + val streams = source.mediaStreams!! + RelativeMediaStream.getRelativeIndex(0, streams).let { relativeIndex -> + assertNotNull(relativeIndex) + assertEquals(0, relativeIndex) + } + RelativeMediaStream.getRelativeIndex(1, streams).let { relativeIndex -> + assertNotNull(relativeIndex) + assertEquals(1, relativeIndex) + } + RelativeMediaStream.getRelativeIndex(2, streams).let { relativeIndex -> + assertNotNull(relativeIndex) + assertEquals(0, relativeIndex) + } + RelativeMediaStream.getRelativeIndex(3, streams).let { relativeIndex -> + assertNotNull(relativeIndex) + assertEquals(1, relativeIndex) + } + } + + @Test + fun `Test getRelativeIndex for stream`() { + val source = + TestTracks + .Builder() + .addSubtitle(2) + .addAudio(2) + .addVideo() + .buildMediaSourceInfo() + val streams = source.mediaStreams!! + RelativeMediaStream.getRelativeIndex(streams[1], streams).let { relativeIndex -> + assertNotNull(relativeIndex) + assertEquals(1, relativeIndex) + } + } + + @Test + fun `Test asRelative basic`() { + val source = + TestTracks + .Builder() + .addSubtitle(2) + .addAudio(2) + .addVideo() + .buildMediaSourceInfo() + val streams = source.mediaStreams!! + assertEquals(5, streams.size) + + val relative = streams.asRelative() + // Subtitle + assertEquals(0, relative[0].relativeIndex) + assertEquals(0, relative[0].stream.index) + assertEquals(1, relative[1].relativeIndex) + assertEquals(1, relative[1].stream.index) + + // Audio + assertEquals(0, relative[2].relativeIndex) + assertEquals(2, relative[2].stream.index) + assertEquals(1, relative[3].relativeIndex) + assertEquals(3, relative[3].stream.index) + + // Video + assertEquals(0, relative[4].relativeIndex) + assertEquals(4, relative[4].stream.index) + } + + @Test + fun `Test asRelative with external`() { + val source = + TestTracks + .Builder() + .addExternalSubtitle(2) + .addSubtitle(2) + .addAudio(2) + .buildMediaSourceInfo() + val streams = source.mediaStreams!! + assertEquals(6, streams.size) + + val relative = streams.asRelative() + // External Subtitle + assertEquals(0, relative[0].relativeIndex) + assertEquals(0, relative[0].stream.index) + assertEquals(1, relative[1].relativeIndex) + assertEquals(1, relative[1].stream.index) + + // Subtitle + assertEquals(0, relative[2].relativeIndex) + assertEquals(2, relative[2].stream.index) + assertEquals(1, relative[3].relativeIndex) + assertEquals(3, relative[3].stream.index) + + // Audio + assertEquals(0, relative[4].relativeIndex) + assertEquals(4, relative[4].stream.index) + assertEquals(1, relative[5].relativeIndex) + assertEquals(5, relative[5].stream.index) + } + + @Test + fun `Test asRelative with external mixed`() { + val source = + TestTracks + .Builder() + .addExternalSubtitle(1) + .addSubtitle(1) + .addAudio(1) + .addExternalSubtitle(1) + .addAudio(1) + .addSubtitle(1) + .buildMediaSourceInfo() + val streams = source.mediaStreams!! + assertEquals(6, streams.size) + + val relative = streams.asRelative() + // External Subtitle + assertEquals(0, relative[0].relativeIndex) + assertEquals(0, relative[0].stream.index) + assertEquals(1, relative[3].relativeIndex) + assertEquals(3, relative[3].stream.index) + + // Subtitle + assertEquals(0, relative[1].relativeIndex) + assertEquals(1, relative[1].stream.index) + assertEquals(1, relative[5].relativeIndex) + assertEquals(5, relative[5].stream.index) + + // Audio + assertEquals(0, relative[2].relativeIndex) + assertEquals(2, relative[2].stream.index) + assertEquals(1, relative[4].relativeIndex) + assertEquals(4, relative[4].stream.index) + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/data/model/SourceRatingsTest.kt b/app/src/test/java/com/github/damontecres/wholphin/data/model/SourceRatingsTest.kt new file mode 100644 index 00000000..e5c12a56 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/data/model/SourceRatingsTest.kt @@ -0,0 +1,204 @@ +package com.github.damontecres.wholphin.data.model + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class SourceRatingsTest { + @Test + fun movieShowsRtCriticsRtAudienceAndImdbButNotTmdb() { + val ratings = + SourceRatings.of( + rtCriticsScore = 96, + rtCriticsLabel = "Certified Fresh", + rtAudienceScore = 90, + rtAudienceLabel = "Upright", + imdbScore = 8.8, + imdbVotes = 2_500_000, + tmdbScore = 8.4, + tmdbVotes = 30_000, + ) + assertEquals(RtScore(96, RtMark.FRESH), ratings.rtCritics) + assertEquals(RtScore(90, RtMark.UPRIGHT), ratings.rtAudience) + assertEquals(8.8, ratings.imdb!!, 0.0) + assertNull(ratings.tmdb) + } + + @Test + fun tmdbIsShownOnlyWhenNothingElseIs() { + val ratings = SourceRatings.of(tmdbScore = 7.633, tmdbVotes = 120) + assertEquals(7.633, ratings.tmdb!!, 0.0) + assertNull(ratings.rtCritics) + assertNull(ratings.imdb) + assertEquals(listOf("7.6"), ratings.communityEntries.map { it.text }) + + // A single non-TMDB source hides TMDB + assertNull(SourceRatings.of(rtAudienceScore = 40, tmdbScore = 7.0, tmdbVotes = 10).tmdb) + assertNull(SourceRatings.of(imdbScore = 5.0, imdbVotes = 10, tmdbScore = 7.0, tmdbVotes = 10).tmdb) + } + + @Test + fun zeroVoteScoresAreUnratedNotZero() { + val ratings = SourceRatings.of(imdbScore = 0.0, imdbVotes = 0, tmdbScore = 0.0, tmdbVotes = 0) + assertTrue(ratings.isEmpty) + + // A zero-vote IMDb score doesn't count as a source, so TMDB can still be the fallback + val withTmdb = SourceRatings.of(imdbScore = 6.5, imdbVotes = 0, tmdbScore = 7.1, tmdbVotes = 55) + assertNull(withTmdb.imdb) + assertEquals(7.1, withTmdb.tmdb!!, 0.0) + } + + @Test + fun missingVoteCountIsStillObserved() { + assertEquals(8.8, SourceRatings.of(imdbScore = 8.8).imdb!!, 0.0) + } + + @Test + fun outOfRangeScoresAreDropped() { + assertTrue(SourceRatings.of(rtCriticsScore = 101, rtAudienceScore = -1).isEmpty) + assertTrue(SourceRatings.of(imdbScore = 10.5, imdbVotes = 5, tmdbScore = -0.1, tmdbVotes = 5).isEmpty) + assertTrue(SourceRatings.of(imdbScore = 7.0, imdbVotes = -1).isEmpty) + } + + @Test + fun rtMarkComesFromSeerrLabelNotThePercentage() { + // 95% but labelled Rotten and 10% labelled Fresh: the label wins + assertEquals(RtMark.ROTTEN, SourceRatings.of(rtCriticsScore = 95, rtCriticsLabel = "Rotten").rtCritics!!.mark) + assertEquals(RtMark.FRESH, SourceRatings.of(rtCriticsScore = 10, rtCriticsLabel = "Fresh").rtCritics!!.mark) + assertEquals(RtMark.FRESH, SourceRatings.of(rtCriticsScore = 80, rtCriticsLabel = "Certified Fresh").rtCritics!!.mark) + assertEquals(RtMark.UPRIGHT, SourceRatings.of(rtAudienceScore = 20, rtAudienceLabel = "Upright").rtAudience!!.mark) + assertEquals(RtMark.SPILLED, SourceRatings.of(rtAudienceScore = 90, rtAudienceLabel = "Spilled").rtAudience!!.mark) + } + + @Test + fun rtWithoutKnownLabelUsesPwaDefaults() { + assertEquals(RtMark.UNKNOWN, SourceRatings.of(rtCriticsScore = 90).rtCritics!!.mark) + assertEquals(RtMark.UNKNOWN, SourceRatings.of(rtCriticsScore = 90, rtCriticsLabel = "Mystery").rtCritics!!.mark) + assertEquals(RtMark.UPRIGHT, SourceRatings.of(rtAudienceScore = 90).rtAudience!!.mark) + } + + @Test + fun statusLabelMapping() { + assertEquals(RtMark.FRESH, RtMark.fromLabel("Certified Fresh")) + assertEquals(RtMark.FRESH, RtMark.fromLabel("Fresh")) + assertEquals(RtMark.ROTTEN, RtMark.fromLabel("Rotten")) + assertEquals(RtMark.UPRIGHT, RtMark.fromLabel("Upright")) + assertEquals(RtMark.SPILLED, RtMark.fromLabel("Spilled")) + assertNull(RtMark.fromLabel(null)) + assertNull(RtMark.fromLabel("fresh")) + } + + @Test + fun entriesUseThePwaTextAndBadges() { + val ratings = + SourceRatings.of( + rtCriticsScore = 85, + rtCriticsLabel = "Fresh", + rtAudienceScore = 40, + rtAudienceLabel = "Spilled", + imdbScore = 8.0, + imdbVotes = 1, + ) + assertEquals(listOf("85%", "40%"), ratings.criticEntries.map { it.text }) + assertEquals(listOf(RatingBadge.RT_FRESH, RatingBadge.RT_SPILLED), ratings.criticEntries.map { it.badge }) + assertEquals(listOf("8.0"), ratings.communityEntries.map { it.text }) + assertEquals(listOf(RatingBadge.IMDB), ratings.communityEntries.map { it.badge }) + } + + @Test + fun everyRtMarkHasItsOwnBadge() { + val badges = + listOf( + RtMark.FRESH to RatingBadge.RT_FRESH, + RtMark.ROTTEN to RatingBadge.RT_ROTTEN, + RtMark.UPRIGHT to RatingBadge.RT_UPRIGHT, + RtMark.SPILLED to RatingBadge.RT_SPILLED, + RtMark.UNKNOWN to RatingBadge.RT_UNKNOWN, + ) + badges.forEach { (mark, badge) -> + assertEquals(badge, RatingEntry.RottenTomatoes(RtScore(50, mark), true).badge) + } + } + + private val movie = + SourceRatings.of( + rtCriticsScore = 96, + rtCriticsLabel = "Certified Fresh", + rtAudienceScore = 59, + rtAudienceLabel = "Spilled", + imdbScore = 8.8, + imdbVotes = 10, + ) + + @Test + fun visibleEntriesAreInPwaOrderAndFollowTheDisplaySettings() { + // Rotten Tomatoes (critic rating setting) before IMDb (community rating setting) + assertEquals( + listOf("96%", "59%", "8.8"), + movie.visibleEntries(community = true, critic = true).map { it.text }, + ) + assertEquals(listOf("8.8"), movie.visibleEntries(community = true, critic = false).map { it.text }) + assertEquals(listOf("96%", "59%"), movie.visibleEntries(community = false, critic = true).map { it.text }) + assertTrue(movie.visibleEntries(community = false, critic = false).isEmpty()) + } + + @Test + fun tmdbIsTheCommunityEntryWhenItIsTheOnlySource() { + val entries = SourceRatings.of(tmdbScore = 7.6, tmdbVotes = 100).visibleEntries(community = true, critic = true) + assertEquals(listOf(RatingBadge.TMDB), entries.map { it.badge }) + assertEquals(listOf("7.6"), entries.map { it.text }) + } + + @Test + fun noEntriesWithoutRatings() { + assertTrue(SourceRatings().isEmpty) + assertTrue(SourceRatings().visibleEntries(community = true, critic = true).isEmpty()) + assertFalse(movie.isEmpty) + } + + @Test + fun episodeScoreFollowsThePwaRulesForAUserScore() { + assertEquals(7.9, SourceRatings.episodeScore(7.9, 120)!!, 0.0) + // unrated, never 0 + assertNull(SourceRatings.episodeScore(0.0, 0)) + assertNull(SourceRatings.episodeScore(8.0, 0)) + assertNull(SourceRatings.episodeScore(8.0, null)) + // not observed or out of range + assertNull(SourceRatings.episodeScore(null, 10)) + assertNull(SourceRatings.episodeScore(10.5, 10)) + assertNull(SourceRatings.episodeScore(-1.0, 10)) + assertEquals(10.0, SourceRatings.episodeScore(10.0, 1)!!, 0.0) + } + + @Test + fun episodeKeepsTheSeriesRottenTomatoesAndHasOneLabelledTmdbNumber() { + val series = + SourceRatings.of(rtCriticsScore = 63, rtCriticsLabel = "Fresh", rtAudienceScore = 71, rtAudienceLabel = "Upright", tmdbScore = 7.6, tmdbVotes = 3000) + // the series only has its TMDB score because it has no other source + val withTmdbOnly = SourceRatings.of(tmdbScore = 7.6, tmdbVotes = 3000) + assertEquals(7.6, withTmdbOnly.tmdb!!, 0.0) + + val episode = withTmdbOnly.withEpisode(8.2) + assertNull(episode.tmdb) + val entries = episode.visibleEntries(community = true, critic = true) + assertEquals(listOf(RatingBadge.TMDB), entries.map { it.badge }) + assertEquals(listOf("8.2"), entries.map { it.text }) + assertEquals(listOf(true), entries.map { it.isEpisode }) + + val full = series.withEpisode(8.2).visibleEntries(community = true, critic = true) + assertEquals(listOf("63%", "71%", "8.2"), full.map { it.text }) + assertEquals(listOf(false, false, true), full.map { it.isEpisode }) + // the display settings treat it as a community (IMDb/TMDB) rating + assertEquals(listOf("8.2"), series.withEpisode(8.2).visibleEntries(community = true, critic = false).map { it.text }) + assertEquals(listOf("63%", "71%"), series.withEpisode(8.2).visibleEntries(community = false, critic = true).map { it.text }) + } + + @Test + fun withoutAnEpisodeScoreTheSeriesRatingsAreUnchanged() { + val series = SourceRatings.of(tmdbScore = 7.6, tmdbVotes = 10) + assertEquals(series, series.withEpisode(null)) + assertFalse(SourceRatings().withEpisode(5.0).isEmpty) + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/ExtrasServiceTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/ExtrasServiceTest.kt new file mode 100644 index 00000000..3998201f --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/services/ExtrasServiceTest.kt @@ -0,0 +1,183 @@ +package com.github.damontecres.wholphin.services + +import android.content.Context +import android.content.res.Resources +import com.github.damontecres.wholphin.data.ExtrasItem +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.ui.successResponse +import com.github.damontecres.wholphin.util.WholphinDispatchers +import com.github.damontecres.wholphin.util.configure +import com.github.damontecres.wholphin.util.reset +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +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.UUID +import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.ExtraType +import org.jellyfin.sdk.model.api.ImageType +import org.junit.After +import org.junit.Assert +import org.junit.Before +import org.junit.Test + +class ExtrasServiceTest { + private val testDispatcher = StandardTestDispatcher() + + private val mockContext = mockk<Context>() + private val mockApiClient = mockk<ApiClient>() + private val mockImageUrlService = mockk<ImageUrlService>() + + private val mockUserLibraryApi = mockk<UserLibraryApi>() + private val mockResources = mockk<Resources>() + + private val extrasService = ExtrasService(mockApiClient, mockContext, mockImageUrlService) + + @OptIn(ExperimentalCoroutinesApi::class) + @Before + fun setup() { + WholphinDispatchers.configure(testDispatcher) + + every { mockApiClient.userLibraryApi } returns mockUserLibraryApi + every { mockContext.resources } returns mockResources + every { mockResources.getString(any()) } returns "Interviews" + every { mockResources.getQuantityString(any(), any()) } returns "Interviews" + every { mockResources.getQuantityString(any(), any(), any<Int>()) } returns "Interviews" + + every { mockImageUrlService.getItemImageUrl(any<BaseItem>(), ImageType.PRIMARY, any(), any(), any()) } returns + "https://localhost/image.jpg" + } + + @OptIn(ExperimentalCoroutinesApi::class) + @After + fun tearDown() { + WholphinDispatchers.reset() + } + + @Test + fun `Test grouping extras`() = + runTest { + val extrasDtos = + List(3) { + BaseItemDto( + id = UUID.randomUUID(), + type = BaseItemKind.VIDEO, + extraType = ExtraType.INTERVIEW, + ) + } + coEvery { mockUserLibraryApi.getSpecialFeatures(any(), any()) } returns + successResponse(extrasDtos) + + val extras = extrasService.getExtras(UUID.randomUUID()) + Assert.assertEquals(1, extras.size) + val extra = extras.first() + Assert.assertTrue(extra is ExtrasItem.Group) + extra as ExtrasItem.Group + val extrasIds = extra.items.map { it.id } + Assert.assertEquals(extrasDtos.map { it.id }, extrasIds) + Assert.assertEquals(extra.type, ExtraType.INTERVIEW) + } + + @Test + fun `Test grouping extras with multiple types`() = + runTest { + val extrasDtos = + List(3) { + BaseItemDto( + id = UUID.randomUUID(), + type = BaseItemKind.VIDEO, + extraType = ExtraType.INTERVIEW, + ) + } + + List(3) { + BaseItemDto( + id = UUID.randomUUID(), + type = BaseItemKind.VIDEO, + extraType = ExtraType.CLIP, + ) + } + + coEvery { mockUserLibraryApi.getSpecialFeatures(any(), any()) } returns + successResponse(extrasDtos) + + val extras = extrasService.getExtras(UUID.randomUUID()) + Assert.assertEquals(2, extras.size) + extras.forEachIndexed { index, item -> + Assert.assertTrue("index=$index", item is ExtrasItem.Group) + } + val extrasIds = extras.flatMap { (it as ExtrasItem.Group).items.map { it.id } }.toSet() + Assert.assertEquals(extrasDtos.map { it.id }.toSet(), extrasIds) + Assert.assertEquals(extras[0].type, ExtraType.CLIP) + Assert.assertEquals(extras[1].type, ExtraType.INTERVIEW) + } + + @Test + fun `Test extras not grouped`() = + runTest { + val extrasDtos = + listOf( + BaseItemDto( + id = UUID.randomUUID(), + type = BaseItemKind.VIDEO, + extraType = ExtraType.INTERVIEW, + ), + BaseItemDto( + id = UUID.randomUUID(), + type = BaseItemKind.VIDEO, + extraType = ExtraType.CLIP, + ), + ) + + coEvery { mockUserLibraryApi.getSpecialFeatures(any(), any()) } returns + successResponse(extrasDtos) + + val extras = extrasService.getExtras(UUID.randomUUID()) + Assert.assertEquals(2, extras.size) + extras.forEachIndexed { index, item -> + Assert.assertTrue("index=$index", item is ExtrasItem.Single) + } + val extrasIds = extras.map { (it as ExtrasItem.Single).item.id }.toSet() + Assert.assertEquals(extrasDtos.map { it.id }.toSet(), extrasIds) + Assert.assertEquals(extras[0].type, ExtraType.CLIP) + Assert.assertEquals(extras[1].type, ExtraType.INTERVIEW) + } + + @Test + fun `Test mixed grouping`() = + runTest { + val extrasDtos = + listOf( + BaseItemDto( + id = UUID.randomUUID(), + type = BaseItemKind.VIDEO, + extraType = ExtraType.CLIP, + ), + ) + + List(3) { + BaseItemDto( + id = UUID.randomUUID(), + type = BaseItemKind.VIDEO, + extraType = ExtraType.INTERVIEW, + ) + } + + coEvery { mockUserLibraryApi.getSpecialFeatures(any(), any()) } returns + successResponse(extrasDtos) + + val extras = extrasService.getExtras(UUID.randomUUID()) + Assert.assertEquals(2, extras.size) + Assert.assertTrue(extras[0] is ExtrasItem.Single) + Assert.assertTrue(extras[1] is ExtrasItem.Group) + + val extrasIds = listOf((extras[0] as ExtrasItem.Single).item.id) + (extras[1] as ExtrasItem.Group).items.map { it.id } + Assert.assertEquals(extrasDtos.map { it.id }, extrasIds) + Assert.assertEquals(extras[0].type, ExtraType.CLIP) + Assert.assertEquals(extras[1].type, ExtraType.INTERVIEW) + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/ImageUrlServiceTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/ImageUrlServiceTest.kt new file mode 100644 index 00000000..d179c7e1 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/services/ImageUrlServiceTest.kt @@ -0,0 +1,207 @@ +package com.github.damontecres.wholphin.services + +import com.github.damontecres.wholphin.data.model.BaseItem +import io.mockk.every +import io.mockk.mockk +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.imageApi +import org.jellyfin.sdk.api.operations.ImageApi +import org.jellyfin.sdk.model.UUID +import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.ImageType +import org.jellyfin.sdk.model.serializer.toUUID +import org.junit.Assert +import org.junit.Before +import org.junit.Test + +class ImageUrlServiceTest { + private val mockApiClient = mockk<ApiClient>() + private val imageUrlService = ImageUrlService(mockApiClient) + + private val itemId = UUID.randomUUID() + private val seriesId = UUID.randomUUID() + private val seasonId = UUID.randomUUID() + + @Before + fun setup() { + every { mockApiClient.baseUrl } returns "http://localhost" + every { mockApiClient.createUrl(any(), any(), any(), any()) } answers { callOriginal() } + every { mockApiClient.imageApi } returns ImageApi(mockApiClient) + } + + private fun episode( + imageTags: Map<ImageType, String> = emptyMap(), + parentLogoItemId: UUID? = null, + parentThumbItemId: UUID? = null, + parentBackdropItemId: UUID? = null, + ) = BaseItemDto( + id = itemId, + type = BaseItemKind.EPISODE, + seriesId = seriesId, + seasonId = seasonId, + imageTags = imageTags, + seriesPrimaryImageTag = "seriesPrimary", + parentLogoItemId = parentLogoItemId, + parentLogoImageTag = parentLogoItemId?.let { "parentLogo" }, + parentThumbItemId = parentThumbItemId, + parentThumbImageTag = parentThumbItemId?.let { "parentThumb" }, + parentBackdropItemId = parentBackdropItemId, + parentBackdropImageTags = parentBackdropItemId?.let { listOf("parentBackdrop0", "parentBackdrop1") }, + ) + + private fun movie( + imageTags: Map<ImageType, String> = emptyMap(), + backdropImageTags: List<String>? = null, + ) = BaseItemDto( + id = itemId, + type = BaseItemKind.MOVIE, + imageTags = imageTags, + backdropImageTags = backdropImageTags, + ) + + private fun assertImage( + url: String?, + expectedId: UUID, + expectedType: ImageType, + expectedTag: String?, + ) { + Assert.assertNotNull(url) + val path = url!!.substringBefore('?') + Assert.assertEquals(expectedId, path.substringAfter("/Items/").substringBefore('/').toUUID()) + Assert.assertEquals(expectedType.serialName, path.substringAfter("/Images/")) + val tag = + url + .substringAfter('?', "") + .split('&') + .firstOrNull { it.startsWith("tag=") } + ?.substringAfter("tag=") + Assert.assertEquals(expectedTag, tag) + } + + @Test + fun `Own images use own tags`() { + val item = + BaseItem( + movie( + imageTags = + mapOf( + ImageType.PRIMARY to "primary", + ImageType.LOGO to "logo", + ImageType.THUMB to "thumb", + ImageType.BANNER to "banner", + ImageType.ART to "art", + ), + backdropImageTags = listOf("backdrop0", "backdrop1"), + ), + ) + assertImage(imageUrlService.getItemImageUrl(item, ImageType.PRIMARY), itemId, ImageType.PRIMARY, "primary") + assertImage(imageUrlService.getItemImageUrl(item, ImageType.LOGO), itemId, ImageType.LOGO, "logo") + assertImage(imageUrlService.getItemImageUrl(item, ImageType.THUMB), itemId, ImageType.THUMB, "thumb") + assertImage(imageUrlService.getItemImageUrl(item, ImageType.BANNER), itemId, ImageType.BANNER, "banner") + assertImage(imageUrlService.getItemImageUrl(item, ImageType.ART), itemId, ImageType.ART, "art") + assertImage(imageUrlService.getItemImageUrl(item, ImageType.BACKDROP), itemId, ImageType.BACKDROP, "backdrop0") + } + + @Test + fun `Missing tags are omitted`() { + val item = BaseItem(movie()) + assertImage(imageUrlService.getItemImageUrl(item, ImageType.PRIMARY), itemId, ImageType.PRIMARY, null) + assertImage(imageUrlService.getItemImageUrl(item, ImageType.LOGO), itemId, ImageType.LOGO, null) + Assert.assertNull(imageUrlService.getItemImageUrl(item, ImageType.BACKDROP)) + } + + @Test + fun `Thumb falls back to own backdrop`() { + val item = BaseItem(movie(backdropImageTags = listOf("backdrop0"))) + assertImage(imageUrlService.getItemImageUrl(item, ImageType.THUMB), itemId, ImageType.BACKDROP, "backdrop0") + } + + @Test + fun `Episode primary uses series tag`() { + val item = BaseItem(episode(imageTags = mapOf(ImageType.PRIMARY to "primary")), useSeriesForPrimary = true) + assertImage(imageUrlService.getItemImageUrl(item, ImageType.PRIMARY), seriesId, ImageType.PRIMARY, "seriesPrimary") + assertImage(imageUrlService.getItemImageUrl(item, ImageType.BANNER), seriesId, ImageType.BANNER, null) + assertImage( + imageUrlService.getItemImageUrl(item, ImageType.PRIMARY, useSeriesForPrimary = false), + itemId, + ImageType.PRIMARY, + "primary", + ) + } + + @Test + fun `Season without primary uses series tag`() { + val item = BaseItem(episode().copy(id = seasonId, type = BaseItemKind.SEASON, seasonId = null)) + assertImage(imageUrlService.getItemImageUrl(item, ImageType.PRIMARY), seriesId, ImageType.PRIMARY, "seriesPrimary") + } + + @Test + fun `Episode logo uses parent logo tag only if from series`() { + assertImage( + imageUrlService.getItemImageUrl(BaseItem(episode(parentLogoItemId = seriesId)), ImageType.LOGO), + seriesId, + ImageType.LOGO, + "parentLogo", + ) + assertImage( + imageUrlService.getItemImageUrl(BaseItem(episode(parentLogoItemId = seasonId)), ImageType.LOGO), + seriesId, + ImageType.LOGO, + null, + ) + } + + @Test + fun `Episode backdrop uses parent backdrop tag only if from series`() { + assertImage( + imageUrlService.getItemImageUrl(BaseItem(episode(parentBackdropItemId = seriesId)), ImageType.BACKDROP), + seriesId, + ImageType.BACKDROP, + "parentBackdrop0", + ) + assertImage( + imageUrlService.getItemImageUrl(BaseItem(episode(parentBackdropItemId = seasonId)), ImageType.BACKDROP), + seriesId, + ImageType.BACKDROP, + null, + ) + } + + @Test + fun `Episode thumb uses parent tags`() { + assertImage( + imageUrlService.getItemImageUrl( + BaseItem(episode(parentThumbItemId = seriesId, parentBackdropItemId = seriesId), useSeriesForPrimary = true), + ImageType.THUMB, + ), + seriesId, + ImageType.THUMB, + "parentThumb", + ) + assertImage( + imageUrlService.getItemImageUrl( + BaseItem(episode(parentBackdropItemId = seriesId), useSeriesForPrimary = true), + ImageType.THUMB, + ), + seriesId, + ImageType.BACKDROP, + "parentBackdrop0", + ) + assertImage( + imageUrlService.getItemImageUrl( + BaseItem(episode(imageTags = mapOf(ImageType.PRIMARY to "primary")), useSeriesForPrimary = true), + ImageType.THUMB, + ), + itemId, + ImageType.PRIMARY, + "primary", + ) + } + + @Test + fun `Season without thumb uses parent thumb tag`() { + val item = BaseItem(episode(parentThumbItemId = seriesId).copy(id = seasonId, type = BaseItemKind.SEASON)) + assertImage(imageUrlService.getItemImageUrl(item, ImageType.THUMB), seriesId, ImageType.THUMB, "parentThumb") + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/IntentServiceTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/IntentServiceTest.kt new file mode 100644 index 00000000..2bcc6ab7 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/services/IntentServiceTest.kt @@ -0,0 +1,470 @@ +package com.github.damontecres.wholphin.services + +import android.app.SearchManager +import android.content.Intent +import com.github.damontecres.wholphin.data.CurrentUser +import com.github.damontecres.wholphin.data.RestoredSession +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.preferences.update +import com.github.damontecres.wholphin.test.assertIs +import com.github.damontecres.wholphin.test.currentUser +import com.github.damontecres.wholphin.test.movie +import com.github.damontecres.wholphin.test.server +import com.github.damontecres.wholphin.test.user +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.successResponse +import com.github.damontecres.wholphin.ui.toServerString +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flow +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.UUID +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE, sdk = [30]) +class IntentServiceTest { + private val api: ApiClient = mockk() + private val serverRepository: ServerRepository = mockk() + private val userPreferencesService: UserPreferencesService = mockk() + private val userLibraryApi: UserLibraryApi = mockk() + + lateinit var intentService: IntentService + + private val serverId = UUID.randomUUID() + private val userId = UUID.randomUUID() + private val currentUser = currentUser(serverId, userId) + private val itemId = UUID.randomUUID() + + private val protectedCurrentUser = + CurrentUser( + server(serverId), + user(serverId, userId).copy(pin = "1234"), + ) + + @Before + fun setup() { + intentService = IntentService(api, serverRepository, userPreferencesService) + every { api.userLibraryApi } returns userLibraryApi + coEvery { userLibraryApi.getItem(itemId) } returns successResponse(movie(itemId)) + } + + private fun setupPreferences(block: AppPreferences.Builder.() -> Unit) { + every { userPreferencesService.flow } returns + flow { + emit( + UserPreferences( + AppPreferences.getDefaultInstance().update(block), + null, + ), + ) + } + } + + private fun setupAutoSignInUnprotected() { + setupPreferences { + signInAutomatically = true + } + every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(currentUser) + coEvery { serverRepository.restoreLastSession() } returns + RestoredSession.Success(currentUser) + coEvery { serverRepository.tryChangeUser(serverId, userId) } returns currentUser + } + + @Test + fun `Test auto sign in with unprotected profile`() = + runTest { + setupAutoSignInUnprotected() + + val intent = Intent() + val result = intentService.prepare(intent) + assertNull(result) + + coVerify { serverRepository.restoreLastSession() } + } + + @Test + fun `Test auto sign disabled in with unprotected profile`() = + runTest { + setupPreferences { + signInAutomatically = false + } + every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(currentUser) + coEvery { serverRepository.restoreLastSession() } returns + RestoredSession.Success(currentUser) + coEvery { serverRepository.tryChangeUser(serverId, userId) } returns currentUser + + val intent = Intent() + val result = intentService.prepare(intent) + assertTrue(result is IntentResult.Error) + + coVerify(exactly = 0) { serverRepository.tryChangeUser(serverId, userId) } + } + + @Test + fun `Test auto sign in, hot load, with protected profile`() = + runTest { + setupPreferences { + signInAutomatically = true + } + every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(protectedCurrentUser) + coEvery { serverRepository.tryChangeUser(serverId, userId) } returns null + coEvery { serverRepository.restoreLastSession() } returns + RestoredSession.ServerOnly(protectedCurrentUser.server) + + val intent = Intent() + val result = intentService.prepare(intent) + assertTrue(result is IntentResult.Error) + + coVerify(exactly = 0) { serverRepository.tryChangeUser(serverId, userId) } + } + + @Test + fun `Test auto sign in, cold load, with protected profile`() = + runTest { + setupPreferences { + signInAutomatically = true + } + every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(null) + coEvery { serverRepository.tryChangeUser(serverId, userId) } returns null + coEvery { serverRepository.restoreLastSession() } returns + RestoredSession.ServerOnly(protectedCurrentUser.server) + + val intent = Intent() + val result = intentService.prepare(intent) + assertTrue(result is IntentResult.Error) + + coVerify(exactly = 1) { serverRepository.restoreLastSession() } + } + + @Test + fun `Test auto sign in, no current user`() = + runTest { + setupPreferences { + signInAutomatically = true + } + every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(null) + coEvery { serverRepository.restoreLastSession() } returns RestoredSession.None +// coEvery { serverRepository.restoreSession(serverId, userId) } returns protectedCurrentUser + + val intent = Intent() + val result = intentService.prepare(intent) + assertTrue(result is IntentResult.Error) + + coVerify(exactly = 0) { serverRepository.tryChangeUser(serverId, userId) } + } + + @Test + fun `Test user specified with unprotected profile`() = + runTest { + setupPreferences { + signInAutomatically = true + } + every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(null) + coEvery { serverRepository.tryChangeUser(serverId, userId) } returns currentUser + coEvery { serverRepository.restoreLastSession() } returns + RestoredSession.Success(currentUser) + + val intent = + Intent().apply { + putExtra(IntentService.INTENT_SERVER_ID, serverId.toServerString()) + putExtra(IntentService.INTENT_USER_ID, userId.toServerString()) + } + val result = intentService.prepare(intent) + assertNull(result) + + coVerify(exactly = 1) { serverRepository.tryChangeUser(serverId, userId) } + } + + @Test + fun `Test user specified with protected profile`() = + runTest { + setupPreferences { + signInAutomatically = true + } + every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(null) + coEvery { serverRepository.tryChangeUser(serverId, userId) } returns null +// coEvery { serverRepository.restoreLastSession() } returns +// RestoredSession.ServerOnly(protectedCurrentUser.server) + + val intent = + Intent().apply { + putExtra(IntentService.INTENT_SERVER_ID, serverId.toServerString()) + putExtra(IntentService.INTENT_USER_ID, userId.toServerString()) + } + val result = intentService.prepare(intent) + assertTrue(result is IntentResult.Error) + + coVerify(exactly = 1) { serverRepository.tryChangeUser(serverId, userId) } + } + + @Test + fun `Test user specified does not exist`() = + runTest { + setupPreferences { + signInAutomatically = true + } + every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(null) + coEvery { serverRepository.tryChangeUser(serverId, userId) } returns null + + val intent = + Intent().apply { + putExtra(IntentService.INTENT_SERVER_ID, serverId.toServerString()) + putExtra(IntentService.INTENT_USER_ID, userId.toServerString()) + } + val result = intentService.prepare(intent) + assertTrue(result is IntentResult.Error) + + coVerify(exactly = 1) { serverRepository.tryChangeUser(serverId, userId) } + } + + @Test + fun `Test parseIntent with view item`() = + runTest { + setupAutoSignInUnprotected() + + val intent = + Intent().apply { + action = Intent.ACTION_VIEW + putExtra("itemId", itemId.toString()) + } + val result = intentService.parseIntent(intent) + assertIs<IntentResult.Target>(result) + + val destinations = result.destinations + assertEquals(1, destinations.size) + assertTrue(result.addHomeToBackStack) + val first = destinations.first() + assertIs<Destination.MediaItem>(first) + + assertEquals(itemId, first.itemId) + + coVerify { userLibraryApi.getItem(itemId) } + } + + @Test + fun `Test parseIntent with view and no itemId`() = + runTest { + setupAutoSignInUnprotected() + + val intent = + Intent().apply { + action = Intent.ACTION_VIEW + } + val result = intentService.parseIntent(intent) + assertIs<IntentResult.Target>(result) + + val destinations = result.destinations + assertEquals(0, destinations.size) + assertTrue(result.addHomeToBackStack) + + coVerify(exactly = 0) { userLibraryApi.getItem(itemId) } + } + + @Test + fun `Test parseIntent with view and switch user`() = + runTest { + setupAutoSignInUnprotected() + + val newUserId = UUID.randomUUID() + val newServerId = UUID.randomUUID() + val newCurrentUser = currentUser(newServerId, newUserId) + + every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(currentUser) + coEvery { + serverRepository.tryChangeUser( + newServerId, + newUserId, + ) + } returns newCurrentUser + + val intent = + Intent().apply { + action = Intent.ACTION_VIEW + putExtra("userId", newUserId.toString()) + putExtra("serverId", newServerId.toString()) + } + val result = intentService.parseIntent(intent) + assertIs<IntentResult.Target>(result) + + val destinations = result.destinations + assertEquals(0, destinations.size) + assertTrue(result.addHomeToBackStack) + + coVerify(exactly = 0) { serverRepository.tryChangeUser(serverId, userId) } + coVerify { serverRepository.tryChangeUser(newServerId, newUserId) } + coVerify(exactly = 0) { userLibraryApi.getItem(itemId) } + } + + @Test + fun `Test parseIntent with view, itemId, and switch user`() = + runTest { + setupAutoSignInUnprotected() + + val newUserId = UUID.randomUUID() + val newServerId = UUID.randomUUID() + val newCurrentUser = currentUser(newServerId, newUserId) + + every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(currentUser) + coEvery { + serverRepository.tryChangeUser( + newServerId, + newUserId, + ) + } returns newCurrentUser + + val intent = + Intent().apply { + action = Intent.ACTION_VIEW + putExtra("userId", newUserId.toString()) + putExtra("serverId", newServerId.toString()) + putExtra("itemId", itemId.toString()) + } + val result = intentService.parseIntent(intent) + assertIs<IntentResult.Target>(result) + + val destinations = result.destinations + assertEquals(1, destinations.size) + assertTrue(result.addHomeToBackStack) + val first = destinations.first() + assertIs<Destination.MediaItem>(first) + + assertEquals(itemId, first.itemId) + + coVerify(exactly = 0) { serverRepository.tryChangeUser(serverId, userId) } + coVerify { serverRepository.tryChangeUser(newServerId, newUserId) } + coVerify { userLibraryApi.getItem(itemId) } + } + + @Test + fun `Test parseIntent with search`() = + runTest { + setupAutoSignInUnprotected() + + val intent = + Intent().apply { + action = Intent.ACTION_SEARCH + } + val result = intentService.parseIntent(intent) + assertIs<IntentResult.Target>(result) + + val destinations = result.destinations + assertEquals(1, destinations.size) + assertTrue(result.addHomeToBackStack) + val first = destinations.first() + assertIs<Destination.Search>(first) + + assertEquals("", first.query) + } + + @Test + fun `Test parseIntent with search query`() = + runTest { + setupAutoSignInUnprotected() + + val query = "query123" + val intent = + Intent().apply { + action = Intent.ACTION_SEARCH + putExtra(SearchManager.QUERY, query) + } + val result = intentService.parseIntent(intent) + assertIs<IntentResult.Target>(result) + + val destinations = result.destinations + assertEquals(1, destinations.size) + assertTrue(result.addHomeToBackStack) + val first = destinations.first() + assertIs<Destination.Search>(first) + + assertEquals(query, first.query) + } + + @Test + fun `Test parseIntent with play but no itemId`() = + runTest { + setupAutoSignInUnprotected() + + val intent = + Intent().apply { + action = IntentService.ACTION_PLAYBACK + } + val result = intentService.parseIntent(intent) + assertIs<IntentResult.Error>(result) + } + + @Test + fun `Test parseIntent with play itemId`() = + runTest { + setupAutoSignInUnprotected() + + val intent = + Intent().apply { + action = IntentService.ACTION_PLAYBACK + putExtra("itemId", itemId.toString()) + } + val result = intentService.parseIntent(intent) + assertIs<IntentResult.Target>(result) + + val destinations = result.destinations + assertEquals(2, destinations.size) + assertTrue(result.addHomeToBackStack) + val first = destinations.first() + val second = destinations[1] + assertIs<Destination.MediaItem>(first) + assertIs<Destination.Playback>(second) + + assertEquals(itemId, first.itemId) + assertEquals(itemId, second.itemId) + + coVerify { userLibraryApi.getItem(itemId) } + } + + @Test + fun `Test parseIntent with play itemId and params`() = + runTest { + setupAutoSignInUnprotected() + + val position = 4_000L + val shuffle = true + val intent = + Intent().apply { + action = IntentService.ACTION_PLAYBACK + putExtra("itemId", itemId.toString()) + putExtra("position", position) + putExtra("shuffle", shuffle) + } + val result = intentService.parseIntent(intent) + assertIs<IntentResult.Target>(result) + + val destinations = result.destinations + assertEquals(2, destinations.size) + assertTrue(result.addHomeToBackStack) + val first = destinations.first() + val second = destinations[1] + assertIs<Destination.MediaItem>(first) + assertIs<Destination.Playback>(second) + + assertEquals(itemId, first.itemId) + assertEquals(itemId, second.itemId) + assertEquals(shuffle, second.shuffle) + assertEquals(position, second.positionMs) + + coVerify { userLibraryApi.getItem(itemId) } + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/LatestNextUpSchedulerServiceTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/LatestNextUpSchedulerServiceTest.kt new file mode 100644 index 00000000..256ee9d8 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/services/LatestNextUpSchedulerServiceTest.kt @@ -0,0 +1,68 @@ +package com.github.damontecres.wholphin.services + +import androidx.appcompat.app.AppCompatActivity +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry +import androidx.work.WorkManager +import com.github.damontecres.wholphin.data.CurrentUser +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.util.WholphinDispatchers +import com.github.damontecres.wholphin.util.configure +import com.github.damontecres.wholphin.util.reset +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class LatestNextUpSchedulerServiceTest { + @get:Rule val instantTaskExecutorRule = InstantTaskExecutorRule() + private val testDispatcher = StandardTestDispatcher() + private val currentUser = MutableStateFlow<CurrentUser?>(null) + private val mockActivity = mockk<AppCompatActivity>(relaxed = true) + private val mockServerRepository = mockk<ServerRepository>(relaxed = true) + private val mockWorkManager = mockk<WorkManager>(relaxed = true) + private val lifecycleRegistry = LifecycleRegistry(mockk<LifecycleOwner>(relaxed = true)) + + @Before + fun setUp() { + WholphinDispatchers.configure(testDispatcher) + every { mockActivity.lifecycle } returns lifecycleRegistry + every { mockServerRepository.current } returns currentUser + lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_RESUME) + } + + @After + fun tearDown() = WholphinDispatchers.reset() + + private fun createService() = + LatestNextUpSchedulerService( + context = mockActivity, + serverRepository = mockServerRepository, + workManager = mockWorkManager, + ).also { + it.dispatcher = testDispatcher + } + + @Test + fun cancels_latestNextUp_work_when_user_null() = + runTest(testDispatcher) { + createService() + + currentUser.value = null + advanceUntilIdle() + + verify { mockWorkManager.cancelUniqueWork(LatestNextUpWorker.WORK_NAME) } + verify(exactly = 0) { mockWorkManager.cancelUniqueWork(SuggestionsWorker.WORK_NAME) } + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/LatestNextUpServiceTests.kt b/app/src/test/java/com/github/damontecres/wholphin/services/LatestNextUpServiceTests.kt index 4c605df4..d4590cf7 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/services/LatestNextUpServiceTests.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/services/LatestNextUpServiceTests.kt @@ -5,15 +5,15 @@ package com.github.damontecres.wholphin.services +import com.github.damontecres.wholphin.util.WholphinDispatchers +import com.github.damontecres.wholphin.util.configure +import com.github.damontecres.wholphin.util.reset import io.mockk.coEvery import io.mockk.every import io.mockk.mockk -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.test.setMain import kotlinx.serialization.UseSerializers import kotlinx.serialization.json.Json import org.jellyfin.sdk.api.client.ApiClient @@ -56,14 +56,14 @@ class LatestNextUpServiceTests { @OptIn(ExperimentalCoroutinesApi::class) @Before fun setUp() { - Dispatchers.setMain(testDispatcher) + WholphinDispatchers.configure(testDispatcher) every { mockApi.tvShowsApi } returns mockTvShowsApi } @OptIn(ExperimentalCoroutinesApi::class) @After fun tearDown() { - Dispatchers.resetMain() + WholphinDispatchers.reset() } @Test diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/MediaManagementServiceTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/MediaManagementServiceTest.kt new file mode 100644 index 00000000..e42600c4 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/services/MediaManagementServiceTest.kt @@ -0,0 +1,184 @@ +package com.github.damontecres.wholphin.services + +import android.content.Context +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.ServerUserConfig +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.InterfacePreferences +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.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.exception.InvalidStatusException +import org.jellyfin.sdk.api.client.extensions.libraryApi +import org.jellyfin.sdk.api.operations.LibraryApi +import org.jellyfin.sdk.model.UUID +import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.UserPolicy +import org.junit.After +import org.junit.Assert +import org.junit.Before +import org.junit.Test + +class MediaManagementServiceTest { + private val testDispatcher = StandardTestDispatcher() + + private val mockContext = mockk<Context>() + private val mockApiClient = mockk<ApiClient>() + private val mockServerRepository = mockk<ServerRepository>() + private val mockUserPreferencesService = mockk<UserPreferencesService>() + + private val mockLibraryApi = mockk<LibraryApi>() + + private val mediaManagementService = + MediaManagementService(mockContext, mockApiClient, mockServerRepository, mockUserPreferencesService) + + @OptIn(ExperimentalCoroutinesApi::class) + @Before + fun setup() { + Dispatchers.setMain(testDispatcher) + + every { mockApiClient.libraryApi } returns mockLibraryApi + } + + @OptIn(ExperimentalCoroutinesApi::class) + @After + fun tearDown() { + Dispatchers.resetMain() + } + + private val movie = + BaseItem( + BaseItemDto( + id = UUID.randomUUID(), + type = BaseItemKind.MOVIE, + canDelete = true, + ), + ) + private val recording = + BaseItem( + BaseItemDto( + id = UUID.randomUUID(), + type = BaseItemKind.RECORDING, + canDelete = true, + ), + ) + + private val movieCannotDelete = + BaseItem( + BaseItemDto( + id = UUID.randomUUID(), + type = BaseItemKind.MOVIE, + canDelete = false, + ), + ) + + private val userWithLiveTv = + ServerUserConfig( + id = UUID.randomUUID(), + policy = + mockk<UserPolicy> { + every { enableLiveTvManagement } returns true + }, + ) + + private val userWithoutLiveTv = + ServerUserConfig( + id = UUID.randomUUID(), + policy = + mockk<UserPolicy> { + every { enableLiveTvManagement } returns false + }, + ) + + private val prefsEnabled = + AppPreferences + .newBuilder() + .apply { + interfacePreferences = + InterfacePreferences + .newBuilder() + .apply { + enableMediaManagement = true + }.build() + }.build() + + private val prefsDisabled = + AppPreferences + .newBuilder() + .apply { + interfacePreferences = + InterfacePreferences + .newBuilder() + .apply { + enableMediaManagement = false + }.build() + }.build() + + @Test + fun `Test canDelete`() { + Assert.assertTrue(mediaManagementService.canDelete(movie, prefsEnabled)) + Assert.assertFalse(mediaManagementService.canDelete(movie, prefsDisabled)) + Assert.assertFalse(mediaManagementService.canDelete(movieCannotDelete, prefsEnabled)) + } + + @Test + fun `Test canDelete recording`() { + every { mockServerRepository.currentUserDto } returns userWithLiveTv + Assert.assertTrue(mediaManagementService.canDelete(recording, prefsEnabled)) + Assert.assertFalse(mediaManagementService.canDelete(recording, prefsDisabled)) + } + + @Test + fun `Test canDelete recording no permission`() { + every { mockServerRepository.currentUserDto } returns userWithoutLiveTv + Assert.assertFalse(mediaManagementService.canDelete(recording, prefsEnabled)) + Assert.assertFalse(mediaManagementService.canDelete(recording, prefsDisabled)) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `Test successful delete`() = + runTest { + coEvery { mockLibraryApi.deleteItem(any()) } returns successResponse(Unit) + + val deletedItems = mutableListOf<DeletedItem>() + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + mediaManagementService.deletedItemFlow.toList(deletedItems) + } + val result = mediaManagementService.deleteItem(movie) + Assert.assertTrue(result is DeleteResult.Success) + coVerify(exactly = 1) { mockLibraryApi.deleteItem(movie.id) } + Assert.assertEquals(1, deletedItems.size) + Assert.assertEquals(deletedItems[0].item.id, movie.id) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `Test unsuccessful delete`() = + runTest { + coEvery { mockLibraryApi.deleteItem(any()) } throws InvalidStatusException(403) + + val deletedItems = mutableListOf<DeletedItem>() + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + mediaManagementService.deletedItemFlow.toList(deletedItems) + } + val result = mediaManagementService.deleteItem(movie) + Assert.assertTrue(result is DeleteResult.Error) + coVerify(exactly = 1) { mockLibraryApi.deleteItem(movie.id) } + Assert.assertEquals(0, deletedItems.size) + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/SeerrRatingsItemsTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/SeerrRatingsItemsTest.kt new file mode 100644 index 00000000..adb5ca33 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/services/SeerrRatingsItemsTest.kt @@ -0,0 +1,426 @@ +@file:OptIn(ExperimentalCoroutinesApi::class) + +package com.github.damontecres.wholphin.services + +import com.github.damontecres.wholphin.api.seerr.MoviesApi +import com.github.damontecres.wholphin.api.seerr.SeerrApiClient +import com.github.damontecres.wholphin.api.seerr.TvApi +import com.github.damontecres.wholphin.api.seerr.model.MovieMovieIdRatingscombinedGet200Response +import com.github.damontecres.wholphin.api.seerr.model.MovieMovieIdRatingsGet200Response +import com.github.damontecres.wholphin.api.seerr.infrastructure.ClientException +import com.github.damontecres.wholphin.api.seerr.model.SeasonDetails +import com.github.damontecres.wholphin.api.seerr.model.SeasonEpisode +import com.github.damontecres.wholphin.api.seerr.model.TvTvIdRatingsGet200Response +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.SeerrItemType +import com.github.damontecres.wholphin.ui.successResponse +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.userLibraryApi +import org.jellyfin.sdk.api.operations.UserLibraryApi +import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.BaseItemKind +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test +import java.io.IOException +import java.util.UUID + +/** + * Which Jellyfin items have ratings, how their TMDB id is found, and when Seerr is asked + */ +class SeerrRatingsItemsTest { + private val moviesApi = mockk<MoviesApi>() + private val tvApi = mockk<TvApi>() + private val seerrApi = + mockk<SeerrApiClient> { + every { moviesApi } returns this@SeerrRatingsItemsTest.moviesApi + every { tvApi } returns this@SeerrRatingsItemsTest.tvApi + } + private val active = MutableStateFlow(true) + private val seerrService = + mockk<SeerrService> { + every { api } returns seerrApi + every { this@mockk.active } returns this@SeerrRatingsItemsTest.active + } + private val userLibraryApi = mockk<UserLibraryApi>() + private val jellyfin = + mockk<ApiClient> { + every { userLibraryApi } returns this@SeerrRatingsItemsTest.userLibraryApi + } + private val service = SeerrRatingsService(seerrService, jellyfin) + + private val seriesId = UUID.randomUUID() + private val seriesTmdb = 1396 + + private val rtOnly = MovieMovieIdRatingscombinedGet200Response(rt = MovieMovieIdRatingsGet200Response(criticsScore = 90, criticsRating = "Fresh")) + private val tvRatings = TvTvIdRatingsGet200Response(criticsScore = 80, criticsRating = "Fresh") + + private fun item( + type: BaseItemKind, + providerIds: Map<String, String>? = null, + seriesId: UUID? = null, + ) = BaseItem(BaseItemDto(id = UUID.randomUUID(), type = type, providerIds = providerIds, seriesId = seriesId)) + + private fun jellyfinHasSeries(tmdb: Int? = seriesTmdb) { + coEvery { userLibraryApi.getItem(seriesId) } returns + successResponse( + BaseItemDto( + id = seriesId, + type = BaseItemKind.SERIES, + providerIds = tmdb?.let { mapOf("Tmdb" to it.toString()) } ?: mapOf("Tvdb" to "1"), + ), + ) + } + + @Test + fun moviesAndSeriesUseTheirOwnProviderIdsWithoutAskingJellyfin() = + runTest { + assertEquals( + RatingsTarget(SeerrItemType.MOVIE, 11), + service.resolveTarget(item(BaseItemKind.MOVIE, mapOf("Tmdb" to "11"))), + ) + assertEquals( + RatingsTarget(SeerrItemType.TV, 22), + service.resolveTarget(item(BaseItemKind.SERIES, mapOf("Tmdb" to "22"))), + ) + coVerify(exactly = 0) { userLibraryApi.getItem(any()) } + } + + @Test + fun episodesAndSeasonsShowTheirSeriesRatingsNotTheirOwnTmdbId() = + runTest { + jellyfinHasSeries() + // The Tmdb id on an episode or season identifies the episode or season, never the series + val episode = item(BaseItemKind.EPISODE, mapOf("Tmdb" to "999999"), seriesId) + val season = item(BaseItemKind.SEASON, mapOf("Tmdb" to "888888"), seriesId) + + assertEquals(RatingsTarget(SeerrItemType.TV, seriesTmdb), service.resolveTarget(episode)) + assertEquals(RatingsTarget(SeerrItemType.TV, seriesTmdb), service.resolveTarget(season)) + } + + @Test + fun itemsThatArePartOfNoPwaTitleHaveNoRatingsAndNeverCallAnything() = + runTest { + val none = + listOf( + BaseItemKind.BOX_SET, + BaseItemKind.MUSIC_ALBUM, + BaseItemKind.MUSIC_ARTIST, + BaseItemKind.AUDIO, + BaseItemKind.PHOTO, + BaseItemKind.PHOTO_ALBUM, + BaseItemKind.PROGRAM, + BaseItemKind.PLAYLIST, + BaseItemKind.VIDEO, + ) + none.forEach { + val item = item(it, mapOf("Tmdb" to "5"), seriesId) + assertNull(it.name, service.resolveTarget(item)) + assertNull(it.name, service.forItem(item).first()) + assertNull(it.name, service.cachedFor(item)) + } + coVerify(exactly = 0) { userLibraryApi.getItem(any()) } + coVerify(exactly = 0) { moviesApi.movieMovieIdRatingscombinedGet(any()) } + coVerify(exactly = 0) { tvApi.tvTvIdRatingsGet(any()) } + } + + @Test + fun episodeWithoutASeriesHasNoRatings() = + runTest { + assertNull(service.resolveTarget(item(BaseItemKind.EPISODE, mapOf("Tmdb" to "3"), null))) + } + + @Test + fun homeItemsWithoutProviderIdsAreResolvedThroughJellyfinOnceAndCached() = + runTest { + jellyfinHasSeries() + val first = item(BaseItemKind.EPISODE, null, seriesId) + val second = item(BaseItemKind.EPISODE, null, seriesId) + + assertEquals(RatingsTarget(SeerrItemType.TV, seriesTmdb), service.resolveTarget(first)) + assertEquals(RatingsTarget(SeerrItemType.TV, seriesTmdb), service.resolveTarget(second)) + + // every other episode of the series is known without a request + coVerify(exactly = 1) { userLibraryApi.getItem(seriesId) } + } + + @Test + fun homeMovieWithoutProviderIdsLooksItUp() = + runTest { + val movie = item(BaseItemKind.MOVIE, null) + coEvery { userLibraryApi.getItem(movie.id) } returns + successResponse(BaseItemDto(id = movie.id, type = BaseItemKind.MOVIE, providerIds = mapOf("Tmdb" to "77"))) + + assertEquals(RatingsTarget(SeerrItemType.MOVIE, 77), service.resolveTarget(movie)) + } + + @Test + fun seriesWithoutTmdbIdHasNoRatingsAndIsNotLookedUpAgain() = + runTest { + jellyfinHasSeries(tmdb = null) + val episode = item(BaseItemKind.EPISODE, null, seriesId) + + assertNull(service.resolveTarget(episode)) + assertNull(service.forItem(episode).first()) + coVerify(exactly = 1) { userLibraryApi.getItem(seriesId) } + } + + @Test + fun failedLookupIsRetriedLater() = + runTest { + coEvery { userLibraryApi.getItem(seriesId) } throws IOException("down") + val episode = item(BaseItemKind.EPISODE, null, seriesId) + assertNull(service.resolveTarget(episode)) + + jellyfinHasSeries() + assertEquals(RatingsTarget(SeerrItemType.TV, seriesTmdb), service.resolveTarget(episode)) + } + + @Test + fun episodeRatingsAreTheSeriesRatingsAndSeedLaterEpisodes() = + runTest { + jellyfinHasSeries() + coEvery { tvApi.tvTvIdRatingsGet(tvId = seriesTmdb) } returns tvRatings + val episode = item(BaseItemKind.EPISODE, null, seriesId) + val other = item(BaseItemKind.EPISODE, null, seriesId) + assertNull(service.cachedFor(episode)) + + val ratings = service.forItem(episode).first() + + assertEquals(80, ratings?.rtCritics?.percent) + // moving to another episode of the same series needs no wait and no request + assertEquals(ratings, service.cachedFor(other)) + assertEquals(ratings, service.forItem(other).first()) + coVerify(exactly = 1) { tvApi.tvTvIdRatingsGet(tvId = seriesTmdb) } + } + + @Test + fun scrollingPastItemsNeverAsksSeerr() = + runTest { + coEvery { moviesApi.movieMovieIdRatingscombinedGet(any()) } returns rtOnly + val jobs = + (1..20).map { id -> + val job = launch { service.forItem(item(BaseItemKind.MOVIE, mapOf("Tmdb" to "$id"))).collect { } } + runCurrent() + // focus moves on before the wait has passed + advanceTimeBy(SeerrRatingsService.DEBOUNCE_MILLIS - 50) + job.cancel() + job + } + jobs.forEach(Job::cancel) + runCurrent() + + coVerify(exactly = 0) { moviesApi.movieMovieIdRatingscombinedGet(any()) } + } + + @Test + fun restingOnAnItemAsksSeerrOnceAfterTheWait() = + runTest { + coEvery { moviesApi.movieMovieIdRatingscombinedGet(movieId = 7) } returns rtOnly + var result: Any? = "unset" + val job = launch { service.forItem(item(BaseItemKind.MOVIE, mapOf("Tmdb" to "7"))).collect { result = it } } + + advanceTimeBy(SeerrRatingsService.DEBOUNCE_MILLIS - 1) + runCurrent() + assertEquals("unset", result) + coVerify(exactly = 0) { moviesApi.movieMovieIdRatingscombinedGet(any()) } + + advanceTimeBy(2) + runCurrent() + assertNotNull(result) + coVerify(exactly = 1) { moviesApi.movieMovieIdRatingscombinedGet(movieId = 7) } + job.cancel() + } + + @Test + fun movingOnWhileTheRequestIsRunningCancelsIt() = + runTest { + var cancelled = false + coEvery { moviesApi.movieMovieIdRatingscombinedGet(movieId = 8) } coAnswers { + try { + awaitCancellation() + } finally { + cancelled = true + } + } + val job = launch { service.forItem(item(BaseItemKind.MOVIE, mapOf("Tmdb" to "8"))).collect { } } + advanceTimeBy(SeerrRatingsService.DEBOUNCE_MILLIS + 1) + runCurrent() + coVerify(exactly = 1) { moviesApi.movieMovieIdRatingscombinedGet(movieId = 8) } + + job.cancel() + runCurrent() + + assertEquals(true, cancelled) + assertNull(service.cached(SeerrItemType.MOVIE, 8)) + } + + private fun season(vararg episodes: Triple<Int, Double?, Long?>) = + SeasonDetails( + seasonNumber = 5, + episodes = episodes.map { (number, average, votes) -> SeasonEpisode(episodeNumber = number, voteAverage = average?.toBigDecimal(), voteCount = votes) }, + ) + + private fun episode( + season: Int?, + episode: Int?, + ) = BaseItem(BaseItemDto(id = UUID.randomUUID(), type = BaseItemKind.EPISODE, seriesId = seriesId, parentIndexNumber = season, indexNumber = episode)) + + private fun seriesKnown() { + jellyfinHasSeries() + coEvery { tvApi.tvTvIdRatingsGet(tvId = seriesTmdb) } returns tvRatings + } + + @Test + fun theSeasonIsFetchedOnceForEveryEpisodeOfIt() = + runTest { + seriesKnown() + coEvery { tvApi.tvTvIdSeasonSeasonNumberGet(tvId = seriesTmdb, seasonNumber = 5) } returns + season(Triple(1, 8.24, 120L), Triple(2, 7.0, 80L), Triple(3, 9.5, 10L)) + + val first = service.forItem(episode(5, 1)).first()!! + val second = service.forItem(episode(5, 2)).first()!! + val third = service.forItem(episode(5, 3)).first()!! + + // each episode has its own score next to the series' Rotten Tomatoes + assertEquals(listOf(8.24, 7.0, 9.5), listOf(first, second, third).map { it.episodeTmdb }) + assertEquals(80, first.rtCritics?.percent) + coVerify(exactly = 1) { tvApi.tvTvIdSeasonSeasonNumberGet(tvId = seriesTmdb, seasonNumber = 5) } + coVerify(exactly = 1) { tvApi.tvTvIdRatingsGet(tvId = seriesTmdb) } + // and another episode of the season is known without waiting + assertEquals(7.0, service.cachedFor(episode(5, 2))!!.episodeTmdb!!, 0.0) + } + + @Test + fun eachSeasonIsItsOwnRequest() = + runTest { + seriesKnown() + coEvery { tvApi.tvTvIdSeasonSeasonNumberGet(tvId = seriesTmdb, seasonNumber = 1) } returns season(Triple(1, 6.0, 5L)) + coEvery { tvApi.tvTvIdSeasonSeasonNumberGet(tvId = seriesTmdb, seasonNumber = 2) } returns season(Triple(1, 9.0, 5L)) + + assertEquals(6.0, service.forItem(episode(1, 1)).first()!!.episodeTmdb!!, 0.0) + assertEquals(9.0, service.forItem(episode(2, 1)).first()!!.episodeTmdb!!, 0.0) + // the same episode number of another season is not mistaken for it + assertNull(service.cachedFor(episode(3, 1))) + } + + @Test + fun unratedEpisodesShowOnlyTheSeriesRatings() = + runTest { + seriesKnown() + coEvery { tvApi.tvTvIdSeasonSeasonNumberGet(tvId = seriesTmdb, seasonNumber = 5) } returns + season(Triple(1, 0.0, 0L), Triple(2, 8.0, null), Triple(3, null, null)) + + listOf(1, 2, 3, 4).forEach { number -> + val ratings = service.forItem(episode(5, number)).first()!! + assertNull("episode $number", ratings.episodeTmdb) + assertEquals("episode $number", 80, ratings.rtCritics?.percent) + } + } + + @Test + fun episodeWithoutSeasonOrEpisodeNumberShowsTheSeriesRatingsWithoutAskingForTheSeason() = + runTest { + seriesKnown() + + assertEquals(80, service.forItem(episode(null, 1)).first()!!.rtCritics?.percent) + assertEquals(80, service.forItem(episode(5, null)).first()!!.rtCritics?.percent) + coVerify(exactly = 0) { tvApi.tvTvIdSeasonSeasonNumberGet(any(), any(), any()) } + } + + @Test + fun episodeTmdbReplacesTheSeriesTmdbWhenThatWasTheOnlySource() = + runTest { + jellyfinHasSeries() + coEvery { tvApi.tvTvIdRatingsGet(tvId = seriesTmdb) } returns TvTvIdRatingsGet200Response() + coEvery { tvApi.tvTvIdGet(tvId = seriesTmdb) } returns + mockk { + every { voteAverage } returns 7.6.toBigDecimal() + every { voteCount } returns 900 + } + coEvery { tvApi.tvTvIdSeasonSeasonNumberGet(tvId = seriesTmdb, seasonNumber = 5) } returns season(Triple(1, 8.8, 40L)) + + val ratings = service.forItem(episode(5, 1)).first()!! + + assertNull(ratings.tmdb) + assertEquals(8.8, ratings.episodeTmdb!!, 0.0) + } + + @Test + fun failedSeasonRequestKeepsTheSeriesRatingsAndIsRetried() = + runTest { + seriesKnown() + coEvery { tvApi.tvTvIdSeasonSeasonNumberGet(tvId = seriesTmdb, seasonNumber = 5) } throws IOException("down") andThen + season(Triple(1, 8.0, 3L)) + + val first = service.forItem(episode(5, 1)).first()!! + assertNull(first.episodeTmdb) + assertEquals(80, first.rtCritics?.percent) + // not cached as "no score": the screen has to wait again + assertNull(service.cachedFor(episode(5, 1))) + + assertEquals(8.0, service.forItem(episode(5, 1)).first()!!.episodeTmdb!!, 0.0) + } + + @Test + fun seasonTmdbDoesNotKnowIsAnAnswer() = + runTest { + seriesKnown() + coEvery { tvApi.tvTvIdSeasonSeasonNumberGet(tvId = seriesTmdb, seasonNumber = 9) } throws ClientException("none", 404) + + assertNull(service.forItem(episode(9, 1)).first()!!.episodeTmdb) + service.forItem(episode(9, 2)).first() + + coVerify(exactly = 1) { tvApi.tvTvIdSeasonSeasonNumberGet(tvId = seriesTmdb, seasonNumber = 9) } + } + + @Test + fun seriesWithoutATmdbIdShowsNothingExtra() = + runTest { + jellyfinHasSeries(tmdb = null) + + assertNull(service.forItem(episode(5, 1)).first()) + coVerify(exactly = 0) { tvApi.tvTvIdSeasonSeasonNumberGet(any(), any(), any()) } + } + + @Test + fun seasonsStillShowOnlyTheirSeriesRatings() = + runTest { + seriesKnown() + val season = BaseItem(BaseItemDto(id = UUID.randomUUID(), type = BaseItemKind.SEASON, seriesId = seriesId, indexNumber = 5, parentIndexNumber = 5)) + + assertNull(service.forItem(season).first()!!.episodeTmdb) + coVerify(exactly = 0) { tvApi.tvTvIdSeasonSeasonNumberGet(any(), any(), any()) } + } + + @Test + fun scrollingPastEpisodesNeverFetchesASeason() = + runTest { + seriesKnown() + coEvery { tvApi.tvTvIdSeasonSeasonNumberGet(any(), any(), any()) } returns season(Triple(1, 8.0, 3L)) + repeat(10) { number -> + val job = launch { service.forItem(episode(5, number + 1)).collect { } } + runCurrent() + advanceTimeBy(SeerrRatingsService.DEBOUNCE_MILLIS - 50) + job.cancel() + } + runCurrent() + + coVerify(exactly = 0) { tvApi.tvTvIdSeasonSeasonNumberGet(any(), any(), any()) } + coVerify(exactly = 0) { userLibraryApi.getItem(any()) } + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/SeerrRatingsServiceTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/SeerrRatingsServiceTest.kt new file mode 100644 index 00000000..c6b008ea --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/services/SeerrRatingsServiceTest.kt @@ -0,0 +1,266 @@ +package com.github.damontecres.wholphin.services + +import com.github.damontecres.wholphin.api.seerr.MoviesApi +import com.github.damontecres.wholphin.api.seerr.SeerrApiClient +import com.github.damontecres.wholphin.api.seerr.TvApi +import com.github.damontecres.wholphin.api.seerr.infrastructure.ClientException +import com.github.damontecres.wholphin.api.seerr.infrastructure.Serializer +import com.github.damontecres.wholphin.api.seerr.model.MovieDetails +import com.github.damontecres.wholphin.api.seerr.model.MovieMovieIdRatingsGet200Response +import com.github.damontecres.wholphin.api.seerr.model.MovieMovieIdRatingscombinedGet200Response +import com.github.damontecres.wholphin.api.seerr.model.MovieMovieIdRatingscombinedGet200ResponseImdb +import com.github.damontecres.wholphin.api.seerr.model.TvDetails +import com.github.damontecres.wholphin.api.seerr.model.TvTvIdRatingsGet200Response +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.RtMark +import com.github.damontecres.wholphin.data.model.SeerrItemType +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.BaseItemKind +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.IOException +import java.util.UUID + +class SeerrRatingsServiceTest { + private val moviesApi = mockk<MoviesApi>() + private val tvApi = mockk<TvApi>() + private val apiClient = + mockk<SeerrApiClient> { + every { moviesApi } returns this@SeerrRatingsServiceTest.moviesApi + every { tvApi } returns this@SeerrRatingsServiceTest.tvApi + } + private var activeFlow = flowOf(true) + private val seerrService = + mockk<SeerrService> { + every { api } returns apiClient + every { active } answers { activeFlow } + } + private val service = SeerrRatingsService(seerrService, mockk<ApiClient>()) + + private fun movieDetails( + average: Double?, + count: Int?, + ) = mockk<MovieDetails> { + every { voteAverage } returns average?.toBigDecimal() + every { voteCount } returns count + } + + private fun tvDetails( + average: Double?, + count: Int?, + ) = mockk<TvDetails> { + every { voteAverage } returns average?.toBigDecimal() + every { voteCount } returns count + } + + private val fullMovieRatings = + MovieMovieIdRatingscombinedGet200Response( + rt = + MovieMovieIdRatingsGet200Response( + criticsScore = 96, + criticsRating = "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<MovieMovieIdRatingscombinedGet200Response>( + """{"rt":{"criticsScore":92,"criticsRating":"Mystery","audienceScore":80,"audienceRating":"Whatever"},""" + + """"imdb":{"criticsScore":8.8,"criticsScoreCount":2500000}}""", + ) + coEvery { tvApi.tvTvIdRatingsGet(tvId = 31) } returns + json.decodeFromString<TvTvIdRatingsGet200Response>( + """{"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<String, String>?, + ) = 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/services/ServerRepositoryTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/ServerRepositoryTest.kt new file mode 100644 index 00000000..2c2fdffe --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/services/ServerRepositoryTest.kt @@ -0,0 +1,558 @@ +package com.github.damontecres.wholphin.services + +import android.content.Context +import androidx.datastore.core.DataStoreFactory +import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler +import com.github.damontecres.wholphin.data.CurrentUser +import com.github.damontecres.wholphin.data.JellyfinServerDao +import com.github.damontecres.wholphin.data.MostRecentServer +import com.github.damontecres.wholphin.data.MostRecentServerProvider +import com.github.damontecres.wholphin.data.RestoredSession +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.JellyfinServer +import com.github.damontecres.wholphin.data.model.JellyfinServerUsers +import com.github.damontecres.wholphin.data.model.JellyfinUser +import com.github.damontecres.wholphin.data.model.ServerUserConfig +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.AppPreferencesSerializer +import com.github.damontecres.wholphin.test.nonBlankString +import com.github.damontecres.wholphin.ui.isNotNullOrBlank +import com.github.damontecres.wholphin.ui.successResponse +import com.github.damontecres.wholphin.ui.toServerString +import com.github.damontecres.wholphin.util.WholphinDispatchers +import com.github.damontecres.wholphin.util.configure +import com.github.damontecres.wholphin.util.reset +import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import org.jellyfin.sdk.Jellyfin +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.Response +import org.jellyfin.sdk.api.client.extensions.systemApi +import org.jellyfin.sdk.api.client.extensions.userApi +import org.jellyfin.sdk.api.operations.SystemApi +import org.jellyfin.sdk.api.operations.UserApi +import org.jellyfin.sdk.model.ClientInfo +import org.jellyfin.sdk.model.DeviceInfo +import org.jellyfin.sdk.model.UUID +import org.jellyfin.sdk.model.api.AuthenticationResult +import org.jellyfin.sdk.model.api.PublicSystemInfo +import org.jellyfin.sdk.model.api.UserDto +import org.junit.After +import org.junit.Assert +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +class ServerRepositoryTest { + private val testDispatcher = StandardTestDispatcher() + + @get:Rule + val temporaryFolder: TemporaryFolder = + TemporaryFolder + .builder() + .assureDeletion() + .build() + + private val mockContext = mockk<Context>(relaxed = true) + private val mockJellyfin = mockk<Jellyfin>() + private val mockJellyfinServerDao = mockk<JellyfinServerDao>() + private val mockApiClient = mockk<ApiClient>() + private val dataStore = + DataStoreFactory.create( + serializer = AppPreferencesSerializer(), + produceFile = { temporaryFolder.newFile("test_datastore.pb") }, + scope = CoroutineScope(testDispatcher), + corruptionHandler = + ReplaceFileCorruptionHandler( + produceNewData = { AppPreferences.getDefaultInstance() }, + ), + ) + + private val mockUserApi = mockk<UserApi>() + private val mockSystemApi = mockk<SystemApi>() + private val mockMostRecentServerProvider = mockk<MostRecentServerProvider>() + + @OptIn(ExperimentalCoroutinesApi::class) + @Before + fun setup() { + WholphinDispatchers.configure(testDispatcher) + + every { mockApiClient.userApi } returns mockUserApi + every { mockApiClient.systemApi } returns mockSystemApi + coEvery { mockUserApi.getCurrentUser() } returns successResponse(userDto) + coEvery { mockSystemApi.getPublicSystemInfo() } returns + Response( + PublicSystemInfo( + id = serverId.toServerString(), + serverName = "test server", + version = "10.11.11", + ), + 200, + emptyMap(), + ) + coEvery { mockJellyfinServerDao.addOrUpdateUser(user) } returns user + coEvery { mockJellyfinServerDao.getServer(serverId) } returns + JellyfinServerUsers(server, listOf(user)) + coEvery { mockJellyfinServerDao.addOrUpdateServer(server) } just Runs + every { mockApiClient.clientInfo } returns ClientInfo("Wholphin test", "0.0.1") + every { mockApiClient.deviceInfo } returns DeviceInfo("Wholphin test ID", "Wholphin test device") + every { mockApiClient.update(any(), any(), any(), any()) } just Runs + coEvery { mockMostRecentServerProvider.save(any()) } just Runs + coEvery { mockMostRecentServerProvider.clear() } just Runs + coEvery { mockMostRecentServerProvider.clearUser() } just Runs + } + + @OptIn(ExperimentalCoroutinesApi::class) + @After + fun tearDown() { + WholphinDispatchers.reset() + } + + private fun create() = + ServerRepository( + mockContext, + mockJellyfin, + mockJellyfinServerDao, + mockApiClient, + testDispatcher, + mockMostRecentServerProvider, + ) + + private val serverId = UUID.randomUUID() + private val userId = UUID.randomUUID() + private val server = JellyfinServer(serverId, "test server", "http://localhost:8096", "10.11.11") + private val user = + JellyfinUser( + rowId = 1, + id = userId, + serverId = serverId, + name = "test-user", + accessToken = "token", + pin = "1234", + ) + private val userDto = + UserDto( + id = userId, + name = "test-user", + serverName = "test server", + hasPassword = true, + hasConfiguredPassword = true, + hasConfiguredEasyPassword = false, + ) + private val serverUserConfig = ServerUserConfig(userDto) + + private fun setUpCurrentUser( + serverRepository: ServerRepository, + currentUser: JellyfinUser = user, + ) { + every { mockApiClient.baseUrl } returns server.url + every { mockApiClient.accessToken } returns user.accessToken + Assert.assertNull(serverRepository.currentUser) + (serverRepository.current as MutableStateFlow).update { + CurrentUser(server, currentUser) + } + Assert.assertNotNull(serverRepository.currentUser) + } + + @Test + fun `Test overriding state`() { + val serverRepository = create() + Assert.assertNull(serverRepository.currentServer) + Assert.assertNull(serverRepository.currentUser) + setUpCurrentUser(serverRepository, currentUser = user) + Assert.assertEquals(serverId, serverRepository.currentServer?.id) + Assert.assertEquals(userId, serverRepository.currentUser?.id) + } + + @Test + fun `Test changeUser`() = + runTest { + every { mockJellyfinServerDao.addOrUpdateServer(any()) } just Runs + every { mockJellyfinServerDao.addOrUpdateUser(user) } returns user + + val serverRepository = create() + Assert.assertNull(serverRepository.currentUser) + + serverRepository.changeUser(server, user) + verify(exactly = 1) { + mockApiClient.update( + baseUrl = server.url, + accessToken = user.accessToken, + clientInfo = any(), + deviceInfo = any(), + ) + } + verify(exactly = 1) { mockJellyfinServerDao.addOrUpdateServer(server) } + verify(exactly = 1) { mockJellyfinServerDao.addOrUpdateUser(user) } + + Assert.assertEquals(server, serverRepository.currentServer) + Assert.assertEquals(user, serverRepository.currentUser) + Assert.assertEquals(serverUserConfig, serverRepository.currentUserDto) + + coVerify(exactly = 1) { mockMostRecentServerProvider.save(CurrentUser(server, user)) } + verify(exactly = 1) { mockJellyfinServerDao.addOrUpdateUser(user) } + } + + @Test + fun `Test restoreUser via changeUser`() = + runTest { + val user = user.copy(pin = null) + every { mockJellyfinServerDao.addOrUpdateServer(any()) } just Runs + every { mockJellyfinServerDao.addOrUpdateUser(user) } returns user + every { mockJellyfinServerDao.getServer(serverId) } returns JellyfinServerUsers(server, listOf(user)) + + val serverRepository = create() + Assert.assertNull(serverRepository.currentUser) + val result = serverRepository.tryChangeUser(server.id, user.id) + assertTrue(result != null) + assertEquals(serverId, result?.server?.id) + assertEquals(user.id, result?.user?.id) + + verify(exactly = 1) { + mockApiClient.update( + baseUrl = server.url, + accessToken = user.accessToken, + clientInfo = any(), + deviceInfo = any(), + ) + } + Assert.assertEquals(server, serverRepository.currentServer) + Assert.assertEquals(user, serverRepository.currentUser) + Assert.assertEquals(serverUserConfig, serverRepository.currentUserDto) + } + + @Test + fun `Test remove user`() = + runTest { + coEvery { mockJellyfinServerDao.deleteUser(any(), any()) } just Runs + + val serverRepository = create() + Assert.assertNull(serverRepository.currentUser) + + serverRepository.removeUser(user) + verify(exactly = 1) { mockJellyfinServerDao.deleteUser(serverId, userId) } + } + + @Test + fun `Test remove different user than current`() = + runTest { + coEvery { mockJellyfinServerDao.deleteUser(any(), any()) } just Runs + + val otherUser = + JellyfinUser( + rowId = 2, + id = UUID.randomUUID(), + name = "other", + serverId = serverId, + accessToken = "other token", + ) + val serverRepository = create() + setUpCurrentUser(serverRepository, currentUser = otherUser) + Assert.assertEquals(otherUser, serverRepository.currentUser) + + serverRepository.removeUser(user) + verify(exactly = 0) { mockApiClient.update(accessToken = null) } + verify(exactly = 1) { mockJellyfinServerDao.deleteUser(serverId, userId) } + Assert.assertEquals(otherUser, serverRepository.currentUser) + } + + @Test + fun `Test remove current user`() = + runTest { + coEvery { mockJellyfinServerDao.deleteUser(any(), any()) } just Runs + val serverRepository = create() + setUpCurrentUser(serverRepository, currentUser = user) + + serverRepository.removeUser(user) + + Assert.assertNull(serverRepository.current.value) + verify { + mockApiClient.update( + accessToken = null, + baseUrl = nonBlankString(), + clientInfo = any(), + deviceInfo = any(), + ) + } + verify(exactly = 1) { mockJellyfinServerDao.deleteUser(serverId, userId) } + coVerify { mockMostRecentServerProvider.clearUser() } + } + + @Test + fun `Test remove different server than current`() = + runTest { + coEvery { mockJellyfinServerDao.deleteServer(any()) } just Runs + val serverRepository = create() + setUpCurrentUser(serverRepository, currentUser = user) + + val toRemove = JellyfinServer(UUID.randomUUID(), null, "http://jellyfin.local", null) + serverRepository.removeServer(toRemove) + + Assert.assertEquals(server, serverRepository.currentServer) + verify(exactly = 0) { + mockApiClient.update( + accessToken = any(), + baseUrl = any(), + clientInfo = any(), + deviceInfo = any(), + ) + } + verify(exactly = 1) { mockJellyfinServerDao.deleteServer(toRemove.id) } + } + + @Test + fun `Test remove current server`() = + runTest { + coEvery { mockJellyfinServerDao.deleteServer(any()) } just Runs + val serverRepository = create() + setUpCurrentUser(serverRepository, currentUser = user) + + serverRepository.removeServer(server) + + Assert.assertNull(serverRepository.current.value) + verify { + mockApiClient.update( + accessToken = null, + baseUrl = null, + clientInfo = any(), + deviceInfo = any(), + ) + } + verify(exactly = 1) { mockJellyfinServerDao.deleteServer(serverId) } + coVerify { mockMostRecentServerProvider.clear() } + } + + @Test + fun `Test changeUser via auth`() = + runTest { + every { mockJellyfinServerDao.addOrUpdateServer(any()) } just Runs + every { mockJellyfinServerDao.addOrUpdateUser(any()) } returns user + + val authResult = + AuthenticationResult( + user = userDto, + serverId = serverId.toServerString(), + accessToken = user.accessToken, + ) + val serverRepository = create() + serverRepository.changeUser(server.url, authResult, null) + + verify(exactly = 1) { + mockApiClient.update( + baseUrl = server.url, + accessToken = user.accessToken, + clientInfo = any(), + deviceInfo = any(), + ) + } + verify(exactly = 1) { mockJellyfinServerDao.addOrUpdateServer(server) } + val savedUser = slot<JellyfinUser>() + verify(exactly = 1) { mockJellyfinServerDao.addOrUpdateUser(capture(savedUser)) } + // Newly created user, so rowId should be 0 + Assert.assertEquals(0, savedUser.captured.rowId) + Assert.assertEquals(user.id, savedUser.captured.id) + + Assert.assertEquals(server, serverRepository.currentServer) + Assert.assertEquals(user, serverRepository.currentUser) + Assert.assertEquals(serverUserConfig, serverRepository.currentUserDto) + + coVerify(exactly = 1) { mockMostRecentServerProvider.save(CurrentUser(server, user)) } + } + + @Test + fun `Test changeUser via auth for existing`() = + runTest { + every { mockJellyfinServerDao.addOrUpdateServer(any()) } just Runs + every { mockJellyfinServerDao.addOrUpdateUser(any()) } returns user + + val authResult = + AuthenticationResult( + user = userDto, + serverId = serverId.toServerString(), + accessToken = user.accessToken, + ) + val serverRepository = create() + serverRepository.changeUser(server.url, authResult, user) + + verify(exactly = 1) { + mockApiClient.update( + baseUrl = server.url, + accessToken = user.accessToken, + clientInfo = any(), + deviceInfo = any(), + ) + } + verify(exactly = 1) { mockJellyfinServerDao.addOrUpdateServer(server) } + val savedUser = slot<JellyfinUser>() + verify(exactly = 1) { mockJellyfinServerDao.addOrUpdateUser(capture(savedUser)) } + // Existing user, so rowId should not change + Assert.assertEquals(user.rowId, savedUser.captured.rowId) + Assert.assertEquals(user.id, savedUser.captured.id) + // PIN should be removed when re-authing existing user + Assert.assertNull(savedUser.captured.pin) + + Assert.assertEquals(server, serverRepository.currentServer) + Assert.assertEquals(user, serverRepository.currentUser) + Assert.assertEquals(serverUserConfig, serverRepository.currentUserDto) + + coVerify(exactly = 1) { mockMostRecentServerProvider.save(CurrentUser(server, user)) } + } + + @Test + fun `Test changeUser via invalid auth`() { + Assert.assertThrows(IllegalArgumentException::class.java) { + runTest { + val authResult = + AuthenticationResult( + user = userDto, + serverId = serverId.toServerString(), + accessToken = null, + ) + create().changeUser(server.url, authResult, user) + } + } + Assert.assertThrows(IllegalArgumentException::class.java) { + runTest { + val authResult = + AuthenticationResult( + user = userDto, + serverId = null, + accessToken = user.accessToken, + ) + create().changeUser(server.url, authResult, user) + } + } + Assert.assertThrows(IllegalArgumentException::class.java) { + runTest { + val authResult = + AuthenticationResult( + user = userDto, + serverId = "invalid-uuid", + accessToken = user.accessToken, + ) + create().changeUser(server.url, authResult, user) + } + } + Assert.assertThrows(IllegalArgumentException::class.java) { + runTest { + val authResult = + AuthenticationResult( + user = null, + serverId = "invalid-uuid", + accessToken = user.accessToken, + ) + create().changeUser(server.url, authResult, user) + } + } + } + + @Test + fun `Test restoreLastSession, success`() = + runTest { + val user = user.copy(pin = null) + coEvery { mockJellyfinServerDao.addOrUpdateUser(user) } returns user + coEvery { mockJellyfinServerDao.getServer(serverId) } returns + JellyfinServerUsers(server, listOf(user)) + every { mockMostRecentServerProvider.get() } returns + MostRecentServer.from(CurrentUser(server, user)) + + val result = create().restoreLastSession() + assertTrue(result is RestoredSession.Success) + val current = (result as RestoredSession.Success).currentUser + assertEquals(server, current.server) + assertEquals(user, current.user) + + verify(exactly = 1) { + mockApiClient.update( + baseUrl = server.url, + accessToken = user.accessToken, + clientInfo = any(), + deviceInfo = any(), + ) + } + coVerify { mockMostRecentServerProvider.save(any()) } + } + + @Test + fun `Test restoreLastSession, user protected`() = + runTest { + every { mockMostRecentServerProvider.get() } returns + MostRecentServer.ServerAndUser(serverId, server.url, userId, "token") + + val result = create().restoreLastSession() + assertTrue(result is RestoredSession.ServerOnly) + val current = (result as RestoredSession.ServerOnly) + assertEquals(server, current.server) + } + + @Test + fun `Test restoreLastSession, server only`() = + runTest { + every { mockMostRecentServerProvider.get() } returns + MostRecentServer.Server(server.id, server.url) + + val result = create().restoreLastSession() + assertTrue(result is RestoredSession.ServerOnly) + val current = (result as RestoredSession.ServerOnly) + assertEquals(server, current.server) + } + + @Test + fun `Test restoreLastSession, nothing to restore`() = + runTest { + every { mockMostRecentServerProvider.get() } returns MostRecentServer.None + + val result = create().restoreLastSession() + assertTrue(result is RestoredSession.None) + } +} + +class TestMostRecentServerProvider : MostRecentServerProvider { + var mostRecentServer: MostRecentServer = MostRecentServer.None + + override fun get(): MostRecentServer = mostRecentServer + + override suspend fun save(current: CurrentUser) { + mostRecentServer = + if (current.user.accessToken.isNotNullOrBlank()) { + MostRecentServer.ServerAndUser( + current.server.id, + current.server.url, + current.user.id, + current.user.accessToken, + ) + } else { + MostRecentServer.Server( + current.server.id, + current.server.url, + ) + } + } + + override suspend fun clear() { + mostRecentServer = MostRecentServer.None + } + + override suspend fun clearUser() { + when (val s = mostRecentServer) { + MostRecentServer.None -> Unit + is MostRecentServer.Server -> Unit + is MostRecentServer.ServerAndUser -> MostRecentServer.Server(s.serverId, s.serverUrl) + } + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestStreamChoiceService.kt b/app/src/test/java/com/github/damontecres/wholphin/services/StreamChoiceServiceTest.kt similarity index 72% rename from app/src/test/java/com/github/damontecres/wholphin/test/TestStreamChoiceService.kt rename to app/src/test/java/com/github/damontecres/wholphin/services/StreamChoiceServiceTest.kt index f4d04c7e..d8304897 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/test/TestStreamChoiceService.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/services/StreamChoiceServiceTest.kt @@ -1,29 +1,32 @@ -package com.github.damontecres.wholphin.test +package com.github.damontecres.wholphin.services -import androidx.lifecycle.MutableLiveData -import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao +import com.github.damontecres.wholphin.data.SeriesTrackChoiceDao import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.ActivationFlag import com.github.damontecres.wholphin.data.model.ItemPlayback -import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice +import com.github.damontecres.wholphin.data.model.JellyfinUserPreferences +import com.github.damontecres.wholphin.data.model.SeriesTrackChoice +import com.github.damontecres.wholphin.data.model.SeriesTrackChoiceType +import com.github.damontecres.wholphin.data.model.ServerUserConfig import com.github.damontecres.wholphin.data.model.TrackIndex import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.DefaultUserConfiguration +import com.github.damontecres.wholphin.preferences.SubtitleModePreference import com.github.damontecres.wholphin.preferences.UserPreferences -import com.github.damontecres.wholphin.services.StreamChoiceService +import com.github.damontecres.wholphin.preferences.UserProfileSettings import io.mockk.every import io.mockk.mockk import org.jellyfin.sdk.model.UUID import org.jellyfin.sdk.model.api.MediaStream import org.jellyfin.sdk.model.api.MediaStreamType import org.jellyfin.sdk.model.api.SubtitlePlaybackMode -import org.jellyfin.sdk.model.api.UserDto import org.junit.Assert import org.junit.Test import org.junit.runner.RunWith import org.junit.runners.Parameterized @RunWith(Parameterized::class) -class TestStreamChoiceServiceBasic( +class StreamChoiceServiceTest( val input: TestInput, ) { @Test @@ -104,6 +107,56 @@ class TestStreamChoiceServiceBasic( ), itemPlayback = itemPlayback(subtitleIndex = TrackIndex.UNSPECIFIED), ), + TestInput( + 1, + SubtitlePlaybackMode.ALWAYS, + userSubtitleLang = "eng", + subtitles = + listOf( + subtitle(0, "eng", default = true, forced = true), + subtitle(1, "eng", default = false, forced = false), + ), + ), + TestInput( + 1, + SubtitlePlaybackMode.ALWAYS, + userSubtitleLang = null, + subtitles = + listOf( + subtitle(0, "eng", default = true, forced = true), + subtitle(1, "eng", default = false, forced = false), + ), + ), + TestInput( + 0, + SubtitlePlaybackMode.SMART, + userSubtitleLang = "eng", + subtitles = + listOf( + subtitle(0, "eng", default = true, forced = true), + subtitle(1, "eng", default = false, forced = false), + ), + ), + TestInput( + 0, + SubtitlePlaybackMode.ONLY_FORCED, + userSubtitleLang = "eng", + subtitles = + listOf( + subtitle(0, "eng", default = true, forced = true), + subtitle(1, "eng", default = false, forced = false), + ), + ), + TestInput( + null, + SubtitlePlaybackMode.NONE, + userSubtitleLang = "eng", + subtitles = + listOf( + subtitle(0, "eng", default = true, forced = true), + subtitle(1, "eng", default = false, forced = false), + ), + ), ) } } @@ -279,6 +332,7 @@ class TestStreamChoiceServiceSmart( TestInput( 0, SubtitlePlaybackMode.SMART, + userSubtitleLang = "eng", subtitles = listOf( subtitle(0, "eng", false), @@ -471,6 +525,54 @@ class TestStreamChoiceServiceOnlyForced( ), streamAudioLang = "eng", ), + TestInput( + 0, + SubtitlePlaybackMode.NONE, + userSubtitleLang = null, + subtitles = + listOf( + subtitle(0, "eng", forced = true), + subtitle(1, "spa", forced = false), + ), + streamAudioLang = "eng", + plc = plc(subtitlesOnlyForced = true), + ), + TestInput( + 0, + SubtitlePlaybackMode.DEFAULT, + userSubtitleLang = null, + subtitles = + listOf( + subtitle(0, "eng", forced = true), + subtitle(1, "spa", forced = true), + ), + streamAudioLang = "eng", + plc = plc(subtitlesOnlyForced = true), + ), + TestInput( + 0, + SubtitlePlaybackMode.DEFAULT, + userSubtitleLang = "eng", + subtitles = + listOf( + subtitle(0, "eng", forced = true), + subtitle(1, "spa", forced = false), + ), + streamAudioLang = "eng", + plc = plc(subtitlesOnlyForced = true), + ), + TestInput( + null, + SubtitlePlaybackMode.NONE, + userSubtitleLang = null, + subtitles = + listOf( + subtitle(0, "eng", forced = false), + subtitle(1, "spa", forced = false), + ), + streamAudioLang = "eng", + plc = plc(subtitlesOnlyForced = true), + ), ) } } @@ -490,7 +592,7 @@ class TestStreamChoiceServiceMultipleChoices( fun data(): Collection<TestInput> = listOf( TestInput( - 0, + 1, SubtitlePlaybackMode.ALWAYS, subtitles = listOf( @@ -751,6 +853,80 @@ class TestStreamChoiceServiceOnlyForcedClientOverride( } } +/** + * Tests for client-side "Only Forced" override (TrackIndex.ONLY_FORCED). + * This tests the findForcedTrack function with user subtitle language preference. + */ +@RunWith(Parameterized::class) +class TestStreamChoiceServiceAppPreferences( + val input: TestInput, +) { + @Test + fun test() { + runTest(input) + } + + companion object { + @JvmStatic + @Parameterized.Parameters(name = "{index}: {0}") + fun data(): Collection<TestInput> = + listOf( + TestInput( + expectedIndex = 1, + userSubtitleMode = SubtitlePlaybackMode.ALWAYS, + userSubtitleLang = "spa", + subtitles = + listOf( + subtitle(0, "eng", default = true), + subtitle(1, "spa"), + ), + streamAudioLang = "eng", + itemPlayback = null, + appSubtitleLang = UserProfileSettings.USE_USER_PROFILE, + ), + TestInput( + expectedIndex = 0, + userSubtitleMode = SubtitlePlaybackMode.ALWAYS, + userSubtitleLang = "spa", + subtitles = + listOf( + subtitle(0, "eng", default = true), + subtitle(1, "spa"), + ), + streamAudioLang = "eng", + itemPlayback = null, + appSubtitleLang = "eng", + ), + TestInput( + expectedIndex = 1, + userSubtitleMode = SubtitlePlaybackMode.ALWAYS, + userSubtitleLang = "spa", + subtitles = + listOf( + subtitle(0, "eng", default = true), + subtitle(1, "spa"), + ), + streamAudioLang = "eng", + itemPlayback = null, + appSubtitleLang = "spa", + ), + TestInput( + expectedIndex = 1, // ItemPlayback + userSubtitleMode = SubtitlePlaybackMode.ALWAYS, + userSubtitleLang = "spa", + subtitles = + listOf( + subtitle(0, "eng", default = true), + subtitle(1, "spa"), + ), + streamAudioLang = "eng", + itemPlayback = itemPlayback(subtitleIndex = 1), + appSubtitleLang = "spa", + ), + ) + } +} + data class TestInput( val expectedIndex: Int?, val userSubtitleMode: SubtitlePlaybackMode?, @@ -759,52 +935,59 @@ data class TestInput( val streamAudioLang: String? = "eng", val subtitles: List<MediaStream>, val itemPlayback: ItemPlayback? = null, - val plc: PlaybackLanguageChoice? = null, + val plc: List<SeriesTrackChoice> = emptyList(), + val appAudioLang: String = UserProfileSettings.USE_USER_PROFILE, + val appSubtitleLang: String = UserProfileSettings.USE_USER_PROFILE, + val appSubtitleMode: SubtitleModePreference = SubtitleModePreference.USE_USER_PROFILE, ) { override fun toString(): String = "test(mode=$userSubtitleMode, subtitles=${subtitles.map { it.toShortString() }})" } private fun MediaStream.toShortString(): String = "$type(index=$index, lang=$language, default=$isDefault, forced=$isForced)" -private fun serverRepo( +fun mockServerRepo( audioLang: String?, subtitleMode: SubtitlePlaybackMode?, subtitleLang: String?, ): ServerRepository { val mocked = mockk<ServerRepository>() every { mocked.currentUserDto } returns - MutableLiveData( - UserDto( - id = UUID.randomUUID(), - hasPassword = true, - hasConfiguredPassword = true, - hasConfiguredEasyPassword = true, - configuration = - DefaultUserConfiguration.copy( - audioLanguagePreference = audioLang, - subtitleMode = subtitleMode ?: SubtitlePlaybackMode.DEFAULT, - subtitleLanguagePreference = subtitleLang, - ), - ), + ServerUserConfig( + id = UUID.randomUUID(), + configuration = + DefaultUserConfiguration.copy( + audioLanguagePreference = audioLang, + subtitleMode = subtitleMode ?: SubtitlePlaybackMode.DEFAULT, + subtitleLanguagePreference = subtitleLang, + ), ) + return mocked } private fun runTest(input: TestInput) { val service = StreamChoiceService( - serverRepo(input.userAudioLang, input.userSubtitleMode, input.userSubtitleLang), - mockk<PlaybackLanguageChoiceDao>(), + mockServerRepo(input.userAudioLang, input.userSubtitleMode, input.userSubtitleLang), + mockk<SeriesTrackChoiceDao>(), ) val result = service.chooseSubtitleStream( audioStreamLang = input.streamAudioLang, candidates = input.subtitles, itemPlayback = input.itemPlayback, - playbackLanguageChoice = input.plc, - prefs = UserPreferences(AppPreferences.getDefaultInstance()), + stc = input.plc, + prefs = + UserPreferences( + AppPreferences.getDefaultInstance(), + JellyfinUserPreferences( + preferredAudioLanguage = input.appAudioLang, + preferredSubtitleLanguage = input.appSubtitleLang, + subtitleMode = input.appSubtitleMode, + ), + ), ) - Assert.assertEquals(input.expectedIndex, result?.index) + Assert.assertEquals(input.expectedIndex, result.stream?.index) } fun subtitle( @@ -812,6 +995,8 @@ fun subtitle( lang: String?, default: Boolean = false, forced: Boolean = false, + sdh: Boolean = false, + external: Boolean = false, title: String? = null, ): MediaStream = MediaStream( @@ -819,6 +1004,26 @@ fun subtitle( language = lang, isDefault = default, isForced = forced, + isHearingImpaired = sdh, + isInterlaced = false, + index = index, + isExternal = external, + isTextSubtitleStream = true, + supportsExternalStream = true, + title = title, + ) + +fun audio( + index: Int, + lang: String?, + default: Boolean = false, + title: String? = null, +): MediaStream = + MediaStream( + type = MediaStreamType.AUDIO, + language = lang, + isDefault = default, + isForced = false, isHearingImpaired = false, isInterlaced = false, index = index, @@ -845,11 +1050,63 @@ private fun plc( audioLang: String? = null, subtitleLang: String? = null, subtitlesDisabled: Boolean? = if (subtitleLang != null) false else null, -): PlaybackLanguageChoice = - PlaybackLanguageChoice( - userId = 1, - seriesId = UUID.randomUUID(), - audioLanguage = audioLang, - subtitleLanguage = subtitleLang, - subtitlesDisabled = subtitlesDisabled, - ) + subtitlesOnlyForced: Boolean? = if (subtitleLang != null) false else null, +): List<SeriesTrackChoice> = + buildList { + audioLang + ?.let { + SeriesTrackChoice( + userId = 1, + language = audioLang, + parentId = UUID.randomUUID(), + type = SeriesTrackChoiceType.AUDIO, + activation = ActivationFlag.ACTIVATED, + trackFlags = 0, + codec = null, + relativeTrackIndex = null, + title = null, + audioChannels = null, + ) + }?.let(::add) + + if (subtitleLang != null) { + SeriesTrackChoice( + userId = 1, + language = subtitleLang, + parentId = UUID.randomUUID(), + type = SeriesTrackChoiceType.SUBTITLE, + activation = ActivationFlag.ACTIVATED, + trackFlags = 0, + codec = null, + relativeTrackIndex = null, + title = null, + audioChannels = null, + ).let(::add) + } else if (subtitlesDisabled == true) { + SeriesTrackChoice( + userId = 1, + language = null, + parentId = UUID.randomUUID(), + type = SeriesTrackChoiceType.SUBTITLE, + activation = ActivationFlag.DISABLED, + trackFlags = 0, + codec = null, + relativeTrackIndex = null, + title = null, + audioChannels = null, + ).let(::add) + } else if (subtitlesOnlyForced == true) { + SeriesTrackChoice( + userId = 1, + language = null, + parentId = UUID.randomUUID(), + type = SeriesTrackChoiceType.SUBTITLE, + activation = ActivationFlag.ONLY_FORCED, + trackFlags = 0, + codec = null, + relativeTrackIndex = null, + title = null, + audioChannels = null, + ).let(::add) + } + } diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionServiceTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionServiceTest.kt index f8fc8445..403a2715 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionServiceTest.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionServiceTest.kt @@ -1,24 +1,26 @@ package com.github.damontecres.wholphin.services import androidx.arch.core.executor.testing.InstantTaskExecutorRule -import androidx.lifecycle.MutableLiveData +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequest import androidx.work.WorkInfo import androidx.work.WorkManager import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.util.GetItemsRequestHandler +import com.github.damontecres.wholphin.util.WholphinDispatchers +import com.github.damontecres.wholphin.util.configure +import com.github.damontecres.wholphin.util.reset import io.mockk.coEvery import io.mockk.every import io.mockk.mockk import io.mockk.verify -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.test.setMain import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.Response import org.jellyfin.sdk.model.api.BaseItemDto @@ -46,12 +48,12 @@ class SuggestionServiceTest { @Before fun setUp() { - Dispatchers.setMain(testDispatcher) + WholphinDispatchers.configure(testDispatcher) } @After fun tearDown() { - Dispatchers.resetMain() + WholphinDispatchers.reset() io.mockk.unmockkObject(GetItemsRequestHandler) } @@ -63,13 +65,15 @@ class SuggestionServiceTest { workManager = mockWorkManager, ) - private fun mockQueryResult(items: List<BaseItemDto>): Response<BaseItemDtoQueryResult> = - mockk { - every { content } returns - mockk { - every { this@mockk.items } returns items - } + private fun mockQueryResult(items: List<BaseItemDto>): Response<BaseItemDtoQueryResult> { + val queryResult = + mockk<BaseItemDtoQueryResult> { + every { this@mockk.items } returns items + } + return mockk { + every { content } returns queryResult } + } private fun mockUser(id: UUID = UUID.randomUUID()): JellyfinUser = JellyfinUser( @@ -81,12 +85,21 @@ class SuggestionServiceTest { private fun mockWorkInfo(state: WorkInfo.State): WorkInfo = mockk<WorkInfo> { every { this@mockk.state } returns state } + private fun mockEnqueueOnDemandWork() { + every { + mockWorkManager.enqueueUniqueWork( + any<String>(), + any<ExistingWorkPolicy>(), + any<OneTimeWorkRequest>(), + ) + } returns mockk(relaxed = true) + } + @Test fun getSuggestionsFlow_returnsEmpty_whenNoUserLoggedIn() = runTest { - val currentUser = MutableLiveData<JellyfinUser?>(null) - every { mockServerRepository.currentUser } returns currentUser - every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns flowOf(emptyList()) + val currentUser = MutableStateFlow<JellyfinUser?>(null) + every { mockServerRepository.currentUserFlow } returns currentUser val service = createService() val result = service.getSuggestionsFlow(UUID.randomUUID(), BaseItemKind.MOVIE).first() @@ -95,22 +108,31 @@ class SuggestionServiceTest { } @Test - fun maps_active_work_states_to_Loading() = + fun maps_active_onDemand_work_states_to_Loading() = runTest { listOf(WorkInfo.State.RUNNING, WorkInfo.State.ENQUEUED).forEach { state -> val userId = UUID.randomUUID() val parentId = UUID.randomUUID() - val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId)) + val currentUser = MutableStateFlow<JellyfinUser?>(mockUser(userId)) + val workName = SuggestionsWorker.getOnDemandWorkName(userId, parentId, BaseItemKind.MOVIE) - every { mockServerRepository.currentUser } returns currentUser + every { mockServerRepository.currentUserFlow } returns currentUser coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returns null - every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns + mockEnqueueOnDemandWork() + every { mockWorkManager.getWorkInfosForUniqueWorkFlow(workName) } returns flowOf(listOf(mockWorkInfo(state))) val service = createService() val result = service.getSuggestionsFlow(parentId, BaseItemKind.MOVIE).first() assertEquals(SuggestionsResource.Loading, result) + verify { + mockWorkManager.enqueueUniqueWork( + workName, + ExistingWorkPolicy.REPLACE, + any<OneTimeWorkRequest>(), + ) + } } } @@ -120,11 +142,13 @@ class SuggestionServiceTest { listOf(WorkInfo.State.SUCCEEDED, WorkInfo.State.FAILED, WorkInfo.State.CANCELLED).forEach { state -> val userId = UUID.randomUUID() val parentId = UUID.randomUUID() - val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId)) + val currentUser = MutableStateFlow<JellyfinUser?>(mockUser(userId)) + val workName = SuggestionsWorker.getOnDemandWorkName(userId, parentId, BaseItemKind.MOVIE) - every { mockServerRepository.currentUser } returns currentUser + every { mockServerRepository.currentUserFlow } returns currentUser coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returns null - every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns + mockEnqueueOnDemandWork() + every { mockWorkManager.getWorkInfosForUniqueWorkFlow(workName) } returns flowOf(listOf(mockWorkInfo(state))) val service = createService() @@ -139,16 +163,56 @@ class SuggestionServiceTest { runTest { val userId = UUID.randomUUID() val parentId = UUID.randomUUID() - val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId)) + val currentUser = MutableStateFlow<JellyfinUser?>(mockUser(userId)) + val workName = SuggestionsWorker.getOnDemandWorkName(userId, parentId, BaseItemKind.MOVIE) - every { mockServerRepository.currentUser } returns currentUser + every { mockServerRepository.currentUserFlow } returns currentUser coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returns null - every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns flowOf(emptyList()) + mockEnqueueOnDemandWork() + every { mockWorkManager.getWorkInfosForUniqueWorkFlow(workName) } returns flowOf(emptyList()) val service = createService() val result = service.getSuggestionsFlow(parentId, BaseItemKind.MOVIE).first() assertEquals(SuggestionsResource.Empty, result) + verify { + mockWorkManager.enqueueUniqueWork( + workName, + ExistingWorkPolicy.REPLACE, + any<OneTimeWorkRequest>(), + ) + } + } + + @Test + fun getSuggestionsFlow_returnsSuccess_whenOnDemandWorkCachesItems() = + runTest { + val userId = UUID.randomUUID() + val parentId = UUID.randomUUID() + val currentUser = MutableStateFlow<JellyfinUser?>(mockUser(userId)) + val workName = SuggestionsWorker.getOnDemandWorkName(userId, parentId, BaseItemKind.MOVIE) + val cachedId = UUID.randomUUID() + + every { mockServerRepository.currentUserFlow } returns currentUser + coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returnsMany + listOf(null, CachedSuggestions(listOf(cachedId))) + mockEnqueueOnDemandWork() + every { mockWorkManager.getWorkInfosForUniqueWorkFlow(workName) } returns + flowOf(listOf(mockWorkInfo(WorkInfo.State.SUCCEEDED))) + + val dto = + mockk<BaseItemDto>(relaxed = true) { + every { id } returns cachedId + every { type } returns BaseItemKind.MOVIE + } + io.mockk.mockkObject(GetItemsRequestHandler) + coEvery { GetItemsRequestHandler.execute(mockApi, any()) } returns mockQueryResult(listOf(dto)) + + val service = createService() + val result = service.getSuggestionsFlow(parentId, BaseItemKind.MOVIE).first() + + assertTrue(result is SuggestionsResource.Success) + assertEquals(cachedId, (result as SuggestionsResource.Success).items.single().id) } @Test @@ -156,37 +220,46 @@ class SuggestionServiceTest { runTest { val userId = UUID.randomUUID() val parentId = UUID.randomUUID() - val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId)) + val currentUser = MutableStateFlow<JellyfinUser?>(mockUser(userId)) - every { mockServerRepository.currentUser } returns currentUser + every { mockServerRepository.currentUserFlow } returns currentUser coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returns CachedSuggestions(emptyList()) - every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns - flowOf(listOf(mockWorkInfo(WorkInfo.State.ENQUEUED))) val service = createService() val result = service.getSuggestionsFlow(parentId, BaseItemKind.MOVIE).first() assertEquals(SuggestionsResource.Empty, result) verify(exactly = 0) { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } + verify(exactly = 0) { + mockWorkManager.enqueueUniqueWork( + any<String>(), + any<ExistingWorkPolicy>(), + any<OneTimeWorkRequest>(), + ) + } } @Test - fun getSuggestionsFlow_returnsLoading_whenCacheMissing_andWorkIsEnqueued() = + fun getSuggestionsFlow_ignores_periodic_work_whenCacheMissing() = runTest { val userId = UUID.randomUUID() val parentId = UUID.randomUUID() - val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId)) + val currentUser = MutableStateFlow<JellyfinUser?>(mockUser(userId)) + val workName = SuggestionsWorker.getOnDemandWorkName(userId, parentId, BaseItemKind.MOVIE) - every { mockServerRepository.currentUser } returns currentUser + every { mockServerRepository.currentUserFlow } returns currentUser coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returns null - every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns + mockEnqueueOnDemandWork() + every { mockWorkManager.getWorkInfosForUniqueWorkFlow(SuggestionsWorker.WORK_NAME) } returns flowOf(listOf(mockWorkInfo(WorkInfo.State.ENQUEUED))) + every { mockWorkManager.getWorkInfosForUniqueWorkFlow(workName) } returns flowOf(emptyList()) val service = createService() val result = service.getSuggestionsFlow(parentId, BaseItemKind.MOVIE).first() - assertEquals(SuggestionsResource.Loading, result) - verify(exactly = 1) { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } + assertEquals(SuggestionsResource.Empty, result) + verify(exactly = 0) { mockWorkManager.getWorkInfosForUniqueWorkFlow(SuggestionsWorker.WORK_NAME) } + verify(exactly = 1) { mockWorkManager.getWorkInfosForUniqueWorkFlow(workName) } } @Test @@ -195,10 +268,12 @@ class SuggestionServiceTest { val userId = UUID.randomUUID() val libraryId = UUID.randomUUID() val otherLibraryId = UUID.randomUUID() - val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId)) + val currentUser = MutableStateFlow<JellyfinUser?>(mockUser(userId)) + val workName = SuggestionsWorker.getOnDemandWorkName(userId, libraryId, BaseItemKind.MOVIE) - every { mockServerRepository.currentUser } returns currentUser - every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns flowOf(emptyList()) + every { mockServerRepository.currentUserFlow } returns currentUser + mockEnqueueOnDemandWork() + every { mockWorkManager.getWorkInfosForUniqueWorkFlow(workName) } returns flowOf(emptyList()) coEvery { mockCache.get(userId, libraryId, BaseItemKind.MOVIE) } returns null coEvery { @@ -229,9 +304,9 @@ class SuggestionServiceTest { runTest { val userId = UUID.randomUUID() val parentId = UUID.randomUUID() - val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId)) + val currentUser = MutableStateFlow<JellyfinUser?>(mockUser(userId)) - every { mockServerRepository.currentUser } returns currentUser + every { mockServerRepository.currentUserFlow } returns currentUser val cachedId = UUID.randomUUID() coEvery { @@ -250,8 +325,6 @@ class SuggestionServiceTest { io.mockk.mockkObject(GetItemsRequestHandler) coEvery { GetItemsRequestHandler.execute(mockApi, any()) } returns mockQueryResult(listOf(dto)) - every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns flowOf(emptyList()) - val service = createService() val result = service.getSuggestionsFlow(parentId, BaseItemKind.MOVIE).first() @@ -266,9 +339,9 @@ class SuggestionServiceTest { runTest { val userId = UUID.randomUUID() val parentId = UUID.randomUUID() - val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId)) + val currentUser = MutableStateFlow<JellyfinUser?>(mockUser(userId)) - every { mockServerRepository.currentUser } returns currentUser + every { mockServerRepository.currentUserFlow } returns currentUser val cachedId = UUID.randomUUID() coEvery { @@ -282,8 +355,6 @@ class SuggestionServiceTest { io.mockk.mockkObject(GetItemsRequestHandler) coEvery { GetItemsRequestHandler.execute(mockApi, any()) } throws RuntimeException("Network error") - every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns flowOf(emptyList()) - val service = createService() val result = service.getSuggestionsFlow(parentId, BaseItemKind.MOVIE).first() diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerServiceTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerServiceTest.kt index 0b48a011..f66e7292 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerServiceTest.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerServiceTest.kt @@ -5,25 +5,25 @@ import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleRegistry -import androidx.lifecycle.MutableLiveData import androidx.work.PeriodicWorkRequest import androidx.work.WorkManager import com.github.damontecres.wholphin.data.CurrentUser import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.data.model.JellyfinUser +import com.github.damontecres.wholphin.util.WholphinDispatchers +import com.github.damontecres.wholphin.util.configure +import com.github.damontecres.wholphin.util.reset import com.google.common.util.concurrent.ListenableFuture import io.mockk.every import io.mockk.mockk import io.mockk.slot import io.mockk.verify -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.test.setMain import org.junit.After import org.junit.Assert.assertEquals import org.junit.Before @@ -35,7 +35,7 @@ import java.util.UUID class SuggestionsSchedulerServiceTest { @get:Rule val instantTaskExecutorRule = InstantTaskExecutorRule() private val testDispatcher = StandardTestDispatcher() - private val currentLiveData = MutableLiveData<CurrentUser?>() + private val currentUser = MutableStateFlow<CurrentUser?>(null) private val mockActivity = mockk<AppCompatActivity>(relaxed = true) private val mockServerRepository = mockk<ServerRepository>(relaxed = true) private val mockWorkManager = mockk<WorkManager>(relaxed = true) @@ -43,22 +43,22 @@ class SuggestionsSchedulerServiceTest { @Before fun setUp() { - Dispatchers.setMain(testDispatcher) + WholphinDispatchers.configure(testDispatcher) every { mockActivity.lifecycle } returns lifecycleRegistry - every { mockServerRepository.current } returns currentLiveData + every { mockServerRepository.current } returns currentUser lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_RESUME) } @After - fun tearDown() = Dispatchers.resetMain() + fun tearDown() = WholphinDispatchers.reset() private fun createService() = SuggestionsSchedulerService( context = mockActivity, serverRepository = mockServerRepository, workManager = mockWorkManager, + dispatcher = testDispatcher, ).also { - it.dispatcher = testDispatcher it.initialDelaySecondsProvider = { 60L } } @@ -74,7 +74,7 @@ class SuggestionsSchedulerServiceTest { runTest { mockWorkInfos(emptyList()) createService() - currentLiveData.value = + currentUser.value = CurrentUser( user = JellyfinUser(id = UUID.randomUUID(), name = "User", serverId = UUID.randomUUID(), accessToken = "token"), server = JellyfinServer(id = UUID.randomUUID(), name = "Server", url = "http://localhost", version = null), @@ -88,13 +88,13 @@ class SuggestionsSchedulerServiceTest { runTest { mockWorkInfos(emptyList()) createService() - currentLiveData.value = + currentUser.value = CurrentUser( user = JellyfinUser(id = UUID.randomUUID(), name = "User", serverId = UUID.randomUUID(), accessToken = "token"), server = JellyfinServer(id = UUID.randomUUID(), name = "Server", url = "http://localhost", version = null), ) advanceUntilIdle() - currentLiveData.value = null + currentUser.value = null advanceUntilIdle() verify { mockWorkManager.cancelUniqueWork(SuggestionsWorker.WORK_NAME) } } @@ -113,7 +113,7 @@ class SuggestionsSchedulerServiceTest { } returns mockk() createService() - currentLiveData.value = + currentUser.value = CurrentUser( user = JellyfinUser(id = UUID.randomUUID(), name = "User", serverId = UUID.randomUUID(), accessToken = "token"), server = JellyfinServer(id = UUID.randomUUID(), name = "Server", url = "http://localhost", version = null), @@ -138,7 +138,7 @@ class SuggestionsSchedulerServiceTest { } returns mockk() createService() - currentLiveData.value = + currentUser.value = CurrentUser( user = JellyfinUser(id = UUID.randomUUID(), name = "User", serverId = UUID.randomUUID(), accessToken = "token"), server = JellyfinServer(id = UUID.randomUUID(), name = "Server", url = "http://localhost", version = null), @@ -159,7 +159,7 @@ class SuggestionsSchedulerServiceTest { mockWorkInfos(listOf(workInfo)) createService() - currentLiveData.value = + currentUser.value = CurrentUser( user = JellyfinUser(id = userId, name = "User", serverId = UUID.randomUUID(), accessToken = "token"), server = JellyfinServer(id = UUID.randomUUID(), name = "Server", url = "http://localhost", version = null), diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsWorkerTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsWorkerTest.kt index 5679777e..0e5e0eba 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsWorkerTest.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsWorkerTest.kt @@ -1,13 +1,14 @@ package com.github.damontecres.wholphin.services import androidx.datastore.core.DataStore -import androidx.lifecycle.MutableLiveData import androidx.work.Data import androidx.work.ListenableWorker import androidx.work.WorkerParameters import com.github.damontecres.wholphin.data.CurrentUser +import com.github.damontecres.wholphin.data.RestoredSession import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.test.currentUser import com.github.damontecres.wholphin.util.GetItemsRequestHandler import io.mockk.coEvery import io.mockk.coVerify @@ -15,6 +16,7 @@ import io.mockk.every import io.mockk.mockk import io.mockk.mockkObject import io.mockk.unmockkObject +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest import org.jellyfin.sdk.api.client.ApiClient @@ -55,6 +57,8 @@ class SuggestionsWorkerTest { private fun createWorker( userId: UUID? = testUserId, serverId: UUID? = testServerId, + parentId: UUID? = null, + itemKind: BaseItemKind? = null, ): SuggestionsWorker { val inputData = Data @@ -62,6 +66,8 @@ class SuggestionsWorkerTest { .apply { userId?.let { putString(SuggestionsWorker.PARAM_USER_ID, it.toString()) } serverId?.let { putString(SuggestionsWorker.PARAM_SERVER_ID, it.toString()) } + parentId?.let { putString(SuggestionsWorker.PARAM_PARENT_ID, it.toString()) } + itemKind?.let { putString(SuggestionsWorker.PARAM_ITEM_KIND, it.serialName) } }.build() every { mockWorkerParams.inputData } returns inputData return SuggestionsWorker( @@ -90,6 +96,17 @@ class SuggestionsWorkerTest { } } + @Test + fun returns_failure_on_partial_onDemand_input() = + runTest { + listOf( + createWorker(parentId = UUID.randomUUID()), + createWorker(itemKind = BaseItemKind.MOVIE), + ).forEach { worker -> + assertEquals(ListenableWorker.Result.failure(), worker.doWork()) + } + } + @Test fun restores_session_when_api_not_configured() = runTest { @@ -97,20 +114,35 @@ class SuggestionsWorkerTest { every { mockApi.accessToken } returns null val mockUser = mockk<CurrentUser>() var restored = false - every { mockServerRepository.current } answers { MutableLiveData(if (restored) mockUser else null) } - coEvery { mockServerRepository.restoreSession(testServerId, testUserId) } coAnswers { + every { mockServerRepository.current } answers { MutableStateFlow(if (restored) mockUser else null) } + coEvery { mockServerRepository.restoreLastSession() } coAnswers { restored = true - mockUser + RestoredSession.Success(currentUser()) } every { mockPreferences.data } returns flowOf(mockPrefs()) coEvery { mockUserViewsApi.getUserViews(userId = testUserId) } returns mockQueryResult() val result = createWorker().doWork() - coVerify { mockServerRepository.restoreSession(testServerId, testUserId) } + coVerify { mockServerRepository.restoreLastSession() } assertEquals(ListenableWorker.Result.success(), result) } + @Test + fun onDemand_work_caches_only_requested_library() = + runTest { + val parentId = UUID.randomUUID() + every { mockPreferences.data } returns flowOf(mockPrefs()) + mockkObject(GetItemsRequestHandler) + coEvery { GetItemsRequestHandler.execute(mockApi, any()) } returns mockQueryResult(emptyList()) + + val result = createWorker(parentId = parentId, itemKind = BaseItemKind.MOVIE).doWork() + + assertEquals(ListenableWorker.Result.success(), result) + coVerify(exactly = 0) { mockUserViewsApi.getUserViews(userId = any()) } + coVerify { mockCache.put(testUserId, parentId, BaseItemKind.MOVIE, emptyList()) } + } + @Test fun caches_suggestions_for_supported_types() = runTest { @@ -136,6 +168,43 @@ class SuggestionsWorkerTest { } } + @Test + fun caches_empty_suggestions_for_supported_types() = + runTest { + val viewId = UUID.randomUUID() + val view = + mockk<BaseItemDto>(relaxed = true) { + every { id } returns viewId + every { this@mockk.collectionType } returns CollectionType.MOVIES + } + every { mockPreferences.data } returns flowOf(mockPrefs()) + coEvery { mockUserViewsApi.getUserViews(userId = testUserId) } returns mockQueryResult(listOf(view)) + mockkObject(GetItemsRequestHandler) + coEvery { GetItemsRequestHandler.execute(mockApi, any()) } returns mockQueryResult(emptyList()) + + val result = createWorker().doWork() + + assertEquals(ListenableWorker.Result.success(), result) + coVerify { mockCache.put(testUserId, viewId, BaseItemKind.MOVIE, emptyList()) } + } + + @Test + fun skips_unsupported_collection_types() = + runTest { + val view = + mockk<BaseItemDto>(relaxed = true) { + every { id } returns UUID.randomUUID() + every { this@mockk.collectionType } returns CollectionType.MUSIC + } + every { mockPreferences.data } returns flowOf(mockPrefs()) + coEvery { mockUserViewsApi.getUserViews(userId = testUserId) } returns mockQueryResult(listOf(view)) + + val result = createWorker().doWork() + + assertEquals(ListenableWorker.Result.success(), result) + coVerify(exactly = 0) { mockCache.put(any(), any(), any(), any()) } + } + @Test fun fetches_contextual_suggestions_when_genres_available() = runTest { @@ -227,7 +296,12 @@ class SuggestionsWorkerTest { } } -fun mockQueryResult(items: List<BaseItemDto> = emptyList()) = - mockk<org.jellyfin.sdk.api.client.Response<BaseItemDtoQueryResult>>(relaxed = true) { - every { content } returns mockk { every { this@mockk.items } returns items } +fun mockQueryResult(items: List<BaseItemDto> = emptyList()): org.jellyfin.sdk.api.client.Response<BaseItemDtoQueryResult> { + val queryResult = + mockk<BaseItemDtoQueryResult> { + every { this@mockk.items } returns items + } + return mockk(relaxed = true) { + every { content } returns queryResult } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/BaseItemDestinationTest.kt b/app/src/test/java/com/github/damontecres/wholphin/test/BaseItemDestinationTest.kt new file mode 100644 index 00000000..4ef8ab8e --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/test/BaseItemDestinationTest.kt @@ -0,0 +1,49 @@ +package com.github.damontecres.wholphin.test + +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.ui.nav.Destination +import org.jellyfin.sdk.model.api.BaseItemKind +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import java.util.UUID + +class BaseItemDestinationTest { + @Test + fun audioWithAlbumId_navigatesToAlbumWithInitialSongId() { + val songId = UUID.randomUUID() + val albumId = UUID.randomUUID() + val item = BaseItem(song(id = songId, albumId = albumId), false) + + val dest = item.destination() + + assertEquals(Destination.MediaItem::class, dest::class) + val media = dest as Destination.MediaItem + assertEquals(albumId, media.itemId) + assertEquals(BaseItemKind.MUSIC_ALBUM, media.type) + assertEquals(songId, media.initialSongId) + } + + @Test + fun audioWithoutAlbumId_returnsSelfAsMediaItem() { + val songId = UUID.randomUUID() + val item = BaseItem(song(id = songId, albumId = null), false) + + val dest = item.destination() + + assertEquals(songId, (dest as Destination.MediaItem).itemId) + assertEquals(BaseItemKind.AUDIO, dest.type) + assertNull(dest.initialSongId) + } + + @Test + fun movie_usesStandardDestination() { + val movieId = UUID.randomUUID() + val item = BaseItem(movie(id = movieId), false) + + val dest = item.destination() + + assertEquals(movieId, (dest as Destination.MediaItem).itemId) + assertEquals(BaseItemKind.MOVIE, dest.type) + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestFindIndexByNumberOrId.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestFindIndexByNumberOrId.kt new file mode 100644 index 00000000..6a801c16 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestFindIndexByNumberOrId.kt @@ -0,0 +1,222 @@ +package com.github.damontecres.wholphin.test + +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.ui.detail.series.findIndexByNumberOrId +import com.github.damontecres.wholphin.util.BlockingList +import kotlinx.coroutines.test.runTest +import org.jellyfin.sdk.model.UUID +import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.BaseItemKind +import org.junit.Assert +import org.junit.Test + +class TestFindIndexByNumberOrId { + val season0Id = UUID.randomUUID() + val season2Id = UUID.randomUUID() + + fun create( + indexNumber: Int, + parentIndexNumber: Int, + ) = BaseItem( + BaseItemDto( + id = UUID.randomUUID(), + type = BaseItemKind.EPISODE, + indexNumber = indexNumber, + parentIndexNumber = parentIndexNumber, + parentId = + when (parentIndexNumber) { + 0 -> season0Id + 2 -> season2Id + else -> null + }, + ), + ) + + val epS02E01 = create(1, 2) + val epS02E02 = create(2, 2) + val epS02E03 = create(3, 2) + val epS02E04 = create(4, 2) + val epS02E05 = create(5, 2) + val epS02E06 = create(6, 2) + val epS00E08 = create(8, 0) + val epS00E02 = create(2, 0) + + private val episodes = + listOf( + epS02E01, + epS02E02, + epS02E03, + ) + + private val missingEpisodes = + listOf( + epS02E01, + epS02E04, + epS02E05, + ) + + private val withSpecials = + listOf( + epS02E01, + epS02E02, + epS02E03, + epS00E08, + ) + + @Test + fun `Basic tests`() = + runTest { + findIndexByNumberOrId(targetNum = 1, targetId = epS02E01.id, list = BlockingList.of(episodes)).let { index -> + Assert.assertEquals(0, index) + } + findIndexByNumberOrId(targetNum = 2, targetId = epS02E02.id, list = BlockingList.of(episodes)).let { index -> + Assert.assertEquals(1, index) + } + findIndexByNumberOrId(targetNum = 3, targetId = epS02E03.id, list = BlockingList.of(episodes)).let { index -> + Assert.assertEquals(2, index) + } + findIndexByNumberOrId(targetNum = 100, targetId = UUID.randomUUID(), list = BlockingList.of(episodes)).let { index -> + Assert.assertEquals(0, index) + } + } + + @Test + fun `Test last index with zero start`() = + runTest { + val episodes = + BlockingList.of( + listOf( + create(0, 2), + epS02E01, + epS02E02, + epS02E03, + epS02E04, + epS02E05, + epS02E06, + ), + ) + findIndexByNumberOrId( + targetNum = 6, + targetId = epS02E06.id, + list = BlockingList.of(episodes), + ).let { index -> + Assert.assertEquals(6, index) + } + } + + @Test + fun `Test missing episodes`() = + runTest { + findIndexByNumberOrId(targetNum = 1, targetId = epS02E01.id, list = BlockingList.of(missingEpisodes)).let { index -> + Assert.assertEquals(0, index) + } + findIndexByNumberOrId(targetNum = 2, targetId = epS02E02.id, list = BlockingList.of(missingEpisodes)).let { index -> + Assert.assertEquals(0, index) + } + findIndexByNumberOrId(targetNum = 4, targetId = epS02E04.id, list = BlockingList.of(missingEpisodes)).let { index -> + Assert.assertEquals(1, index) + } + findIndexByNumberOrId(targetNum = 5, targetId = epS02E05.id, list = BlockingList.of(missingEpisodes)).let { index -> + Assert.assertEquals(2, index) + } + } + + @Test + fun `Test missing episodes without ID`() = + runTest { + findIndexByNumberOrId(targetNum = 1, targetId = null, list = BlockingList.of(missingEpisodes)).let { index -> + Assert.assertEquals(0, index) + } + findIndexByNumberOrId(targetNum = 2, targetId = null, list = BlockingList.of(missingEpisodes)).let { index -> + Assert.assertEquals(0, index) + } + findIndexByNumberOrId(targetNum = 4, targetId = null, list = BlockingList.of(missingEpisodes)).let { index -> + Assert.assertEquals(0, index) + } + findIndexByNumberOrId(targetNum = 5, targetId = null, list = BlockingList.of(missingEpisodes)).let { index -> + Assert.assertEquals(0, index) + } + } + + @Test + fun `Test with special`() = + runTest { + findIndexByNumberOrId(targetNum = 1, targetId = epS02E01.id, list = BlockingList.of(withSpecials)).let { index -> + Assert.assertEquals(0, index) + } + findIndexByNumberOrId(targetNum = 2, targetId = epS02E02.id, list = BlockingList.of(withSpecials)).let { index -> + Assert.assertEquals(1, index) + } + findIndexByNumberOrId(targetNum = 3, targetId = epS02E03.id, list = BlockingList.of(withSpecials)).let { index -> + Assert.assertEquals(2, index) + } + } + + @Test + fun `Test with special matching number`() = + runTest { + val list = listOf(epS02E01, epS00E02, epS02E02) + findIndexByNumberOrId( + targetNum = 2, + targetId = epS02E02.id, + list = BlockingList.of(list), + parentIndex = 2, + ).let { index -> + Assert.assertEquals(2, index) + } + } + + @Test + fun `Test with special in middle`() = + runTest { + val episodes = + BlockingList.of<BaseItem?>( + listOf( + epS02E01, + epS02E02, + epS02E03, + create(6, 0), + epS02E04, + epS02E05, + epS02E06, + ), + ) + + findIndexByNumberOrId( + targetNum = 3, + targetId = epS02E03.id, + list = episodes, + parentId = season2Id, + ).let { index -> + Assert.assertEquals(2, index) + } + + findIndexByNumberOrId( + targetNum = 4, + targetId = epS02E04.id, + list = episodes, + parentId = season2Id, + ).let { index -> + Assert.assertEquals(4, index) + } + } + + @Test + fun `Test with non-matching parent ID`() = + runTest { + val altSeason2Id = UUID.randomUUID() + Assert.assertNotEquals(altSeason2Id, season2Id) + Assert.assertNotEquals(altSeason2Id, epS02E02.data.parentId) + + val list = listOf(epS02E01, epS02E02) + findIndexByNumberOrId( + targetNum = 2, + targetId = epS02E02.id, + list = BlockingList.of(list), + parentId = altSeason2Id, + parentIndex = 2, + ).let { index -> + Assert.assertEquals(1, index) + } + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestHdrFormat.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestHdrFormat.kt new file mode 100644 index 00000000..e38479f0 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestHdrFormat.kt @@ -0,0 +1,85 @@ +package com.github.damontecres.wholphin.test + +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.HdrOverrideMode +import com.github.damontecres.wholphin.preferences.PlaybackOverrides +import com.github.damontecres.wholphin.preferences.getHdrRangeTypesFor +import com.github.damontecres.wholphin.preferences.updatePlaybackOverrides +import com.github.damontecres.wholphin.util.profile.WholphinVideoRangeType +import org.junit.Assert +import org.junit.Test + +class TestHdrFormat { + private fun appPreferences(block: PlaybackOverrides.Builder.() -> Unit): AppPreferences = + AppPreferences.getDefaultInstance().updatePlaybackOverrides(block) + + @Test + fun `Test all auto returns empty sets`() { + val prefs = appPreferences { } + Assert.assertEquals(emptySet<String>(), prefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_ENABLE, jellyfinTenEleven = true)) + Assert.assertEquals(emptySet<String>(), prefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_DISABLE, jellyfinTenEleven = true)) + } + + @Test + fun `Test HDR10 override`() { + val prefs = appPreferences { hdr10Override = HdrOverrideMode.HDR_OVERRIDE_ENABLE } + Assert.assertEquals( + setOf(WholphinVideoRangeType.HDR10.serialName), + prefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_ENABLE, jellyfinTenEleven = true), + ) + Assert.assertEquals(emptySet<String>(), prefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_DISABLE, jellyfinTenEleven = true)) + } + + @Test + fun `Test Dolby Vision Profile 5 override`() { + val prefs = appPreferences { doviProfile5Override = HdrOverrideMode.HDR_OVERRIDE_DISABLE } + Assert.assertEquals( + setOf(WholphinVideoRangeType.DOVI.serialName), + prefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_DISABLE, jellyfinTenEleven = true), + ) + } + + @Test + fun `Test Dolby Vision Profile 7 override requires Jellyfin 10_11`() { + val prefs = appPreferences { doviProfile7Override = HdrOverrideMode.HDR_OVERRIDE_ENABLE } + Assert.assertEquals( + emptySet<String>(), + prefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_ENABLE, jellyfinTenEleven = false), + ) + Assert.assertEquals( + setOf(WholphinVideoRangeType.DOVI_WITH_EL.serialName, WholphinVideoRangeType.DOVI_WITH_EL_HDR10_PLUS.serialName), + prefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_ENABLE, jellyfinTenEleven = true), + ) + } + + @Test + fun `Test Dolby Vision Profile 8 override`() { + val prefs = appPreferences { doviProfile8Override = HdrOverrideMode.HDR_OVERRIDE_DISABLE } + Assert.assertEquals( + setOf(WholphinVideoRangeType.DOVI_WITH_HDR10.serialName), + prefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_DISABLE, jellyfinTenEleven = false), + ) + Assert.assertEquals( + setOf(WholphinVideoRangeType.DOVI_WITH_HDR10.serialName, WholphinVideoRangeType.DOVI_WITH_HDR10_PLUS.serialName), + prefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_DISABLE, jellyfinTenEleven = true), + ) + } + + @Test + fun `Test multiple formats set to the same mode are unioned`() { + val prefs = + appPreferences { + hdr10Override = HdrOverrideMode.HDR_OVERRIDE_ENABLE + hdr10PlusOverride = HdrOverrideMode.HDR_OVERRIDE_ENABLE + doviProfile5Override = HdrOverrideMode.HDR_OVERRIDE_DISABLE + } + Assert.assertEquals( + setOf(WholphinVideoRangeType.HDR10.serialName, WholphinVideoRangeType.HDR10_PLUS.serialName), + prefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_ENABLE, jellyfinTenEleven = true), + ) + Assert.assertEquals( + setOf(WholphinVideoRangeType.DOVI.serialName), + prefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_DISABLE, jellyfinTenEleven = true), + ) + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestHomeRowSamples.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestHomeRowSamples.kt index e8c2fed1..a6fe6e2a 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/test/TestHomeRowSamples.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestHomeRowSamples.kt @@ -1,7 +1,9 @@ package com.github.damontecres.wholphin.test +import com.github.damontecres.wholphin.data.model.HomeGetItemsFilter import com.github.damontecres.wholphin.data.model.HomeRowConfig import com.github.damontecres.wholphin.data.model.HomeRowViewOptions +import com.github.damontecres.wholphin.data.model.KeyValueEntry import com.github.damontecres.wholphin.preferences.PrefContentScale import com.github.damontecres.wholphin.services.HomeSettingsService import com.github.damontecres.wholphin.ui.AspectRatio @@ -13,7 +15,6 @@ import org.jellyfin.sdk.model.UUID import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.SortOrder -import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.junit.Assert import org.junit.Test import kotlin.reflect.KClass @@ -61,8 +62,8 @@ class TestHomeRowSamples { ), HomeRowConfig.GetItems( name = "Episodes by date created", - getItems = - GetItemsRequest( + filter = + HomeGetItemsFilter( parentId = UUID.randomUUID(), recursive = true, isFavorite = true, @@ -77,6 +78,12 @@ class TestHomeRowSamples { HomeRowConfig.TvPrograms(), HomeRowConfig.TvChannels(), HomeRowConfig.Suggestions(parentId = UUID.randomUUID()), + HomeRowConfig.CustomEndpoint( + endpoint = "/path/to/items", + title = "Items", + query = listOf(KeyValueEntry("key", "value")), + headers = listOf(KeyValueEntry("X-Header", "1234")), + ), ) } @@ -100,6 +107,7 @@ class TestHomeRowSamples { is HomeRowConfig.Suggestions -> foundTypes.add(it::class) is HomeRowConfig.TvChannels -> foundTypes.add(it::class) is HomeRowConfig.Studios -> foundTypes.add(it::class) + is HomeRowConfig.CustomEndpoint -> foundTypes.add(it::class) } } Assert.assertEquals(HomeRowConfig::class.sealedSubclasses.size, foundTypes.size) @@ -126,12 +134,10 @@ class TestHomeRowSamples { context = mockk(), api = mockk(), serverRepository = mockk(), - userPreferencesService = mockk(), navDrawerService = mockk(), - latestNextUpService = mockk(), - imageUrlService = mockk(), - suggestionService = mockk(), displayPreferencesService = mockk(), + serverPluginApi = mockk(), + serverDao = mockk(), ) val str = """{ @@ -149,7 +155,7 @@ class TestHomeRowSamples { ] }""" - val jsonElement = service.jsonParser.parseToJsonElement(str) + val jsonElement = HomeSettingsService.jsonParser.parseToJsonElement(str) val settings = service.decode(jsonElement) Assert.assertEquals(1, settings.rows.size) } diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestSeerrPermission.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestSeerrPermission.kt new file mode 100644 index 00000000..b5dfa5c3 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestSeerrPermission.kt @@ -0,0 +1,74 @@ +package com.github.damontecres.wholphin.test + +import com.github.damontecres.wholphin.data.model.SeerrPermission +import com.github.damontecres.wholphin.data.model.hasPermission +import com.github.damontecres.wholphin.services.SeerrUserConfig +import org.junit.Assert +import org.junit.Test + +class TestSeerrPermission { + @Test + fun `Is Admin`() { + val user = + SeerrUserConfig( + id = 0, + permissions = 2, + ) + Assert.assertTrue(user.hasPermission(SeerrPermission.ADMIN)) + Assert.assertTrue(user.hasPermission(SeerrPermission.REQUEST_4K_MOVIE)) + Assert.assertTrue(user.hasPermission(SeerrPermission.REQUEST_4K_TV)) + Assert.assertTrue(user.hasPermission(SeerrPermission.REQUEST_4K)) + } + + @Test + fun `Request any 4k has request movie 4k`() { + val user = + SeerrUserConfig( + id = 0, + permissions = 1024, + ) + Assert.assertFalse(user.hasPermission(SeerrPermission.ADMIN)) + Assert.assertTrue(user.hasPermission(SeerrPermission.REQUEST_4K_MOVIE)) + Assert.assertTrue(user.hasPermission(SeerrPermission.REQUEST_4K_TV)) + Assert.assertTrue(user.hasPermission(SeerrPermission.REQUEST_4K)) + } + + @Test + fun `Request movie 4k and does not have request tv 4k`() { + val user = + SeerrUserConfig( + id = 0, + permissions = 2048, + ) + Assert.assertFalse(user.hasPermission(SeerrPermission.ADMIN)) + Assert.assertTrue(user.hasPermission(SeerrPermission.REQUEST_4K_MOVIE)) + Assert.assertFalse(user.hasPermission(SeerrPermission.REQUEST_4K_TV)) + Assert.assertFalse(user.hasPermission(SeerrPermission.REQUEST_4K)) + } + + @Test + fun `Has explicit request 4k and children`() { + val user = + SeerrUserConfig( + id = 0, + permissions = 7168, + ) + Assert.assertFalse(user.hasPermission(SeerrPermission.ADMIN)) + Assert.assertTrue(user.hasPermission(SeerrPermission.REQUEST_4K_MOVIE)) + Assert.assertTrue(user.hasPermission(SeerrPermission.REQUEST_4K_TV)) + Assert.assertTrue(user.hasPermission(SeerrPermission.REQUEST_4K)) + } + + @Test + fun `Has explicit request 4k children`() { + val user = + SeerrUserConfig( + id = 0, + permissions = 6144, + ) + Assert.assertFalse(user.hasPermission(SeerrPermission.ADMIN)) + Assert.assertTrue(user.hasPermission(SeerrPermission.REQUEST_4K_MOVIE)) + Assert.assertTrue(user.hasPermission(SeerrPermission.REQUEST_4K_TV)) + Assert.assertFalse(user.hasPermission(SeerrPermission.REQUEST_4K)) + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestSelectionTrackExamples.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestSelectionTrackExamples.kt new file mode 100644 index 00000000..a98440f4 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestSelectionTrackExamples.kt @@ -0,0 +1,356 @@ +package com.github.damontecres.wholphin.test + +import androidx.media3.common.C +import androidx.media3.common.TrackSelectionParameters +import com.github.damontecres.wholphin.preferences.PlayerBackend +import com.github.damontecres.wholphin.ui.playback.TrackSelected +import com.github.damontecres.wholphin.ui.playback.TrackSelectionResult +import com.github.damontecres.wholphin.ui.playback.TrackSelectionUtils +import org.jellyfin.sdk.model.api.MediaStreamType +import org.junit.Assert +import org.junit.Test + +class TestSelectionTrackExamples { + private fun runTest( + builder: TestTracks.Builder, + playerBackend: PlayerBackend, + audioIndex: Int, + subtitleIndex: Int, + onResult: (TrackSelectionResult) -> Unit, + ) { + val testTacks = + when (playerBackend) { + PlayerBackend.EXO_PLAYER -> builder.buildForExoPlayer() + PlayerBackend.MPV -> builder.buildForMpv() + else -> throw IllegalArgumentException("Unsupported backend: $playerBackend") + } + + // These checks help check for invalid inputs + val audioTrack = testTacks.tracks.filter { it.index == audioIndex } + Assert.assertEquals(1, audioTrack.size) + Assert.assertEquals(MediaStreamType.AUDIO, audioTrack.first().type) + val subtitleTrack = testTacks.tracks.filter { it.index == subtitleIndex } + Assert.assertEquals(1, subtitleTrack.size) + Assert.assertEquals(MediaStreamType.SUBTITLE, subtitleTrack.first().type) + + val trackSelectionParameters = TrackSelectionParameters.Builder().build() + TrackSelectionUtils + .createTrackSelections( + trackSelectionParams = trackSelectionParameters, + tracks = testTacks.toTracks(), + audioIndex = audioIndex, + subtitleIndex = subtitleIndex, + source = builder.buildMediaSourceInfo(), + ).also(onResult) + } + + @Test + fun `test VAS Exo`() { + runTest(TrackExamples.builderVAASSS, PlayerBackend.EXO_PLAYER, audioIndex = 1, subtitleIndex = 4) { result -> + Assert.assertTrue(result.bothSelected) + Assert.assertEquals("2", result.trackSelectionParameters.getAudioOverride()?.id) + Assert.assertEquals("5", result.trackSelectionParameters.getSubtitleOverride()?.id) + } + } + + @Test + fun `test VAS MPV`() { + runTest(TrackExamples.builderVAASSS, PlayerBackend.MPV, audioIndex = 1, subtitleIndex = 4) { result -> + Assert.assertTrue(result.bothSelected) + Assert.assertEquals("1:1", result.trackSelectionParameters.getAudioOverride()?.id) + Assert.assertEquals("4:2", result.trackSelectionParameters.getSubtitleOverride()?.id) + } + } + + @Test + fun `test EVAS Exo`() { + runTest( + TrackExamples.builderEVAASSS, + PlayerBackend.EXO_PLAYER, + audioIndex = 2, + subtitleIndex = 4, + ) { result -> + Assert.assertTrue(result.bothSelected) + Assert.assertEquals("0:2", result.trackSelectionParameters.getAudioOverride()?.id) + Assert.assertEquals("0:4", result.trackSelectionParameters.getSubtitleOverride()?.id) + } + + runTest( + TrackExamples.builderEVAASSS, + PlayerBackend.EXO_PLAYER, + audioIndex = 2, + subtitleIndex = 0, // external + ) { result -> + Assert.assertTrue(result.bothSelected) + Assert.assertEquals("0:2", result.trackSelectionParameters.getAudioOverride()?.id) + Assert.assertEquals("1:e:0", result.trackSelectionParameters.getSubtitleOverride()?.id) + } + } + + @Test + fun `test EVAS MPV`() { + runTest( + TrackExamples.builderEVAASSS, + PlayerBackend.MPV, + audioIndex = 2, + subtitleIndex = 4, + ) { result -> + Assert.assertTrue(result.bothSelected) + Assert.assertEquals("1:1", result.trackSelectionParameters.getAudioOverride()?.id) + Assert.assertEquals("3:1", result.trackSelectionParameters.getSubtitleOverride()?.id) + } + + runTest( + TrackExamples.builderEVAASSS, + PlayerBackend.MPV, + audioIndex = 2, + subtitleIndex = 0, // external + ) { result -> + Assert.assertTrue(result.bothSelected) + Assert.assertEquals("1:1", result.trackSelectionParameters.getAudioOverride()?.id) + Assert.assertEquals("6:e:4", result.trackSelectionParameters.getSubtitleOverride()?.id) + } + } + + @Test + fun `test builderVASASS`() { + runTest( + TrackExamples.builderVASASS, + PlayerBackend.EXO_PLAYER, + audioIndex = 1, + subtitleIndex = 2, + ) { result -> + Assert.assertTrue(result.bothSelected) + Assert.assertEquals("2", result.trackSelectionParameters.getAudioOverride()?.id) + Assert.assertEquals("3", result.trackSelectionParameters.getSubtitleOverride()?.id) + } + + runTest( + TrackExamples.builderVASASS, + PlayerBackend.MPV, + audioIndex = 1, + subtitleIndex = 2, + ) { result -> + Assert.assertTrue(result.bothSelected) + Assert.assertEquals("1:1", result.trackSelectionParameters.getAudioOverride()?.id) + Assert.assertEquals("2:1", result.trackSelectionParameters.getSubtitleOverride()?.id) + } + + runTest( + TrackExamples.builderVASASS, + PlayerBackend.EXO_PLAYER, + audioIndex = 3, + subtitleIndex = 5, + ) { result -> + Assert.assertTrue(result.bothSelected) + Assert.assertEquals("4", result.trackSelectionParameters.getAudioOverride()?.id) + Assert.assertEquals("6", result.trackSelectionParameters.getSubtitleOverride()?.id) + } + + runTest( + TrackExamples.builderVASASS, + PlayerBackend.MPV, + audioIndex = 3, + subtitleIndex = 5, + ) { result -> + Assert.assertTrue(result.bothSelected) + Assert.assertEquals("3:2", result.trackSelectionParameters.getAudioOverride()?.id) + Assert.assertEquals("5:3", result.trackSelectionParameters.getSubtitleOverride()?.id) + } + } + + @Test + fun `Test external subtitles at end`() { + val builder = + TestTracks + .Builder() + .addVideo() + .addAudio(2) + .addSubtitle(2) + .addExternalSubtitle(1) + + runTest( + builder, + PlayerBackend.EXO_PLAYER, + audioIndex = 1, + subtitleIndex = 5, // external + ) { result -> + Assert.assertTrue(result.bothSelected) + Assert.assertEquals("0:2", result.trackSelectionParameters.getAudioOverride()?.id) + Assert.assertEquals("1:e:5", result.trackSelectionParameters.getSubtitleOverride()?.id) + } + } + + @Test + fun `test AAVASS in issue 1005`() { + // https://github.com/damontecres/Wholphin/issues/1005#issuecomment-4085440175 + val builder = + TestTracks + .Builder() + .addAudio(2) + .addVideo() + .addAudio() + .addSubtitle(42) + runTest( + builder, + PlayerBackend.MPV, + audioIndex = 1, + subtitleIndex = 5, + ) { result -> + Assert.assertTrue(result.bothSelected) + Assert.assertEquals("1:2", result.trackSelectionParameters.getAudioOverride()?.id) + Assert.assertEquals("5:2", result.trackSelectionParameters.getSubtitleOverride()?.id) + } + } + + @Test + fun `test unsupported tracks`() { + val builder = + TestTracks + .Builder() + .addVideo() + .addAudio() + .addTrack(1, MediaStreamType.AUDIO, false, C.FORMAT_UNSUPPORTED_TYPE) + + val trackSelectionParameters = TrackSelectionParameters.Builder().build() + TrackSelectionUtils + .createTrackSelections( + trackSelectionParams = trackSelectionParameters, + tracks = builder.buildForExoPlayer().toTracks(), + audioIndex = 1, + subtitleIndex = null, + source = builder.buildMediaSourceInfo(), + ).let { result -> + Assert.assertEquals(TrackSelected.SELECTED, result.audio) + Assert.assertTrue(result.bothSelected) + } + + TrackSelectionUtils + .createTrackSelections( + trackSelectionParams = trackSelectionParameters, + tracks = builder.buildForExoPlayer().toTracks(), + audioIndex = 2, + subtitleIndex = null, + source = builder.buildMediaSourceInfo(), + ).let { result -> + Assert.assertEquals(TrackSelected.UNSUPPORTED, result.audio) + Assert.assertFalse(result.bothSelected) + } + + TrackSelectionUtils + .createTrackSelections( + trackSelectionParams = trackSelectionParameters, + tracks = builder.buildForExoPlayer().toTracks(), + audioIndex = 4, + subtitleIndex = null, + source = builder.buildMediaSourceInfo(), + ).let { result -> + Assert.assertEquals(TrackSelected.NOT_FOUND, result.audio) + Assert.assertFalse(result.bothSelected) + } + } + + @Test + fun `test exceeds capabilities tracks`() { + val builder = + TestTracks + .Builder() + .addVideo() + .addAudio() + .addTrack(1, MediaStreamType.AUDIO, false, C.FORMAT_EXCEEDS_CAPABILITIES) + + val trackSelectionParameters = TrackSelectionParameters.Builder().build() + TrackSelectionUtils + .createTrackSelections( + trackSelectionParams = trackSelectionParameters, + tracks = builder.buildForExoPlayer().toTracks(), + audioIndex = 1, + subtitleIndex = null, + source = builder.buildMediaSourceInfo(), + ).let { result -> + Assert.assertEquals(TrackSelected.SELECTED, result.audio) + Assert.assertTrue(result.bothSelected) + } + + TrackSelectionUtils + .createTrackSelections( + trackSelectionParams = trackSelectionParameters, + tracks = builder.buildForExoPlayer().toTracks(), + audioIndex = 2, + subtitleIndex = null, + source = builder.buildMediaSourceInfo(), + ).let { result -> + Assert.assertEquals(TrackSelected.UNSUPPORTED, result.audio) + Assert.assertFalse(result.bothSelected) + } + + TrackSelectionUtils + .createTrackSelections( + trackSelectionParams = trackSelectionParameters, + tracks = builder.buildForExoPlayer().toTracks(), + audioIndex = 4, + subtitleIndex = null, + source = builder.buildMediaSourceInfo(), + ).let { result -> + Assert.assertEquals(TrackSelected.NOT_FOUND, result.audio) + Assert.assertFalse(result.bothSelected) + } + } + + @Test + fun `test exo track IDs with slashes`() { + val testTracks = + TestTracks( + listOf( + // .ts files (eg recorded TV) report IDs like this in ExoPlayer + TestTrack("3/49", 0, MediaStreamType.VIDEO, false), + TestTrack("3/52", 1, MediaStreamType.AUDIO, false), + TestTrack("", 2, MediaStreamType.DATA, false), + TestTrack("3/53", 3, MediaStreamType.AUDIO, false), + ), + ) + val mediaSource = + TestTracks + .Builder() + .addVideo() + .addAudio() + .addTrack(1, MediaStreamType.DATA, false, C.FORMAT_HANDLED) + .addAudio() + .buildMediaSourceInfo() + + val trackSelectionParameters = TrackSelectionParameters.Builder().build() + TrackSelectionUtils + .createTrackSelections( + trackSelectionParams = trackSelectionParameters, + tracks = testTracks.toTracks(), + audioIndex = 1, + subtitleIndex = null, + source = mediaSource, + ).let { result -> + Assert.assertEquals(TrackSelected.SELECTED, result.audio) + Assert.assertTrue(result.bothSelected) + } + TrackSelectionUtils + .createTrackSelections( + trackSelectionParams = trackSelectionParameters, + tracks = testTracks.toTracks(), + audioIndex = 2, + subtitleIndex = null, + source = mediaSource, + ).let { result -> + Assert.assertEquals(TrackSelected.NOT_FOUND, result.audio) + Assert.assertFalse(result.bothSelected) + } + TrackSelectionUtils + .createTrackSelections( + trackSelectionParams = trackSelectionParameters, + tracks = testTracks.toTracks(), + audioIndex = 3, + subtitleIndex = null, + source = mediaSource, + ).let { result -> + Assert.assertEquals(TrackSelected.SELECTED, result.audio) + Assert.assertTrue(result.bothSelected) + } + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestSeriesTrackChoice.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestSeriesTrackChoice.kt new file mode 100644 index 00000000..e58d8aec --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestSeriesTrackChoice.kt @@ -0,0 +1,534 @@ +package com.github.damontecres.wholphin.test + +import com.github.damontecres.wholphin.data.SeriesTrackChoiceDao +import com.github.damontecres.wholphin.data.model.ActivationFlag +import com.github.damontecres.wholphin.data.model.ItemPlayback +import com.github.damontecres.wholphin.data.model.JellyfinUser +import com.github.damontecres.wholphin.data.model.JellyfinUserPreferences +import com.github.damontecres.wholphin.data.model.SeriesTrackChoice +import com.github.damontecres.wholphin.data.model.SeriesTrackChoiceType +import com.github.damontecres.wholphin.data.model.TrackChoiceParentType +import com.github.damontecres.wholphin.data.model.TrackFlag +import com.github.damontecres.wholphin.data.model.TrackFlag.Companion.calculateFlag +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.services.StreamChoiceReason +import com.github.damontecres.wholphin.services.StreamChoiceService +import com.github.damontecres.wholphin.services.audio +import com.github.damontecres.wholphin.services.mockServerRepo +import com.github.damontecres.wholphin.services.subtitle +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.jellyfin.sdk.model.UUID +import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.MediaStreamType +import org.jellyfin.sdk.model.api.SubtitlePlaybackMode +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class TestSeriesTrackChoice { + private val mockServerRepository = + mockServerRepo( + audioLang = "eng", + subtitleLang = "eng", + subtitleMode = SubtitlePlaybackMode.DEFAULT, + ) + private val mockSeriesTrackChoiceDao = mockk<SeriesTrackChoiceDao>(relaxed = false) + + private val streamChoiceService = + StreamChoiceService(mockServerRepository, mockSeriesTrackChoiceDao) + + private val seriesId = UUID.randomUUID() + private val season1Id = UUID.randomUUID() + private val season2Id = UUID.randomUUID() + private val season3Id = UUID.randomUUID() + + private val user = + JellyfinUser( + 1, + UUID.randomUUID(), + null, + UUID.randomUUID(), + null, + ) + + @Before + fun before() { + every { mockServerRepository.currentUser } returns user + } + + @Test + fun `Test StreamChoiceService call correct dao function`() = + runTest { + val mockSeriesTrackChoiceDao = mockk<SeriesTrackChoiceDao>(relaxed = true) + val streamChoiceService = + StreamChoiceService(mockServerRepository, mockSeriesTrackChoiceDao) + + val seriesId = UUID.randomUUID() + val seasonId = UUID.randomUUID() + + // Has both series & season + BaseItemDto( + id = UUID.randomUUID(), + type = BaseItemKind.EPISODE, + seriesId = seriesId, + parentId = seasonId, + ).let { item -> + streamChoiceService.getSeriesTrackChoices(item, MediaStreamType.SUBTITLE) + coVerify(exactly = 1) { + mockSeriesTrackChoiceDao.get(any(), seriesId = seriesId, seasonId = seasonId, type = SeriesTrackChoiceType.SUBTITLE) + } + } + + // Has only series + BaseItemDto( + id = UUID.randomUUID(), + type = BaseItemKind.EPISODE, + seriesId = seriesId, + parentId = null, + ).let { item -> + streamChoiceService.getSeriesTrackChoices(item, MediaStreamType.SUBTITLE) + coVerify(exactly = 1) { + mockSeriesTrackChoiceDao.getBySeriesId(any(), seriesId = seriesId, type = SeriesTrackChoiceType.SUBTITLE) + } + } + + // Has only season + BaseItemDto( + id = UUID.randomUUID(), + type = BaseItemKind.EPISODE, + seriesId = null, + parentId = seasonId, + ).let { item -> + streamChoiceService.getSeriesTrackChoices(item, MediaStreamType.SUBTITLE) + coVerify(exactly = 1) { + mockSeriesTrackChoiceDao.getBySeasonId(any(), seasonId = seasonId, type = SeriesTrackChoiceType.SUBTITLE) + } + } + } + + @Test + fun `Test series disabled`() = + runTest { + val result = + streamChoiceService.chooseSubtitleStream( + audioStreamLang = "eng", + itemPlayback = null, + prefs = UserPreferences(AppPreferences.getDefaultInstance(), null), + candidates = + listOf( + subtitle(0, "eng", default = true), + subtitle(1, "spa"), + ), + stc = + listOf( + SeriesTrackChoice( + userId = user.rowId, + parentId = seriesId, + parentType = TrackChoiceParentType.SERIES, + type = SeriesTrackChoiceType.SUBTITLE, + activation = ActivationFlag.DISABLED, + ), + ), + ) + assertNull(result.stream) + } + + @Test + fun `Test series only forced`() = + runTest { + val mockServerRepository = + mockServerRepo( + audioLang = "eng", + subtitleLang = "eng", + subtitleMode = SubtitlePlaybackMode.NONE, + ) + val streamChoiceService = + StreamChoiceService(mockServerRepository, mockSeriesTrackChoiceDao) + + val result = + streamChoiceService.chooseSubtitleStream( + audioStreamLang = "eng", + itemPlayback = null, + prefs = + UserPreferences( + AppPreferences.getDefaultInstance(), + JellyfinUserPreferences(), + ), + candidates = + listOf( + subtitle(0, "eng", forced = true), + subtitle(1, "eng", forced = false), + ), + stc = + listOf( + SeriesTrackChoice( + userId = user.rowId, + parentId = seriesId, + parentType = TrackChoiceParentType.SERIES, + type = SeriesTrackChoiceType.SUBTITLE, + activation = ActivationFlag.ONLY_FORCED, + ), + ), + ) + assertNotNull(result.stream) + assertEquals(0, result.stream!!.index) + assertTrue(result.reason is StreamChoiceReason.Series) + } + + @Test + fun `Test series disabled with season`() = + runTest { + val result = + streamChoiceService.chooseSubtitleStream( + audioStreamLang = "eng", + itemPlayback = null, + prefs = UserPreferences(AppPreferences.getDefaultInstance(), null), + candidates = + listOf( + subtitle(0, "eng", default = true), + subtitle(1, "spa"), + ), + stc = + listOf( + SeriesTrackChoice( + userId = user.rowId, + parentId = seriesId, + parentType = TrackChoiceParentType.SERIES, + type = SeriesTrackChoiceType.SUBTITLE, + activation = ActivationFlag.DISABLED, + ), + SeriesTrackChoice( + userId = user.rowId, + parentId = season1Id, + parentType = TrackChoiceParentType.SEASON, + type = SeriesTrackChoiceType.SUBTITLE, + activation = ActivationFlag.DISABLED, + ), + ), + ) + assertNull(result.stream) + assertTrue(result.reason is StreamChoiceReason.Series) + } + + @Test + fun `Test item playback overrides STC for subtitles`() = + runTest { + val result = + streamChoiceService.chooseSubtitleStream( + audioStreamLang = "eng", + itemPlayback = + ItemPlayback( + userId = user.rowId, + itemId = UUID.randomUUID(), + subtitleIndex = 1, + ), + prefs = UserPreferences(AppPreferences.getDefaultInstance(), null), + candidates = + listOf( + subtitle(0, "eng", default = true), + subtitle(1, "spa"), + ), + stc = + listOf( + SeriesTrackChoice( + userId = user.rowId, + parentId = seriesId, + parentType = TrackChoiceParentType.SERIES, + type = SeriesTrackChoiceType.SUBTITLE, + activation = ActivationFlag.DISABLED, + ), + SeriesTrackChoice( + userId = user.rowId, + parentId = season1Id, + parentType = TrackChoiceParentType.SEASON, + type = SeriesTrackChoiceType.SUBTITLE, + activation = ActivationFlag.DISABLED, + ), + ), + ) + assertNotNull(result.stream) + assertEquals(1, result.stream!!.index) + assertTrue(result.reason is StreamChoiceReason.Item) + } + + @Test + fun `Test item playback overrides STC for audio`() = + runTest { + val result = + streamChoiceService.chooseAudioStream( + itemPlayback = + ItemPlayback( + userId = user.rowId, + itemId = UUID.randomUUID(), + audioIndex = 1, + ), + prefs = UserPreferences(AppPreferences.getDefaultInstance(), null), + candidates = + listOf( + audio(0, "eng", default = true), + audio(1, "spa"), + ), + stc = + listOf( + SeriesTrackChoice( + userId = user.rowId, + parentId = seriesId, + parentType = TrackChoiceParentType.SERIES, + type = SeriesTrackChoiceType.AUDIO, + language = "eng", + ), + SeriesTrackChoice( + userId = user.rowId, + parentId = season1Id, + parentType = TrackChoiceParentType.SEASON, + type = SeriesTrackChoiceType.AUDIO, + language = "eng", + ), + ), + ) + assertNotNull(result.stream) + assertEquals(1, result.stream!!.index) + assertTrue(result.reason is StreamChoiceReason.Item) + } + + @Test + fun `Test season STC doesn't apply but series STC does - subtitle`() = + runTest { + val result = + streamChoiceService.chooseSubtitleStream( + audioStreamLang = "eng", + itemPlayback = + ItemPlayback( + userId = user.rowId, + itemId = UUID.randomUUID(), + audioIndex = 1, + ), + prefs = UserPreferences(AppPreferences.getDefaultInstance(), null), + candidates = + listOf( + subtitle(0, "eng", default = true), + subtitle(1, "spa"), + ), + stc = + listOf( + SeriesTrackChoice( + userId = user.rowId, + parentId = season1Id, + parentType = TrackChoiceParentType.SEASON, + type = SeriesTrackChoiceType.SUBTITLE, + activation = ActivationFlag.ONLY_FORCED, + ), + SeriesTrackChoice( + userId = user.rowId, + parentId = seriesId, + parentType = TrackChoiceParentType.SERIES, + type = SeriesTrackChoiceType.SUBTITLE, + activation = ActivationFlag.ACTIVATED, + language = "spa", + ), + ), + ) + assertNotNull(result.stream) + assertEquals(1, result.stream!!.index) + assertTrue(result.reason is StreamChoiceReason.Series) + } + + @Test + fun `Test season STC doesn't apply but series STC does - audio`() = + runTest { + val result = + streamChoiceService.chooseSubtitleStream( + audioStreamLang = "eng", + itemPlayback = null, + prefs = UserPreferences(AppPreferences.getDefaultInstance(), null), + candidates = + listOf( + audio(0, "eng", default = true), + audio(1, "spa"), + ), + stc = + listOf( + SeriesTrackChoice( + userId = user.rowId, + parentId = season1Id, + parentType = TrackChoiceParentType.SEASON, + type = SeriesTrackChoiceType.AUDIO, + activation = ActivationFlag.ONLY_FORCED, + ), + SeriesTrackChoice( + userId = user.rowId, + parentId = seriesId, + parentType = TrackChoiceParentType.SERIES, + type = SeriesTrackChoiceType.AUDIO, + activation = ActivationFlag.ACTIVATED, + language = "spa", + ), + ), + ) + assertNotNull(result.stream) + assertEquals(1, result.stream!!.index) + assertTrue(result.reason is StreamChoiceReason.Series) + } + + @Test + fun `Test STC has forced flag`() = + runTest { + val stc = + listOf( + SeriesTrackChoice( + userId = user.rowId, + parentId = season1Id, + parentType = TrackChoiceParentType.SEASON, + type = SeriesTrackChoiceType.SUBTITLE, + activation = ActivationFlag.ACTIVATED, + trackFlags = listOf(TrackFlag.FORCED).calculateFlag(), + ), + ) + + // Choose forced track + streamChoiceService + .chooseSubtitleStream( + audioStreamLang = "eng", + itemPlayback = null, + prefs = UserPreferences(AppPreferences.getDefaultInstance(), null), + candidates = + listOf( + subtitle(0, "eng", default = true, forced = true), + subtitle(1, "spa"), + ), + stc = stc, + ).let { result -> + assertNotNull(result.stream) + assertEquals(0, result.stream!!.index) + assertTrue(result.reason is StreamChoiceReason.Season) + } + + // No forced track to choose from, so nothing should be chosen + streamChoiceService + .chooseSubtitleStream( + audioStreamLang = "eng", + itemPlayback = null, + prefs = UserPreferences(AppPreferences.getDefaultInstance(), null), + candidates = + listOf( + subtitle(0, "eng", default = false, forced = false), + subtitle(1, "spa"), + ), + stc = stc, + ).let { result -> + assertNull(result.stream) + } + } + + @Test + fun `Test prefer SDH`() = + runTest { + val stc = + listOf( + SeriesTrackChoice( + userId = user.rowId, + parentId = season1Id, + parentType = TrackChoiceParentType.SEASON, + type = SeriesTrackChoiceType.SUBTITLE, + activation = ActivationFlag.ACTIVATED, + trackFlags = listOf(TrackFlag.SDH).calculateFlag(), + ), + ) + + // Prefer SDH over default and non-SDH + streamChoiceService + .chooseSubtitleStream( + audioStreamLang = "eng", + itemPlayback = null, + prefs = UserPreferences(AppPreferences.getDefaultInstance(), null), + candidates = + listOf( + subtitle(0, "eng", default = true, forced = true), + subtitle(1, "eng", default = false, forced = false, sdh = false), + subtitle(2, "eng", default = false, forced = false, sdh = true), + ), + stc = stc, + ).let { result -> + assertNotNull(result.stream) + assertEquals(2, result.stream!!.index) + assertTrue(result.reason is StreamChoiceReason.Season) + } + + // No SDH, so prefer regular over forced + streamChoiceService + .chooseSubtitleStream( + audioStreamLang = "eng", + itemPlayback = null, + prefs = UserPreferences(AppPreferences.getDefaultInstance(), null), + candidates = + listOf( + subtitle(0, "eng", default = true, forced = true), + subtitle(1, "eng", default = false, forced = false, sdh = false), + ), + stc = stc, + ).let { result -> + assertNotNull(result.stream) + assertEquals(1, result.stream!!.index) + assertTrue(result.reason is StreamChoiceReason.Season) + } + } + + @Test + fun `Test prefer default track with no other flags`() = + runTest { + val stc = + listOf( + SeriesTrackChoice( + userId = user.rowId, + parentId = season1Id, + parentType = TrackChoiceParentType.SEASON, + type = SeriesTrackChoiceType.SUBTITLE, + activation = ActivationFlag.ACTIVATED, + trackFlags = 0, + ), + ) + + streamChoiceService + .chooseSubtitleStream( + audioStreamLang = "eng", + itemPlayback = null, + prefs = UserPreferences(AppPreferences.getDefaultInstance(), null), + candidates = + listOf( + subtitle(0, "eng", default = true, forced = false), + subtitle(1, "eng", default = false, forced = false), + ), + stc = stc, + ).let { result -> + assertNotNull(result.stream) + assertEquals(0, result.stream!!.index) + assertTrue(result.reason is StreamChoiceReason.Season) + } + + // Test out-of-order tracks + streamChoiceService + .chooseSubtitleStream( + audioStreamLang = "eng", + itemPlayback = null, + prefs = UserPreferences(AppPreferences.getDefaultInstance(), null), + candidates = + listOf( + subtitle(0, "eng", default = false, forced = false), + subtitle(1, "eng", default = true, forced = false), + ), + stc = stc, + ).let { result -> + assertNotNull(result.stream) + assertEquals(1, result.stream!!.index) + assertTrue(result.reason is StreamChoiceReason.Season) + } + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestSubtitleHdrMigration.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestSubtitleHdrMigration.kt new file mode 100644 index 00000000..fec67c6b --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestSubtitleHdrMigration.kt @@ -0,0 +1,54 @@ +package com.github.damontecres.wholphin.test + +import com.github.damontecres.wholphin.preferences.InterfacePreferences +import com.github.damontecres.wholphin.preferences.SubtitlePreferences +import com.github.damontecres.wholphin.preferences.resetSubtitles +import com.github.damontecres.wholphin.ui.preferences.subtitle.shouldEnableSeparateHdrToggle +import org.junit.Assert +import org.junit.Test + +class TestSubtitleHdrMigration { + private fun subtitles(block: SubtitlePreferences.Builder.() -> Unit): SubtitlePreferences = + SubtitlePreferences + .newBuilder() + .apply { + resetSubtitles() + block.invoke(this) + }.build() + + private fun shouldSeparate( + sdr: SubtitlePreferences, + hdr: SubtitlePreferences, + ): Boolean = + InterfacePreferences + .newBuilder() + .apply { + subtitlesPreferences = sdr + hdrSubtitlesPreferences = hdr + }.build() + .shouldEnableSeparateHdrToggle() + + @Test + fun `Test separate enabled`() { + val sdr = subtitles { fontSize = 60 } + val differentHdr = subtitles { fontSize = 30 } + val shouldSeparate = shouldSeparate(sdr, differentHdr) + Assert.assertTrue(shouldSeparate) + } + + @Test + fun `Test separate disabled`() { + val sdr = subtitles { fontSize = 60 } + val differentHdr = subtitles { fontSize = 60 } + val shouldSeparate = shouldSeparate(sdr, differentHdr) + Assert.assertFalse(shouldSeparate) + } + + @Test + fun `Test HDR unchanged`() { + val sdr = subtitles { fontSize = 60 } + val differentHdr = subtitles { } + val shouldSeparate = shouldSeparate(sdr, differentHdr) + Assert.assertFalse(shouldSeparate) + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestSuggestionsLogic.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestSuggestionsLogic.kt index 5027aee8..90b45182 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/test/TestSuggestionsLogic.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestSuggestionsLogic.kt @@ -2,7 +2,6 @@ package com.github.damontecres.wholphin.test import org.jellyfin.sdk.model.UUID import org.jellyfin.sdk.model.api.BaseItemDto -import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.NameGuidPair import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue @@ -264,32 +263,3 @@ class TestSuggestionsCombineAndDeduplicate( } } } - -// Helper functions to create test data - -private fun movie( - id: UUID = UUID.randomUUID(), - name: String = "Test Movie", - genres: List<NameGuidPair>? = null, -): BaseItemDto = - BaseItemDto( - id = id, - type = BaseItemKind.MOVIE, - name = name, - seriesId = null, - genreItems = genres, - ) - -private fun episode( - id: UUID = UUID.randomUUID(), - seriesId: UUID, - name: String = "Test Episode", - genres: List<NameGuidPair>? = null, -): BaseItemDto = - BaseItemDto( - id = id, - type = BaseItemKind.EPISODE, - name = name, - seriesId = seriesId, - genreItems = genres, - ) diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestTrackFlag.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestTrackFlag.kt new file mode 100644 index 00000000..d2013f19 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestTrackFlag.kt @@ -0,0 +1,44 @@ +package com.github.damontecres.wholphin.test + +import com.github.damontecres.wholphin.data.model.TrackFlag +import com.github.damontecres.wholphin.data.model.TrackFlag.Companion.calculateFlag +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.EnumSet + +class TestTrackFlag { + @Test + fun basicTest() { + val flag = listOf(TrackFlag.DEFAULT, TrackFlag.EXTERNAL).calculateFlag() + assertTrue(TrackFlag.DEFAULT.within(flag)) + assertTrue(TrackFlag.EXTERNAL.within(flag)) + assertFalse(TrackFlag.FORCED.within(flag)) + assertFalse(TrackFlag.SDH.within(flag)) + } + + @Test + fun none() { + val flag = EnumSet.noneOf(TrackFlag::class.java).calculateFlag() + assertFalse(TrackFlag.DEFAULT.within(flag)) + assertFalse(TrackFlag.EXTERNAL.within(flag)) + assertFalse(TrackFlag.FORCED.within(flag)) + assertFalse(TrackFlag.SDH.within(flag)) + } + + @Test + fun all() { + val flag = EnumSet.allOf(TrackFlag::class.java).calculateFlag() + TrackFlag.entries.forEach { assertTrue(it.toString(), it.within(flag)) } + } + + @Test + fun checkFlags() { + TrackFlag.entries.forEach { + assertTrue("$it is not a power of two", it.flag > 0 && (it.flag and (it.flag - 1)) == 0) + } + val distinctFlags = TrackFlag.entries.map { it.flag }.distinct() + assertEquals(TrackFlag.entries.size, distinctFlags.size) + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestTrackSelection.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestTrackSelection.kt index cad496ca..41db76aa 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/test/TestTrackSelection.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestTrackSelection.kt @@ -235,22 +235,153 @@ class TestTrackSelection { return Tracks(groups) } - private fun TrackSelectionParameters.getAudioOverride(): Format? { - this.overrides.forEach { (trackGroup, trackSelectionOverride) -> - if (trackGroup.type == C.TRACK_TYPE_AUDIO) { - return trackGroup.getFormat(trackSelectionOverride.trackIndices.first()) + private fun buildMissingIdTracks(backend: PlayerBackend): Tracks { + val formats = + if (backend == PlayerBackend.MPV) { + val video = + Format + .Builder() + .setId("0:1") + .setSampleMimeType("video/default") + .build() + val audios = + (1..3).map { + Format + .Builder() + .setId("$it:$it") + .setSampleMimeType("audio/default") + .build() + } + val subtitles = + (1..3).map { + Format + .Builder() + .setId("${it + 3}:$it") + .setSampleMimeType("text/default") + .build() + } + (listOf(video) + audios + subtitles) + } else { + // ExoPlayer + val video = + Format + .Builder() + .setId("0:1") + .setSampleMimeType("video/default") + .build() + val audios = + (3..5).map { + Format + .Builder() + .setId("0:$it") + .setSampleMimeType("audio/default") + .build() + } + val subtitles = + (6..8).map { + Format + .Builder() + .setId("0:$it") + .setSampleMimeType("text/default") + .build() + } + (listOf(video) + audios + subtitles) } - } - return null + val groups = + formats + .map { TrackGroup(it) } + .map { + Tracks.Group( + it, + false, + intArrayOf(C.FORMAT_HANDLED), + booleanArrayOf(false), + ) + } + return Tracks(groups) } - private fun TrackSelectionParameters.getSubtitleOverride(): Format? { - this.overrides.forEach { (trackGroup, trackSelectionOverride) -> - if (trackGroup.type == C.TRACK_TYPE_TEXT) { - return trackGroup.getFormat(trackSelectionOverride.trackIndices.first()) + private fun buildDifferentOrderedTracks(backend: PlayerBackend): Tracks { + val formats = + if (backend == PlayerBackend.MPV) { + val video = + Format + .Builder() + .setId("0:1") + .setSampleMimeType("video/default") + .build() + val audios = + (1..3).map { + Format + .Builder() + .setId("$it:$it") + .setSampleMimeType("audio/default") + .build() + } + val subtitles = + (1..3).map { + Format + .Builder() + .setId("${it + 3}:$it") + .setSampleMimeType("text/default") + .build() + } + (listOf(video) + audios + subtitles) + } else { + // ExoPlayer + val video = + Format + .Builder() + .setId("0:1") + .setSampleMimeType("video/default") + .build() + val audios = + listOf( + Format + .Builder() + .setId("0:2") + .setSampleMimeType("audio/default") + .build(), + Format + .Builder() + .setId("0:4") + .setSampleMimeType("audio/default") + .build(), + Format + .Builder() + .setId("0:3") + .setSampleMimeType("audio/default") + .build(), + ) + val subtitles = + (5..8).map { + Format + .Builder() + .setId("0:$it") + .setSampleMimeType("text/default") + .build() + } + + listOf( + Format + .Builder() + .setId("1:e:8") // External + .setSampleMimeType("text/default") + .build(), + ) + (listOf(video) + audios + subtitles) } - } - return null + val groups = + formats + .map { TrackGroup(it) } + .map { + Tracks.Group( + it, + false, + intArrayOf(C.FORMAT_HANDLED), + booleanArrayOf(false), + ) + } + return Tracks(groups) } @Test @@ -268,8 +399,6 @@ class TestTrackSelection { .createTrackSelections( trackSelectionParams = trackSelectionParameters, tracks = tracks, - playerBackend = PlayerBackend.MPV, - supportsDirectPlay = true, audioIndex = 1, subtitleIndex = 4, source = source, @@ -283,8 +412,6 @@ class TestTrackSelection { .createTrackSelections( trackSelectionParams = trackSelectionParameters, tracks = tracks, - playerBackend = PlayerBackend.MPV, - supportsDirectPlay = true, audioIndex = 2, subtitleIndex = 4, source = source, @@ -298,8 +425,6 @@ class TestTrackSelection { .createTrackSelections( trackSelectionParams = trackSelectionParameters, tracks = tracks, - playerBackend = PlayerBackend.MPV, - supportsDirectPlay = true, audioIndex = 1, subtitleIndex = TrackIndex.DISABLED, source = source, @@ -325,8 +450,6 @@ class TestTrackSelection { .createTrackSelections( trackSelectionParams = trackSelectionParameters, tracks = tracks, - playerBackend = PlayerBackend.EXO_PLAYER, - supportsDirectPlay = true, audioIndex = 1, subtitleIndex = 4, source = source, @@ -340,8 +463,6 @@ class TestTrackSelection { .createTrackSelections( trackSelectionParams = trackSelectionParameters, tracks = tracks, - playerBackend = PlayerBackend.EXO_PLAYER, - supportsDirectPlay = true, audioIndex = 2, subtitleIndex = 4, source = source, @@ -355,8 +476,6 @@ class TestTrackSelection { .createTrackSelections( trackSelectionParams = trackSelectionParameters, tracks = tracks, - playerBackend = PlayerBackend.EXO_PLAYER, - supportsDirectPlay = true, audioIndex = 2, subtitleIndex = 6, source = source, @@ -370,8 +489,6 @@ class TestTrackSelection { .createTrackSelections( trackSelectionParams = trackSelectionParameters, tracks = tracks, - playerBackend = PlayerBackend.EXO_PLAYER, - supportsDirectPlay = true, audioIndex = 1, subtitleIndex = TrackIndex.DISABLED, source = source, @@ -397,8 +514,6 @@ class TestTrackSelection { .createTrackSelections( trackSelectionParams = trackSelectionParameters, tracks = tracks, - playerBackend = PlayerBackend.MPV, - supportsDirectPlay = true, audioIndex = 2, subtitleIndex = 0, source = source, @@ -412,8 +527,6 @@ class TestTrackSelection { .createTrackSelections( trackSelectionParams = trackSelectionParameters, tracks = tracks, - playerBackend = PlayerBackend.MPV, - supportsDirectPlay = true, audioIndex = 3, subtitleIndex = 0, source = source, @@ -439,8 +552,6 @@ class TestTrackSelection { .createTrackSelections( trackSelectionParams = trackSelectionParameters, tracks = tracks, - playerBackend = PlayerBackend.EXO_PLAYER, - supportsDirectPlay = true, audioIndex = 2, subtitleIndex = 0, source = source, @@ -466,8 +577,6 @@ class TestTrackSelection { .createTrackSelections( trackSelectionParams = trackSelectionParameters, tracks = tracks, - playerBackend = PlayerBackend.MPV, - supportsDirectPlay = true, audioIndex = 3, subtitleIndex = 0, source = source, @@ -483,8 +592,6 @@ class TestTrackSelection { .createTrackSelections( trackSelectionParams = trackSelectionParameters, tracks = tracks, - playerBackend = PlayerBackend.MPV, - supportsDirectPlay = true, audioIndex = 3, subtitleIndex = 5, source = source, @@ -518,8 +625,6 @@ class TestTrackSelection { .createTrackSelections( trackSelectionParams = trackSelectionParameters, tracks = tracks, - playerBackend = PlayerBackend.EXO_PLAYER, - supportsDirectPlay = true, audioIndex = 3, subtitleIndex = 0, source = source, @@ -541,8 +646,6 @@ class TestTrackSelection { .createTrackSelections( trackSelectionParams = trackSelectionParameters, tracks = tracks, - playerBackend = PlayerBackend.EXO_PLAYER, - supportsDirectPlay = true, audioIndex = 3, subtitleIndex = 5, source = source, @@ -569,8 +672,6 @@ class TestTrackSelection { .createTrackSelections( trackSelectionParams = trackSelectionParameters, tracks = tracks, - playerBackend = PlayerBackend.EXO_PLAYER, - supportsDirectPlay = true, audioIndex = 3, subtitleIndex = 2, source = source, @@ -588,4 +689,129 @@ class TestTrackSelection { } } } + + @Test + fun `test ExoPlayer missing ids`() { + val resource = javaClass.classLoader?.getResource("no_embedded_subs.json") + Assert.assertNotNull(resource) + val fileContents = Paths.get(resource!!.toURI()).readText() + val source = Json.decodeFromString<MediaSourceInfo>(fileContents) + val tracks = buildMissingIdTracks(PlayerBackend.EXO_PLAYER) + val ids = tracks.groups.flatMap { g -> (0..<g.length).map { g.getTrackFormat(it).id } } + Assert.assertTrue("0:1" in ids) + Assert.assertTrue("0:2" !in ids) + Assert.assertTrue("0:3" in ids) + + Assert.assertEquals(5, source.mediaStreams?.size) + + val trackSelectionParameters = TrackSelectionParameters.Builder().build() + + TrackSelectionUtils + .createTrackSelections( + trackSelectionParams = trackSelectionParameters, + tracks = tracks, + audioIndex = 3, + subtitleIndex = TrackIndex.DISABLED, + source = source, + ).also { result -> + Assert.assertTrue(result.bothSelected) + Assert.assertEquals("0:4", result.trackSelectionParameters.getAudioOverride()?.id) + Assert.assertEquals(null, result.trackSelectionParameters.getSubtitleOverride()?.id) + } + } + + @Test + fun `test MPV missing ids`() { + val resource = javaClass.classLoader?.getResource("no_embedded_subs.json") + Assert.assertNotNull(resource) + val fileContents = Paths.get(resource!!.toURI()).readText() + val source = Json.decodeFromString<MediaSourceInfo>(fileContents) + val tracks = buildMissingIdTracks(PlayerBackend.MPV) + val ids = tracks.groups.flatMap { g -> (0..<g.length).map { g.getTrackFormat(it).id } } + println(ids) + + Assert.assertEquals(5, source.mediaStreams?.size) + + val trackSelectionParameters = TrackSelectionParameters.Builder().build() + + TrackSelectionUtils + .createTrackSelections( + trackSelectionParams = trackSelectionParameters, + tracks = tracks, + audioIndex = 3, + subtitleIndex = TrackIndex.DISABLED, + source = source, + ).also { result -> + Assert.assertTrue(result.bothSelected) + Assert.assertEquals("2:2", result.trackSelectionParameters.getAudioOverride()?.id) + Assert.assertEquals(null, result.trackSelectionParameters.getSubtitleOverride()?.id) + } + } + + @Test + fun `test ExoPlayer different order`() { + val source = + TestTracks + .Builder() + .addVideo() + .addAudio(3) + .addSubtitle(4) + .addExternalSubtitle(1) + .buildMediaSourceInfo() + val tracks = buildDifferentOrderedTracks(PlayerBackend.EXO_PLAYER) + + Assert.assertEquals(9, source.mediaStreams?.size) + + val trackSelectionParameters = TrackSelectionParameters.Builder().build() + + TrackSelectionUtils + .createTrackSelections( + trackSelectionParams = trackSelectionParameters, + tracks = tracks, + audioIndex = 1, + subtitleIndex = 8, + source = source, + ).also { result -> + Assert.assertTrue(result.bothSelected) + Assert.assertEquals("0:2", result.trackSelectionParameters.getAudioOverride()?.id) + Assert.assertEquals( + "1:e:8", + result.trackSelectionParameters.getSubtitleOverride()?.id, + ) + } + + TrackSelectionUtils + .createTrackSelections( + trackSelectionParams = trackSelectionParameters, + tracks = tracks, + audioIndex = 3, + subtitleIndex = 8, + source = source, + ).also { result -> + Assert.assertTrue(result.bothSelected) + Assert.assertEquals("0:4", result.trackSelectionParameters.getAudioOverride()?.id) + Assert.assertEquals( + "1:e:8", + result.trackSelectionParameters.getSubtitleOverride()?.id, + ) + } + } +} + +internal fun TrackSelectionParameters.getAudioOverride(): Format? { + this.overrides.forEach { (trackGroup, trackSelectionOverride) -> + if (trackGroup.type == C.TRACK_TYPE_AUDIO) { + return trackGroup.getFormat(trackSelectionOverride.trackIndices.first()) + } + } + return null +} + +internal fun TrackSelectionParameters.getSubtitleOverride(): Format? { + this.overrides.forEach { (trackGroup, trackSelectionOverride) -> + if (trackGroup.type == C.TRACK_TYPE_TEXT) { + return trackGroup.getFormat(trackSelectionOverride.trackIndices.first()) + } + } + return null } diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestTracks.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestTracks.kt new file mode 100644 index 00000000..7370f29e --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestTracks.kt @@ -0,0 +1,595 @@ +package com.github.damontecres.wholphin.test + +import androidx.media3.common.C +import androidx.media3.common.Format +import androidx.media3.common.MimeTypes +import androidx.media3.common.TrackGroup +import androidx.media3.common.Tracks +import org.jellyfin.sdk.model.api.MediaProtocol +import org.jellyfin.sdk.model.api.MediaSourceInfo +import org.jellyfin.sdk.model.api.MediaSourceType +import org.jellyfin.sdk.model.api.MediaStream +import org.jellyfin.sdk.model.api.MediaStreamProtocol +import org.jellyfin.sdk.model.api.MediaStreamType +import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod +import org.junit.Assert +import org.junit.Test + +/** + * Represents a list of video, audio or, subtitle tracks in a file when direct playing + * + * @see Builder + */ +class TestTracks( + val tracks: List<TestTrack> = emptyList(), +) { + /** + * Convert to media3 [Tracks] + */ + fun toTracks(): Tracks { + val groups = + tracks + .sortedBy { it.external } + .map { + val mimeType = + when (it.type) { + MediaStreamType.VIDEO -> "video/sample" + MediaStreamType.AUDIO -> "audio/default" + MediaStreamType.SUBTITLE -> "text/sample" + MediaStreamType.DATA -> MimeTypes.APPLICATION_ID3 + else -> throw UnsupportedOperationException("${it.type}") + } + val format = + Format + .Builder() + .setId(it.id) + .setSampleMimeType(mimeType) + .build() + Tracks.Group( + TrackGroup(format), + false, + intArrayOf(it.support), + booleanArrayOf(false), + ) + } + + return Tracks(groups) + } + + /** + * Builder for adding tracks + */ + class Builder { + private val tracks = mutableListOf<TestTrackBuilder>() + + fun addVideo(): Builder = addTrack(1, MediaStreamType.VIDEO, false, C.FORMAT_HANDLED) + + fun addAudio(count: Int = 1): Builder = addTrack(count, MediaStreamType.AUDIO, false, C.FORMAT_HANDLED) + + fun addSubtitle(count: Int = 1): Builder = addTrack(count, MediaStreamType.SUBTITLE, false, C.FORMAT_HANDLED) + + fun addExternalSubtitle(count: Int = 1): Builder = addTrack(count, MediaStreamType.SUBTITLE, true, C.FORMAT_HANDLED) + + fun addEmpty(count: Int = 1): Builder = addTrack(count, null, false, C.FORMAT_UNSUPPORTED_TYPE) + + fun addTrack( + count: Int, + type: MediaStreamType?, + external: Boolean, + @C.FormatSupport support: Int, + ): Builder { + repeat(count) { + tracks.add(TestTrackBuilder(type, external, support)) + } + return this + } + + /** + * Create the [TestTracks] as if being playing by ExoPlayer + */ + fun buildForExoPlayer(): TestTracks { + // Format is: [<file index>:][e:]<track index>, "e:" indicates external subtitle + val hasExternal = tracks.firstOrNull { it.external } != null + val testTracks = + buildList { + var externalSubCount = 0 + val t = + tracks + .mapIndexedNotNull { index, track -> + if (track.type == null) { + return@mapIndexedNotNull null + } + if (track.external) { + externalSubCount++ + TestTrack( + "$externalSubCount:e:$index", + index, + track.type, + track.external, + track.support, + ) + } else if (hasExternal) { + val idx = index + 1 - externalSubCount + // If there's a sidecar file, the actual file + TestTrack( + "0:$idx", + index, + track.type, + track.external, + track.support, + ) + } else { + val idx = index + 1 + TestTrack( + "$idx", + index, + track.type, + track.external, + track.support, + ) + } + } + addAll(t) + } + return TestTracks(testTracks) + } + + /** + * Create the [TestTracks] as if being playing by MPV + */ + fun buildForMpv(): TestTracks { + val embeddedCount = tracks.count { !it.external } + val embeddedSubtitleCount = + tracks.count { it.type == MediaStreamType.SUBTITLE && !it.external } + val externalSubCount = + tracks.count { it.type == MediaStreamType.SUBTITLE && it.external } +// val firstNonExternal = tracks.indexOfFirst { it.type != MediaStreamType.SUBTITLE } + val firstExternal = + tracks.indexOfFirst { it.type == MediaStreamType.SUBTITLE && it.external } + val externalSubtitleAreFirst = externalSubCount > 0 && firstExternal == 0 + var videoCount = 0 + var audioCount = 0 + var subtitleCount = 0 + val testTracks = + tracks.mapIndexedNotNull { index, track -> + if (track.type == null) { + return@mapIndexedNotNull null + } + val id = + if (track.external) { + // MPV places external subtitles last + val idx = index + if (externalSubtitleAreFirst) embeddedCount else 0 + subtitleCount++ + val count = + subtitleCount + if (externalSubtitleAreFirst) embeddedSubtitleCount else 0 + "$idx:e:$count" + } else { + val idx = + if (externalSubtitleAreFirst) index - externalSubCount else index + when (track.type) { + MediaStreamType.AUDIO -> { + audioCount++ + "$idx:$audioCount" + } + + MediaStreamType.VIDEO -> { + videoCount++ + "$idx:$videoCount" + } + + MediaStreamType.SUBTITLE -> { + subtitleCount++ + val count = + subtitleCount - (if (externalSubtitleAreFirst) externalSubCount else 0) + "$idx:$count" + } + + else -> { + throw IllegalArgumentException("Unsupported type " + track.type) + } + } + } + TestTrack(id, index, track.type, track.external) + } + return TestTracks(testTracks) + } + + fun buildMediaSourceInfo(): MediaSourceInfo { + val streams = + tracks + .filter { it.type != null } + .mapIndexed { index, track -> + MediaStream( + index = index, + type = track.type!!, + isExternal = track.external, + deliveryMethod = + if (track.type == MediaStreamType.SUBTITLE && track.external) { + SubtitleDeliveryMethod.EXTERNAL + } else { + SubtitleDeliveryMethod.EMBED + }, + codec = null, + language = null, + isInterlaced = false, + isDefault = false, + isForced = false, + isHearingImpaired = false, + isTextSubtitleStream = false, + supportsExternalStream = false, + ) + } + val source = + MediaSourceInfo( + mediaStreams = streams, + protocol = MediaProtocol.HTTP, + type = MediaSourceType.DEFAULT, + isRemote = false, + readAtNativeFramerate = false, + ignoreDts = false, + ignoreIndex = false, + genPtsInput = false, + supportsTranscoding = true, + supportsDirectStream = true, + supportsDirectPlay = true, + isInfiniteStream = false, + requiresOpening = false, + requiresClosing = false, + requiresLooping = false, + supportsProbing = false, + transcodingSubProtocol = MediaStreamProtocol.HLS, + hasSegments = false, + ) + return source + } + } + + companion object { + fun fromMediaSourceInfo(source: MediaSourceInfo): TestTracks.Builder = + TestTracks.Builder().apply { + source.mediaStreams.orEmpty().forEach { + when (it.type) { + MediaStreamType.AUDIO -> addAudio() + MediaStreamType.VIDEO -> addVideo() + MediaStreamType.SUBTITLE -> if (it.isExternal) addExternalSubtitle() else addSubtitle() + else -> throw IllegalArgumentException("Unsupported type ${it.type}") + } + } + } + } +} + +data class TestTrack( + val id: String, + val index: Int, + val type: MediaStreamType, + val external: Boolean, + @param:C.FormatSupport val support: Int = C.FORMAT_HANDLED, +) + +class TestTrackBuilder( + val type: MediaStreamType?, + val external: Boolean = false, + @param:C.FormatSupport val support: Int = C.FORMAT_HANDLED, +) + +fun assertIdType( + expectedId: String, + expectedType: MediaStreamType, + track: TestTrack, +) { + Assert.assertEquals(expectedId, track.id) + Assert.assertEquals(expectedType, track.type) +} + +/** + * Varies [TestTracks.Builder] examples + * + * They are named by the order of the tracks, V=video, A=audio, S=subtitle, E=external subtitle + */ +object TrackExamples { + val builderVAASSS = + TestTracks + .Builder() + .addVideo() + .addAudio(2) + .addSubtitle(3) + + val builderAASSSV = + TestTracks + .Builder() + .addAudio(2) + .addSubtitle(3) + .addVideo() + + val builderAAVSSS = + TestTracks + .Builder() + .addAudio(2) + .addVideo() + .addSubtitle(3) + + val builderVASASS = + TestTracks + .Builder() + .addVideo() + .addAudio(1) + .addSubtitle(1) + .addAudio(1) + .addSubtitle(2) + + val builderEVAASSS = + TestTracks + .Builder() + .addExternalSubtitle() + .addVideo() + .addAudio(2) + .addSubtitle(3) +} + +class TestTracksTests { + @Test + fun testMpv() { + val testTracks = + TestTracks + .Builder() + .addExternalSubtitle() + .addVideo() + .addAudio(4) + .addSubtitle(4) + .buildForMpv() + .tracks + Assert.assertEquals(10, testTracks.size) + Assert.assertEquals("9:e:5", testTracks[0].id) + Assert.assertEquals("0:1", testTracks[1].id) + Assert.assertEquals("1:1", testTracks[2].id) + Assert.assertEquals("2:2", testTracks[3].id) + Assert.assertEquals("3:3", testTracks[4].id) + Assert.assertEquals("4:4", testTracks[5].id) + Assert.assertEquals("5:1", testTracks[6].id) + Assert.assertEquals("6:2", testTracks[7].id) + Assert.assertEquals("7:3", testTracks[8].id) + Assert.assertEquals("8:4", testTracks[9].id) + } + + @Test + fun `test ExoPlayer with external`() { + val testTracks = + TestTracks + .Builder() + .addExternalSubtitle() + .addVideo() + .addAudio(4) + .addSubtitle(4) + .buildForExoPlayer() + .tracks + Assert.assertEquals(10, testTracks.size) + Assert.assertEquals("1:e:0", testTracks[0].id) + Assert.assertEquals("0:1", testTracks[1].id) + Assert.assertEquals("0:2", testTracks[2].id) + Assert.assertEquals("0:3", testTracks[3].id) + Assert.assertEquals("0:4", testTracks[4].id) + Assert.assertEquals("0:5", testTracks[5].id) + Assert.assertEquals("0:6", testTracks[6].id) + Assert.assertEquals("0:7", testTracks[7].id) + Assert.assertEquals("0:8", testTracks[8].id) + Assert.assertEquals("0:9", testTracks[9].id) + } + + @Test + fun `test ExoPlayer without external`() { + val testTracks = + TestTracks + .Builder() + .addVideo() + .addAudio(4) + .addSubtitle(4) + .buildForExoPlayer() + .tracks + Assert.assertEquals(9, testTracks.size) + Assert.assertEquals("1", testTracks[0].id) + Assert.assertEquals("2", testTracks[1].id) + Assert.assertEquals("3", testTracks[2].id) + Assert.assertEquals("4", testTracks[3].id) + Assert.assertEquals("5", testTracks[4].id) + Assert.assertEquals("6", testTracks[5].id) + Assert.assertEquals("7", testTracks[6].id) + Assert.assertEquals("8", testTracks[7].id) + Assert.assertEquals("9", testTracks[8].id) + } + + @Test + fun `test original-VAS`() { + TrackExamples.builderVAASSS.buildForExoPlayer().tracks.let { exo -> + Assert.assertEquals(6, exo.size) + Assert.assertEquals("1", exo[0].id) + Assert.assertEquals("2", exo[1].id) + Assert.assertEquals("3", exo[2].id) + Assert.assertEquals("4", exo[3].id) + Assert.assertEquals("5", exo[4].id) + Assert.assertEquals("6", exo[5].id) + } + + TrackExamples.builderVAASSS.buildForMpv().tracks.let { mpv -> + Assert.assertEquals(6, mpv.size) + Assert.assertEquals("0:1", mpv[0].id) + Assert.assertEquals("1:1", mpv[1].id) + Assert.assertEquals("2:2", mpv[2].id) + Assert.assertEquals("3:1", mpv[3].id) + Assert.assertEquals("4:2", mpv[4].id) + Assert.assertEquals("5:3", mpv[5].id) + } + } + + @Test + fun `test VAS with external`() { + TrackExamples.builderEVAASSS.buildForExoPlayer().tracks.let { exo -> + Assert.assertEquals(7, exo.size) + Assert.assertTrue(exo[0].external) + assertIdType("1:e:0", MediaStreamType.SUBTITLE, exo[0]) + assertIdType("0:1", MediaStreamType.VIDEO, exo[1]) + assertIdType("0:2", MediaStreamType.AUDIO, exo[2]) + assertIdType("0:3", MediaStreamType.AUDIO, exo[3]) + assertIdType("0:4", MediaStreamType.SUBTITLE, exo[4]) + assertIdType("0:5", MediaStreamType.SUBTITLE, exo[5]) + assertIdType("0:6", MediaStreamType.SUBTITLE, exo[6]) + } + + TrackExamples.builderEVAASSS.buildForMpv().tracks.let { mpv -> + Assert.assertEquals(7, mpv.size) + Assert.assertTrue(mpv[0].external) + Assert.assertEquals("6:e:4", mpv[0].id) + Assert.assertEquals("0:1", mpv[1].id) + Assert.assertEquals("1:1", mpv[2].id) + Assert.assertEquals("2:2", mpv[3].id) + Assert.assertEquals("3:1", mpv[4].id) + Assert.assertEquals("4:2", mpv[5].id) + Assert.assertEquals("5:3", mpv[6].id) + } + } + + @Test + fun `test ASV`() { + TrackExamples.builderAASSSV.buildForExoPlayer().tracks.let { exo -> + Assert.assertEquals(6, exo.size) + assertIdType("1", MediaStreamType.AUDIO, exo[0]) + assertIdType("2", MediaStreamType.AUDIO, exo[1]) + assertIdType("3", MediaStreamType.SUBTITLE, exo[2]) + assertIdType("4", MediaStreamType.SUBTITLE, exo[3]) + assertIdType("5", MediaStreamType.SUBTITLE, exo[4]) + assertIdType("6", MediaStreamType.VIDEO, exo[5]) + } + + TrackExamples.builderAASSSV.buildForMpv().tracks.let { mpv -> + Assert.assertEquals(6, mpv.size) + assertIdType("0:1", MediaStreamType.AUDIO, mpv[0]) + assertIdType("1:2", MediaStreamType.AUDIO, mpv[1]) + assertIdType("2:1", MediaStreamType.SUBTITLE, mpv[2]) + assertIdType("3:2", MediaStreamType.SUBTITLE, mpv[3]) + assertIdType("4:3", MediaStreamType.SUBTITLE, mpv[4]) + assertIdType("5:1", MediaStreamType.VIDEO, mpv[5]) + } + } + + @Test + fun `test AVS`() { + TrackExamples.builderAAVSSS.buildForExoPlayer().tracks.let { exo -> + Assert.assertEquals(6, exo.size) + assertIdType("1", MediaStreamType.AUDIO, exo[0]) + assertIdType("2", MediaStreamType.AUDIO, exo[1]) + assertIdType("3", MediaStreamType.VIDEO, exo[2]) + assertIdType("4", MediaStreamType.SUBTITLE, exo[3]) + assertIdType("5", MediaStreamType.SUBTITLE, exo[4]) + assertIdType("6", MediaStreamType.SUBTITLE, exo[5]) + } + + TrackExamples.builderAAVSSS.buildForMpv().tracks.let { mpv -> + Assert.assertEquals(6, mpv.size) + assertIdType("0:1", MediaStreamType.AUDIO, mpv[0]) + assertIdType("1:2", MediaStreamType.AUDIO, mpv[1]) + assertIdType("2:1", MediaStreamType.VIDEO, mpv[2]) + assertIdType("3:1", MediaStreamType.SUBTITLE, mpv[3]) + assertIdType("4:2", MediaStreamType.SUBTITLE, mpv[4]) + assertIdType("5:3", MediaStreamType.SUBTITLE, mpv[5]) + } + } + + @Test + fun `test VASASS`() { + TrackExamples.builderVASASS.buildForExoPlayer().tracks.let { exo -> + Assert.assertEquals(6, exo.size) + assertIdType("1", MediaStreamType.VIDEO, exo[0]) + assertIdType("2", MediaStreamType.AUDIO, exo[1]) + assertIdType("3", MediaStreamType.SUBTITLE, exo[2]) + assertIdType("4", MediaStreamType.AUDIO, exo[3]) + assertIdType("5", MediaStreamType.SUBTITLE, exo[4]) + assertIdType("6", MediaStreamType.SUBTITLE, exo[5]) + } + + TrackExamples.builderVASASS.buildForMpv().tracks.let { mpv -> + Assert.assertEquals(6, mpv.size) + assertIdType("0:1", MediaStreamType.VIDEO, mpv[0]) + assertIdType("1:1", MediaStreamType.AUDIO, mpv[1]) + assertIdType("2:1", MediaStreamType.SUBTITLE, mpv[2]) + assertIdType("3:2", MediaStreamType.AUDIO, mpv[3]) + assertIdType("4:2", MediaStreamType.SUBTITLE, mpv[4]) + assertIdType("5:3", MediaStreamType.SUBTITLE, mpv[5]) + } + } + + @Test + fun `Test external subtitles at end`() { + val builder = + TestTracks + .Builder() + .addVideo() + .addAudio(3) + .addSubtitle(4) + .addExternalSubtitle() + + builder.buildForExoPlayer().tracks.let { tracks -> + Assert.assertEquals(9, tracks.size) + Assert.assertEquals("0:1", tracks[0].id) + Assert.assertEquals("0:2", tracks[1].id) + Assert.assertEquals("0:3", tracks[2].id) + Assert.assertEquals("0:4", tracks[3].id) + Assert.assertEquals("0:5", tracks[4].id) + Assert.assertEquals("0:6", tracks[5].id) + Assert.assertEquals("0:7", tracks[6].id) + Assert.assertEquals("0:8", tracks[7].id) + Assert.assertEquals("1:e:8", tracks[8].id) + } + + builder.buildForMpv().tracks.let { tracks -> + Assert.assertEquals(9, tracks.size) + Assert.assertEquals("0:1", tracks[0].id) + Assert.assertEquals("1:1", tracks[1].id) + Assert.assertEquals("2:2", tracks[2].id) + Assert.assertEquals("3:3", tracks[3].id) + Assert.assertEquals("4:1", tracks[4].id) + Assert.assertEquals("5:2", tracks[5].id) + Assert.assertEquals("6:3", tracks[6].id) + Assert.assertEquals("7:4", tracks[7].id) + Assert.assertEquals("8:e:5", tracks[8].id) + } + + builder.buildMediaSourceInfo().let { source -> + val index = + source.mediaStreams?.indexOfFirst { it.type == MediaStreamType.SUBTITLE && it.isExternal } + Assert.assertEquals(8, index) + } + } + + @Test + fun `test AAVASS in issue 1005`() { + // https://github.com/damontecres/Wholphin/issues/1005#issuecomment-4085440175 + val builder = + TestTracks + .Builder() + .addAudio(2) + .addVideo() + .addAudio() + .addSubtitle(42) + + builder.buildForExoPlayer().tracks.let { exo -> + Assert.assertEquals(46, exo.size) + assertIdType("1", MediaStreamType.AUDIO, exo[0]) + assertIdType("2", MediaStreamType.AUDIO, exo[1]) + assertIdType("3", MediaStreamType.VIDEO, exo[2]) + assertIdType("4", MediaStreamType.AUDIO, exo[3]) + assertIdType("5", MediaStreamType.SUBTITLE, exo[4]) + assertIdType("6", MediaStreamType.SUBTITLE, exo[5]) + } + + builder.buildForMpv().tracks.let { mpv -> + Assert.assertEquals(46, mpv.size) + assertIdType("0:1", MediaStreamType.AUDIO, mpv[0]) + assertIdType("1:2", MediaStreamType.AUDIO, mpv[1]) + assertIdType("2:1", MediaStreamType.VIDEO, mpv[2]) + assertIdType("3:3", MediaStreamType.AUDIO, mpv[3]) + assertIdType("4:1", MediaStreamType.SUBTITLE, mpv[4]) + assertIdType("5:2", MediaStreamType.SUBTITLE, mpv[5]) + assertIdType("6:3", MediaStreamType.SUBTITLE, mpv[6]) + } + } +} 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 ca8ef324..2e84e2ca 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/test/TestUpdateChecker.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestUpdateChecker.kt @@ -1,11 +1,16 @@ package com.github.damontecres.wholphin.test +import com.github.damontecres.wholphin.services.UpdateChecker import com.github.damontecres.wholphin.services.getDownloadUrl +import com.github.damontecres.wholphin.util.Version import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive import org.junit.Assert import org.junit.Before import org.junit.Test @@ -16,41 +21,154 @@ class TestUpdateChecker { lateinit var releaseJson: JsonObject val assetsJson: JsonArray by lazy { releaseJson["assets"]!!.jsonArray } - @Before - fun setup() { - val resource = javaClass.classLoader?.getResource("release_develop.json") + 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 { + val resource = javaClass.classLoader?.getResource(filename) Assert.assertNotNull(resource) val fileContents = Paths.get(resource!!.toURI()).readText() - releaseJson = Json.parseToJsonElement(fileContents).jsonObject + return Json.parseToJsonElement(fileContents) } + private fun read(filename: String): JsonObject = readElement(filename).jsonObject + + private fun forgejoDownload(name: String) = + "https://git.justinvisser.org/justinzeus/wolfskin/releases/download/pwolfskin-v1.0.9-2-g3094fc8c/$name" + + @Before + fun setup() { + releaseJson = read("release_develop.json") + } + + // The GitHub fixtures are upstream Wholphin releases: their assets are named Wholphin*.apk, which + // the Wolfskin updater must not select. + @Test - fun `Release chooses release`() { + fun `Release does not choose upstream Wholphin assets`() { val url = getDownloadUrl(assetsJson, false, listOf()) - Assert.assertEquals("https://github.com/damontecres/Wholphin/releases/download/develop/Wholphin-release.apk", url) + Assert.assertNull(url) } @Test - fun `Choose abi`() { + fun `Choose abi does not choose upstream Wholphin assets`() { val url = getDownloadUrl(assetsJson, false, listOf("arm64-v8a")) - Assert.assertEquals("https://github.com/damontecres/Wholphin/releases/download/develop/Wholphin-release-arm64-v8a.apk", url) + Assert.assertNull(url) } @Test - fun `Choose unknown abi`() { + fun `Choose unknown abi does not choose upstream Wholphin assets`() { val url = getDownloadUrl(assetsJson, false, listOf("unknown")) - Assert.assertEquals("https://github.com/damontecres/Wholphin/releases/download/develop/Wholphin-release.apk", url) + Assert.assertNull(url) } @Test - fun `Debug chooses debug`() { + fun `Debug does not choose upstream Wholphin assets`() { val url = getDownloadUrl(assetsJson, true, listOf()) - Assert.assertEquals("https://github.com/damontecres/Wholphin/releases/download/develop/Wholphin-debug.apk", url) + Assert.assertNull(url) } @Test - fun `Choose debug abi`() { + fun `Choose debug abi does not choose upstream Wholphin assets`() { val url = getDownloadUrl(assetsJson, true, listOf("arm64-v8a")) - Assert.assertEquals("https://github.com/damontecres/Wholphin/releases/download/develop/Wholphin-debug-arm64-v8a.apk", url) + Assert.assertNull(url) + } + + @Test + fun `Test regular release does not choose upstream Wholphin assets`() { + 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<String>() to "Wolfskin-debug.apk", + )) { + Assert.assertEquals(forgejoDownload(name), getDownloadUrl(suffixedAssets, true, abis)) + } + } + + @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<String>() to "Wolfskin-release.apk", + )) { + Assert.assertEquals(forgejoDownload(name), getDownloadUrl(assets, false, abis)) + } + } + + @Test + fun `Release and debug assets do not cross build types`() { + val debugAssets = JsonArray(suffixedAssets.filter { it.jsonObject["name"]!!.jsonPrimitive.content.contains("-debug") }) + val releaseAssets = JsonArray(suffixedAssets.filter { it.jsonObject["name"]!!.jsonPrimitive.content.contains("-release") }) + Assert.assertNull(getDownloadUrl(debugAssets, false, listOf("arm64-v8a"))) + Assert.assertNull(getDownloadUrl(releaseAssets, true, listOf("arm64-v8a"))) + } + + @Test + fun `Find installed version in release list`() { + val release = UpdateChecker.findReleaseByVersion(forgejoReleases, Version.fromString("1.0.9-2-g3094fc8c")) + Assert.assertEquals("v1.0.9-2-g3094fc8c", release?.get("name")?.jsonPrimitive?.content) + } + + @Test + fun `Find release with different hash abbreviation length`() { + val release = UpdateChecker.findReleaseByVersion(forgejoReleases, Version.fromString("1.0.9-2-g3094fc8")) + Assert.assertEquals("v1.0.9-2-g3094fc8c", release?.get("name")?.jsonPrimitive?.content) + } + + @Test + fun `Find release with absent or zero commit count`() { + val release = UpdateChecker.findReleaseByVersion(forgejoReleases, Version.fromString("1.0.10")) + Assert.assertEquals("v1.0.10-0-gfeedbee", release?.get("name")?.jsonPrimitive?.content) + val plainRelease = JsonObject(forgejoReleases.first().jsonObject + ("name" to JsonPrimitive("v1.0.10"))) + val plainReleases = JsonArray(listOf(plainRelease)) + Assert.assertEquals(plainReleases.first(), UpdateChecker.findReleaseByVersion(plainReleases, Version.fromString("1.0.10-0-gfeedbee"))) + } + + @Test + fun `Find no release for version missing from list`() { + Assert.assertNull(UpdateChecker.findReleaseByVersion(forgejoReleases, Version.fromString("1.0.9-3-gffffff"))) } } diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestUtils.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestUtils.kt new file mode 100644 index 00000000..c291d067 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestUtils.kt @@ -0,0 +1,163 @@ +package com.github.damontecres.wholphin.test + +import com.github.damontecres.wholphin.data.CurrentUser +import com.github.damontecres.wholphin.data.model.JellyfinServer +import com.github.damontecres.wholphin.data.model.JellyfinUser +import com.github.damontecres.wholphin.ui.isNotNullOrBlank +import com.ibm.icu.impl.Assert +import io.mockk.MockKMatcherScope +import org.jellyfin.sdk.model.UUID +import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.NameGuidPair +import org.jellyfin.sdk.model.api.UserDto +import kotlin.contracts.ExperimentalContracts +import kotlin.contracts.contract + +fun MockKMatcherScope.nonBlankString() = match<String> { it.isNotNullOrBlank() } + +/** + * Create a simple [BaseItemDto] movie + */ +fun movie( + id: UUID = UUID.randomUUID(), + name: String = "Test Movie", + genres: List<NameGuidPair>? = null, +): BaseItemDto = + BaseItemDto( + id = id, + type = BaseItemKind.MOVIE, + name = name, + seriesId = null, + genreItems = genres, + ) + +/** + * Create a simple [BaseItemDto] tv episode + */ +fun episode( + id: UUID = UUID.randomUUID(), + seriesId: UUID, + name: String = "Test Episode", + genres: List<NameGuidPair>? = null, +): BaseItemDto = + BaseItemDto( + id = id, + type = BaseItemKind.EPISODE, + name = name, + seriesId = seriesId, + genreItems = genres, + ) + +/** + * Create a simple [BaseItemDto] song + */ +fun song( + id: UUID = UUID.randomUUID(), + albumId: UUID? = null, + name: String = "Test Song", + genres: List<NameGuidPair>? = null, +): BaseItemDto = + BaseItemDto( + id = id, + type = BaseItemKind.AUDIO, + name = name, + albumId = albumId, + seriesId = null, + genreItems = genres, + ) + +/** + * Create a simple [BaseItemDto] playlist + */ +fun playlist( + id: UUID = UUID.randomUUID(), + name: String = "Test Playlist", + genres: List<NameGuidPair>? = null, +): BaseItemDto = + BaseItemDto( + id = id, + type = BaseItemKind.PLAYLIST, + name = name, + seriesId = null, + genreItems = genres, + ) + +/** + * Create a simple [BaseItemDto] of the specified type + */ +fun item( + type: BaseItemKind, + id: UUID = UUID.randomUUID(), + name: String = "Test $type", + genres: List<NameGuidPair>? = null, +): BaseItemDto = + BaseItemDto( + id = id, + type = type, + name = name, + seriesId = null, + genreItems = genres, + ) + +fun server(serverId: UUID = UUID.randomUUID()) = JellyfinServer(serverId, "test server", "http://localhost:8096", "10.11.11") + +fun user( + serverId: UUID, + userId: UUID = UUID.randomUUID(), +) = JellyfinUser( + rowId = 1, + id = userId, + serverId = serverId, + name = "test-user", + accessToken = "token", + pin = null, +) + +fun userDto(userId: UUID) = + UserDto( + id = userId, + name = "test-user", + serverName = "test server", + hasPassword = true, + hasConfiguredPassword = true, + hasConfiguredEasyPassword = false, + ) + +fun currentUser( + serverId: UUID = UUID.randomUUID(), + userId: UUID = UUID.randomUUID(), +) = CurrentUser( + server(serverId), + user(userId), +) + +/** + * Asserts that the object is not null and is type `T` + * + * Includes a [contract] to smart-cast the object to T + */ +@OptIn(ExperimentalContracts::class) +inline fun <reified T : Any> assertIs(obj: Any?) { + contract { + returns() implies (obj is T) + } + assertNotNull(obj) + val result = T::class.isInstance(obj) + if (!result) { + Assert.fail("Expected type=${T::class.qualifiedName}, actual type= ${obj!!::class.qualifiedName} ") + } +} + +/** + * Asserts that the object is not null + * + * Includes a [contract] to smart-cast the object to T + */ +@OptIn(ExperimentalContracts::class) +fun assertNotNull(obj: Any?) { + contract { + returns() implies (obj != null) + } + org.junit.Assert.assertNotNull(obj) +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/VersionCompareTests.kt b/app/src/test/java/com/github/damontecres/wholphin/test/VersionCompareTests.kt new file mode 100644 index 00000000..fdd45e6a --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/test/VersionCompareTests.kt @@ -0,0 +1,92 @@ +package com.github.damontecres.wholphin.test + +import com.github.damontecres.wholphin.BuildConfig +import com.github.damontecres.wholphin.util.Version +import org.junit.Assert +import org.junit.Test + +class VersionCompareTests { + companion object { + val V_0_2_0 = Version.fromString("v0.2.0") + val V_0_1_0 = Version.fromString("v0.1.0") + val V_0_1_1 = Version.fromString("v0.1.1") + + val V_0_2_0_0 = Version.fromString("v0.2.0-0-gabc1234") + val V_0_2_0_7 = Version.fromString("v0.2.0-7-gabc1234") + val V_0_1_0_7 = Version.fromString("v0.1.0-7-gabc1234") + val V_0_1_0_6 = Version.fromString("v0.1.0-6-gabc1234") + val V_0_1_1_4 = Version.fromString("v0.1.1-4-gabc1234") + val V_0_1_1_5 = Version.fromString("v0.1.1-5-gabc1234") + } + + @Test + fun optimizedVersionRemainsCompatibleWithUpgradeAndUpdateChecks() { + val optimizedVersion = Version.fromString(requireNotNull(System.getProperty("wolfskin.optimizedVersionName"))) + Assert.assertEquals(Version.fromString(BuildConfig.VERSION_NAME), optimizedVersion) + } + + @Test + fun testIsAtLeast() { + Assert.assertTrue(V_0_2_0.isAtLeast(V_0_2_0)) + Assert.assertTrue(V_0_2_0.isAtLeast(V_0_1_0)) + Assert.assertTrue(V_0_1_1.isAtLeast(V_0_1_0)) + Assert.assertFalse(V_0_1_0.isAtLeast(V_0_2_0)) + Assert.assertFalse(V_0_1_1.isAtLeast(V_0_2_0)) + + Assert.assertTrue(V_0_2_0.isAtLeast(V_0_2_0_0)) + Assert.assertTrue(V_0_2_0_0.isAtLeast(V_0_2_0)) + Assert.assertTrue(V_0_1_0_7.isAtLeast(V_0_1_0_7)) + Assert.assertTrue(V_0_1_0_7.isAtLeast(V_0_1_0_6)) + + Assert.assertFalse(V_0_1_0_6.isAtLeast(V_0_1_0_7)) + } + + @Test + fun testisGreaterThan() { + Assert.assertFalse(V_0_2_0.isGreaterThan(V_0_2_0)) + Assert.assertTrue(V_0_2_0.isGreaterThan(V_0_1_0)) + Assert.assertTrue(V_0_1_1.isGreaterThan(V_0_1_0)) + Assert.assertFalse(V_0_1_0.isGreaterThan(V_0_2_0)) + + Assert.assertFalse(V_0_2_0.isGreaterThan(V_0_2_0_0)) + Assert.assertFalse(V_0_2_0_0.isGreaterThan(V_0_2_0)) + Assert.assertFalse(V_0_1_0_7.isGreaterThan(V_0_1_0_7)) + Assert.assertTrue(V_0_1_0_7.isGreaterThan(V_0_1_0_6)) + + Assert.assertTrue(V_0_2_0_7.isGreaterThan(V_0_2_0_0)) + Assert.assertTrue(V_0_1_1_4.isGreaterThan(V_0_1_0_6)) + Assert.assertTrue(V_0_1_1_4.isGreaterThan(V_0_1_0)) + Assert.assertFalse(V_0_1_1_4.isGreaterThan(V_0_1_1_5)) + Assert.assertTrue(V_0_1_1_5.isGreaterThan(V_0_1_1_4)) + } + + @Test + fun testEqualOrBefore() { + Assert.assertTrue(V_0_2_0.isEqualOrBefore(V_0_2_0)) + Assert.assertTrue(V_0_1_1.isEqualOrBefore(V_0_2_0)) + Assert.assertTrue(V_0_1_1.isEqualOrBefore(V_0_1_1_5)) + Assert.assertTrue(V_0_1_1_4.isEqualOrBefore(V_0_1_1_5)) + + Assert.assertFalse(V_0_2_0.isEqualOrBefore(V_0_1_1)) + } + + @Test + fun testLessThan() { + Assert.assertFalse(V_0_2_0.isLessThan(V_0_2_0)) + Assert.assertTrue(V_0_1_1.isLessThan(V_0_2_0)) + Assert.assertTrue(V_0_1_1.isLessThan(V_0_1_1_5)) + Assert.assertTrue(V_0_1_1_4.isLessThan(V_0_1_1_5)) + + Assert.assertFalse(V_0_2_0.isLessThan(V_0_1_1)) + } + + @Test + fun testToString() { + Assert.assertEquals("v0.2.0", V_0_2_0.toString()) + Assert.assertEquals("v0.2.0", V_0_2_0_0.toString()) + Assert.assertEquals("v0.3.0", Version(0, 3, 0, null).toString()) + Assert.assertEquals("v0.3.0", Version(0, 3, 0, 0, "abc1234").toString()) + Assert.assertEquals("v0.2.0-1-gabc1234", Version(0, 2, 0, 1, "abc1234").toString()) + Assert.assertEquals("v0.2.0-7-gabc1234", V_0_2_0_7.toString()) + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/ui/BasicUiTests.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/BasicUiTests.kt index 027afb98..2e2c3a89 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/ui/BasicUiTests.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/BasicUiTests.kt @@ -27,18 +27,17 @@ import com.github.damontecres.wholphin.ui.setup.SwitchServerContent import com.github.damontecres.wholphin.ui.setup.SwitchServerViewModel import com.github.damontecres.wholphin.ui.theme.WholphinTheme import com.github.damontecres.wholphin.util.LoadingState +import com.github.damontecres.wholphin.util.WholphinDispatchers +import com.github.damontecres.wholphin.util.configure +import com.github.damontecres.wholphin.util.reset import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest import dagger.hilt.android.testing.HiltTestApplication import io.mockk.coEvery import io.mockk.every import io.mockk.mockk -import io.mockk.mockkStatic -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.setMain import org.jellyfin.sdk.Jellyfin import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.quickConnectApi @@ -105,11 +104,7 @@ class BasicUiTests { @OptIn(ExperimentalCoroutinesApi::class) @Before fun setup() { - Dispatchers.setMain(TestModule.testDispatcher) - mockkStatic(Dispatchers::class) - - every { Dispatchers.IO } returns TestModule.testDispatcher - every { Dispatchers.Default } returns TestModule.testDispatcher + WholphinDispatchers.configure(TestModule.testDispatcher) hiltRule.inject() screensaverService = mockk(relaxed = true) @@ -130,7 +125,7 @@ class BasicUiTests { @OptIn(ExperimentalCoroutinesApi::class) @After fun tearDown() { - Dispatchers.resetMain() + WholphinDispatchers.reset() } /** diff --git a/app/src/test/java/com/github/damontecres/wholphin/ui/TestModule.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/TestModule.kt index 6ebdde41..b266bb96 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/ui/TestModule.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/TestModule.kt @@ -17,14 +17,18 @@ import com.github.damontecres.wholphin.data.ItemPlaybackDao import com.github.damontecres.wholphin.data.JellyfinServerDao import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao import com.github.damontecres.wholphin.data.Migrations +import com.github.damontecres.wholphin.data.MostRecentServerProvider import com.github.damontecres.wholphin.data.PlaybackEffectDao import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao +import com.github.damontecres.wholphin.data.RememberedTabDao import com.github.damontecres.wholphin.data.SeerrServerDao +import com.github.damontecres.wholphin.data.SeriesTrackChoiceDao import com.github.damontecres.wholphin.data.ServerPreferencesDao import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppPreferencesSerializer import com.github.damontecres.wholphin.services.SeerrApi +import com.github.damontecres.wholphin.services.TestMostRecentServerProvider import com.github.damontecres.wholphin.services.hilt.AppModule import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient import com.github.damontecres.wholphin.services.hilt.DatabaseModule @@ -35,7 +39,7 @@ import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope import com.github.damontecres.wholphin.services.hilt.IoDispatcher import com.github.damontecres.wholphin.services.hilt.StandardOkHttpClient import com.github.damontecres.wholphin.util.CoroutineContextApiClientFactory -import com.github.damontecres.wholphin.util.RememberTabManager +import com.github.damontecres.wholphin.util.WholphinDispatchers import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -46,7 +50,6 @@ import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.asExecutor import kotlinx.coroutines.test.StandardTestDispatcher @@ -108,7 +111,7 @@ object TestModule { .addInterceptor { val request = it.request() val newRequest = - serverRepository.currentUser.value?.accessToken?.let { token -> + serverRepository.currentUser?.accessToken?.let { token -> request .newBuilder() .addHeader( @@ -166,17 +169,6 @@ object TestModule { return api } - /** - * Implementation of [RememberTabManager] which remembers by server, user, & item - */ - @Provides - @Singleton - fun rememberTabManager( - serverRepository: ServerRepository, - appPreference: DataStore<AppPreferences>, - @IoCoroutineScope scope: CoroutineScope, - ): RememberTabManager = mockk() - @Provides @Singleton @IoDispatcher @@ -212,6 +204,10 @@ object TestModule { fun seerrApi( @StandardOkHttpClient okHttpClient: OkHttpClient, ): SeerrApi = mockk() + + @Provides + @Singleton + fun mostRecentServerProvider(): MostRecentServerProvider = TestMostRecentServerProvider() } @Module @@ -236,7 +232,7 @@ object TestDatabaseModule { .allowMainThreadQueries() .setQueryCallback({ sqlQuery, args -> Timber.v("sqlQuery=$sqlQuery, args=$args") - }, Dispatchers.IO.asExecutor()) + }, WholphinDispatchers.IO.asExecutor()) .build() @Provides @@ -267,6 +263,14 @@ object TestDatabaseModule { @Singleton fun playbackEffectDao(db: AppDatabase): PlaybackEffectDao = db.playbackEffectDao() + @Provides + @Singleton + fun rememberedTabDao(db: AppDatabase): RememberedTabDao = db.rememberedTabDao() + + @Provides + @Singleton + fun seriesTrackChoiceTabDao(db: AppDatabase): SeriesTrackChoiceDao = db.seriesTrackChoiceDao() + @Provides @Singleton fun userPreferencesDataStore( diff --git a/app/src/test/java/com/github/damontecres/wholphin/ui/Utils.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/Utils.kt index d0bc8532..6d30a600 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/ui/Utils.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/Utils.kt @@ -6,6 +6,8 @@ import androidx.compose.ui.test.SemanticsNodeInteraction import androidx.compose.ui.test.performKeyInput import androidx.compose.ui.test.pressKey import org.jellyfin.sdk.api.client.Response +import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult @OptIn(ExperimentalTestApi::class) fun SemanticsNodeInteraction.performClickEnter() = @@ -14,3 +16,8 @@ fun SemanticsNodeInteraction.performClickEnter() = } fun <T> successResponse(content: T) = Response(content, 200, emptyMap()) + +fun successQueryResult( + items: List<BaseItemDto> = emptyList(), + totalRecordCount: Int = items.size, +) = successResponse(BaseItemDtoQueryResult(items, totalRecordCount, 0)) diff --git a/app/src/test/java/com/github/damontecres/wholphin/ui/WolfskinThemeColorsTest.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/WolfskinThemeColorsTest.kt new file mode 100644 index 00000000..e262e2ad --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/WolfskinThemeColorsTest.kt @@ -0,0 +1,44 @@ +package com.github.damontecres.wholphin.ui + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import org.justinvisser.wolfskin.design.WolfskinColors +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 = WolfskinColors.darkScheme + val material = WolfskinColors.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/CollectionFolderViewModelTest.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/components/CollectionFolderViewModelTest.kt new file mode 100644 index 00000000..ba7f1e14 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/components/CollectionFolderViewModelTest.kt @@ -0,0 +1,243 @@ +package com.github.damontecres.wholphin.ui.components + +import androidx.lifecycle.SavedStateHandle +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.CollectionFolderFilter +import com.github.damontecres.wholphin.data.model.GetItemsFilter +import com.github.damontecres.wholphin.data.model.GetItemsFilterOverride +import com.github.damontecres.wholphin.services.DeletedItem +import com.github.damontecres.wholphin.services.MediaManagementService +import com.github.damontecres.wholphin.ui.successQueryResult +import com.github.damontecres.wholphin.ui.successResponse +import com.github.damontecres.wholphin.util.GetAlbumArtistsHandler +import com.github.damontecres.wholphin.util.GetArtistsHandler +import com.github.damontecres.wholphin.util.GetItemsRequestHandler +import com.github.damontecres.wholphin.util.GetPersonsHandler +import com.github.damontecres.wholphin.util.WholphinDispatchers +import com.github.damontecres.wholphin.util.configure +import com.github.damontecres.wholphin.util.reset +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.slot +import io.mockk.unmockkObject +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +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.jellyfin.sdk.model.api.CollectionType +import org.jellyfin.sdk.model.api.ItemFields +import org.jellyfin.sdk.model.api.request.GetAlbumArtistsRequest +import org.jellyfin.sdk.model.api.request.GetArtistsRequest +import org.jellyfin.sdk.model.api.request.GetItemsRequest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.util.UUID + +/** + * Each endpoint is stubbed with a distinct total, so a returned index identifies which endpoint + * produced it. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class CollectionFolderViewModelTest { + private val testDispatcher = StandardTestDispatcher() + + private val mockApi = mockk<ApiClient>(relaxed = true) + private val mockServerRepository = mockk<ServerRepository>(relaxed = true) + private val mockMediaManagementService = mockk<MediaManagementService>(relaxed = true) + private val mockUserLibraryApi = mockk<UserLibraryApi>() + + private val artistsRequest = slot<GetArtistsRequest>() + private val albumArtistsRequest = slot<GetAlbumArtistsRequest>() + private val itemsRequest = slot<GetItemsRequest>() + + private val libraryId = UUID.randomUUID() + + private val library = + BaseItemDto( + id = libraryId, + type = BaseItemKind.COLLECTION_FOLDER, + collectionType = CollectionType.MUSIC, + ) + + @Before + fun setUp() { + WholphinDispatchers.configure(testDispatcher) + + every { mockApi.userLibraryApi } returns mockUserLibraryApi + coEvery { mockUserLibraryApi.getItem(libraryId) } returns successResponse(library) + + // No current user, so no saved LibraryDisplayInfo is consulted + every { mockServerRepository.currentUser } returns null + every { mockMediaManagementService.deletedItemFlow } returns MutableSharedFlow<DeletedItem>() + + // mockkObject spies by default, so every handler must be stubbed or the real one runs + // against the relaxed ApiClient + mockkObject(GetItemsRequestHandler) + mockkObject(GetArtistsHandler) + mockkObject(GetAlbumArtistsHandler) + mockkObject(GetPersonsHandler) + coEvery { GetItemsRequestHandler.execute(mockApi, capture(itemsRequest)) } returns + successQueryResult(totalRecordCount = LIBRARY_ITEM_COUNT) + coEvery { GetArtistsHandler.execute(mockApi, capture(artistsRequest)) } returns + successQueryResult(totalRecordCount = ARTIST_COUNT) + coEvery { GetAlbumArtistsHandler.execute(mockApi, capture(albumArtistsRequest)) } returns + successQueryResult(totalRecordCount = ALBUM_ARTIST_COUNT) + coEvery { GetPersonsHandler.execute(mockApi, any()) } returns + successQueryResult(totalRecordCount = PERSON_COUNT) + } + + @After + fun tearDown() { + WholphinDispatchers.reset() + unmockkObject(GetItemsRequestHandler) + unmockkObject(GetArtistsHandler) + unmockkObject(GetAlbumArtistsHandler) + unmockkObject(GetPersonsHandler) + } + + private fun createViewModel( + filter: GetItemsFilter, + viewOptions: ViewOptions = ViewOptionsSquare, + ) = CollectionFolderViewModel( + savedStateHandle = SavedStateHandle(), + api = mockApi, + context = mockk(relaxed = true), + serverRepository = mockServerRepository, + libraryDisplayInfoDao = mockk(relaxed = true), + favoriteWatchManager = mockk(relaxed = true), + backdropService = mockk(relaxed = true), + navigationManager = mockk(relaxed = true), + themeSongPlayer = mockk(relaxed = true), + userPreferencesService = mockk(relaxed = true), + mediaManagementService = mockMediaManagementService, + musicService = mockk(relaxed = true), + streamChoiceService = mockk(relaxed = true), + serverReportService = mockk(relaxed = true), + filterOptionCache = mockk(relaxed = true), + itemId = libraryId.toString(), + initialSortAndDirection = null, + recursive = true, + collectionFilter = CollectionFolderFilter(filter = filter), + useSeriesForPrimary = false, + defaultViewOptions = viewOptions, + ) + + /** Counting via /Items would return every song and album, overshooting the artist grid */ + @Test + fun `positionOfLetter counts artists for the ARTIST override`() = + runTest(testDispatcher) { + val viewModel = createViewModel(GetItemsFilter(override = GetItemsFilterOverride.ARTIST)) + advanceUntilIdle() + + val position = viewModel.positionOfLetter('K') + + assertEquals(ARTIST_COUNT, position) + assertEquals("K", artistsRequest.captured.nameLessThan) + assertEquals(libraryId, artistsRequest.captured.parentId) + assertEquals(0, artistsRequest.captured.limit) + + // Counting library items rather than artists is the bug this guards against + coVerify(exactly = 0) { GetItemsRequestHandler.execute(any(), any<GetItemsRequest>()) } + } + + @Test + fun `positionOfLetter counts album artists for the ALBUM_ARTIST source`() = + runTest(testDispatcher) { + val viewModel = + createViewModel( + GetItemsFilter(override = GetItemsFilterOverride.ARTIST), + ViewOptionsSquare.copy(extras = ViewOptionsExtras.MusicLibrary(ArtistSource.ALBUM_ARTIST)), + ) + advanceUntilIdle() + + val position = viewModel.positionOfLetter('K') + + assertEquals(ALBUM_ARTIST_COUNT, position) + assertEquals("K", albumArtistsRequest.captured.nameLessThan) + assertEquals(libraryId, albumArtistsRequest.captured.parentId) + coVerify(exactly = 0) { GetArtistsHandler.execute(any(), any<GetArtistsRequest>()) } + } + + @Test + fun `changing the artist source reloads from the album artists endpoint`() = + runTest(testDispatcher) { + val viewModel = createViewModel(GetItemsFilter(override = GetItemsFilterOverride.ARTIST)) + advanceUntilIdle() + coVerify(exactly = 0) { GetAlbumArtistsHandler.execute(any(), any<GetAlbumArtistsRequest>()) } + + viewModel.saveViewOptions(ViewOptionsSquare.copy(extras = ViewOptionsExtras.MusicLibrary(ArtistSource.ALBUM_ARTIST))) + advanceUntilIdle() + + coVerify { GetAlbumArtistsHandler.execute(any(), any<GetAlbumArtistsRequest>()) } + } + + /** + * Without SortName, the alphabet highlight falls back to Name and highlights "T" for artists + * like "The Beatles" even though the grid sorts them under "B". + */ + @Test + fun `artist pager requests SortName for alphabet highlighting`() = + runTest(testDispatcher) { + createViewModel(GetItemsFilter(override = GetItemsFilterOverride.ARTIST)) + advanceUntilIdle() + + assertTrue(ItemFields.SORT_NAME in artistsRequest.captured.fields.orEmpty()) + } + + /** + * /Persons has no nameLessThan or startIndex, so counting up to a letter is not expressible. + * Falling back to /Items would count media rather than people, ie the same defect as the artist + * grid, so guard against it even though no screen currently sorts people by name. + */ + @Test + fun `positionOfLetter does not fall back to counting items for the PERSON override`() = + runTest(testDispatcher) { + val viewModel = createViewModel(GetItemsFilter(override = GetItemsFilterOverride.PERSON)) + advanceUntilIdle() + + val position = viewModel.positionOfLetter('K') + + assertEquals(null, position) + coVerify(exactly = 0) { GetItemsRequestHandler.execute(any(), any<GetItemsRequest>()) } + coVerify(exactly = 0) { GetArtistsHandler.execute(any(), any<GetArtistsRequest>()) } + } + + @Test + fun `positionOfLetter counts items for the NONE override`() = + runTest(testDispatcher) { + val viewModel = + createViewModel( + GetItemsFilter( + override = GetItemsFilterOverride.NONE, + includeItemTypes = listOf(BaseItemKind.MUSIC_ALBUM), + ), + ) + advanceUntilIdle() + + val position = viewModel.positionOfLetter('K') + + assertEquals(LIBRARY_ITEM_COUNT, position) + assertEquals("K", itemsRequest.captured.nameLessThan) + assertEquals(libraryId, itemsRequest.captured.parentId) + assertEquals(0, itemsRequest.captured.limit) + } + + companion object { + private const val LIBRARY_ITEM_COUNT = 7315 + private const val ARTIST_COUNT = 730 + private const val ALBUM_ARTIST_COUNT = 215 + private const val PERSON_COUNT = 12 + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/ui/components/SourceRatingsRowTest.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/components/SourceRatingsRowTest.kt new file mode 100644 index 00000000..4fb3dc5c --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/components/SourceRatingsRowTest.kt @@ -0,0 +1,281 @@ +package com.github.damontecres.wholphin.ui.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import com.github.damontecres.wholphin.api.seerr.MoviesApi +import com.github.damontecres.wholphin.api.seerr.SeerrApiClient +import com.github.damontecres.wholphin.api.seerr.TvApi +import com.github.damontecres.wholphin.api.seerr.model.MovieMovieIdRatingsGet200Response +import com.github.damontecres.wholphin.api.seerr.model.MovieMovieIdRatingscombinedGet200Response +import com.github.damontecres.wholphin.api.seerr.model.MovieMovieIdRatingscombinedGet200ResponseImdb +import com.github.damontecres.wholphin.api.seerr.model.SeasonDetails +import com.github.damontecres.wholphin.api.seerr.model.SeasonEpisode +import com.github.damontecres.wholphin.api.seerr.model.TvTvIdRatingsGet200Response +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.SourceRatings +import com.github.damontecres.wholphin.preferences.DisplayToggle +import com.github.damontecres.wholphin.services.SeerrRatingsService +import com.github.damontecres.wholphin.services.SeerrService +import com.github.damontecres.wholphin.ui.successResponse +import com.github.damontecres.wholphin.ui.theme.WholphinTheme +import com.github.damontecres.wholphin.ui.util.InterfaceCustomization +import com.github.damontecres.wholphin.ui.util.LocalInterfaceCustomization +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.runBlocking +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.userLibraryApi +import org.jellyfin.sdk.api.operations.UserLibraryApi +import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.BaseItemKind +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.util.EnumSet +import java.util.UUID + +/** + * What every surface shows: the rating row comes only from Seerr, per the PWA's rules for the kind of item, and + * the Jellyfin rating of an item is never drawn + */ +@Config(sdk = [34]) +@RunWith(RobolectricTestRunner::class) +class SourceRatingsRowTest { + @get:Rule val rule = createComposeRule() + + private val moviesApi = mockk<MoviesApi>() + private val tvApi = mockk<TvApi>() + private val seerrApi = + mockk<SeerrApiClient> { + every { moviesApi } returns this@SourceRatingsRowTest.moviesApi + every { tvApi } returns this@SourceRatingsRowTest.tvApi + } + private val seerrService = + mockk<SeerrService> { + every { api } returns seerrApi + every { active } returns flowOf(true) + } + private val userLibraryApi = mockk<UserLibraryApi>() + private val jellyfin = mockk<ApiClient> { every { userLibraryApi } returns this@SourceRatingsRowTest.userLibraryApi } + private val service = SeerrRatingsService(seerrService, jellyfin) + + private val seriesId = UUID.randomUUID() + private val seriesTmdb = 1396 + + /** Every item carries Jellyfin ratings that must never be drawn */ + private fun item( + type: BaseItemKind, + tmdb: Int? = null, + seriesId: UUID? = null, + ) = BaseItem( + BaseItemDto( + id = UUID.randomUUID(), + type = type, + name = "Title", + providerIds = tmdb?.let { mapOf("Tmdb" to it.toString()) }, + seriesId = seriesId, + communityRating = 7.8f, + criticRating = 59f, + ), + ) + + private fun movieRatings() { + coEvery { moviesApi.movieMovieIdRatingscombinedGet(movieId = 603) } returns + MovieMovieIdRatingscombinedGet200Response( + rt = MovieMovieIdRatingsGet200Response(criticsScore = 96, criticsRating = "Certified Fresh", audienceScore = 90, audienceRating = "Upright"), + imdb = MovieMovieIdRatingscombinedGet200ResponseImdb(criticsScore = 8.8.toBigDecimal(), criticsScoreCount = 10), + ) + } + + private fun seriesRatings() { + coEvery { tvApi.tvTvIdRatingsGet(tvId = seriesTmdb) } returns + TvTvIdRatingsGet200Response(criticsScore = 81, criticsRating = "Fresh", audienceScore = 45, audienceRating = "Spilled") + coEvery { userLibraryApi.getItem(seriesId) } returns + successResponse(BaseItemDto(id = seriesId, type = BaseItemKind.SERIES, providerIds = mapOf("Tmdb" to "$seriesTmdb"))) + } + + /** + * Shows the quick details of the items, after what a screen's wait for Seerr would have loaded + */ + private fun show( + vararg items: BaseItem, + toggles: EnumSet<DisplayToggle> = EnumSet.allOf(DisplayToggle::class.java), + ) { + runBlocking { + items.forEach { service.forItem(it, 0).collect { } } + } + rule.setContent { + WholphinTheme { + CompositionLocalProvider( + LocalSeerrRatingsService provides service, + LocalInterfaceCustomization provides InterfaceCustomization(toggles), + ) { + Column { + items.forEach { QuickDetails(it, null) } + } + } + } + } + rule.waitForIdle() + } + + private fun assertShown(text: String) = rule.onNodeWithText(text, substring = true).assertExists() + + private fun assertNotShown(text: String) = rule.onNodeWithText(text, substring = true).assertDoesNotExist() + + private fun assertJellyfinRatingNotShown() { + assertNotShown("7.8") + assertNotShown("59%") + } + + @Test + fun movieShowsRottenTomatoesThenImdbAndNotItsJellyfinRating() { + movieRatings() + show(item(BaseItemKind.MOVIE, 603)) + assertShown("96%") + assertShown("90%") + assertShown("8.8") + assertJellyfinRatingNotShown() + } + + @Test + fun seriesShowsItsRatings() { + seriesRatings() + show(item(BaseItemKind.SERIES, seriesTmdb)) + assertShown("81%") + assertShown("45%") + assertJellyfinRatingNotShown() + } + + @Test + fun episodeAndSeasonShowTheirSeriesRatings() { + seriesRatings() + show(item(BaseItemKind.EPISODE, seriesId = seriesId)) + assertShown("81%") + assertJellyfinRatingNotShown() + } + + private fun episodeWithScores(): BaseItem { + seriesRatings() + coEvery { tvApi.tvTvIdSeasonSeasonNumberGet(tvId = seriesTmdb, seasonNumber = 5) } returns + SeasonDetails(episodes = listOf(SeasonEpisode(episodeNumber = 1, voteAverage = 8.1.toBigDecimal(), voteCount = 50))) + return BaseItem( + BaseItemDto( + id = UUID.randomUUID(), + type = BaseItemKind.EPISODE, + providerIds = mapOf("Tmdb" to "331212"), + seriesId = seriesId, + parentIndexNumber = 5, + indexNumber = 1, + communityRating = 7.8f, + criticRating = 59f, + ), + ) + } + + @Test + fun episodeShowsItsOwnLabelledTmdbScoreNextToTheSeriesRottenTomatoes() { + show(episodeWithScores()) + assertShown("81%") + assertShown("45%") + assertShown("Ep 8.1") + assertJellyfinRatingNotShown() + } + + @Test + fun episodeScoreFollowsTheCommunityRatingSetting() { + show(episodeWithScores(), toggles = EnumSet.of(DisplayToggle.CRITIC_RATING)) + assertShown("81%") + assertNotShown("Ep 8.1") + } + + @Test + fun episodeScoreStaysWhenRottenTomatoesIsHidden() { + show(episodeWithScores(), toggles = EnumSet.of(DisplayToggle.COMMUNITY_RATING)) + assertShown("Ep 8.1") + assertNotShown("81%") + } + + @Test + fun seasonShowsItsSeriesRatings() { + seriesRatings() + show(item(BaseItemKind.SEASON, seriesId = seriesId)) + assertShown("45%") + assertJellyfinRatingNotShown() + } + + @Test + fun surfacesWithoutAPwaEquivalentShowNoRatingNotTheJellyfinStar() { + movieRatings() + seriesRatings() + show( + *listOf( + BaseItemKind.BOX_SET, + BaseItemKind.MUSIC_ALBUM, + BaseItemKind.MUSIC_ARTIST, + BaseItemKind.PHOTO, + BaseItemKind.PROGRAM, + ).map { item(it, 603, seriesId) }.toTypedArray(), + ) + assertNotShown("96%") + assertNotShown("81%") + assertJellyfinRatingNotShown() + } + + @Test + fun titleSeerrHasNoRatingsForShowsNoRatingNotTheJellyfinStar() { + coEvery { moviesApi.movieMovieIdRatingscombinedGet(movieId = 5) } returns MovieMovieIdRatingscombinedGet200Response() + coEvery { moviesApi.movieMovieIdGet(movieId = 5) } returns + mockk { + every { voteAverage } returns null + every { voteCount } returns null + } + show(item(BaseItemKind.MOVIE, 5)) + assertJellyfinRatingNotShown() + } + + @Test + fun theDisplaySettingsStillControlWhichSourcesAreDrawn() { + movieRatings() + show(item(BaseItemKind.MOVIE, 603), toggles = EnumSet.of(DisplayToggle.COMMUNITY_RATING)) + assertShown("8.8") + assertNotShown("96%") + } + + @Test + fun criticSettingHidesImdb() { + movieRatings() + show(item(BaseItemKind.MOVIE, 603), toggles = EnumSet.of(DisplayToggle.CRITIC_RATING)) + assertShown("96%") + assertNotShown("8.8") + } + + @Test + fun ratingsTextHasOneBadgeAndNumberPerEntryInOrder() { + val ratings = + SourceRatings.of( + rtCriticsScore = 96, + rtCriticsLabel = "Certified Fresh", + rtAudienceScore = 30, + rtAudienceLabel = "Spilled", + imdbScore = 8.8, + imdbVotes = 10, + ) + val entries = ratings.visibleEntries(community = true, critic = true) + val text = ratingsText(entries, leadingDot = false, episodeLabel = "Ep") + + assertTrue(text.text.indexOf("96%") < text.text.indexOf("30%")) + assertTrue(text.text.indexOf("30%") < text.text.indexOf("8.8")) + // dots only between entries, or also in front when something precedes the ratings + assertEquals(2, text.text.split("\u2022").size - 1) + assertEquals(3, ratingsText(entries, leadingDot = true, episodeLabel = "Ep").text.split("\u2022").size - 1) + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/ui/detail/TestFavoritesViewModel.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/detail/TestFavoritesViewModel.kt new file mode 100644 index 00000000..0b4cece1 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/detail/TestFavoritesViewModel.kt @@ -0,0 +1,427 @@ +package com.github.damontecres.wholphin.ui.detail + +import android.content.Context +import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.GetItemsFilter +import com.github.damontecres.wholphin.services.BackdropService +import com.github.damontecres.wholphin.services.FavoriteWatchManager +import com.github.damontecres.wholphin.services.FilterOptionCache +import com.github.damontecres.wholphin.services.MediaManagementService +import com.github.damontecres.wholphin.services.NavDrawerItemState +import com.github.damontecres.wholphin.services.NavDrawerService +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.RememberedTabService +import com.github.damontecres.wholphin.services.ServerReportService +import com.github.damontecres.wholphin.services.StreamChoiceService +import com.github.damontecres.wholphin.services.UserPreferencesService +import com.github.damontecres.wholphin.test.item +import com.github.damontecres.wholphin.test.movie +import com.github.damontecres.wholphin.ui.components.ViewOptions +import com.github.damontecres.wholphin.ui.data.SortAndDirection +import com.github.damontecres.wholphin.ui.main.settings.Library +import com.github.damontecres.wholphin.ui.successQueryResult +import com.github.damontecres.wholphin.util.DataLoadingState +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.artistsApi +import org.jellyfin.sdk.api.client.extensions.itemsApi +import org.jellyfin.sdk.api.client.extensions.personsApi +import org.jellyfin.sdk.api.operations.ArtistsApi +import org.jellyfin.sdk.api.operations.ItemsApi +import org.jellyfin.sdk.api.operations.PersonsApi +import org.jellyfin.sdk.model.UUID +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.CollectionType +import org.jellyfin.sdk.model.api.request.GetArtistsRequest +import org.jellyfin.sdk.model.api.request.GetItemsRequest +import org.jellyfin.sdk.model.api.request.GetPersonsRequest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class TestFavoritesViewModel { + private val context: Context = mockk() + private val api: ApiClient = mockk() + private val navigationManager: NavigationManager = mockk() + private val serverRepository: ServerRepository = mockk() + private val libraryDisplayInfoDao: LibraryDisplayInfoDao = mockk() + private val favoriteWatchManager: FavoriteWatchManager = mockk() + private val backdropService: BackdropService = mockk() + private val userPreferencesService: UserPreferencesService = mockk() + private val mediaManagementService: MediaManagementService = mockk() + private val streamChoiceService: StreamChoiceService = mockk() + private val serverReportService: ServerReportService = mockk() + private val filterOptionCache: FilterOptionCache = mockk() + private val rememberedTabService: RememberedTabService = mockk() + private val navDrawerService: NavDrawerService = mockk() + + private val itemsApi: ItemsApi = mockk() + private val artistsApi: ArtistsApi = mockk() + private val personsApi: PersonsApi = mockk() + + @Before + fun setup() { + every { api.itemsApi } returns itemsApi + every { api.artistsApi } returns artistsApi + every { api.personsApi } returns personsApi + every { navDrawerService.state } returns + MutableStateFlow( + NavDrawerItemState( + allLibraries = + listOf( + library(CollectionType.MOVIES), + library(CollectionType.TVSHOWS), + library(CollectionType.BOXSETS), + ), + ), + ) + } + + private fun createViewModel(): FavoritesViewModel = + FavoritesViewModel( + context = context, + api = api, + navigationManager = navigationManager, + serverRepository = serverRepository, + libraryDisplayInfoDao = libraryDisplayInfoDao, + favoriteWatchManager = favoriteWatchManager, + backdropService = backdropService, + userPreferencesService = userPreferencesService, + mediaManagementService = mediaManagementService, + streamChoiceService = streamChoiceService, + serverReportService = serverReportService, + filterOptionCache = filterOptionCache, + rememberedTabService = rememberedTabService, + navDrawerService = navDrawerService, + ).apply { + setupPossibleTypes() + } + + private val items = listOf(BaseItem(movie())) + + private val empty = emptyList<BaseItem>() + + private fun successJob(items: List<BaseItem>) = CompletableDeferred(DataLoadingState.Success(items)) + + private fun errorJob() = CompletableDeferred(DataLoadingState.Error()) + + private suspend fun testInitialFetchAndWait( + firstTabKey: BaseItemKind, + jobs: Map<BaseItemKind, Deferred<DataLoadingState<List<BaseItem?>>>>, + asserts: (FavoritesPageState) -> Unit, + ) { + createViewModel().let { viewModel -> + viewModel.initialFetchAndWait( + firstTabKey = firstTabKey, + jobs = jobs, + ) + viewModel.state.value.let { state -> + asserts.invoke(state) + } + } + } + + @Test + fun `Test tabKey for first success`() = + runTest { + val jobs = + mapOf( + BaseItemKind.MOVIE to successJob(items), + BaseItemKind.SERIES to successJob(items), + ) + + testInitialFetchAndWait( + firstTabKey = BaseItemKind.MOVIE, + jobs = jobs, + ) { state -> + assertEquals(BaseItemKind.MOVIE, state.tabKey) + } + } + + @Test + fun `Test tabKey for second success`() = + runTest { + val jobs = + mapOf( + BaseItemKind.MOVIE to successJob(empty), + BaseItemKind.SERIES to successJob(items), + ) + + testInitialFetchAndWait( + firstTabKey = BaseItemKind.MOVIE, + jobs = jobs, + ) { state -> + assertEquals(BaseItemKind.SERIES, state.tabKey) + } + + testInitialFetchAndWait( + firstTabKey = BaseItemKind.SERIES, + jobs = jobs, + ) { state -> + assertEquals(BaseItemKind.SERIES, state.tabKey) + } + } + + @Test + fun `Test tabKey for first failure`() = + runTest { + val jobs = + mapOf( + BaseItemKind.MOVIE to errorJob(), + BaseItemKind.SERIES to successJob(items), + ) + + testInitialFetchAndWait( + firstTabKey = BaseItemKind.MOVIE, + jobs = jobs, + ) { state -> + assertEquals(BaseItemKind.MOVIE, state.tabKey) + } + + testInitialFetchAndWait( + firstTabKey = BaseItemKind.SERIES, + jobs = jobs, + ) { state -> + assertEquals(BaseItemKind.SERIES, state.tabKey) + } + } + + @Test + fun `Test tabKey for multiple empty`() = + runTest { + val jobs = + mapOf( + BaseItemKind.MOVIE to successJob(empty), + BaseItemKind.SERIES to successJob(items), + BaseItemKind.EPISODE to successJob(empty), + BaseItemKind.BOX_SET to successJob(items), + ) + + testInitialFetchAndWait( + firstTabKey = BaseItemKind.MOVIE, + jobs = jobs, + ) { state -> + assertEquals(BaseItemKind.SERIES, state.tabKey) + } + + testInitialFetchAndWait( + firstTabKey = BaseItemKind.SERIES, + jobs = jobs, + ) { state -> + assertEquals(BaseItemKind.SERIES, state.tabKey) + } + + testInitialFetchAndWait( + firstTabKey = BaseItemKind.EPISODE, + jobs = jobs, + ) { state -> + assertEquals(BaseItemKind.SERIES, state.tabKey) + } + } + + @Test + fun `Test tabKey for multiple empty with first error`() = + runTest { + val jobs = + mapOf( + BaseItemKind.MOVIE to errorJob(), + BaseItemKind.SERIES to successJob(empty), + BaseItemKind.EPISODE to successJob(items), + BaseItemKind.BOX_SET to successJob(items), + ) + + testInitialFetchAndWait( + firstTabKey = BaseItemKind.SERIES, + jobs = jobs, + ) { state -> + assertEquals(BaseItemKind.MOVIE, state.tabKey) + } + } + + @Test + fun `Test tabKey for multiple empty with first empty`() = + runTest { + val jobs = + mapOf( + BaseItemKind.MOVIE to successJob(empty), + BaseItemKind.SERIES to successJob(empty), + BaseItemKind.EPISODE to successJob(empty), + BaseItemKind.BOX_SET to successJob(items), + ) + + testInitialFetchAndWait( + firstTabKey = BaseItemKind.EPISODE, + jobs = jobs, + ) { state -> + assertEquals(BaseItemKind.BOX_SET, state.tabKey) + } + + testInitialFetchAndWait( + firstTabKey = BaseItemKind.BOX_SET, + jobs = jobs, + ) { state -> + assertEquals(BaseItemKind.BOX_SET, state.tabKey) + } + } + + @Test + fun `Test fetchType basic`() = + runTest { + val movie = movie() + coEvery { itemsApi.getItems(any<GetItemsRequest>()) } returns successQueryResult(listOf(movie)) + + val viewModel = createViewModel() + val data = + viewModel.fetchType( + type = BaseItemKind.MOVIE, + sortAndDirection = SortAndDirection.DEFAULT, + filter = GetItemsFilter(), + viewOptions = ViewOptions(), + ) + assertTrue(data is DataLoadingState.Success) + assertEquals(BaseItem(movie), (data as DataLoadingState.Success).data.first()) + viewModel.state.value.let { state -> + assertTrue(BaseItemKind.MOVIE in state.tabs) + assertTrue(BaseItemKind.MOVIE in state.favorites) + state.favorites[BaseItemKind.MOVIE]!!.let { st -> + assertTrue(st.item is DataLoadingState.Success) + assertTrue(st.items is DataLoadingState.Success) + } + } + coVerify(exactly = 1) { itemsApi.getItems(any<GetItemsRequest>()) } + } + + @Test + fun `Test fetchType with error`() = + runTest { + val ex = Exception() + coEvery { itemsApi.getItems(any<GetItemsRequest>()) } throws ex + + val viewModel = createViewModel() + val data = + viewModel.fetchType( + type = BaseItemKind.MOVIE, + sortAndDirection = SortAndDirection.DEFAULT, + filter = GetItemsFilter(), + viewOptions = ViewOptions(), + ) + assertTrue(data is DataLoadingState.Error) + assertEquals(ex, (data as DataLoadingState.Error).exception) + viewModel.state.value.let { state -> + assertTrue(BaseItemKind.MOVIE in state.tabs) + assertTrue(BaseItemKind.MOVIE in state.favorites) + state.favorites[BaseItemKind.MOVIE]!!.let { st -> + assertTrue(st.item is DataLoadingState.Success) + assertTrue(st.items is DataLoadingState.Error) + assertEquals(ex, (st.items as DataLoadingState.Error).exception) + } + } + coVerify(exactly = 1) { itemsApi.getItems(any<GetItemsRequest>()) } + } + + @Test + fun `Test fetchType with empty result does not add tab`() = + runTest { + coEvery { itemsApi.getItems(any<GetItemsRequest>()) } returns successQueryResult() + + val viewModel = createViewModel() + val data = + viewModel.fetchType( + type = BaseItemKind.MOVIE, + sortAndDirection = SortAndDirection.DEFAULT, + filter = GetItemsFilter(), + viewOptions = ViewOptions(), + ) + assertTrue(data is DataLoadingState.Success) + assertEquals(0, (data as DataLoadingState.Success).data.size) + viewModel.state.value.let { state -> + assertFalse(BaseItemKind.MOVIE in state.tabs) + + assertTrue(BaseItemKind.MOVIE in state.favorites) + state.favorites[BaseItemKind.MOVIE]!!.let { st -> + assertTrue(st.item is DataLoadingState.Success) + assertTrue(st.items is DataLoadingState.Success) + assertEquals(0, (st.items as DataLoadingState.Success).data.size) + } + } + coVerify(exactly = 1) { itemsApi.getItems(any<GetItemsRequest>()) } + } + + @Test + fun `Test fetchType for artists`() = + runTest { + val item = item(BaseItemKind.MUSIC_ARTIST) + coEvery { artistsApi.getArtists(any<GetArtistsRequest>()) } returns successQueryResult(listOf(item)) + + val viewModel = createViewModel() + val data = + viewModel.fetchType( + type = BaseItemKind.MUSIC_ARTIST, + sortAndDirection = SortAndDirection.DEFAULT, + filter = GetItemsFilter(), + viewOptions = ViewOptions(), + ) + assertTrue(data is DataLoadingState.Success) + assertEquals(BaseItem(item), (data as DataLoadingState.Success).data.first()) + viewModel.state.value.let { state -> + assertTrue(BaseItemKind.MUSIC_ARTIST in state.tabs) + assertTrue(BaseItemKind.MUSIC_ARTIST in state.favorites) + state.favorites[BaseItemKind.MUSIC_ARTIST]!!.let { st -> + assertTrue(st.item is DataLoadingState.Success) + assertTrue(st.items is DataLoadingState.Success) + } + } + coVerify(exactly = 1) { artistsApi.getArtists(any<GetArtistsRequest>()) } + coVerify(exactly = 0) { itemsApi.getItems(any<GetItemsRequest>()) } + coVerify(exactly = 0) { personsApi.getPersons(any<GetPersonsRequest>()) } + } + + @Test + fun `Test fetchType for persons`() = + runTest { + val item = item(BaseItemKind.PERSON) + coEvery { personsApi.getPersons(any<GetPersonsRequest>()) } returns successQueryResult(listOf(item)) + + val viewModel = createViewModel() + val data = + viewModel.fetchType( + type = BaseItemKind.PERSON, + sortAndDirection = SortAndDirection.DEFAULT, + filter = GetItemsFilter(), + viewOptions = ViewOptions(), + ) + assertTrue(data is DataLoadingState.Success) + assertEquals(BaseItem(item), (data as DataLoadingState.Success).data.first()) + viewModel.state.value.let { state -> + assertTrue(BaseItemKind.PERSON in state.tabs) + assertTrue(BaseItemKind.PERSON in state.favorites) + state.favorites[BaseItemKind.PERSON]!!.let { st -> + assertTrue(st.item is DataLoadingState.Success) + assertTrue(st.items is DataLoadingState.Success) + } + } + coVerify(exactly = 1) { personsApi.getPersons(any<GetPersonsRequest>()) } + coVerify(exactly = 0) { itemsApi.getItems(any<GetItemsRequest>()) } + coVerify(exactly = 0) { artistsApi.getArtists(any<GetArtistsRequest>()) } + } +} + +private fun library(type: CollectionType) = + Library( + itemId = UUID.randomUUID(), + name = "name-${type.serialName}", + type = BaseItemKind.COLLECTION_FOLDER, + collectionType = type, + isRecordingFolder = false, + ) diff --git a/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlayExternalViewModelTest.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlayExternalViewModelTest.kt new file mode 100644 index 00000000..c6bc32f0 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlayExternalViewModelTest.kt @@ -0,0 +1,277 @@ +package com.github.damontecres.wholphin.ui.playback + +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.lifecycle.SavedStateHandle +import com.github.damontecres.wholphin.data.ItemPlaybackDao +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.Playlist +import com.github.damontecres.wholphin.data.model.SeriesTrackChoiceType +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.preferences.updatePlaybackPreferences +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.PlaylistCreationResult +import com.github.damontecres.wholphin.services.PlaylistCreator +import com.github.damontecres.wholphin.services.StreamChoiceReason +import com.github.damontecres.wholphin.services.StreamChoiceResult +import com.github.damontecres.wholphin.services.StreamChoiceService +import com.github.damontecres.wholphin.services.UserPreferencesService +import com.github.damontecres.wholphin.test.TestTracks +import com.github.damontecres.wholphin.test.movie +import com.github.damontecres.wholphin.test.playlist +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.preferences.ExternalPlayerApp +import com.github.damontecres.wholphin.ui.preferences.getExternalPlayers +import com.github.damontecres.wholphin.ui.successResponse +import com.github.damontecres.wholphin.util.LoadingState +import com.github.damontecres.wholphin.util.WholphinDispatchers +import com.github.damontecres.wholphin.util.configure +import com.github.damontecres.wholphin.util.reset +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.playStateApi +import org.jellyfin.sdk.api.client.extensions.subtitleApi +import org.jellyfin.sdk.api.client.extensions.userLibraryApi +import org.jellyfin.sdk.api.client.extensions.videosApi +import org.jellyfin.sdk.api.operations.PlayStateApi +import org.jellyfin.sdk.api.operations.SubtitleApi +import org.jellyfin.sdk.api.operations.UserLibraryApi +import org.jellyfin.sdk.api.operations.VideosApi +import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.extensions.inWholeTicks +import org.junit.After +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.time.Duration.Companion.minutes + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE, sdk = [30]) +class PlayExternalViewModelTest { + private val testDispatcher = StandardTestDispatcher() + private val context = mockk<Context>() + private val api = mockk<ApiClient>() + private val userLibraryApi = mockk<UserLibraryApi>() + private val videosApi = mockk<VideosApi>() + private val subtitleApi = mockk<SubtitleApi>() + private val playStateApi = mockk<PlayStateApi>(relaxed = true) + private val serverRepository = mockk<ServerRepository>() + private val itemPlaybackDao = mockk<ItemPlaybackDao>() + private val playlistCreator = mockk<PlaylistCreator>() + private val streamChoiceService = mockk<StreamChoiceService>() + private val navigationManager = mockk<NavigationManager>(relaxed = true) + private val userPreferencesService = mockk<UserPreferencesService>() + + private val movie = movie() + private val subtitleUrl = "http://localhost:8096/subtitles/0.srt" + private val videoUrl = "http://localhost:8096/video/stream" + private val mediaSource = + TestTracks + .Builder() + .addExternalSubtitle() + .buildMediaSourceInfo() + .let { source -> + source.copy( + id = "source-id", + runTimeTicks = 90.minutes.inWholeTicks, + mediaStreams = + source.mediaStreams!!.map { + it.copy(codec = "srt", path = "subtitle.srt", displayTitle = "English") + }, + ) + } + private val subtitle = mediaSource.mediaStreams!!.single() + private val vlc = ComponentName("org.videolan.vlc", "org.videolan.vlc.gui.video.VideoPlayerActivity") + + @OptIn(ExperimentalCoroutinesApi::class) + @Before + fun setUp() { + WholphinDispatchers.configure(testDispatcher) + mockkStatic(::getExternalPlayers) + every { getExternalPlayers(context) } returns + listOf(ExternalPlayerApp("VLC", null, vlc.flattenToString())) + every { api.userLibraryApi } returns userLibraryApi + every { api.videosApi } returns videosApi + every { api.subtitleApi } returns subtitleApi + every { api.playStateApi } returns playStateApi + every { serverRepository.currentUser } returns null + every { serverRepository.currentServer } returns null + coEvery { userLibraryApi.getItem(movie.id) } returns successResponse(movie) + coEvery { streamChoiceService.chooseSource(any(), any()) } returns mediaSource + coEvery { streamChoiceService.chooseSubtitleStream(any(), any(), any(), any(), any(), any()) } returns + StreamChoiceResult(subtitle, StreamChoiceReason.Unknown) + coEvery { streamChoiceService.getSeriesTrackChoices(any<BaseItemDto>(), SeriesTrackChoiceType.SUBTITLE) } returns emptyList() + every { + videosApi.getVideoStreamUrl( + itemId = movie.id, + mediaSourceId = mediaSource.id, + static = true, + ) + } returns videoUrl + every { + subtitleApi.getSubtitleUrl( + routeItemId = any(), + routeMediaSourceId = mediaSource.id!!, + routeIndex = subtitle.index, + routeFormat = "srt", + ) + } returns subtitleUrl + setupPlayer("") + } + + @OptIn(ExperimentalCoroutinesApi::class) + @After + fun tearDown() { + unmockkStatic(::getExternalPlayers) + WholphinDispatchers.reset() + } + + private fun setupPlayer(playerId: String) { + val preferences = + AppPreferences.getDefaultInstance().updatePlaybackPreferences { + externalPlayer = playerId + } + coEvery { userPreferencesService.getCurrent() } returns UserPreferences(preferences, null) + } + + private fun createIntent(destination: Destination): Intent { + val viewModel = + PlayExternalViewModel( + SavedStateHandle(), + context, + api, + serverRepository, + itemPlaybackDao, + playlistCreator, + streamChoiceService, + navigationManager, + userPreferencesService, + ) + viewModel.init(destination) + testDispatcher.scheduler.advanceUntilIdle() + assertEquals(LoadingState.Success, viewModel.state.value.loading) + return viewModel.state.value.intent + } + + private fun assertStartFromBeginning(intent: Intent) { + assertEquals(true, intent.extras!!.get("from_start")) + assertEquals(0, intent.extras!!.get("position")) + } + + @Test + fun `Explicit VLC starts from beginning at zero position`() = + runTest(testDispatcher) { + setupPlayer(vlc.flattenToString()) + val intent = createIntent(Destination.Playback(movie.id, 0L)) + assertEquals(vlc, intent.component) + assertStartFromBeginning(intent) + } + + @Test + fun `Implicit player starts VLC from beginning at zero position`() = + runTest(testDispatcher) { + val intent = createIntent(Destination.Playback(movie.id, 0L)) + assertNull(intent.component) + assertStartFromBeginning(intent) + } + + @Test + fun `Unavailable selected player falls back to implicit start from beginning`() = + runTest(testDispatcher) { + setupPlayer("missing.player/missing.player.Activity") + val intent = createIntent(Destination.Playback(movie.id, 0L)) + assertNull(intent.component) + assertStartFromBeginning(intent) + } + + @Test + fun `Explicit VLC preserves positive resume position`() = + runTest(testDispatcher) { + setupPlayer(vlc.flattenToString()) + val intent = createIntent(Destination.Playback(movie.id, 42_000L)) + assertEquals(vlc, intent.component) + assertEquals(false, intent.extras!!.get("from_start")) + assertEquals(42_000, intent.extras!!.get("position")) + } + + @Test + fun `Implicit player preserves positive resume position`() = + runTest(testDispatcher) { + val intent = createIntent(Destination.Playback(movie.id, 42_000L)) + assertNull(intent.component) + assertEquals(false, intent.extras!!.get("from_start")) + assertEquals(42_000, intent.extras!!.get("position")) + } + + @Test + fun `Playback list starts from beginning`() = + runTest(testDispatcher) { + val playlist = playlist() + coEvery { userLibraryApi.getItem(playlist.id) } returns successResponse(playlist) + coEvery { playlistCreator.createFrom(any(), any(), any(), any(), any(), any()) } returns + PlaylistCreationResult.Success(Playlist.fromMedia(listOf(BaseItem(movie)))) + + val intent = createIntent(Destination.PlaybackList(playlist.id)) + assertStartFromBeginning(intent) + } + + @Test + fun `VLC starts from beginning without subtitles or duration`() = + runTest(testDispatcher) { + coEvery { streamChoiceService.chooseSource(any(), any()) } returns + mediaSource.copy(mediaStreams = emptyList(), runTimeTicks = null) + coEvery { streamChoiceService.chooseSubtitleStream(any(), any(), any(), any(), any(), any()) } returns null + + val intent = createIntent(Destination.Playback(movie.id, 0L)) + assertStartFromBeginning(intent) + assertFalse(intent.hasExtra("subtitles_location")) + assertFalse(intent.hasExtra("extra_duration")) + assertFalse(intent.hasExtra("subs.enable")) + assertFalse(intent.hasExtra("forcedsrt")) + } + + @Test + fun `Existing player extras and playback-start reporting remain unchanged`() = + runTest(testDispatcher) { + val intent = createIntent(Destination.Playback(movie.id, 42_000L)) + val title = "${BaseItem(movie).title} ${BaseItem(movie).subtitleLong}" + val subtitleUri = Uri.parse(subtitleUrl) + assertEquals(Intent.ACTION_VIEW, intent.action) + assertEquals(Uri.parse(videoUrl), intent.data) + assertEquals("video/*", intent.type) + assertEquals(title, intent.getStringExtra("title")) + assertEquals(42_000, intent.extras!!.get("position")) + assertTrue(intent.getBooleanExtra("return_result", false)) + assertTrue(intent.getBooleanExtra("secure_uri", false)) + assertArrayEquals(arrayOf(subtitleUri), intent.getParcelableArrayExtra("subs")) + assertArrayEquals(arrayOf("English"), intent.getStringArrayExtra("subs.name")) + assertArrayEquals(arrayOf(subtitleUri), intent.getParcelableArrayExtra("subs.enable")) + assertEquals(subtitleUrl, intent.getStringExtra("subtitles_location")) + assertEquals(90.minutes.inWholeMilliseconds, intent.extras!!.get("extra_duration")) + assertEquals(42_000, intent.extras!!.get("startfrom")) + assertFalse(intent.getBooleanExtra("forceresume", true)) + assertEquals(title, intent.getStringExtra("forcename")) + assertEquals(subtitleUri, intent.getParcelableExtra<Uri>("forcedsrt")) + coVerify(exactly = 1) { playStateApi.reportPlaybackStart(any()) } + coVerify(exactly = 0) { playStateApi.reportPlaybackStopped(any()) } + } +} 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 new file mode 100644 index 00000000..87e5670a --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModelTests.kt @@ -0,0 +1,916 @@ +package com.github.damontecres.wholphin.ui.playback + +import android.content.Context +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import com.github.damontecres.wholphin.data.ItemPlaybackDao +import com.github.damontecres.wholphin.data.ItemPlaybackRepository +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.JellyfinServer +import com.github.damontecres.wholphin.data.model.JellyfinUser +import com.github.damontecres.wholphin.data.model.Playlist +import com.github.damontecres.wholphin.data.model.PlaylistItem +import com.github.damontecres.wholphin.data.model.SeriesTrackChoiceType +import com.github.damontecres.wholphin.data.model.ServerUserConfig +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.PlaybackPreferences +import com.github.damontecres.wholphin.preferences.ShowNextUpWhen +import com.github.damontecres.wholphin.preferences.SkipSegmentBehavior +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.preferences.updatePlaybackPreferences +import com.github.damontecres.wholphin.services.DatePlayedService +import com.github.damontecres.wholphin.services.DeviceProfileService +import com.github.damontecres.wholphin.services.ImageUrlService +import com.github.damontecres.wholphin.services.MusicService +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.PlayerCreation +import com.github.damontecres.wholphin.services.PlayerFactory +import com.github.damontecres.wholphin.services.PlaylistCreationResult +import com.github.damontecres.wholphin.services.PlaylistCreator +import com.github.damontecres.wholphin.services.RefreshRateService +import com.github.damontecres.wholphin.services.ScreensaverService +import com.github.damontecres.wholphin.services.StreamChoiceReason +import com.github.damontecres.wholphin.services.StreamChoiceResult +import com.github.damontecres.wholphin.services.StreamChoiceService +import com.github.damontecres.wholphin.services.UserPreferencesService +import com.github.damontecres.wholphin.test.TestTracks +import com.github.damontecres.wholphin.test.movie +import com.github.damontecres.wholphin.test.playlist +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.successQueryResult +import com.github.damontecres.wholphin.ui.successResponse +import com.github.damontecres.wholphin.util.LoadingState +import com.github.damontecres.wholphin.util.WholphinDispatchers +import com.github.damontecres.wholphin.util.configure +import com.github.damontecres.wholphin.util.reset +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import io.mockk.verifyOrder +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.InternalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.mediaInfoApi +import org.jellyfin.sdk.api.client.extensions.mediaSegmentsApi +import org.jellyfin.sdk.api.client.extensions.userLibraryApi +import org.jellyfin.sdk.api.client.extensions.videosApi +import org.jellyfin.sdk.api.operations.MediaInfoApi +import org.jellyfin.sdk.api.operations.MediaSegmentsApi +import org.jellyfin.sdk.api.operations.UserLibraryApi +import org.jellyfin.sdk.api.operations.VideosApi +import org.jellyfin.sdk.model.DeviceInfo +import org.jellyfin.sdk.model.UUID +import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.MediaSegmentDto +import org.jellyfin.sdk.model.api.MediaSegmentDtoQueryResult +import org.jellyfin.sdk.model.api.MediaSegmentType +import org.jellyfin.sdk.model.api.MediaStreamType +import org.jellyfin.sdk.model.api.PlaybackInfoResponse +import org.jellyfin.sdk.model.extensions.inWholeTicks +import org.jellyfin.sdk.model.extensions.ticks +import org.junit.After +import org.junit.Assert +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE, sdk = [30]) +class PlaybackViewModelTests { + private val testDispatcher = StandardTestDispatcher() + + private val mockContext = mockk<Context>(relaxed = true) + private val mockApi = mockk<ApiClient>(relaxed = true) + private val mockNavigationManager = mockk<NavigationManager>(relaxed = true) + private val mockPlaylistCreator = mockk<PlaylistCreator>(relaxed = true) + private val mockItemPlaybackDao = mockk<ItemPlaybackDao>(relaxed = true) + private val mockServerRepository = mockk<ServerRepository>(relaxed = true) + private val mockItemPlaybackRepository = mockk<ItemPlaybackRepository>(relaxed = true) + private val mockPlayerFactory = mockk<PlayerFactory>() + private val mockDatePlayedService = mockk<DatePlayedService>(relaxed = true) + private val mockDeviceInfo = mockk<DeviceInfo>(relaxed = true) + private val mockDeviceProfileService = mockk<DeviceProfileService>(relaxed = true) + private val mockRefreshRateService = mockk<RefreshRateService>(relaxed = true) + private val mockStreamChoiceService = mockk<StreamChoiceService>(relaxed = true) + private val mockUserPreferencesService = mockk<UserPreferencesService>(relaxed = true) + private val mockImageUrlService = mockk<ImageUrlService>(relaxed = true) + private val mockScreensaverService = mockk<ScreensaverService>(relaxed = true) + private val mockMusicService = mockk<MusicService>(relaxed = true) + + private val mockUserLibraryApi = mockk<UserLibraryApi>() + private val mockMediaInfoApi = mockk<MediaInfoApi>() + private val mockVideosApi = mockk<VideosApi>() + private val mockMediaSegmentsApi = mockk<MediaSegmentsApi>() + private val mockPlayer = mockk<Player>(relaxed = true) + + fun create(destination: Destination): PlaybackViewModel = + PlaybackViewModel( + context = mockContext, + api = mockApi, + navigationManager = mockNavigationManager, + playlistCreator = mockPlaylistCreator, + itemPlaybackDao = mockItemPlaybackDao, + serverRepository = mockServerRepository, + itemPlaybackRepository = mockItemPlaybackRepository, + playerFactory = mockPlayerFactory, + datePlayedService = mockDatePlayedService, + deviceInfo = mockDeviceInfo, + deviceProfileService = mockDeviceProfileService, + refreshRateService = mockRefreshRateService, + streamChoiceService = mockStreamChoiceService, + userPreferencesService = mockUserPreferencesService, + imageUrlService = mockImageUrlService, + screensaverService = mockScreensaverService, + musicService = mockMusicService, + destination = destination, + ) + + private fun setupPreferences(block: PlaybackPreferences.Builder.() -> Unit) { + val appPrefs = AppPreferences.getDefaultInstance().updatePlaybackPreferences(block) + val prefs = UserPreferences(appPrefs, null) + coEvery { mockUserPreferencesService.getCurrent() } returns prefs + } + + private val serverId = UUID.randomUUID() + private val userId = UUID.randomUUID() + private val server = + JellyfinServer(serverId, "test server", "http://localhost:8096", "10.11.11") + private val user = + JellyfinUser( + rowId = 1, + id = userId, + serverId = serverId, + name = "test-user", + accessToken = "token", + pin = "1234", + ) + private val userDto = + ServerUserConfig( + id = userId, + name = "test-user", + ) + + private val mediaSource = + TestTracks + .Builder() + .addVideo() + .addAudio() + .addSubtitle() + .buildMediaSourceInfo() + + private val playSessionId = "playsessionid12345" + private val videoStreamUrl = "http://localhost:8096/video/stream" + + @OptIn(ExperimentalCoroutinesApi::class) + @Before + fun setUp() { + WholphinDispatchers.configure(testDispatcher) + + every { mockApi.userLibraryApi } returns mockUserLibraryApi + every { mockApi.mediaInfoApi } returns mockMediaInfoApi + every { mockApi.videosApi } returns mockVideosApi + every { mockApi.mediaSegmentsApi } returns mockMediaSegmentsApi + + coEvery { mockMediaInfoApi.getPostedPlaybackInfo(any(), any()) } returns + successResponse( + PlaybackInfoResponse( + mediaSources = listOf(mediaSource), + playSessionId = playSessionId, + ), + ) + + every { + mockVideosApi.getVideoStreamUrl( + itemId = any(), + mediaSourceId = mediaSource.id, + static = true, + tag = mediaSource.eTag, + playSessionId = playSessionId, + ) + } returns videoStreamUrl + + coEvery { mockServerRepository.currentUser } returns user + coEvery { mockServerRepository.currentUserDto } returns userDto + + coEvery { mockItemPlaybackDao.getItem(user, any()) } returns null + coEvery { mockStreamChoiceService.chooseSource(any(), any()) } returns mediaSource + coEvery { + mockStreamChoiceService.getSeriesTrackChoices( + any<BaseItemDto>(), + any<SeriesTrackChoiceType>(), + ) + } returns emptyList() + coEvery { mockPlayerFactory.createVideoPlayer(any(), any()) } returns + PlayerCreation(mockPlayer) + every { mockPlayerFactory.createMediaSession(any()) } returns mockk(relaxed = true) + + coEvery { + mockStreamChoiceService.chooseAudioStream( + source = mediaSource, + any(), + any(), + any(), + any(), + ) + } returns + StreamChoiceResult( + mediaSource.mediaStreams!!.first { it.type == MediaStreamType.AUDIO }, + StreamChoiceReason.Unknown, + ) + + coEvery { + mockStreamChoiceService.chooseSubtitleStream( + item = any(), + source = mediaSource, + audioStream = any(), + itemPlayback = any(), + stc = any(), + prefs = any(), + ) + } returns + StreamChoiceResult( + mediaSource.mediaStreams!!.first { it.type == MediaStreamType.SUBTITLE }, + StreamChoiceReason.Unknown, + ) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @After + fun tearDown() { + WholphinDispatchers.reset() + } + + // Test data + val playlist = playlist() + val movie = movie(name = "Test Movie 1") + val movie2 = movie(name = "Test Movie 2") + val movie3 = movie(name = "Test Movie 3") + val intro = movie(name = "Test Intro") + val playlistItems = + Playlist.fromMedia(listOf(BaseItem(movie), BaseItem(movie2), BaseItem(movie3))) + val introSegmentMovie2 = + MediaSegmentDto( + id = UUID.randomUUID(), + itemId = movie2.id, + type = MediaSegmentType.INTRO, + startTicks = 5.seconds.inWholeTicks, + endTicks = 10.seconds.inWholeTicks, + ) + val segments = + MediaSegmentDtoQueryResult( + items = listOf(introSegmentMovie2), + totalRecordCount = 1, + startIndex = 0, + ) + // END test data + + /** + * Create the [PlaybackViewModel] and wait for its init job to complete + * + * Throws an exception if the job throws one + */ + @OptIn(InternalCoroutinesApi::class) + private suspend fun TestScope.createViewModel(destination: Destination): PlaybackViewModel { + val viewModel = create(destination) + testScheduler.advanceUntilIdle() + viewModel.initJob.join() + viewModel.initJob + .getCancellationException() + .cause + ?.let { throw it } + 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<MediaItem>() + verify(exactly = 1) { mockPlayer.setMediaItem(capture(mediaItem), any<Long>()) } + 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) { + setupPreferences { + cinemaMode = true + } + coEvery { mockUserLibraryApi.getItem(movie.id) } returns successResponse(movie) + coEvery { mockUserLibraryApi.getIntros(movie.id, any()) } returns + successQueryResult(listOf(intro)) + + val viewModel = createViewModel(Destination.Playback(movie.id, 0L)) + + val mediaItem = slot<MediaItem>() + verify(exactly = 1) { mockPlayer.setMediaItem(capture(mediaItem), any<Long>()) } + // Should be playing the intro + Assert.assertEquals(intro.id.toString(), mediaItem.captured.mediaId) + val state = viewModel.state.value + Assert.assertEquals(LoadingState.Success, state.loading) + Assert.assertEquals(mediaSource.id, state.currentMediaInfo.sourceId) + } + + @Test + fun `Play two intros first`() = + runTest(testDispatcher) { + val movie = movie() + val intro = movie() + val intro2 = movie() + setupPreferences { + cinemaMode = true + } + coEvery { mockUserLibraryApi.getItem(movie.id) } returns successResponse(movie) + coEvery { mockUserLibraryApi.getIntros(movie.id, any()) } returns + successQueryResult(listOf(intro, intro2)) + + val viewModel = createViewModel(Destination.Playback(movie.id, 0L)) + + val mediaItem = slot<MediaItem>() + verify(exactly = 1) { mockPlayer.setMediaItem(capture(mediaItem), any<Long>()) } + // Should be playing the first intro + Assert.assertEquals(intro.id.toString(), mediaItem.captured.mediaId) + val state = viewModel.state.value + Assert.assertEquals(LoadingState.Success, state.loading) + Assert.assertEquals(mediaSource.id, state.currentMediaInfo.sourceId) + val playlist = state.playlist.items + Assert.assertEquals(2, playlist.size) + Assert.assertEquals(intro.id, playlist[0].id) + Assert.assertEquals(intro2.id, playlist[1].id) + } + + @Test + fun `Don't play intro`() = + runTest(testDispatcher) { + val movie = movie() + setupPreferences { + cinemaMode = false + } + coEvery { mockUserLibraryApi.getItem(movie.id) } returns successResponse(movie) + + val viewModel = createViewModel(Destination.Playback(movie.id, 0L)) + + coVerify(exactly = 0) { mockUserLibraryApi.getIntros(any(), any()) } + val mediaItem = slot<MediaItem>() + verify(exactly = 1) { mockPlayer.setMediaItem(capture(mediaItem), any<Long>()) } + Assert.assertEquals(movie.id.toString(), mediaItem.captured.mediaId) + viewModel.state.value.also { state -> + Assert.assertEquals(LoadingState.Success, state.loading) + Assert.assertEquals(mediaSource.id, state.currentMediaInfo.sourceId) + } + } + + private fun setupForPlaylist() { + coEvery { mockUserLibraryApi.getItem(playlist.id, any()) } returns + successResponse(playlist) + coEvery { + mockPlaylistCreator.createFrom( + item = playlist, + startIndex = any(), + sortAndDirection = any(), + shuffled = any(), + recursive = any(), + filter = any(), + ) + } returns PlaylistCreationResult.Success(playlistItems) + } + + @Test + fun `Play next up`() = + runTest(testDispatcher) { + setupForPlaylist() + + val viewModel = createViewModel(Destination.PlaybackList(playlist.id)) + + viewModel.state.value.also { state -> + Assert.assertEquals(playlistItems.items, state.playlist.items) + Assert.assertEquals(0, state.playlistIndex) + } + + viewModel.playNextUp() + testScheduler.advanceUntilIdle() + + viewModel.state.value.also { state -> + Assert.assertEquals(1, state.playlistIndex) + } + + val mediaItem = slot<MediaItem>() + val mediaItem2 = slot<MediaItem>() + verifyOrder { + mockPlayer.setMediaItem(capture(mediaItem), any<Long>()) + mockPlayer.setMediaItem(capture(mediaItem2), any<Long>()) + } + Assert.assertEquals(movie.id.toString(), mediaItem.captured.mediaId) + Assert.assertEquals(movie2.id.toString(), mediaItem2.captured.mediaId) + } + + @Test + fun `Play previous`() = + runTest(testDispatcher) { + setupForPlaylist() + + val viewModel = createViewModel(Destination.PlaybackList(playlist.id)) + + viewModel.state.value.also { state -> + Assert.assertEquals(playlistItems.items, state.playlist.items) + Assert.assertEquals(0, state.playlistIndex) + } + + viewModel.playNextUp() + testScheduler.advanceUntilIdle() + viewModel.playNextUp() + testScheduler.advanceUntilIdle() + + viewModel.state.value.also { state -> + Assert.assertEquals(2, state.playlistIndex) + } + + viewModel.playPrevious() + testScheduler.advanceUntilIdle() + + viewModel.state.value.also { state -> + Assert.assertEquals(1, state.playlistIndex) + } + + val mediaItems = mutableListOf<MediaItem>() + verify(exactly = 4) { + mockPlayer.setMediaItem(withArg { mediaItems.add(it) }, any<Long>()) + } + Assert.assertEquals(4, mediaItems.size) + Assert.assertEquals(movie.id.toString(), mediaItems[0].mediaId) + Assert.assertEquals(movie2.id.toString(), mediaItems[1].mediaId) + Assert.assertEquals(movie3.id.toString(), mediaItems[2].mediaId) + Assert.assertEquals(movie2.id.toString(), mediaItems[3].mediaId) + } + + @OptIn(InternalCoroutinesApi::class) + @Test + fun `Show next up at end of playback`() = + runTest(testDispatcher) { + setupForPlaylist() + setupPreferences { + showNextUpWhen = ShowNextUpWhen.END_OF_PLAYBACK + } + + val viewModel = createViewModel(Destination.PlaybackList(playlist.id)) + + viewModel.state.value.also { state -> + Assert.assertEquals(playlistItems.items, state.playlist.items) + Assert.assertEquals(0, state.playlistIndex) + Assert.assertNull(state.nextUp) + } + + viewModel.onPlaybackStateChanged(Player.STATE_ENDED) + testScheduler.advanceUntilIdle() + + viewModel.state.value.also { state -> + Assert.assertEquals(movie2, state.nextUp?.data) + } + } + + @Test + fun `Show next up never with automatic play`() = + runTest(testDispatcher) { + setupForPlaylist() + setupPreferences { + showNextUpWhen = ShowNextUpWhen.NEXT_UP_NEVER + autoPlayNext = true + } + + val viewModel = createViewModel(Destination.PlaybackList(playlist.id)) + + viewModel.state.value.also { state -> + Assert.assertEquals(playlistItems.items, state.playlist.items) + Assert.assertEquals(0, state.playlistIndex) + Assert.assertNull(state.nextUp) + } + + viewModel.onPlaybackStateChanged(Player.STATE_ENDED) + testScheduler.advanceUntilIdle() + + viewModel.state.value.also { state -> + Assert.assertNull(state.nextUp) + Assert.assertTrue(state.hasNext) + } + verify(exactly = 0) { mockNavigationManager.goBack() } + } + + @Test + fun `Show next up never without automatic play`() = + runTest(testDispatcher) { + setupForPlaylist() + setupPreferences { + showNextUpWhen = ShowNextUpWhen.NEXT_UP_NEVER + autoPlayNext = false + } + + val viewModel = createViewModel(Destination.PlaybackList(playlist.id)) + + viewModel.state.value.also { state -> + Assert.assertEquals(playlistItems.items, state.playlist.items) + Assert.assertEquals(0, state.playlistIndex) + Assert.assertNull(state.nextUp) + } + + viewModel.onPlaybackStateChanged(Player.STATE_ENDED) + testScheduler.advanceUntilIdle() + + viewModel.state.value.also { state -> + Assert.assertNull(state.nextUp) + Assert.assertTrue(state.hasNext) + } + verify(exactly = 1) { mockNavigationManager.goBack() } + } + + @Test + fun `Playback ends with no next up`() = + runTest(testDispatcher) { + setupForPlaylist() + setupPreferences { + showNextUpWhen = ShowNextUpWhen.END_OF_PLAYBACK + autoPlayNext = false + } + + val viewModel = createViewModel(Destination.PlaybackList(playlist.id)) + + viewModel.state.value.also { state -> + Assert.assertEquals(playlistItems.items, state.playlist.items) + Assert.assertEquals(0, state.playlistIndex) + Assert.assertNull(state.nextUp) + } + viewModel.playNextUp() + viewModel.playNextUp() + testScheduler.advanceUntilIdle() + + viewModel.onPlaybackStateChanged(Player.STATE_ENDED) + testScheduler.advanceUntilIdle() + + viewModel.state.value.also { state -> + Assert.assertNull(state.nextUp) + Assert.assertFalse(state.hasNext) + } + verify(exactly = 1) { mockNavigationManager.goBack() } + } + + @Test + fun `Intro playback ends always play next`() = + runTest(testDispatcher) { + setupPreferences { + cinemaMode = true + showNextUpWhen = ShowNextUpWhen.NEXT_UP_NEVER + autoPlayNext = false + } + setupForPlaylist() + coEvery { mockUserLibraryApi.getItem(movie.id) } returns successResponse(movie) + coEvery { mockUserLibraryApi.getIntros(movie.id, any()) } returns + successQueryResult(listOf(intro)) + + val viewModel = createViewModel(Destination.PlaybackList(playlist.id)) + + viewModel.state.value.also { state -> + Assert.assertEquals( + listOf(PlaylistItem.Intro(BaseItem(intro))) + playlistItems.items, + state.playlist.items, + ) + Assert.assertEquals(0, state.playlistIndex) + Assert.assertNull(state.nextUp) + } + + viewModel.onPlaybackStateChanged(Player.STATE_ENDED) + testScheduler.advanceUntilIdle() + + viewModel.state.value.also { state -> + Assert.assertEquals(1, state.playlistIndex) + Assert.assertNull(state.nextUp) + } + verify(exactly = 0) { mockNavigationManager.goBack() } + + val mediaItems = mutableListOf<MediaItem>() + verify(exactly = 2) { + mockPlayer.setMediaItem(withArg { mediaItems.add(it) }, any<Long>()) + } + Assert.assertEquals(2, mediaItems.size) + Assert.assertEquals(intro.id.toString(), mediaItems[0].mediaId) + Assert.assertEquals(movie.id.toString(), mediaItems[1].mediaId) + } + + @Test + fun playItemInPlaylist() = + runTest(testDispatcher) { + setupForPlaylist() + setupPreferences { + } + + val viewModel = createViewModel(Destination.PlaybackList(playlist.id)) + + viewModel.state.value.also { state -> + Assert.assertEquals(playlistItems.items, state.playlist.items) + Assert.assertEquals(0, state.playlistIndex) + Assert.assertNull(state.nextUp) + } + + viewModel.playItemInPlaylist(BaseItem(movie3)) + testScheduler.advanceUntilIdle() + + viewModel.state.value.also { state -> + Assert.assertEquals(2, state.playlistIndex) + Assert.assertNull(state.nextUp) + } + val mediaItem = slot<MediaItem>() + val mediaItem2 = slot<MediaItem>() + verifyOrder { + mockPlayer.setMediaItem(capture(mediaItem), any<Long>()) + mockPlayer.setMediaItem(capture(mediaItem2), any<Long>()) + } + Assert.assertEquals(movie.id.toString(), mediaItem.captured.mediaId) + Assert.assertEquals(movie3.id.toString(), mediaItem2.captured.mediaId) + } + + @Test + fun `Media segments auto skip`() = + runTest(testDispatcher, timeout = 8.seconds) { + setupForPlaylist() + setupPreferences { + skipIntros = SkipSegmentBehavior.AUTO_SKIP + } + + coEvery { mockMediaSegmentsApi.getItemSegments(movie.id) } returns + successResponse(MediaSegmentDtoQueryResult(emptyList(), 0, 0)) + coEvery { mockMediaSegmentsApi.getItemSegments(movie2.id) } returns + successResponse(segments) + + val viewModel = createViewModel(Destination.PlaybackList(playlist.id)) + + viewModel.state.value.also { state -> + Assert.assertNull(state.currentSegment) + } + + every { mockPlayer.currentPosition } returns 5.5.seconds.inWholeMilliseconds + + try { + // TODO better testability + // This will start an infinite loop + viewModel.playNextUp() + testScheduler.advanceTimeBy(1500.milliseconds) + + viewModel.state.value.also { state -> + Assert.assertEquals(introSegmentMovie2, state.currentSegment?.segment) + Assert.assertTrue(state.currentSegment?.interacted == true) + } + verify(exactly = 1) { + // Verify only skipping a single time + mockPlayer.seekTo(introSegmentMovie2.endTicks.ticks.inWholeMilliseconds + 1) + } + } finally { + viewModel.segmentJob?.cancel() + } + } + + @Test + fun `Media segment ask to skip`() = + runTest(testDispatcher, timeout = 8.seconds) { + setupForPlaylist() + setupPreferences { + skipIntros = SkipSegmentBehavior.ASK_TO_SKIP + } + + coEvery { mockMediaSegmentsApi.getItemSegments(movie.id) } returns + successResponse(MediaSegmentDtoQueryResult(emptyList(), 0, 0)) + coEvery { mockMediaSegmentsApi.getItemSegments(movie2.id) } returns + successResponse(segments) + + val viewModel = createViewModel(Destination.PlaybackList(playlist.id)) + + viewModel.state.value.also { state -> + Assert.assertNull(state.currentSegment) + } + + every { mockPlayer.currentPosition } returns 5.5.seconds.inWholeMilliseconds + + try { + // TODO better testability + // This will start an infinite loop + viewModel.playNextUp() + testScheduler.advanceTimeBy(1000.milliseconds) + + viewModel.state.value.also { state -> + Assert.assertEquals(introSegmentMovie2, state.currentSegment?.segment) + Assert.assertTrue(state.currentSegment?.interacted == false) + } + verify(exactly = 0) { mockPlayer.seekTo(any()) } + } finally { + viewModel.segmentJob?.cancel() + } + } + + @Test + fun `Media segment ask to skip and clicked`() = + runTest(testDispatcher, timeout = 8.seconds) { + setupForPlaylist() + setupPreferences { + skipIntros = SkipSegmentBehavior.ASK_TO_SKIP + } + + coEvery { mockMediaSegmentsApi.getItemSegments(movie.id) } returns + successResponse(MediaSegmentDtoQueryResult(emptyList(), 0, 0)) + coEvery { mockMediaSegmentsApi.getItemSegments(movie2.id) } returns + successResponse(segments) + + val viewModel = createViewModel(Destination.PlaybackList(playlist.id)) + + viewModel.state.value.also { state -> + Assert.assertNull(state.currentSegment) + } + + every { mockPlayer.currentPosition } returns 5.5.seconds.inWholeMilliseconds + + try { + // TODO better testability + // This will start an infinite loop + viewModel.playNextUp() + testScheduler.advanceTimeBy(1000.milliseconds) + + viewModel.state.value.also { state -> + Assert.assertEquals(introSegmentMovie2, state.currentSegment?.segment) + Assert.assertTrue(state.currentSegment?.interacted == false) + } + verify(exactly = 0) { mockPlayer.seekTo(any()) } + + viewModel.updateSegment(introSegmentMovie2.id, false) + every { mockPlayer.currentPosition } returns introSegmentMovie2.endTicks.ticks.inWholeMilliseconds + 1 + testScheduler.advanceTimeBy(10.milliseconds) + viewModel.state.value.also { state -> + Assert.assertNull(state.currentSegment) + } + verify(exactly = 1) { + // Verify only skipping a single time + mockPlayer.seekTo(introSegmentMovie2.endTicks.ticks.inWholeMilliseconds + 1) + } + } finally { + viewModel.segmentJob?.cancel() + } + } + + @Test + fun `Media segment ask to skip and dismissed`() = + runTest(testDispatcher, timeout = 8.seconds) { + setupForPlaylist() + setupPreferences { + skipIntros = SkipSegmentBehavior.ASK_TO_SKIP + } + + coEvery { mockMediaSegmentsApi.getItemSegments(movie.id) } returns + successResponse(MediaSegmentDtoQueryResult(emptyList(), 0, 0)) + coEvery { mockMediaSegmentsApi.getItemSegments(movie2.id) } returns + successResponse(segments) + + val viewModel = createViewModel(Destination.PlaybackList(playlist.id)) + + viewModel.state.value.also { state -> + Assert.assertNull(state.currentSegment) + } + + every { mockPlayer.currentPosition } returns 5.5.seconds.inWholeMilliseconds + + try { + // TODO better testability + // This will start an infinite loop + viewModel.playNextUp() + testScheduler.advanceTimeBy(1000.milliseconds) + + viewModel.state.value.also { state -> + Assert.assertEquals(introSegmentMovie2, state.currentSegment?.segment) + Assert.assertTrue(state.currentSegment?.interacted == false) + } + + viewModel.updateSegment(introSegmentMovie2.id, true) + testScheduler.advanceTimeBy(10.milliseconds) + viewModel.state.value.also { state -> + Assert.assertEquals(introSegmentMovie2, state.currentSegment?.segment) + Assert.assertTrue(state.currentSegment?.interacted == true) + } + + // Never skipped + verify(exactly = 0) { mockPlayer.seekTo(any()) } + } finally { + viewModel.segmentJob?.cancel() + } + } + + @Test + fun `Media segment ignore`() = + runTest(testDispatcher, timeout = 8.seconds) { + setupForPlaylist() + setupPreferences { + skipIntros = SkipSegmentBehavior.IGNORE + } + + coEvery { mockMediaSegmentsApi.getItemSegments(movie.id) } returns + successResponse(MediaSegmentDtoQueryResult(emptyList(), 0, 0)) + coEvery { mockMediaSegmentsApi.getItemSegments(movie2.id) } returns + successResponse(segments) + + val viewModel = createViewModel(Destination.PlaybackList(playlist.id)) + + viewModel.state.value.also { state -> + Assert.assertNull(state.currentSegment) + } + + every { mockPlayer.currentPosition } returns 5.5.seconds.inWholeMilliseconds + + try { + // TODO better testability + // This will start an infinite loop + viewModel.playNextUp() + testScheduler.advanceTimeBy(1000.milliseconds) + + viewModel.state.value.also { state -> + Assert.assertNull(state.currentSegment) + } + verify(exactly = 0) { mockPlayer.seekTo(any()) } + } finally { + viewModel.segmentJob?.cancel() + } + } + + @Test + fun `Media segment not at current position`() = + runTest(testDispatcher, timeout = 8.seconds) { + setupForPlaylist() + setupPreferences { + skipIntros = SkipSegmentBehavior.AUTO_SKIP + } + + coEvery { mockMediaSegmentsApi.getItemSegments(movie.id) } returns + successResponse(MediaSegmentDtoQueryResult(emptyList(), 0, 0)) + coEvery { mockMediaSegmentsApi.getItemSegments(movie2.id) } returns + successResponse(segments) + + val viewModel = createViewModel(Destination.PlaybackList(playlist.id)) + + viewModel.state.value.also { state -> + Assert.assertNull(state.currentSegment) + } + + every { mockPlayer.currentPosition } returns 2.5.seconds.inWholeMilliseconds + + try { + // TODO better testability + // This will start an infinite loop + viewModel.playNextUp() + testScheduler.advanceTimeBy(1000.milliseconds) + + viewModel.state.value.also { state -> + Assert.assertNull(state.currentSegment) + } + verify(exactly = 0) { mockPlayer.seekTo(any()) } + } finally { + viewModel.segmentJob?.cancel() + } + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/ui/setup/UserListFocusTest.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/setup/UserListFocusTest.kt new file mode 100644 index 00000000..a6a23244 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/setup/UserListFocusTest.kt @@ -0,0 +1,87 @@ +package com.github.damontecres.wholphin.ui.setup + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.test.assertIsFocused +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.pressKey +import com.github.damontecres.wholphin.data.model.JellyfinUser +import com.github.damontecres.wholphin.ui.theme.WholphinTheme +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.util.UUID + +@Config(sdk = [34]) +@RunWith(RobolectricTestRunner::class) +class UserListFocusTest { + @get:Rule val composeTestRule = createComposeRule() + + private val serverId = UUID.fromString("00000000-0000-0000-0000-000000000010") + private val users = + listOf("Ada", "Bert", "Cleo").mapIndexed { index, name -> + JellyfinUserAndImage( + user = + JellyfinUser( + id = UUID(0, (index + 1).toLong()), + name = name, + serverId = serverId, + accessToken = "token", + ), + imageUrl = null, + known = true, + ) + } + + private fun showUsers(currentUser: JellyfinUser?) { + composeTestRule.setContent { + WholphinTheme { + UserList( + users = users, + currentUser = currentUser, + onSwitchUser = {}, + onAddUser = {}, + onRemoveUser = {}, + onSwitchServer = {}, + modifier = Modifier.fillMaxSize(), + ) + } + } + } + + @Test + fun `opening selector focuses active profile and supports D-pad navigation`() { + showUsers(users[1].user) + + composeTestRule.onNodeWithText("B").assertIsFocused().performKeyInput { + pressKey(Key.DirectionRight) + } + composeTestRule.onNodeWithText("C").assertIsFocused() + } + + @Test + fun `opening selector scrolls to an active profile outside the initial viewport`() { + showUsers(users[2].user) + + composeTestRule.onNodeWithText("C").assertIsFocused() + } + + @Test + fun `opening selector without a listed active profile focuses first profile`() { + showUsers(null) + + composeTestRule.onNodeWithText("A").assertIsFocused() + } + + @Test + fun `opening selector with a removed active profile focuses first profile`() { + showUsers(users[1].user.copy(id = UUID(0, 99))) + + composeTestRule.onNodeWithText("A").assertIsFocused() + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/util/BitstreamFilterChainTest.kt b/app/src/test/java/com/github/damontecres/wholphin/util/BitstreamFilterChainTest.kt new file mode 100644 index 00000000..8175371e --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/util/BitstreamFilterChainTest.kt @@ -0,0 +1,136 @@ +package com.github.damontecres.wholphin.util + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.nio.ByteBuffer + +class BitstreamFilterChainTest { + @Test + fun keepsTheSizeWhenThereIsNoFilter() { + assertEquals(DATA.size, applyBitstreamFilters(emptyList(), buffer(), 0, DATA.size)) + } + + @Test + fun keepsTheSizeOfAnInPlaceFilter() { + val inPlace = + BitstreamFilter { data, offset, size -> + data.put(offset, 0xFF.toByte()) + size + } + val target = buffer() + + val size = applyBitstreamFilters(listOf(inPlace), target, 0, DATA.size) + + assertEquals(DATA.size, size) + assertArrayEquals(byteArrayOf(0xFF.toByte()) + DATA.copyOfRange(1, DATA.size), target.array()) + } + + @Test + fun reportsTheShorterSizeOfAFilterWhichDropsBytes() { + // What a Dolby Vision profile 7 to 8.1 conversion does: it removes the enhancement layer, + // compacts what it keeps and comes out shorter than it went in. The buffer is direct, + // like the decoder input buffer a filter is handed on a device: it has no backing array, + // so the bytes are moved with absolute gets and puts. + val dropsTheThirdAndFourthBytes = + BitstreamFilter { data, offset, size -> + for (i in 2 until size - 2) { + data.put(offset + i, data.get(offset + i + 2)) + } + size - 2 + } + val target = directBuffer() + + val size = applyBitstreamFilters(listOf(dropsTheThirdAndFourthBytes), target, 0, DATA.size) + + assertFalse("the test should exercise a buffer without a backing array", target.hasArray()) + assertEquals(DATA.size - 2, size) + assertEquals(listOf<Byte>(1, 2, 5, 6, 7, 8), (0 until size).map { target.get(it) }) + } + + @Test + fun handsTheNextFilterTheSizeReportedByThePreviousOne() { + val seen = mutableListOf<Int>() + val dropTwo = + BitstreamFilter { _, _, size -> + seen.add(size) + size - 2 + } + val dropOne = + BitstreamFilter { _, _, size -> + seen.add(size) + size - 1 + } + + val size = applyBitstreamFilters(listOf(dropTwo, dropOne), buffer(), 0, DATA.size) + + assertEquals(listOf(DATA.size, DATA.size - 2), seen) + assertEquals(DATA.size - 3, size) + } + + @Test + fun ignoresAFilterWhichReportsMoreBytesThanItWasGiven() { + val seen = mutableListOf<Int>() + val tooLong = BitstreamFilter { _, _, size -> size + 1 } + val recording = + BitstreamFilter { _, _, size -> + seen.add(size) + size + } + + val size = applyBitstreamFilters(listOf(tooLong, recording), buffer(), 0, DATA.size) + + assertEquals(DATA.size, size) + assertEquals(listOf(DATA.size), seen) + } + + @Test + fun ignoresAFilterWhichReportsANegativeSize() { + val negative = BitstreamFilter { _, _, _ -> -1 } + + assertEquals(DATA.size, applyBitstreamFilters(listOf(negative), buffer(), 0, DATA.size)) + } + + @Test + fun stopsOnceNothingIsLeftOfTheAccessUnit() { + var called = false + val dropsEverything = BitstreamFilter { _, _, _ -> 0 } + val next = + BitstreamFilter { _, _, size -> + called = true + size + } + + val size = applyBitstreamFilters(listOf(dropsEverything, next), buffer(), 0, DATA.size) + + assertEquals(0, size) + assertTrue("a filter should not be handed an empty access unit", !called) + } + + @Test + fun filtersAreGivenTheOffsetTheyWereCalledWith() { + val offsets = mutableListOf<Int>() + val recording = + BitstreamFilter { _, offset, size -> + offsets.add(offset) + size + } + + applyBitstreamFilters(listOf(recording, recording), buffer(), 2, DATA.size - 2) + + assertEquals(listOf(2, 2), offsets) + } + + companion object { + private val DATA = byteArrayOf(1, 2, 3, 4, 5, 6, 7, 8) + + private fun buffer() = ByteBuffer.wrap(DATA.copyOf()) + + private fun directBuffer() = + ByteBuffer.allocateDirect(DATA.size).also { buffer -> + DATA.forEachIndexed { index, byte -> buffer.put(index, byte) } + } + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/util/BitstreamFilteringCodecAdapterTest.kt b/app/src/test/java/com/github/damontecres/wholphin/util/BitstreamFilteringCodecAdapterTest.kt new file mode 100644 index 00000000..3c8fdd23 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/util/BitstreamFilteringCodecAdapterTest.kt @@ -0,0 +1,103 @@ +@file:OptIn(markerClass = [UnstableApi::class]) + +package com.github.damontecres.wholphin.util + +import android.media.MediaCodec +import androidx.annotation.OptIn +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.mediacodec.MediaCodecAdapter +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.Assert.assertEquals +import org.junit.Test +import java.nio.ByteBuffer + +/** + * Covers the hand off to the decoder: the adapter has to queue the size the filters report, not + * the size it was called with, and it has to leave the buffers no filter may touch alone. + */ +class BitstreamFilteringCodecAdapterTest { + private val delegate = mockk<MediaCodecAdapter>(relaxed = true) + private val buffer = ByteBuffer.allocateDirect(SIZE) + + @Test + fun queuesTheSizeTheFilterReports() { + every { delegate.getInputBuffer(INDEX) } returns buffer + val adapter = adapterWith(BitstreamFilter { _, _, size -> size - 2 }) + + adapter.queueInputBuffer(INDEX, OFFSET, SIZE, PRESENTATION_TIME_US, 0) + + verify { delegate.queueInputBuffer(INDEX, OFFSET, SIZE - 2, PRESENTATION_TIME_US, 0) } + } + + @Test + fun queuesTheSizeItWasGivenWhenTheFilterLeavesTheAccessUnitAlone() { + every { delegate.getInputBuffer(INDEX) } returns buffer + val adapter = adapterWith(BitstreamFilter { _, _, size -> size }) + + adapter.queueInputBuffer(INDEX, OFFSET, SIZE, PRESENTATION_TIME_US, 0) + + verify { delegate.queueInputBuffer(INDEX, OFFSET, SIZE, PRESENTATION_TIME_US, 0) } + } + + @Test + fun leavesCodecConfigBuffersAlone() { + every { delegate.getInputBuffer(INDEX) } returns buffer + val filter = RecordingFilter() + val adapter = adapterWith(filter) + + adapter.queueInputBuffer(INDEX, OFFSET, SIZE, PRESENTATION_TIME_US, MediaCodec.BUFFER_FLAG_CODEC_CONFIG) + + assertEquals(0, filter.calls) + verify { + delegate.queueInputBuffer(INDEX, OFFSET, SIZE, PRESENTATION_TIME_US, MediaCodec.BUFFER_FLAG_CODEC_CONFIG) + } + } + + @Test + fun leavesEmptyBuffersAlone() { + every { delegate.getInputBuffer(INDEX) } returns buffer + val filter = RecordingFilter() + val adapter = adapterWith(filter) + + adapter.queueInputBuffer(INDEX, OFFSET, 0, PRESENTATION_TIME_US, 0) + + assertEquals(0, filter.calls) + verify { delegate.queueInputBuffer(INDEX, OFFSET, 0, PRESENTATION_TIME_US, 0) } + } + + @Test + fun queuesTheSizeItWasGivenWhenThereIsNoInputBuffer() { + every { delegate.getInputBuffer(INDEX) } returns null + val filter = RecordingFilter() + val adapter = adapterWith(filter) + + adapter.queueInputBuffer(INDEX, OFFSET, SIZE, PRESENTATION_TIME_US, 0) + + assertEquals(0, filter.calls) + verify { delegate.queueInputBuffer(INDEX, OFFSET, SIZE, PRESENTATION_TIME_US, 0) } + } + + private fun adapterWith(filter: BitstreamFilter) = BitstreamFilteringCodecAdapter(delegate, listOf(filter)) + + private class RecordingFilter : BitstreamFilter { + var calls = 0 + + override fun filter( + data: ByteBuffer, + offset: Int, + size: Int, + ): Int { + calls++ + return size + } + } + + companion object { + private const val INDEX = 3 + private const val OFFSET = 1 + private const val SIZE = 8 + private const val PRESENTATION_TIME_US = 1234L + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7ConversionTest.kt b/app/src/test/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7ConversionTest.kt new file mode 100644 index 00000000..483fa3ef --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7ConversionTest.kt @@ -0,0 +1,86 @@ +package com.github.damontecres.wholphin.util + +import androidx.media3.common.DrmInitData +import androidx.media3.common.Format +import androidx.media3.common.MimeTypes +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test + +class DolbyVisionProfile7ConversionTest { + @Test + fun recognisesProfile7InBothSampleEntryForms() { + assertTrue(DolbyVisionProfile7Conversion.isProfile7(dolbyVision("dvhe.07.06"))) + assertTrue(DolbyVisionProfile7Conversion.isProfile7(dolbyVision("dvh1.07.06"))) + } + + @Test + fun doesNotMistakeOtherProfilesOrCodecsForProfile7() { + assertFalse(DolbyVisionProfile7Conversion.isProfile7(dolbyVision("dvhe.08.06"))) + assertFalse(DolbyVisionProfile7Conversion.isProfile7(dolbyVision("dvhe.05.06"))) + assertFalse(DolbyVisionProfile7Conversion.isProfile7(dolbyVision(null))) + assertFalse( + DolbyVisionProfile7Conversion.isProfile7( + Format + .Builder() + .setSampleMimeType(MimeTypes.VIDEO_H265) + .setCodecs("dvhe.07.06") + .build(), + ), + ) + } + + @Test + fun rewritesOnlyTheProfileOfTheCodecString() { + assertEquals("dvhe.08.06", DolbyVisionProfile7Conversion.toProfile8("dvhe.07.06")) + assertEquals("dvh1.08.09", DolbyVisionProfile7Conversion.toProfile8("dvh1.07.09")) + } + + @Test + fun advertisesAProfile7TrackAsProfile8AndKeepsTheRestOfTheFormat() { + val format = dolbyVision("dvhe.07.06") + + val advertised = DolbyVisionProfile7Conversion.advertiseAsProfile8(format) + + assertEquals("dvhe.08.06", advertised.codecs) + assertEquals(MimeTypes.VIDEO_DOLBY_VISION, advertised.sampleMimeType) + assertEquals(format.width, advertised.width) + assertEquals(format.height, advertised.height) + assertEquals(format.initializationData, advertised.initializationData) + } + + @Test + fun leavesTracksWhichAreNotProfile7AsTheSameInstance() { + val profile8 = dolbyVision("dvhe.08.06") + val hevc = Format.Builder().setSampleMimeType(MimeTypes.VIDEO_H265).build() + + assertSame(profile8, DolbyVisionProfile7Conversion.advertiseAsProfile8(profile8)) + assertSame(hevc, DolbyVisionProfile7Conversion.advertiseAsProfile8(hevc)) + } + + @Test + fun leavesAnEncryptedProfile7TrackAlone() { + // Encrypted samples take the secure path, which the bitstream filter does not touch + val encrypted = + dolbyVision("dvhe.07.06") + .buildUpon() + .setDrmInitData(DrmInitData(DrmInitData.SchemeData(java.util.UUID.randomUUID(), MimeTypes.VIDEO_MP4, byteArrayOf(1)))) + .build() + + assertSame(encrypted, DolbyVisionProfile7Conversion.advertiseAsProfile8(encrypted)) + } + + private companion object { + fun dolbyVision(codecs: String?): Format = + Format + .Builder() + .setSampleMimeType(MimeTypes.VIDEO_DOLBY_VISION) + .setCodecs(codecs) + .setWidth(3840) + .setHeight(2160) + .setInitializationData(listOf(byteArrayOf(0x01, 0x02))) + .build() + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7ExtractorsFactoryTest.kt b/app/src/test/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7ExtractorsFactoryTest.kt new file mode 100644 index 00000000..d888be15 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7ExtractorsFactoryTest.kt @@ -0,0 +1,119 @@ +@file:OptIn(markerClass = [UnstableApi::class]) + +package com.github.damontecres.wholphin.util + +import androidx.annotation.OptIn +import androidx.media3.common.C +import androidx.media3.common.Format +import androidx.media3.common.MimeTypes +import androidx.media3.common.util.ParsableByteArray +import androidx.media3.common.util.UnstableApi +import androidx.media3.extractor.Extractor +import androidx.media3.extractor.ExtractorOutput +import androidx.media3.extractor.ExtractorsFactory +import androidx.media3.extractor.TrackOutput +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Test + +/** + * The factory has to reach the video track's format through three layers of media3 forwarding, + * rewrite it there and nowhere else, and let sample data through untouched. + */ +class DolbyVisionProfile7ExtractorsFactoryTest { + private val videoTrack = mockk<TrackOutput>(relaxed = true) + private val audioTrack = mockk<TrackOutput>(relaxed = true) + private val output = + mockk<ExtractorOutput>(relaxed = true) { + every { track(VIDEO_ID, C.TRACK_TYPE_VIDEO) } returns videoTrack + every { track(AUDIO_ID, C.TRACK_TYPE_AUDIO) } returns audioTrack + } + private val extractor = mockk<Extractor>(relaxed = true) + private val delegate = + mockk<ExtractorsFactory> { + every { createExtractors() } returns arrayOf(extractor) + } + + @Test + fun advertisesTheVideoTrackAsProfile8() { + val video = initialisedVideoTrack() + + video.format(dolbyVision("dvhe.07.06")) + + val published = slot<Format>() + verify { videoTrack.format(capture(published)) } + assertEquals("dvhe.08.06", published.captured.codecs) + } + + @Test + fun leavesOtherVideoFormatsAsTheyAre() { + val video = initialisedVideoTrack() + val hevc = + Format + .Builder() + .setSampleMimeType(MimeTypes.VIDEO_H265) + .setCodecs("hvc1.2.4.L153") + .build() + + video.format(hevc) + + verify { videoTrack.format(hevc) } + } + + @Test + fun leavesTheAudioTrackUnwrapped() { + val wrapped = wrappedOutput() + + assertSame(audioTrack, wrapped.track(AUDIO_ID, C.TRACK_TYPE_AUDIO)) + } + + @Test + fun passesSampleDataStraightThrough() { + val video = initialisedVideoTrack() + val data = ParsableByteArray(byteArrayOf(1, 2, 3)) + + video.sampleData(data, 3) + video.sampleMetadata(1_000L, C.BUFFER_FLAG_KEY_FRAME, 3, 0, null) + + verify { videoTrack.sampleData(data, 3) } + verify { videoTrack.sampleMetadata(1_000L, C.BUFFER_FLAG_KEY_FRAME, 3, 0, null) } + } + + @Test + fun wrapsEveryExtractorOfTheDelegate() { + val another = mockk<Extractor>(relaxed = true) + every { delegate.createExtractors() } returns arrayOf(extractor, another) + + val extractors = DolbyVisionProfile7ExtractorsFactory(delegate).createExtractors() + + assertEquals(2, extractors.size) + extractors[1].init(output) + verify { another.init(any()) } + } + + private fun wrappedOutput(): ExtractorOutput { + val extractors = DolbyVisionProfile7ExtractorsFactory(delegate).createExtractors() + extractors.single().init(output) + val wrapped = slot<ExtractorOutput>() + verify { extractor.init(capture(wrapped)) } + return wrapped.captured + } + + private fun initialisedVideoTrack(): TrackOutput = wrappedOutput().track(VIDEO_ID, C.TRACK_TYPE_VIDEO) + + private companion object { + const val VIDEO_ID = 1 + const val AUDIO_ID = 2 + + fun dolbyVision(codecs: String): Format = + Format + .Builder() + .setSampleMimeType(MimeTypes.VIDEO_DOLBY_VISION) + .setCodecs(codecs) + .build() + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7FilterTest.kt b/app/src/test/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7FilterTest.kt new file mode 100644 index 00000000..b5c6189c --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7FilterTest.kt @@ -0,0 +1,293 @@ +package com.github.damontecres.wholphin.util + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Test +import java.nio.ByteBuffer + +/** + * The RPU rewrite itself is libdovi's and runs on a device; here it is a fake, and what is under + * test is everything around it: which access units are touched at all, that the enhancement layer + * goes and the rest stays contiguous, that a refused rewrite leaves the decoder's buffer exactly as + * it was, and that the filter keeps to the [BitstreamFilter] contract on a direct buffer. + */ +class DolbyVisionProfile7FilterTest { + @Test + fun dropsTheEnhancementLayerAndRewritesTheRpu() { + val filter = filterWith(FakeRewriter(profile = 7)) + val target = directBuffer(annexB(VPS, VCL, EL, EL, RPU)) + + val size = filter.filter(target, 0, target.capacity()) + + assertEquals(annexB(VPS, VCL, CONVERTED_RPU).toList(), target.bytes(0, size)) + } + + @Test + fun convertsAnRpuWhichArrivedWithoutAnEnhancementLayer() { + // A dual layer remux whose enhancement layer lives in Matroska block additions, which the + // extractor discards: the RPU still needs its rewrite for the profile 8 decoder + val filter = filterWith(FakeRewriter(profile = 7)) + val target = directBuffer(annexB(VCL, RPU)) + + val size = filter.filter(target, 0, target.capacity()) + + assertEquals(annexB(VCL, CONVERTED_RPU).toList(), target.bytes(0, size)) + } + + @Test + fun worksFromAnOffsetAndLeavesTheBytesAroundTheAccessUnitAlone() { + val filter = filterWith(FakeRewriter(profile = 7)) + val accessUnit = annexB(VCL, EL, RPU) + val prefix = byteArrayOf(0x7A, 0x7B) + val suffix = byteArrayOf(0x7C) + val target = directBuffer(prefix + accessUnit + suffix) + + val size = filter.filter(target, prefix.size, accessUnit.size) + + assertEquals(annexB(VCL, CONVERTED_RPU).toList(), target.bytes(prefix.size, size)) + assertEquals(prefix.toList(), target.bytes(0, prefix.size)) + assertEquals(suffix.toList(), target.bytes(prefix.size + accessUnit.size, suffix.size)) + } + + @Test + fun doesNotMoveThePositionOrLimitOfTheBuffer() { + val filter = filterWith(FakeRewriter(profile = 7)) + val target = directBuffer(annexB(VCL, EL, RPU)) + target.position(3) + target.limit(target.capacity() - 1) + + filter.filter(target, 0, target.capacity()) + + assertEquals(3, target.position()) + assertEquals(target.capacity() - 1, target.limit()) + } + + @Test + fun leavesAnAccessUnitWithoutAnRpuAlone() { + val rewriter = FakeRewriter(profile = 7) + val filter = filterWith(rewriter) + val accessUnit = annexB(VPS, VCL, EL) + val target = directBuffer(accessUnit) + + val size = filter.filter(target, 0, target.capacity()) + + assertEquals(accessUnit.size, size) + assertEquals(accessUnit.toList(), target.bytes(0, size)) + assertEquals(0, rewriter.conversions) + } + + @Test + fun leavesAnRpuOfAnotherProfileAlone() { + val rewriter = FakeRewriter(profile = 8) + val filter = filterWith(rewriter) + val accessUnit = annexB(VCL, RPU) + val target = directBuffer(accessUnit) + + val size = filter.filter(target, 0, target.capacity()) + + assertEquals(accessUnit.size, size) + assertEquals(accessUnit.toList(), target.bytes(0, size)) + assertEquals(0, rewriter.conversions) + } + + @Test + fun leavesAnAccessUnitAloneWhenTheRewriteThrows() { + val filter = filterWith(FakeRewriter(profile = 7, throws = true)) + val accessUnit = annexB(VCL, EL, RPU) + val target = directBuffer(accessUnit) + + val size = filter.filter(target, 0, target.capacity()) + + assertEquals(accessUnit.size, size) + assertEquals(accessUnit.toList(), target.bytes(0, size)) + } + + @Test + fun leavesAnAccessUnitAloneWhenTheRewriteWouldNotFit() { + // No enhancement layer to make room, and an RPU which comes out longer than it went in + val filter = filterWith(FakeRewriter(profile = 7, converted = RPU + ByteArray(16) { 0x55 })) + val accessUnit = annexB(VCL, RPU) + val target = directBuffer(accessUnit) + + val size = filter.filter(target, 0, target.capacity()) + + assertEquals(accessUnit.size, size) + assertEquals(accessUnit.toList(), target.bytes(0, size)) + } + + @Test + fun takesALongerRpuWhenTheEnhancementLayerMadeRoomForIt() { + val longerRpu = RPU + byteArrayOf(0x55, 0x56) + val filter = filterWith(FakeRewriter(profile = 7, converted = longerRpu)) + val target = directBuffer(annexB(VCL, EL, RPU)) + + val size = filter.filter(target, 0, target.capacity()) + + assertEquals(annexB(VCL, longerRpu).toList(), target.bytes(0, size)) + } + + @Test + fun leavesAnAccessUnitAloneWhenTheRewriteReportsNonsense() { + val filter = filterWith(FakeRewriter(profile = 7, reportedSize = -1)) + val accessUnit = annexB(VCL, EL, RPU) + val target = directBuffer(accessUnit) + + val size = filter.filter(target, 0, target.capacity()) + + assertEquals(accessUnit.size, size) + assertEquals(accessUnit.toList(), target.bytes(0, size)) + } + + @Test + fun leavesDataWhichIsNotAnnexBAlone() { + val rewriter = FakeRewriter(profile = 7) + val filter = filterWith(rewriter) + val data = byteArrayOf(0x12, 0x34, 0x56, 0x78, 0x00, 0x00, 0x01, 0x7C) + val target = directBuffer(data) + + val size = filter.filter(target, 0, target.capacity()) + + assertEquals(data.size, size) + assertEquals(data.toList(), target.bytes(0, size)) + assertEquals(0, rewriter.conversions) + } + + @Test + fun walksThreeByteStartCodesToo() { + val filter = filterWith(FakeRewriter(profile = 7)) + val accessUnit = SHORT_START_CODE + VCL + SHORT_START_CODE + EL + SHORT_START_CODE + RPU + val target = directBuffer(accessUnit) + + val size = filter.filter(target, 0, target.capacity()) + + assertEquals((SHORT_START_CODE + VCL + SHORT_START_CODE + CONVERTED_RPU).toList(), target.bytes(0, size)) + } + + @Test + fun givesUpForGoodWhenTheNativeLibraryIsMissing() { + var attempts = 0 + val filter = + DolbyVisionProfile7Filter { + attempts++ + throw UnsatisfiedLinkError("no libdovi_android for this ABI") + } + val accessUnit = annexB(VCL, EL, RPU) + val target = directBuffer(accessUnit) + + val first = filter.filter(target, 0, target.capacity()) + val second = filter.filter(target, 0, target.capacity()) + + assertEquals(accessUnit.size, first) + assertEquals(accessUnit.size, second) + assertEquals(accessUnit.toList(), target.bytes(0, accessUnit.size)) + assertEquals("the library should be tried once, not on every access unit", 1, attempts) + } + + @Test + fun handsTheRewriterTheAccessUnitWithoutTheEnhancementLayer() { + val rewriter = FakeRewriter(profile = 7) + val filter = filterWith(rewriter) + val target = directBuffer(annexB(VPS, VCL, EL, RPU, EL)) + + filter.filter(target, 0, target.capacity()) + + assertEquals(annexB(VPS, VCL, RPU).toList(), rewriter.lastFrame) + } + + /** + * Stands in for libdovi. It finds the RPU NAL unit the way the native code does, by walking the + * Annex B start codes, and swaps its payload for [converted], moving whatever follows. + */ + private class FakeRewriter( + private val profile: Int, + private val converted: ByteArray = CONVERTED_RPU, + private val throws: Boolean = false, + private val reportedSize: Int? = null, + ) : RpuRewriter { + var conversions = 0 + var lastFrame: List<Byte> = emptyList() + + override fun profileOf( + frame: ByteBuffer, + size: Int, + ): Int { + assertFalse("libdovi reads the buffer's address, so the frame has to be direct", frame.hasArray()) + return profile + } + + override fun convertToProfile8( + frame: ByteBuffer, + size: Int, + ): Int { + conversions++ + lastFrame = frame.bytes(0, size) + if (throws) { + throw IllegalArgumentException("Unable to parse Dolby Vision RPU") + } + val bytes = frame.bytes(0, size).toByteArray() + val rpuStart = indexOfNal(bytes, RPU) + val rpuEnd = nextStartCode(bytes, rpuStart + 1) + val rewritten = bytes.copyOfRange(0, rpuStart) + converted + bytes.copyOfRange(rpuEnd, bytes.size) + check(rewritten.size <= frame.capacity()) { "the fake outgrew the scratch buffer" } + rewritten.forEachIndexed { index, byte -> frame.put(index, byte) } + return reportedSize ?: rewritten.size + } + + private fun indexOfNal( + bytes: ByteArray, + nal: ByteArray, + ): Int { + for (i in 0..bytes.size - nal.size) { + if (bytes.copyOfRange(i, i + nal.size).contentEquals(nal)) { + return i + } + } + throw AssertionError("RPU not found in the frame handed to the rewriter") + } + + private fun nextStartCode( + bytes: ByteArray, + from: Int, + ): Int { + for (i in from..bytes.size - 3) { + if (bytes[i] == 0.toByte() && bytes[i + 1] == 0.toByte() && bytes[i + 2] == 1.toByte()) { + return if (i > from && bytes[i - 1] == 0.toByte()) i - 1 else i + } + } + return bytes.size + } + } + + companion object { + private val START_CODE = byteArrayOf(0, 0, 0, 1) + private val SHORT_START_CODE = byteArrayOf(0, 0, 1) + + /** NAL unit header for [type]: forbidden_zero_bit 0, nuh_layer_id 0, nuh_temporal_id_plus1 1. */ + private fun nal( + type: Int, + vararg payload: Byte, + ): ByteArray = byteArrayOf((type shl 1).toByte(), 0x01) + payload + + private val VPS = nal(32, 0x0C, 0x01, 0x7F) + private val VCL = nal(1, 0x11, 0x22, 0x33, 0x44) + private val EL = nal(63, 0x0E, 0x0E, 0x0E, 0x0E, 0x0E, 0x0E) + private val RPU = nal(62, 0x19, 0x07, 0x07, 0x07) + private val CONVERTED_RPU = nal(62, 0x19, 0x08, 0x08) + + private fun annexB(vararg nals: ByteArray): ByteArray = nals.fold(byteArrayOf()) { acc, nal -> acc + START_CODE + nal } + + private fun filterWith(rewriter: RpuRewriter) = DolbyVisionProfile7Filter { rewriter } + + /** Like the decoder input buffer on a device: direct, with no backing array. */ + private fun directBuffer(data: ByteArray): ByteBuffer = + ByteBuffer.allocateDirect(data.size).also { buffer -> + data.forEachIndexed { index, byte -> buffer.put(index, byte) } + assertFalse(buffer.hasArray()) + } + + private fun ByteBuffer.bytes( + from: Int, + count: Int, + ): List<Byte> = (from until from + count).map { get(it) } + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/util/Hdr10PlusSeiFilterTest.kt b/app/src/test/java/com/github/damontecres/wholphin/util/Hdr10PlusSeiFilterTest.kt new file mode 100644 index 00000000..f3f75193 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/util/Hdr10PlusSeiFilterTest.kt @@ -0,0 +1,277 @@ +package com.github.damontecres.wholphin.util + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Test +import java.nio.ByteBuffer + +class Hdr10PlusSeiFilterTest { + @Test + fun masksSoleHdr10PlusMessage() { + val accessUnit = annexB(seiNal(seiMessage(4, HDR10_PLUS_PAYLOAD)), vclNal()) + // Payload type byte follows the start code and the 2 byte NAL unit header + val payloadTypeIndex = START_CODE.size + 2 + + val (masked, result) = filter(accessUnit) + + assertEquals(1, masked) + assertArrayEquals(maskTypeByte(accessUnit, payloadTypeIndex), result) + } + + @Test + fun masksWithThreeByteStartCodes() { + val accessUnit = + SHORT_START_CODE + seiNal(seiMessage(4, HDR10_PLUS_PAYLOAD)) + SHORT_START_CODE + vclNal() + val payloadTypeIndex = SHORT_START_CODE.size + 2 + + val (masked, result) = filter(accessUnit) + + assertEquals(1, masked) + assertArrayEquals(maskTypeByte(accessUnit, payloadTypeIndex), result) + } + + @Test + fun masksOnlyHdr10PlusInMultiMessageNal() { + val masteringDisplay = seiMessage(137, MASTERING_DISPLAY_PAYLOAD) + val contentLightLevel = seiMessage(144, CONTENT_LIGHT_LEVEL_PAYLOAD) + val accessUnit = + annexB( + seiNal(masteringDisplay, seiMessage(4, HDR10_PLUS_PAYLOAD), contentLightLevel), + vclNal(), + ) + val payloadTypeIndex = START_CODE.size + 2 + masteringDisplay.size + + val (masked, result) = filter(accessUnit) + + assertEquals(1, masked) + assertArrayEquals(maskTypeByte(accessUnit, payloadTypeIndex), result) + } + + @Test + fun skipsEmulationPreventionBytesInPrecedingPayload() { + // The escaped form of this payload is longer than its payload size, so naive byte + // counting would misparse the HDR10+ message that follows it + val zeroHeavyMessage = seiMessage(137, byteArrayOf(0, 0, 0, 0, 1)) + val accessUnit = + annexB(seiNal(zeroHeavyMessage, seiMessage(4, HDR10_PLUS_PAYLOAD)), vclNal()) + val payloadTypeIndex = START_CODE.size + 2 + escape(zeroHeavyMessage).size + + val (masked, result) = filter(accessUnit) + + assertEquals(1, masked) + assertArrayEquals(maskTypeByte(accessUnit, payloadTypeIndex), result) + } + + @Test + fun handlesMultiBytePayloadType() { + val bigTypeMessage = seiMessage(300, byteArrayOf(0x42)) + val accessUnit = + annexB(seiNal(bigTypeMessage, seiMessage(4, HDR10_PLUS_PAYLOAD)), vclNal()) + val payloadTypeIndex = START_CODE.size + 2 + bigTypeMessage.size + + val (masked, result) = filter(accessUnit) + + assertEquals(1, masked) + assertArrayEquals(maskTypeByte(accessUnit, payloadTypeIndex), result) + } + + @Test + fun ignoresOtherItuTT35Messages() { + val accessUnit = annexB(seiNal(seiMessage(4, CEA_708_PAYLOAD)), vclNal()) + + val (masked, result) = filter(accessUnit) + + assertEquals(0, masked) + assertArrayEquals(accessUnit, result) + } + + @Test + fun ignoresSeiNalUnitsAfterTheVclNalUnit() { + val accessUnit = annexB(vclNal(), seiNal(seiMessage(4, HDR10_PLUS_PAYLOAD))) + + val (masked, result) = filter(accessUnit) + + assertEquals(0, masked) + assertArrayEquals(accessUnit, result) + } + + @Test + fun masksAcrossMultipleNonVclNalUnits() { + val accessUnit = + annexB( + audNal(), + seiNal(seiMessage(137, MASTERING_DISPLAY_PAYLOAD)), + seiNal(seiMessage(4, HDR10_PLUS_PAYLOAD)), + vclNal(), + ) + val payloadTypeIndex = + START_CODE.size + audNal().size + + START_CODE.size + seiNal(seiMessage(137, MASTERING_DISPLAY_PAYLOAD)).size + + START_CODE.size + 2 + + val (masked, result) = filter(accessUnit) + + assertEquals(1, masked) + assertArrayEquals(maskTypeByte(accessUnit, payloadTypeIndex), result) + } + + @Test + fun leavesLengthPrefixedSamplesUntouched() { + val nal = seiNal(seiMessage(4, HDR10_PLUS_PAYLOAD)) + val accessUnit = + byteArrayOf( + (nal.size ushr 24).toByte(), + (nal.size ushr 16).toByte(), + (nal.size ushr 8).toByte(), + nal.size.toByte(), + ) + nal + + val (masked, result) = filter(accessUnit) + + assertEquals(0, masked) + assertArrayEquals(accessUnit, result) + } + + @Test + fun leavesTruncatedSeiNalUnitUntouched() { + // Payload size claims more bytes than the NAL unit contains + val brokenNal = byteArrayOf(0x4E, 0x01, 0x04, 0x30, 0xB5.toByte(), 0x00, 0x3C) + val accessUnit = annexB(brokenNal, vclNal()) + + val (masked, result) = filter(accessUnit) + + assertEquals(0, masked) + assertArrayEquals(accessUnit, result) + } + + @Test + fun leavesShortItuTT35MessageUntouched() { + val accessUnit = annexB(seiNal(seiMessage(4, byteArrayOf(0xB5.toByte(), 0x00, 0x3C))), vclNal()) + + val (masked, result) = filter(accessUnit) + + assertEquals(0, masked) + assertArrayEquals(accessUnit, result) + } + + @Test + fun respectsOffsetAndSize() { + val accessUnit = annexB(seiNal(seiMessage(4, HDR10_PLUS_PAYLOAD)), vclNal()) + val prefix = byteArrayOf(0x55, 0x66) + val data = prefix + accessUnit + val payloadTypeIndex = prefix.size + START_CODE.size + 2 + + val buffer = ByteBuffer.wrap(data.copyOf()) + val masked = Hdr10PlusSeiFilter.maskHdr10PlusSeiMessages(buffer, prefix.size, accessUnit.size) + + assertEquals(1, masked) + assertArrayEquals(maskTypeByte(data, payloadTypeIndex), buffer.array()) + } + + @Test + fun masksMultipleHdr10PlusMessages() { + val firstMessage = seiMessage(4, HDR10_PLUS_PAYLOAD) + val accessUnit = + annexB(seiNal(firstMessage, seiMessage(4, HDR10_PLUS_PAYLOAD)), vclNal()) + val expected = + maskTypeByte( + maskTypeByte(accessUnit, START_CODE.size + 2), + START_CODE.size + 2 + firstMessage.size, + ) + + val (masked, result) = filter(accessUnit) + + assertEquals(2, masked) + assertArrayEquals(expected, result) + } + + @Test + fun maskingFilterKeepsTheAccessUnitSize() { + val accessUnit = annexB(seiNal(seiMessage(4, HDR10_PLUS_PAYLOAD)), vclNal()) + val buffer = ByteBuffer.wrap(accessUnit.copyOf()) + + val size = Hdr10PlusMaskingFilter().filter(buffer, 0, accessUnit.size) + + assertEquals(accessUnit.size, size) + assertArrayEquals(maskTypeByte(accessUnit, START_CODE.size + 2), buffer.array()) + } + + companion object { + private val START_CODE = byteArrayOf(0, 0, 0, 1) + private val SHORT_START_CODE = byteArrayOf(0, 0, 1) + + /** ITU-T T.35 header for SMPTE ST 2094-40 plus a few opaque payload bytes */ + private val HDR10_PLUS_PAYLOAD = + byteArrayOf(0xB5.toByte(), 0x00, 0x3C, 0x00, 0x01, 0x04, 0x01, 0x12, 0x34) + + /** ITU-T T.35 with the ATSC provider code (0x0031), as used for CEA-708 captions */ + private val CEA_708_PAYLOAD = + byteArrayOf(0xB5.toByte(), 0x00, 0x31, 0x47, 0x41, 0x39, 0x34, 0x03) + + private val MASTERING_DISPLAY_PAYLOAD = ByteArray(24) { (it + 1).toByte() } + + private val CONTENT_LIGHT_LEVEL_PAYLOAD = byteArrayOf(0x03, 0xE8.toByte(), 0x01, 0xF4.toByte()) + + /** Encodes an SEI message with the ff-extended payload type and size coding of D.2.1 */ + private fun seiMessage( + payloadType: Int, + payload: ByteArray, + ): ByteArray { + val out = mutableListOf<Byte>() + var type = payloadType + while (type >= 255) { + out.add(0xFF.toByte()) + type -= 255 + } + out.add(type.toByte()) + var size = payload.size + while (size >= 255) { + out.add(0xFF.toByte()) + size -= 255 + } + out.add(size.toByte()) + payload.forEach { out.add(it) } + return out.toByteArray() + } + + /** Inserts emulation prevention bytes as required by H.265 section 7.4.2 */ + private fun escape(rbsp: ByteArray): ByteArray { + val out = mutableListOf<Byte>() + var zeroRun = 0 + for (b in rbsp) { + if (zeroRun >= 2 && (b.toInt() and 0xFF) <= 0x03) { + out.add(0x03) + zeroRun = 0 + } + out.add(b) + zeroRun = if (b == 0.toByte()) zeroRun + 1 else 0 + } + return out.toByteArray() + } + + /** Builds a prefix SEI NAL unit (type 39) containing the given messages */ + private fun seiNal(vararg messages: ByteArray): ByteArray { + val rbsp = messages.fold(byteArrayOf()) { acc, message -> acc + message } + 0x80.toByte() + return byteArrayOf(0x4E, 0x01) + escape(rbsp) + } + + /** A minimal VCL NAL unit (type 1, TRAIL_R) */ + private fun vclNal(): ByteArray = byteArrayOf(0x02, 0x01, 0x11, 0x22, 0x33) + + /** A minimal access unit delimiter NAL unit (type 35) */ + private fun audNal(): ByteArray = byteArrayOf(0x46, 0x01, 0x50) + + private fun annexB(vararg nals: ByteArray): ByteArray = nals.fold(byteArrayOf()) { acc, nal -> acc + START_CODE + nal } + + private fun maskTypeByte( + data: ByteArray, + index: Int, + ): ByteArray = data.copyOf().also { it[index] = 0xFE.toByte() } + + private fun filter(data: ByteArray): Pair<Int, ByteArray> { + val buffer = ByteBuffer.wrap(data.copyOf()) + val masked = Hdr10PlusSeiFilter.maskHdr10PlusSeiMessages(buffer, 0, data.size) + return masked to buffer.array() + } + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/util/PlaybackWatchStateTest.kt b/app/src/test/java/com/github/damontecres/wholphin/util/PlaybackWatchStateTest.kt new file mode 100644 index 00000000..73791251 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/util/PlaybackWatchStateTest.kt @@ -0,0 +1,95 @@ +package com.github.damontecres.wholphin.util + +import android.app.Application +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import androidx.media3.common.Player +import io.mockk.* +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.* +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.playStateApi +import org.jellyfin.sdk.api.operations.PlayStateApi +import org.jellyfin.sdk.model.api.PlayMethod +import com.github.damontecres.wholphin.ui.successResponse +import org.junit.Assert.* +import org.junit.Test +import java.util.UUID +import org.justinvisser.wolfskin.conductarr.FileCacheStore +import org.justinvisser.wolfskin.conductarr.CacheEntry +import java.io.File + +/** Return refresh waits for the stop write rather than racing its acknowledgement. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class) +@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) +class PlaybackWatchStateTest { + @Test fun revisionAppearsOnlyAfterTheCapturedUsersStopIsAcknowledged() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val previous = WholphinDispatchers.IO + Dispatchers.setMain(dispatcher) + WholphinDispatchers.IO = dispatcher + val api = mockk<ApiClient>() + val reports = mockk<PlayStateApi>() + every { api.playStateApi } returns reports + val player = mockk<Player> { every { currentPosition } returns 751_246L } + val scope = "server:${UUID.randomUUID()}" + val another = "server:${UUID.randomUUID()}" + val context = RuntimeEnvironment.getApplication() + val cache = FileCacheStore(File(context.filesDir, "wolfskin-library-cache")) + val saved = CacheEntry("old progress", null, 1L, 1L, group = "library") + cache.write(scope, "library:movies", saved) + cache.write(another, "library:movies", saved) + val acknowledgement = CompletableDeferred<Unit>() + coEvery { reports.reportPlaybackStopped(any()) } coAnswers { + acknowledgement.await() + successResponse(Unit) + } + val state = PlaybackItemState(UUID.randomUUID(), PlayMethod.DIRECT_PLAY) + val listener = TrackActivityPlaybackListener(api, player, onStopped = { PlaybackWatchState.stopped(RuntimeEnvironment.getApplication(), scope) }, getState = { state }) + try { + listener.release() + runCurrent() + assertNull(PlaybackWatchState.revisions.value[scope]) + assertNotNull(cache.read(scope, "library:movies")) + acknowledgement.complete(Unit) + advanceUntilIdle() + assertNotNull(PlaybackWatchState.revisions.value[scope]) + assertNull(PlaybackWatchState.revisions.value[another]) + assertNull(cache.read(scope, "library:movies")) + assertEquals(saved, cache.read(another, "library:movies")) + coVerify(exactly = 1) { reports.reportPlaybackStopped(match { it.itemId == state.itemId && it.positionTicks == 7_512_460_000L }) } + listener.release() + advanceUntilIdle() + coVerify(exactly = 1) { reports.reportPlaybackStopped(any()) } + } finally { + WholphinDispatchers.IO = previous + Dispatchers.resetMain() + } + } + + @Test fun failedStopDoesNotPublishAFictitiousAcknowledgement() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val previous = WholphinDispatchers.IO + Dispatchers.setMain(dispatcher) + WholphinDispatchers.IO = dispatcher + val api = mockk<ApiClient>() + val reports = mockk<PlayStateApi>() + every { api.playStateApi } returns reports + coEvery { reports.reportPlaybackStopped(any()) } throws java.io.IOException("Stop rejected") + val scope = "server:${UUID.randomUUID()}" + val listener = TrackActivityPlaybackListener(api, mockk<Player> { every { currentPosition } returns 751_246L }, + onStopped = { PlaybackWatchState.stopped(RuntimeEnvironment.getApplication(), scope) }, getState = { PlaybackItemState(UUID.randomUUID(), PlayMethod.DIRECT_PLAY) }) + try { + listener.release() + advanceUntilIdle() + assertNull(PlaybackWatchState.revisions.value[scope]) + } finally { + WholphinDispatchers.IO = previous + Dispatchers.resetMain() + } + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/util/WholphinDispatchersTest.kt b/app/src/test/java/com/github/damontecres/wholphin/util/WholphinDispatchersTest.kt new file mode 100644 index 00000000..daeb934d --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/util/WholphinDispatchersTest.kt @@ -0,0 +1,21 @@ +package com.github.damontecres.wholphin.util + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain + +@OptIn(ExperimentalCoroutinesApi::class) +fun WholphinDispatchers.configure(testDispatcher: TestDispatcher) { + Dispatchers.setMain(testDispatcher) + IO = testDispatcher + Default = testDispatcher +} + +@OptIn(ExperimentalCoroutinesApi::class) +fun WholphinDispatchers.reset() { + Dispatchers.resetMain() + IO = Dispatchers.IO + Default = Dispatchers.Default +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/util/profile/TestDeviceProfileUtils.kt b/app/src/test/java/com/github/damontecres/wholphin/util/profile/TestDeviceProfileUtils.kt new file mode 100644 index 00000000..491280df --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/util/profile/TestDeviceProfileUtils.kt @@ -0,0 +1,141 @@ +package com.github.damontecres.wholphin.util.profile + +import io.mockk.every +import io.mockk.mockk +import org.jellyfin.sdk.model.api.CodecType +import org.jellyfin.sdk.model.api.DeviceProfile +import org.jellyfin.sdk.model.api.ProfileConditionValue +import org.junit.Assert +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Verifies that forceEnabledHdr/forceDisabledHdr correctly override the device's own + * codec-capability detection when building the unsupported video range type list. + */ +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE, sdk = [30]) +class TestDeviceProfileUtils { + private fun unsupportedRangeTypes( + profile: DeviceProfile, + codec: String, + ): Set<String> = + profile.codecProfiles + .filter { it.type == CodecType.VIDEO && it.codec == codec } + .firstNotNullOf { codecProfile -> + codecProfile.applyConditions.firstOrNull { it.property == ProfileConditionValue.VIDEO_RANGE_TYPE }?.value + }.split("|") + .toSet() + + private fun buildProfile( + mediaTest: MediaCodecCapabilitiesTest, + jellyfinTenEleven: Boolean = false, + decodeAv1: Boolean = false, + forceEnabledHdr: Set<String> = emptySet(), + forceDisabledHdr: Set<String> = emptySet(), + ): DeviceProfile = + createDeviceProfile( + mediaTest = mediaTest, + maxBitrate = 100_000_000, + isAC3Enabled = true, + downMixAudio = false, + assDirectPlay = true, + pgsDirectPlay = true, + decodeAv1 = decodeAv1, + jellyfinTenEleven = jellyfinTenEleven, + preferAc3ForSurround = false, + forceEnabledHdr = forceEnabledHdr, + forceDisabledHdr = forceDisabledHdr, + maxResolution = 0, + ) + + @Test + fun `Test HEVC Profile 7 unsupported when device does not support it`() { + val mediaTest = mockk<MediaCodecCapabilitiesTest>(relaxed = true) + every { mediaTest.supportsHevcDolbyVisionEL() } returns false + every { mediaTest.supportsHevcDolbyVision() } returns true + every { mediaTest.supportsHevcHDR10Plus() } returns true + + val profile = buildProfile(mediaTest, jellyfinTenEleven = true) + Assert.assertTrue(unsupportedRangeTypes(profile, Codec.Video.HEVC).contains(WholphinVideoRangeType.DOVI_WITH_EL.serialName)) + } + + @Test + fun `Test force enable overrides HEVC Profile 7 device support check`() { + val mediaTest = mockk<MediaCodecCapabilitiesTest>(relaxed = true) + every { mediaTest.supportsHevcDolbyVisionEL() } returns false + every { mediaTest.supportsHevcDolbyVision() } returns true + every { mediaTest.supportsHevcHDR10Plus() } returns true + + val profile = + buildProfile( + mediaTest, + jellyfinTenEleven = true, + forceEnabledHdr = + setOf( + WholphinVideoRangeType.DOVI_WITH_EL.serialName, + WholphinVideoRangeType.DOVI_WITH_EL_HDR10_PLUS.serialName, + ), + ) + Assert.assertFalse(unsupportedRangeTypes(profile, Codec.Video.HEVC).contains(WholphinVideoRangeType.DOVI_WITH_EL.serialName)) + } + + @Test + fun `Test force disable overrides HEVC Profile 7 device support check`() { + val mediaTest = mockk<MediaCodecCapabilitiesTest>(relaxed = true) + every { mediaTest.supportsHevcDolbyVisionEL() } returns true + every { mediaTest.supportsHevcDolbyVision() } returns true + every { mediaTest.supportsHevcHDR10Plus() } returns true + + val profile = + buildProfile( + mediaTest, + jellyfinTenEleven = true, + forceDisabledHdr = + setOf( + WholphinVideoRangeType.DOVI_WITH_EL.serialName, + WholphinVideoRangeType.DOVI_WITH_EL_HDR10_PLUS.serialName, + ), + ) + Assert.assertTrue(unsupportedRangeTypes(profile, Codec.Video.HEVC).contains(WholphinVideoRangeType.DOVI_WITH_EL.serialName)) + } + + @Test + fun `Test AV1 Profile 5 unsupported when device does not support it`() { + val mediaTest = mockk<MediaCodecCapabilitiesTest>(relaxed = true) + every { mediaTest.supportsAV1DolbyVision() } returns false + + val profile = buildProfile(mediaTest, decodeAv1 = false) + Assert.assertTrue(unsupportedRangeTypes(profile, Codec.Video.AV1).contains(WholphinVideoRangeType.DOVI.serialName)) + } + + @Test + fun `Test force enable overrides AV1 Profile 5 device support check`() { + val mediaTest = mockk<MediaCodecCapabilitiesTest>(relaxed = true) + every { mediaTest.supportsAV1DolbyVision() } returns false + + val profile = + buildProfile( + mediaTest, + decodeAv1 = false, + forceEnabledHdr = setOf(WholphinVideoRangeType.DOVI.serialName), + ) + Assert.assertFalse(unsupportedRangeTypes(profile, Codec.Video.AV1).contains(WholphinVideoRangeType.DOVI.serialName)) + } + + @Test + fun `Test force disable overrides AV1 Profile 5 device support check`() { + val mediaTest = mockk<MediaCodecCapabilitiesTest>(relaxed = true) + every { mediaTest.supportsAV1DolbyVision() } returns true + + val profile = + buildProfile( + mediaTest, + decodeAv1 = false, + forceDisabledHdr = setOf(WholphinVideoRangeType.DOVI.serialName), + ) + Assert.assertTrue(unsupportedRangeTypes(profile, Codec.Video.AV1).contains(WholphinVideoRangeType.DOVI.serialName)) + } +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/ActivityTruthProjectionTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/ActivityTruthProjectionTest.kt new file mode 100644 index 00000000..89afd29e --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/ActivityTruthProjectionTest.kt @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.tv + +import kotlinx.serialization.json.Json +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.justinvisser.wolfskin.conductarr.model.ReadinessStage +import org.justinvisser.wolfskin.design.ComingInStage +import org.justinvisser.wolfskin.design.ShellPerson +import org.junit.Assert.assertEquals +import org.junit.Test +import org.justinvisser.wolfskin.conductarr.model.ActivityFeed + +/** Recorded history separates acquisition, visibility proof, entitlement and file health. */ +class ActivityTruthProjectionTest { + private val rows = Json { ignoreUnknownKeys = true }.decodeFromString<ActivityFeed>( + javaClass.getResourceAsStream("/conductarr/activity-history.json")!!.bufferedReader().use { it.readText() }).items.orEmpty().associateBy { it.id } + @Test fun recordedHistoryDoesNotClaimActiveWorkOrFailedDownloadsWithoutEvidence() { + val viewer = ShellPerson("justin", "Justin", "J", false) + val expected = mapOf( + "request-2" to "Check needed", // Steins;Gate: represented file, failed visibility proof. + "request-1" to "Check needed", // Yu Yu Hakusho: represented files, failed visibility proof. + "dispatch-732" to "Check needed", // Love is Blind: same proof failure, not failed acquisition. + "dispatch-750" to "In another library", // Spider-Man: Homecoming: held elsewhere. + "dispatch-725" to "Removed", // One Hour Photo: deleted, absent actor. + "dispatch-723" to "Removed", // A historical play is not a current holding. + "dispatch-728" to "Not out yet", // The Body: unreleased. + "dispatch-721" to "File health needs attention", // Avatar: playback fault despite a holding. + "request-8" to "Waiting for a file", // Partition: no active transfer is established. + "dispatch-755" to "Waiting for a file", // Netherlands: primary import not observed. + ) + assertNull(rows.getValue("dispatch-725").comingIn().actor(viewer)) + expected.forEach { (id, label) -> assertEquals(rows.getValue(id).title, label, rows.getValue(id).comingIn().stage.label) } + listOf("request-2", "request-1", "dispatch-732", "dispatch-721").forEach { id -> + val source = rows.getValue(id) + val projected = source.comingIn() + val affected = source.stages.orEmpty().first { it.state == ReadinessStage.State.FAILED } + assertEquals(affected.summary, projected.summary) + assertFalse(projected.readiness[1] == "Failed") + } + } + + @Test fun currentHealthSummaryKeepsEpisodeAndSourceScopeEvenWhenVisibilityIsDone() { + val row = rows.getValue("dispatch-721") + val summary = "S01E15 · primary source: unreadable; S02E08 · mirror source: unreadable." + val projected = row.copy(stages = row.stages.orEmpty().map { + when (it.name) { + ReadinessStage.Name.PLAYBACK -> it.copy(summary = summary) + ReadinessStage.Name.VISIBILITY -> it.copy(state = ReadinessStage.State.DONE) + else -> it + } + }).comingIn() + assertEquals(ComingInStage.FileHealth, projected.stage) + assertEquals(summary, projected.summary) + } + + @Test fun absentTransferEvidenceNeverTurnsInProgressIntoDownloading() { + val row = rows.getValue("request-8") + val projected = row.copy(stages = row.stages.orEmpty().map { + if (it.name == ReadinessStage.Name.ACQUISITION) it.copy(summary = "Dispatch accepted.") else it + }).comingIn() + assertEquals(ComingInStage.Waiting, projected.stage) + } + + @Test fun missingMembershipIsNeutralButProvenReadinessStaysReady() { + val row = rows.getValue("dispatch-725") + val absent = row.copy(stages = row.stages.orEmpty().map { + if (it.name == ReadinessStage.Name.ACQUISITION) it.copy(state = ReadinessStage.State.UNKNOWN, summary = "No current observation.") else it + }) + assertEquals(ComingInStage.NotInLibrary, absent.comingIn().stage) + val ready = rows.values.first { it.stages.orEmpty().any { stage -> stage.name == ReadinessStage.Name.VISIBILITY && stage.state == ReadinessStage.State.DONE } } + assertEquals(ComingInStage.Ready, ready.comingIn().stage) + } +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/AnimeAvailabilityTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/AnimeAvailabilityTest.kt new file mode 100644 index 00000000..35e45892 --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/AnimeAvailabilityTest.kt @@ -0,0 +1,106 @@ +package org.justinvisser.wolfskin.tv + +import android.app.Application +import androidx.lifecycle.viewModelScope +import androidx.test.core.app.ApplicationProvider +import com.github.damontecres.wholphin.data.model.JellyfinServer +import com.github.damontecres.wholphin.data.model.JellyfinUser +import com.github.damontecres.wholphin.ui.successQueryResult +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import java.util.UUID +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.itemsApi +import org.jellyfin.sdk.api.client.extensions.userViewsApi +import org.jellyfin.sdk.api.operations.ItemsApi +import org.jellyfin.sdk.api.operations.UserViewsApi +import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.CollectionType +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.justinvisser.wolfskin.conductarr.MemoryCacheStore +import org.justinvisser.wolfskin.design.* +import org.justinvisser.wolfskin.tv.read.LibraryReadFacade +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** An audience grants a grouping, not evidence that this user's Jellyfin library has titles. */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class) +class AnimeAvailabilityTest { + private var previousFixtures = false + + @Before fun setUp() { + Dispatchers.setMain(Dispatchers.Unconfined) + ConductarrPreference.initialize(ApplicationProvider.getApplicationContext<Application>()) + previousFixtures = ConductarrPreference.fixtures + ConductarrPreference.updateFixtures(false) + } + + @After fun tearDown() { + ConductarrPreference.updateFixtures(previousFixtures) + Dispatchers.resetMain() + } + + @Test fun configuredAnimeAudienceNeedsActualTitlesBeforeItCreatesATabOrHomeShelf() = runBlocking { + val context = ApplicationProvider.getApplicationContext<Application>() + for ((key, token) in listOf("shared" to SHARED_TOKEN, "mindel" to MINDEL_TOKEN)) { + for (count in listOf(0, 1)) FakeConductarr().use { fake -> + ConductarrSession(MemoryCacheStore()).use { conductarr -> + val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null) + val user = JellyfinUser(id = UUID.randomUUID(), name = key, serverId = server.id, accessToken = token) + conductarr.select("${server.id}:${user.id}", token, fake.url) + conductarr.authenticate() + val identity = conductarr.identity.value!! + val audiences = identity.provider.audiences()!!.filter { + it.isAnime && (it.owner == identity.operator!!.requester || identity.operator.watchUser in it.users.orEmpty()) + } + val names = audiences.flatMap { it.libraries.orEmpty() }.distinct() + assertTrue("The wire fixture must grant this person an Anime grouping", names.isNotEmpty()) + val views = mockk<UserViewsApi>() + val items = mockk<ItemsApi>() + val api = mockk<ApiClient>() + every { api.userViewsApi } returns views + every { api.itemsApi } returns items + coEvery { views.getUserViews(userId = user.id) } returns successQueryResult(names.map { + BaseItemDto(id = UUID.randomUUID(), name = it, type = BaseItemKind.COLLECTION_FOLDER, collectionType = CollectionType.TVSHOWS) + }) + val titles = List(count) { BaseItemDto(id = UUID.randomUUID(), name = "An Anime series", type = BaseItemKind.SERIES) } + coEvery { items.getItems(userId = user.id, parentId = any(), recursive = true, + includeItemTypes = listOf(BaseItemKind.SERIES), limit = 1) } returns successQueryResult(titles) + val libraries = LibraryReadFacade(api) + val session = WolfskinSession().apply { + state = ShellState(listOf(ShellPerson(key, key, key.take(1), count == 0, user.id.toString())), key).apply { + pickerVisible = false + focusNav(tabs.size) + } + } + val model = WolfskinShellViewModel(context, session, mockk(), mockk(), mockk(), mockk(relaxed = true), + libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true)) + try { + requireNotNull(model.onForeground(server, user)).join() + assertEquals("$key with $count Anime titles", count > 0, "anime" in session.state!!.tabs) + assertEquals("The avatar must remain focused when Anime appears or disappears", + session.state!!.tabs.size, session.state!!.navIndex) + val home = HomeLibrary(destinations = mapOf("anime" to titles.map { HomeTitle(it.id.toString(), it.name.orEmpty(), "tv") }), + destinationTotals = mapOf("anime" to count)) + assertEquals("An empty Anime audience must not create a Home shelf", count > 0, + buildHomeRows(home, emptyList()).any { it.kind == HomeRowKind.Shelf && it.target == "anime" }) + } finally { model.viewModelScope.cancel() } + } + } + } + } +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/AnimeRootRemovalTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/AnimeRootRemovalTest.kt new file mode 100644 index 00000000..1af82b5c --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/AnimeRootRemovalTest.kt @@ -0,0 +1,111 @@ +package org.justinvisser.wolfskin.tv + +import android.app.Application +import androidx.lifecycle.viewModelScope +import androidx.test.core.app.ApplicationProvider +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.JellyfinServer +import com.github.damontecres.wholphin.data.model.JellyfinUser +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.ui.nav.Destination +import io.mockk.coEvery +import io.mockk.mockk +import java.util.UUID +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.justinvisser.wolfskin.conductarr.MemoryCacheStore +import org.justinvisser.wolfskin.design.ShellKey +import org.justinvisser.wolfskin.tv.read.LibraryReadFacade +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class) +class AnimeRootRemovalTest { + private var previousFixtures = false + private var previousUrl = "" + + @Before fun setUp() { + Dispatchers.setMain(Dispatchers.Unconfined) + ConductarrPreference.initialize(ApplicationProvider.getApplicationContext<Application>()) + previousFixtures = ConductarrPreference.fixtures + previousUrl = ConductarrPreference.baseUrl + ConductarrPreference.updateFixtures(false) + } + + @After fun tearDown() { + ConductarrPreference.updateBaseUrl(previousUrl) + ConductarrPreference.updateFixtures(previousFixtures) + Dispatchers.resetMain() + } + + @Test fun removedAnimeRootReturnsHomeWithoutDiscardingPushedPagesOrAvatarFocus() = runBlocking { + val title = Destination.Wolfskin("title/tv%3A123") + val person = Destination.Wolfskin("person/456") + val scenarios = listOf(emptyList<Destination.Wolfskin>() to false, emptyList<Destination.Wolfskin>() to true, + listOf(title) to false, listOf(title, person) to false) + for ((pushed, avatarFocused) in scenarios) FakeConductarr().use { fake -> + ConductarrPreference.updateBaseUrl(fake.url) + ConductarrSession(MemoryCacheStore()).use { conductarr -> + val context = ApplicationProvider.getApplicationContext<Application>() + val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null) + val user = JellyfinUser(id = UUID.randomUUID(), name = "Shared", serverId = server.id, accessToken = SHARED_TOKEN) + val repository = mockk<ServerRepository>(relaxed = true) + coEvery { repository.serverDao.getServer(server.id) } returns null + var hasTitles = true + val libraries = mockk<LibraryReadFacade>(relaxed = true) + coEvery { libraries.readDestinations(user.id) } returns emptyMap() + coEvery { libraries.hasAnimeTitles(user.id) } coAnswers { hasTitles } + val navigation = NavigationManager() + val session = WolfskinSession() + val model = WolfskinShellViewModel(context, session, repository, navigation, mockk(), mockk(relaxed = true), + libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true)) + try { + model.load(server, user) + requireNotNull(model.onForeground(server, user)).join() + val shell = requireNotNull(session.state) + assertTrue(shell.person.hasAnime) + shell.key(ShellKey.Ok) + shell.focusNav(shell.tabs.indexOf("anime")) + shell.key(ShellKey.Ok) + assertEquals(listOf(Destination.Wolfskin("anime")), navigation.backStack.toList()) + for (page in pushed) { + navigation.navigateTo(page) + shell.enterDestination(page.route) + } + if (avatarFocused) shell.focusNav(shell.tabs.size) + + hasTitles = false + requireNotNull(model.onForeground(server, user)).join() + assertFalse("The foreground read removed the last accessible Anime title", "anime" in shell.tabs) + assertEquals("Home must replace the missing root, not the current pushed page", + listOf(Destination.Home()) + pushed, navigation.backStack.toList()) + assertEquals(pushed.lastOrNull()?.route ?: "home", shell.route) + assertEquals("The selected root must be Home even below a pushed page", 0, shell.currentTab) + if (avatarFocused) assertEquals(shell.tabs.size, shell.navIndex) + + shell.key(ShellKey.Back) + if (pushed.isEmpty()) { + assertTrue("Back from the replacement Home must enter its nav ladder", shell.navFocused) + assertEquals("home", shell.route) + assertEquals(listOf(Destination.Home()), navigation.backStack.toList()) + if (!avatarFocused) assertEquals(0, shell.navIndex) + } else { + assertEquals("Back pops only the current page and retains the replacement Home root", + listOf(Destination.Home()) + pushed.dropLast(1), navigation.backStack.toList()) + } + } finally { model.viewModelScope.cancel() } + } + } + } +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrAudienceRecoveryTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrAudienceRecoveryTest.kt new file mode 100644 index 00000000..b748367e --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrAudienceRecoveryTest.kt @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.tv + +import android.app.Application +import androidx.lifecycle.viewModelScope +import androidx.test.core.app.ApplicationProvider +import com.github.damontecres.wholphin.data.model.JellyfinServer +import com.github.damontecres.wholphin.data.model.JellyfinUser +import io.mockk.mockk +import java.util.UUID +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.jellyfin.sdk.model.api.CollectionType +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.justinvisser.wolfskin.conductarr.MemoryCacheStore +import org.justinvisser.wolfskin.tv.read.LibraryReadFacade +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** Conductarr metadata failure cannot revoke libraries granted by Jellyfin. */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class) +class ConductarrAudienceRecoveryTest { + @Before fun immediateMain() { Dispatchers.setMain(Dispatchers.Unconfined) } + @After fun restoreMain() { Dispatchers.resetMain() } + + @Test fun operatorSuccessWithFailedOrEmptyAudiencesKeepsTheNameRuleAndForegroundCanHeal() = runBlocking { + val context = ApplicationProvider.getApplicationContext<Application>() + ConductarrPreference.initialize(context) + ConductarrPreference.updateFixtures(false) + for (empty in listOf(false, true)) FakeConductarr().use { fake -> + fake.audiencesStatus = if (empty) 200 else 503 + fake.emptyAudiences = empty + ConductarrSession(MemoryCacheStore()).use { session -> + val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null) + val user = JellyfinUser(id = UUID.randomUUID(), name = "Shared", serverId = server.id, accessToken = SHARED_TOKEN) + session.select("${server.id}:${user.id}", SHARED_TOKEN, fake.url) + session.authenticate() + assertTrue(session.identity.value!!.signedIn) + val client = session.identity.value!!.client + val libraries = LibraryReadFacade(mockk()) + val model = WolfskinShellViewModel(context, WolfskinSession(), mockk(), mockk(), mockk(), mockk(relaxed = true), + libraries, WolfskinSeeAllLists(), session, mockk(relaxed = true)) + try { + requireNotNull(model.onForeground(server, user)).join() + assertNull(libraries.audiences) + assertEquals("movies", libraries.destination("Movies", CollectionType.MOVIES)) + assertEquals("shows", libraries.destination("Shows", CollectionType.TVSHOWS)) + assertEquals("anime", libraries.destination("Shared Anime", CollectionType.TVSHOWS)) + if (!empty) { + fake.audiencesStatus = 200 + requireNotNull(model.onForeground(server, user)).join() + assertEquals(listOf("Shared Anime"), libraries.audiences!!.single().libraries) + assertEquals("anime", libraries.destination("Shared Anime", CollectionType.TVSHOWS)) + assertSame(client, session.identity.value!!.client) + } + } finally { model.viewModelScope.cancel() } + } + } + } + + @Test fun retryingSignedInPeopleKeepsTheUncertainSubmissionLedger() = runBlocking { + val context = ApplicationProvider.getApplicationContext<Application>() + ConductarrPreference.initialize(context) + ConductarrPreference.updateFixtures(false) + val previousUrl = ConductarrPreference.baseUrl + FakeConductarr().use { fake -> + ConductarrPreference.updateBaseUrl(fake.url) + ConductarrSession(MemoryCacheStore()).use { session -> + val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null) + val user = JellyfinUser(id = UUID.randomUUID(), name = "Shared", serverId = server.id, accessToken = SHARED_TOKEN) + session.select("${server.id}:${user.id}", SHARED_TOKEN, fake.url) + session.authenticate() + val libraries = LibraryReadFacade(mockk()) + val model = WolfskinShellViewModel(context, WolfskinSession(), mockk(), mockk(), mockk(), mockk(relaxed = true), + libraries, WolfskinSeeAllLists(), session, mockk(relaxed = true)) + try { + val identity = session.identity.value!! + val intent = org.justinvisser.wolfskin.design.DiscoverRequest( + org.justinvisser.wolfskin.design.DiscoverTitle(42, "movie", "Moon"), "mindel", "shared") + fake.postStatus = 503 + assertEquals(org.justinvisser.wolfskin.design.RequestPhase.NotConfirmed, + identity.provider.operations(identity.operator!!).request(intent).phase) + model.retryConductarr(server, user) + val refreshed = session.identity.value!! + assertEquals(org.justinvisser.wolfskin.design.RequestPhase.NotConfirmed, + refreshed.provider.operations(refreshed.operator!!).request(intent).phase) + assertEquals(1, fake.calls.count { it.method == "POST" }) + } finally { + model.viewModelScope.cancel() + ConductarrPreference.updateBaseUrl(previousUrl) + } + } + } + } +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrHomeSurfaceTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrHomeSurfaceTest.kt new file mode 100644 index 00000000..7842289d --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrHomeSurfaceTest.kt @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.tv + +import android.app.Application +import androidx.activity.ComponentActivity +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.pressKey +import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.data.model.JellyfinServer +import com.github.damontecres.wholphin.data.model.JellyfinUser +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.takahirom.roborazzi.captureRoboImage +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import java.io.File +import java.util.UUID +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.last +import kotlinx.coroutines.runBlocking +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.justinvisser.wolfskin.conductarr.MemoryCacheStore +import org.justinvisser.wolfskin.design.* +import org.justinvisser.wolfskin.tv.read.HomeLibraryCache +import org.justinvisser.wolfskin.tv.read.HomeReadFacade +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class ConductarrHomeSurfaceTest { + @get:Rule val rule = createAndroidComposeRule<ComponentActivity>() + + @Test fun currentIdentityFeedsRealHomeAndSavedRequestsWithoutInventingJellyfinPlayback() { + FakeConductarr().use { fake -> + fake.localArt = true + ConductarrSession(MemoryCacheStore()).use { session -> + val server = JellyfinServer(UUID.fromString("00000000-0000-0000-0000-000000000001"), "Fake", fake.url, null) + val user = JellyfinUser(id = UUID.fromString("00000000-0000-0000-0000-000000000002"), name = "Shared", serverId = server.id, accessToken = SHARED_TOKEN) + session.select("${server.id}:${user.id}", SHARED_TOKEN, fake.url) + runBlocking { session.authenticate() } + val home = mockk<HomeReadFacade>() + coEvery { home.readWatch(user.id) } returns HomeLibrary(resume = listOf( + HomeTitle("00000000-0000-0000-0000-000000000010", "Jellyfin held item", "movie", held = true))) + coEvery { home.readLibrary(user.id) } returns HomeLibrary() + val cache = mockk<HomeLibraryCache>(relaxed = true) + every { cache.read(any()) } returns null + val model = WolfskinHomeViewModel(rule.activity, home, cache, NavigationManager(), WolfskinSeeAllLists(), session) + try { + val shell = ShellState(listOf(ShellPerson("shared", "Shared", "S", true)), "shared").apply { pickerVisible = false; pickerLaunch = false } + rule.setContent { WolfskinTheme { + LaunchedEffect(model) { model.loadLibrary(server, user, shell.person) } + val snapshot = model.snapshot + CompositionLocalProvider(LocalScreenRatings provides androidx.compose.runtime.remember(session.identity.value?.client) { ConductarrRatings(session.identity.value!!.client) }) { + ShellSurface(shell) { HomeScreen(shell, snapshot, it) { action -> model.act(action, shell, server, user) } } + } + } } + rule.waitUntil(timeoutMillis = 30_000) { + org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle() + model.snapshot.rows.any { row -> row.cards.any { it.title == "Moon" } } + } + rule.runOnIdle { shell.navFocused = false; shell.view.row = 0; shell.view.home.focusedKey = "hero" } + rule.onRoot().performKeyInput { pressKey(Key.DirectionDown) } + rule.mainClock.advanceTimeBy(500) + org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle() + rule.onAllNodesWithText("Moon")[0].assertIsDisplayed() + assertTrue(model.snapshot.rows.filter { it.catalogue }.flatMap { it.cards }.none { it.held }) + val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory"))).apply { mkdirs() } + rule.onRoot().captureRoboImage(File(directory, "conductarr-home-real.png").absolutePath) + val provider = session.identity.value!!.provider + runBlocking { provider.requests().last() } + fake.status = 503 + val saved = runBlocking { provider.requests().last() } + assertNotNull(saved.savedAt) + assertEquals(ComingInStage.Downloading, saved.items.single().stage) + assertEquals("Shared", saved.items.single().actor(shell.person)) + rule.runOnIdle { shell.requests.update(saved); shell.openRequests() } + rule.onAllNodesWithText("Moon")[0].assertIsDisplayed() + rule.onRoot().captureRoboImage(File(directory, "conductarr-requests-saved.png").absolutePath) + } finally { model.viewModelScope.cancel() } + } + } + } +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrProjectionTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrProjectionTest.kt new file mode 100644 index 00000000..7fcdf8c4 --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrProjectionTest.kt @@ -0,0 +1,104 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.tv + +import kotlinx.coroutines.flow.last +import kotlinx.coroutines.runBlocking +import org.junit.Assert.* +import org.junit.Test +import org.justinvisser.wolfskin.conductarr.* +import org.justinvisser.wolfskin.design.* + +/** Consumer-visible mappings exercise realistic wire responses over a local HTTP server. */ +class ConductarrProjectionTest { + @Test fun householdFeedAndCatalogueBecomePersonalRowsWithoutRawLibraryNamesOrHomeComingSoon() = runBlocking { + FakeConductarr().use { fake -> + ConductarrClient(fake.url, SHARED_TOKEN, MemoryCacheStore()).use { client -> + val provider = ConductarrProvider(client) + assertEquals("Shared", provider.profiles()!!.profiles!!.first().name) + assertEquals(listOf("Shared Anime"), provider.audiences()!!.single().libraries) + val snapshot = provider.discover("shared").last() + assertEquals(DiscoverStatus.Ready, snapshot.status) + assertEquals("https://image.tmdb.org/t/p/w780/moon.jpg", snapshot.picks.single().poster) + assertEquals("Solaris", snapshot.seeds.single().title) + assertEquals(listOf(42L), snapshot.seeds.single().items.map { it.tmdb }) + assertEquals(listOf("New on Netflix", "Trending"), snapshot.lanes.take(2).map { it.title }) + assertTrue(snapshot.lanes.first { it.id == "genre-Comedy" }.pending) + assertEquals(99L, snapshot.hidden.single().tmdb) + assertEquals(8L, snapshot.services.single().id) + val rows = snapshot.homeRows() + assertEquals("Moon", rows.first { it.id == "pk-movie" }.cards.single().title) + assertFalse(rows.any { "Coming soon" in it.title || "Shared Anime" in it.title }) + assertTrue(fake.calls.filter { it.url.encodedPath == "/catalogue/discover" }.all { it.url.queryParameter("genre_id") == null }) + val expanded = provider.lanesThrough(99) + assertFalse(expanded.any { it.pending }) + assertTrue(fake.calls.any { it.url.queryParameter("genre_id") == "35" }) + assertEquals("Coming soon", expanded.last().title) + val service = provider.service(snapshot.services.single(), "tv") + assertEquals(listOf("Popular series", "New series", "Top rated series"), service.take(3).map { it.title }) + assertTrue(fake.calls.filter { it.url.queryParameter("service_id") == "8" }.any { it.url.queryParameter("media_type") == "tv" }) + val requests = provider.requests().last() + val request = requests.items.single() + assertEquals(ComingInStage.Downloading, request.stage) + assertEquals("Mindel", request.requesterName) + assertEquals("Shared", request.actor(ShellPerson("shared", "Shared", "S", true))) + } + } + } + + @Test fun uncertainRequestIsLookedUpRatherThanSentTwiceAndSharedRemainsTheAuthenticatedActor() = runBlocking { + FakeConductarr().use { fake -> + ConductarrClient(fake.url, SHARED_TOKEN, MemoryCacheStore()).use { client -> + val operations = ConductarrDiscoverOperations(client, { DiscoverSnapshot() }, profile = "shared", allowedRequesters = setOf("shared", "mindel")) + val intent = DiscoverRequest(DiscoverTitle(42, "movie", "Moon"), "mindel", "untrusted-ui-profile") + fake.postStatus = 503 + assertEquals(RequestPhase.NotConfirmed, operations.request(intent).phase) + assertEquals(RequestPhase.NotConfirmed, operations.request(intent).phase) + assertEquals(1, fake.calls.count { it.method == "POST" }) + val post = fake.calls.single { it.method == "POST" } + val body = post.body!!.utf8() + assertTrue(body.contains("\"requester\":\"mindel\"")) + assertTrue(body.contains("\"recommendation_profile\":\"shared\"")) + assertFalse(body.contains("actor")) + assertEquals(listOf("GET", "POST", "GET"), fake.calls.map { it.method }) + fake.existingRequests = true + val receipt = operations.check(intent) + assertEquals(RequestPhase.Receipt, receipt.phase) + assertEquals("Shared", receipt.actor) + assertEquals("mindel", receipt.requester) + assertEquals(RequestPhase.Refused, operations.request(intent.copy(requester = "outsider")).phase) + assertEquals(RequestPhase.Refused, operations.request(intent.copy(title = DiscoverTitle(44, "tv", "Dark"))).phase) + assertEquals(1, fake.calls.count { it.method == "POST" }) + assertTrue(operations.hide("untrusted-ui-profile", intent.title)) + assertTrue(operations.restore("untrusted-ui-profile", intent.title)) + assertTrue(fake.calls.takeLast(2).all { "/profiles/shared/dismissals" in it.url.encodedPath }) + } + } + } + + @Test fun episodeVotesReplaceSeriesScoreAndConditionalTitleReadsKeepTheOriginalBody() = runBlocking { + FakeConductarr().use { fake -> + ConductarrClient(fake.url, SHARED_TOKEN, MemoryCacheStore()).use { client -> + val ratings = ConductarrRatings(client) + assertEquals(listOf("rtCritics", "rtAudience", "imdb"), ratings.read(42, "movie", null, null)!!.map { it.source }) + val episode = ratings.read(44, "episode", 1, 1)!! + assertEquals(listOf("rtCritics", "rtAudience", "tmdb"), episode.map { it.source }) + assertTrue(episode.last().episode) + assertEquals(8.1, episode.last().score, 0.0) + ratings.read(44, "episode", 1, 2) + assertEquals(1, fake.calls.count { it.url.encodedPath.contains("/seasons/") }) + assertFalse(fake.calls.any { it.url.encodedPath.endsWith("/enrichment") }) + val provider: ConductarrTitleProvider = ConductarrProvider(client) + val title = (provider.detail(42, MediaType.MOVIE).last() as ReadResult.Value).saved + fake.notModified = true + val revalidated = (provider.detail(42, MediaType.MOVIE).last() as ReadResult.Value).saved + assertEquals(title.value, revalidated.value) + assertEquals(title.savedAtMillis, revalidated.savedAtMillis) + assertEquals("\"wire-v1\"", fake.calls.last().headers["If-None-Match"]) + fake.notModified = false + fake.ratingsStatus = 401 + assertEquals(emptyList<LibraryRating>(), ratings.read(45, "movie", null, null)) + assertEquals(emptyList<LibraryRating>(), ratings.read(42, "movie", null, null)) + } + } + } +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrSessionTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrSessionTest.kt new file mode 100644 index 00000000..3027d5fc --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrSessionTest.kt @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.tv + +import java.nio.file.Files +import kotlinx.coroutines.flow.last +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.runBlocking +import org.junit.Assert.* +import org.junit.Test +import org.justinvisser.wolfskin.conductarr.* +import org.justinvisser.wolfskin.design.* + +/** Real sockets and reopened disk stores prove identity and authentication boundaries. */ +class ConductarrSessionTest { + @Test fun switchingPersonRebuildsIdentityAndNeverServesTheOtherPersonsDiskCopy() = runBlocking { + val directory = Files.createTempDirectory("tv-session-").toFile() + try { + FakeConductarr().use { fake -> + ConductarrSession(FileCacheStore(directory)).use { session -> + session.select("server:shared", SHARED_TOKEN, fake.url) + session.authenticate() + val first = session.identity.value!! + assertEquals("shared", first.operator!!.requester) + assertEquals("Moon", first.provider.discover("shared").last().picks.single().title) + session.select("server:mindel", MINDEL_TOKEN, fake.url) + val second = session.identity.value!! + assertNotSame(first.client, second.client) + assertFalse(second.signedIn) + assertTrue(second.client.read(TvRoutes.feed("shared"), ReadTrigger.CACHE_ONLY).toList().isEmpty()) + session.authenticate() + assertEquals("Mindel", session.identity.value!!.operator!!.name) + val mindelCall = fake.calls.last { it.url.encodedPath == "/api/v1/operator" } + assertEquals("MediaBrowser Token=\"$MINDEL_TOKEN\"", mindelCall.headers["Authorization"]) + session.select("server:other", SHARED_TOKEN, fake.url) + assertTrue(session.identity.value!!.client.read(TvRoutes.operator(), ReadTrigger.CACHE_ONLY).toList().isEmpty()) + session.select("server:shared", SHARED_TOKEN, fake.url) + assertEquals("shared", (session.identity.value!!.client.read(TvRoutes.operator(), ReadTrigger.CACHE_ONLY).last() as ReadResult.Value).saved.value!!.requester) + session.select("server:shared", null, fake.url) + assertNull(session.identity.value) + } + fake.calls.forEach { request -> + assertTrue(request.headers["Authorization"] in setOf("MediaBrowser Token=\"$SHARED_TOKEN\"", "MediaBrowser Token=\"$MINDEL_TOKEN\"")) + listOf("Cookie", "X-Emby-Token", "X-MediaBrowser-Token", "X-Api-Key").forEach { assertNull(request.headers[it]) } + assertFalse(request.url.toString().contains(SHARED_TOKEN)) + } + } + assertFalse(directory.walkTopDown().filter { it.isFile }.any { it.readText().contains(SHARED_TOKEN) || it.readText().contains(MINDEL_TOKEN) }) + } finally { directory.deleteRecursively() } + } + + @Test fun offlineReopenPaintsSavedDataBut401RemovesRatingsAndRequestAuthority() = runBlocking { + val directory = Files.createTempDirectory("tv-offline-").toFile() + try { + FakeConductarr().use { fake -> + ConductarrSession(FileCacheStore(directory)).use { original -> + original.select("server:shared", SHARED_TOKEN, fake.url) + original.authenticate() + original.identity.value!!.provider.discover("shared").toList() + } + ConductarrSession(FileCacheStore(directory)).use { reopened -> + reopened.select("server:shared", SHARED_TOKEN, fake.url) + fake.status = 503 + reopened.authenticate() + assertTrue(reopened.identity.value!!.signedIn) + val snapshots = reopened.identity.value!!.provider.discover("shared", force = true).toList() + assertEquals("Moon", snapshots.first().picks.single().title) + assertEquals("Moon", snapshots.last().picks.single().title) + assertNotNull(snapshots.last().savedAt) + fake.status = 200 + fake.ratingsStatus = 401 + val ratings = ConductarrRatings(reopened.identity.value!!.client).read(42, "movie", null, null) + assertTrue(ratings!!.isEmpty()) + assertFalse(reopened.identity.value!!.signedIn) + assertNull(reopened.identity.value!!.operator) + assertEquals(ClientError.NeedsSignIn, reopened.identity.value!!.error) + assertEquals(DiscoverStatus.NeedsSignIn, reopened.identity.value!!.provider.discover("shared").last().status) + fake.ratingsStatus = 200 + reopened.select("server:shared", SHARED_TOKEN, fake.url, retry = true) + reopened.authenticate() + assertTrue(reopened.identity.value!!.signedIn) + } + } + } finally { directory.deleteRecursively() } + } +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrSurfaceTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrSurfaceTest.kt new file mode 100644 index 00000000..265dcbd9 --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrSurfaceTest.kt @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.tv + +import android.app.Application +import androidx.activity.ComponentActivity +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onRoot +import com.github.takahirom.roborazzi.captureRoboImage +import java.io.File +import kotlinx.coroutines.runBlocking +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.justinvisser.wolfskin.conductarr.MemoryCacheStore +import org.justinvisser.wolfskin.design.* +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** The shipped host reads a local server and draws real data, dwell ratings and 401 recovery. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class ConductarrSurfaceTest { + @get:Rule val rule = createAndroidComposeRule<ComponentActivity>() + + @Test fun realHttpProviderDrawsCatalogueAnd401ReturnsToSignInWithoutRequestActions() { + FakeConductarr().use { fake -> + ConductarrSession(MemoryCacheStore()).use { session -> + session.select("server:shared", SHARED_TOKEN, fake.url) + runBlocking { session.authenticate() } + val shell = ShellState(listOf(ShellPerson("shared", "Shared", "S", true), ShellPerson("mindel", "Mindel", "M", true)), "shared").apply { + pickerVisible = false + enterDestination("discover") + } + lateinit var current: DiscoverState + val art = ImageBitmap(1, 1) + rule.setContent { + val identity by session.identity.collectAsState() + val state = rememberDiscoverState(shell, shell::enterDestination, { shell.enterDestination("discover") }, identity = identity, fixtures = false) + current = state + CompositionLocalProvider(LocalDiscoverArt provides { art }, LocalScreenRatings provides identity?.takeIf { it.signedIn }?.let { selected -> androidx.compose.runtime.remember(selected.client) { ConductarrRatings(selected.client) } }) { + WolfskinTheme { ShellSurface(shell) { DiscoverScreen(state, it) } } + } + } + rule.waitUntil(30_000) { + org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle() + current.snapshot.status == DiscoverStatus.Ready + } + rule.onNodeWithText("Moon").assertIsDisplayed() + rule.mainClock.autoAdvance = false + rule.runOnIdle { + val lane = current.lanes.indexOfFirst { it.titles.any { title -> title.tmdb == 43L } } + current.focus(current.firstShelf + lane, 0) + } + rule.mainClock.advanceTimeByFrame() + rule.waitForIdle() + rule.mainClock.advanceTimeBy(100) + org.junit.Assert.assertFalse(fake.calls.any { it.url.encodedPath == "/catalogue/titles/43/movie/ratings" }) + rule.mainClock.advanceTimeBy(500) + rule.waitUntil(10_000) { + rule.mainClock.advanceTimeByFrame() + org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle() + fake.calls.any { it.url.encodedPath == "/catalogue/titles/43/movie/ratings" } + } + rule.waitForIdle() + val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory"))) + directory.mkdirs() + rule.onRoot().captureRoboImage(File(directory, "conductarr-real-provider.png").absolutePath) + fake.ratingsStatus = 401 + runBlocking { ConductarrRatings(session.identity.value!!.client).read(45, "movie", null, null) } + rule.mainClock.autoAdvance = true + rule.waitUntil(10_000) { current.snapshot.status == DiscoverStatus.NeedsSignIn } + rule.onNodeWithText("Discover needs sign-in").assertIsDisplayed() + org.junit.Assert.assertFalse(current.canRequest) + rule.onRoot().captureRoboImage(File(directory, "conductarr-401.png").absolutePath) + } + } + } +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrTitlePagesTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrTitlePagesTest.kt new file mode 100644 index 00000000..3c77fa66 --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrTitlePagesTest.kt @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.tv + +import kotlinx.coroutines.flow.last +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.Json +import org.junit.Assert.* +import org.junit.Test +import org.justinvisser.wolfskin.conductarr.* +import org.justinvisser.wolfskin.conductarr.model.Rating +import org.justinvisser.wolfskin.design.* + +class ConductarrTitlePagesTest { + @Test fun catalogueEvidenceNeverGrantsPlaybackAndOfflinePagesKeepTheirLastKnownGoodFacts() = runBlocking { + FakeConductarr().use { fake -> + ConductarrSession(MemoryCacheStore()).use { session -> + session.select("server:shared", SHARED_TOKEN, fake.url) + session.authenticate() + val identity = session.identity.value!! + identity.provider.audiences() + val pages = ConductarrTitlePages(identity) + val live = pages.title(42, MediaType.MOVIE).last() + val state = TitleState(live, ShellPerson("shared", "Shared", "S", true)) {} + assertEquals(97 * 60L, live.item.runtimeSeconds) + assertEquals(listOf("Comedy"), live.item.genres) + assertEquals("216", live.cast.single().id) + assertEquals("Sam Bell", live.cast.single().character) + assertEquals("movie:43", live.related.single().id) + assertFalse(live.item.held) + assertNull(live.playTarget) + assertFalse(state.options.any { it.id in listOf("play", "watched", "version") }) + assertTrue(state.options.any { it.id == "request" }) + val actor = pages.person(216).last() + assertEquals("Actor.", actor.person!!.biography) + assertEquals("Sam Bell", actor.credits.single().character) + assertFalse(actor.credits.single().held) + assertFalse(actor.hasMore) + fake.status = 503 + val saved = pages.title(42, MediaType.MOVIE).last() + assertEquals(live.item, saved.item) + assertEquals(live.cast, saved.cast) + assertEquals(TitleConnection.Offline, saved.connection) + assertNotNull(saved.savedAt) + assertFalse(TitleState(saved, state.person) {}.options.any { it.id == "request" }) + } + } + } + + @Test fun onlyResolvedJellyfinEpisodesArePlayableAndTheirObservedVotesReplaceSeriesScores() = runBlocking { + FakeConductarr().use { fake -> + ConductarrSession(MemoryCacheStore()).use { session -> + session.select("server:shared", SHARED_TOKEN, fake.url) + session.authenticate() + fake.seasonTwoScore = 7.2 + val episode = TitleItem("00000000-0000-0000-0000-000000000012", "Episode", "episode", held = true, season = 1, episode = 1) + val held = TitlePageData(TitleItem("00000000-0000-0000-0000-000000000011", "Dark", "tv", held = true, seasons = 2), + playTarget = episode, watchTarget = episode, episodes = listOf(episode.copy(id = "00000000-0000-0000-0000-000000000013", season = 2)), selectedSeason = 2) + val page = ConductarrTitlePages(session.identity.value!!).title(44, MediaType.TV, held).last() + assertEquals(episode.id, page.playTarget!!.id) + assertEquals(episode.id, page.watchTarget.id) + assertEquals(held.item.id, page.item.id) + assertEquals(44L, page.episodes.single().tmdbId) + assertTrue(page.episodes.single().ratings.last().episode) + assertEquals(8.1, page.playTarget!!.ratings.last().score, 0.0) + assertEquals(7.2, page.episodes.single().ratings.last().score, 0.0) + assertFalse(page.item.ratings.any { it.episode }) + fake.status = 401 + val revoked = ConductarrTitlePages(session.identity.value!!).title(44, MediaType.TV, held).last() + assertFalse(session.identity.value!!.signedIn) + assertEquals(TitleConnection.SignedOut, revoked.connection) + assertTrue(revoked.item.ratings.isEmpty()) + assertTrue(revoked.playTarget!!.ratings.isEmpty()) + assertFalse(TitleState(revoked, ShellPerson("shared", "Shared", "S", true)) {}.options.any { it.id == "add" }) + } + } + } + + @Test fun ratingsUseObservedLabelsPrecedenceAndPositiveVoteCountsWithoutDiscardingRealZeroes() { + fun ratings(json: String) = Json.decodeFromString<List<Rating>>(json).screenRatings("tv") + assertEquals(listOf("rtCritics"), ratings("""[{"source":"rt_critics","state":"observed","score":0},{"source":"tmdb","state":"observed","score":9,"votes":20}]""").map { it.source }) + assertNull(ratings("""[{"source":"rt_critics","state":"observed","score":99,"status":"unknown"}]""").single().label) + assertTrue(ratings("""[{"source":"tmdb","state":"observed","score":7,"votes":0},{"source":"imdb","state":"observed","score":8,"votes":10}]""").isEmpty()) + assertEquals(0.0, ratings("""[{"source":"tmdb","state":"observed","score":0,"votes":1}]""").single().score, 0.0) + assertTrue(emptyList<LibraryRating>().withEpisode(0.0, 1).single().episode) + assertTrue(emptyList<LibraryRating>().withEpisode(8.0, null).isEmpty()) + assertTrue(emptyList<LibraryRating>().withEpisode(8.0, 0).isEmpty()) + } +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrTitleSurfaceTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrTitleSurfaceTest.kt new file mode 100644 index 00000000..e41862d8 --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrTitleSurfaceTest.kt @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.tv + +import android.app.Application +import androidx.activity.ComponentActivity +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.pressKey +import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.takahirom.roborazzi.captureRoboImage +import io.mockk.coEvery +import io.mockk.mockk +import java.io.File +import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.* +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.justinvisser.wolfskin.conductarr.MemoryCacheStore +import org.justinvisser.wolfskin.design.* +import org.justinvisser.wolfskin.tv.read.TitleReadFacade +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class ConductarrTitleSurfaceTest { + @get:Rule val rule = createAndroidComposeRule<ComponentActivity>() + + @Test fun realTitleOffersNoFakePlaybackAndSharedRequestForMindelHasARealReceipt() { + FakeConductarr().use { fake -> + ConductarrSession(MemoryCacheStore()).use { session -> + session.select("server:shared", SHARED_TOKEN, fake.url) + runBlocking { session.authenticate() } + val facade = mockk<TitleReadFacade>() + coEvery { facade.findHeld(any(), any()) } returns null + val viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk(), mockk<NavigationManager>(relaxed = true), session, mockk<ServerRepository>(relaxed = true)) + try { + val route = titleRoute("movie:42") + val shell = ShellState(listOf(ShellPerson("shared", "Shared", "S", true)), "shared").apply { + pickerVisible = false; pickerLaunch = false; enterDestination(route) + } + val art = mutableMapOf<String, ImageBitmap>() + fun decode(element: JsonElement) { + when (element) { + is JsonObject -> element.values.forEach(::decode) + is JsonArray -> element.forEach(::decode) + is JsonPrimitive -> element.contentOrNull?.takeIf { it.startsWith("http") || it.startsWith('/') }?.let { art[catalogueImage(it)!!] = ImageBitmap(1, 1) } + } + } + decode(fake.wire) + rule.setContent { WolfskinTheme { CompositionLocalProvider(LocalTitleBitmaps provides art) { + TitleDestination(route, shell, UserPreferences(AppPreferences.getDefaultInstance(), null), viewModel = viewModel, fixtures = false) + } } } + rule.waitUntil(30_000) { + org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle() + viewModel.title?.data?.connection == TitleConnection.SignedIn && session.identity.value!!.provider.requesterProfiles.value.any { it.key == "mindel" } + } + rule.onNodeWithText("Moon").assertIsDisplayed() + assertFalse(viewModel.title!!.options.any { it.id == "play" }) + val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory"))).apply { mkdirs() } + rule.onRoot().captureRoboImage(File(directory, "conductarr-title-real.png").absolutePath) + rule.onRoot().performKeyInput { pressKey(Key.DirectionRight); pressKey(Key.DirectionCenter) } + rule.onNodeWithText("Request for Mindel").assertIsDisplayed() + rule.onRoot().performKeyInput { pressKey(Key.DirectionDown); pressKey(Key.DirectionCenter) } + rule.waitUntil(30_000) { + org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle() + viewModel.title?.data?.requestActor == "Shared" + } + assertEquals("Mindel", viewModel.title!!.data.requestFor) + rule.onAllNodesWithText("for Mindel · by Shared")[0].assertIsDisplayed() + val post = fake.calls.single { it.method == "POST" && it.url.encodedPath == "/api/v1/requests" } + val body = Json.parseToJsonElement(post.body!!.utf8()).jsonObject + assertEquals("mindel", body.getValue("requester").jsonPrimitive.content) + assertFalse(body.containsKey("actor")) + rule.onRoot().captureRoboImage(File(directory, "conductarr-title-receipt.png").absolutePath) + rule.onRoot().performKeyInput { pressKey(Key.DirectionCenter) } + fake.ratingsStatus = 401 + runBlocking { ConductarrRatings(session.identity.value!!.client).read(45, "movie", null, null) } + rule.waitUntil(30_000) { + org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle() + viewModel.title?.data?.connection == TitleConnection.SignedOut && viewModel.title?.data?.status == TitleReadStatus.Unavailable + } + assertTrue(viewModel.title!!.data.item.ratings.isEmpty()) + assertFalse(viewModel.title!!.options.any { it.id in listOf("request", "other", "add") }) + } finally { viewModel.viewModelScope.cancel() } + } + } + } +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrTruthCaptureTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrTruthCaptureTest.kt new file mode 100644 index 00000000..41d4fffb --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrTruthCaptureTest.kt @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.tv + +import android.app.Application +import android.view.View +import android.view.WindowManager +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithText +import com.github.takahirom.roborazzi.captureRoboImage +import java.io.File +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.last +import kotlinx.coroutines.runBlocking +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.justinvisser.wolfskin.conductarr.ConductarrClient +import org.justinvisser.wolfskin.conductarr.MemoryCacheStore +import org.justinvisser.wolfskin.design.* +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** Native frames exercise recorded activity and Mindel's feed through the shipped provider. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class ConductarrTruthCaptureTest { + @get:Rule val rule = createAndroidComposeRule<ComponentActivity>() + private val people = listOf(ShellPerson("justin", "Justin", "J", false), ShellPerson("mindel", "Mindel", "M", false)) + + @Before fun fullscreen() { + rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN) + @Suppress("DEPRECATION") + rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY + } + + @Test fun requestHistory() = requests("requests-history", "request-2") + @Test fun requestHealth() = requests("requests-health", "dispatch-721", expanded = true) + @Test fun requestProofDetails() = requests("requests-proof", "request-2", expanded = true) + @Test fun requestRemoved() = requests("requests-removed", "dispatch-725") + @Test fun discoverMovies() = discover("movie") + @Test fun discoverSeries() = discover("tv") + + private fun requests(name: String, focused: String, expanded: Boolean = false) { + FakeConductarr().use { fake -> + fake.activityBody = resource("activity-history.json") + ConductarrClient(fake.url, JUSTIN_TOKEN, MemoryCacheStore()).use { client -> + val snapshot = runBlocking { ConductarrProvider(client).requests().last() }.let { it.copy(items = it.items.map { row -> row.copy(poster = null) }) } + val shell = ShellState(people, "justin").apply { + pickerVisible = false + enterDestination("settings") + requests.update(snapshot) + openRequests() + requests.focus(focused) + if (expanded) requests.key(ShellKey.Right) + } + rule.setContent { WolfskinTheme { ShellSurface(shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) } } + capture(name) + rule.onAllNodesWithText("by Unknown", substring = true).assertCountEquals(0) + rule.onNodeWithText("Requests · 56 requests").assertExists() + if (expanded) rule.onNodeWithText(snapshot.items.first { it.id == focused }.summary!!).assertIsDisplayed() + } + } + } + + private fun discover(type: String) { + FakeConductarr().use { fake -> + fake.feedBody = resource("mindel-feed.json") + ConductarrClient(fake.url, MINDEL_TOKEN, MemoryCacheStore()).use { client -> + val provider = ConductarrProvider(client) + val snapshot = runBlocking { provider.discover("mindel").last() }.let { original -> + fun noArt(title: DiscoverTitle) = title.copy(poster = null, backdrop = null) + original.copy(picks = original.picks.map(::noArt), seeds = original.seeds.map { it.copy(items = it.items.map(::noArt)) }) + } + val shell = ShellState(people, "mindel").apply { pickerVisible = false; enterDestination("discover") } + val operator = runBlocking { (client.read(org.justinvisser.wolfskin.conductarr.TvRoutes.operator()).last() as org.justinvisser.wolfskin.conductarr.ReadResult.Value).saved.value } + val state = DiscoverState(shell, snapshot, provider.operations(requireNotNull(operator)), CoroutineScope(Dispatchers.Main.immediate)) + state.filter = type + state.focus(state.firstShelf + state.lanes.indexOfFirst { it.title.startsWith("Because you watched") }, 0) + rule.setContent { WolfskinTheme { ShellSurface(shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) { DiscoverScreen(state, it) } } } + capture("discover-$type") + rule.onNodeWithText(if (type == "tv") "Because you watched Gilmore Girls" else "Because you watched Locke").assertIsDisplayed() + } + } + } + + private fun capture(name: String) { + rule.mainClock.advanceTimeBy(1000) + rule.waitForIdle() + val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "truth").apply { mkdirs() } + rule.onRoot().captureRoboImage(File(directory, "$name.png").absolutePath) + } + + private fun resource(name: String) = javaClass.getResourceAsStream("/conductarr/$name")!!.bufferedReader().use { it.readText() } +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverLoadingFocusTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverLoadingFocusTest.kt new file mode 100644 index 00000000..d2530d4f --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverLoadingFocusTest.kt @@ -0,0 +1,100 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.tv + +import android.app.Application +import androidx.activity.ComponentActivity +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.pressKey +import kotlinx.coroutines.CompletableDeferred +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.justinvisser.wolfskin.design.DiscoverFixture +import org.justinvisser.wolfskin.design.DiscoverScreen +import org.justinvisser.wolfskin.design.DiscoverState +import org.justinvisser.wolfskin.design.DiscoverStatus +import org.justinvisser.wolfskin.design.ShellPerson +import org.justinvisser.wolfskin.design.ShellState +import org.justinvisser.wolfskin.design.ShellSurface +import org.justinvisser.wolfskin.design.WolfskinTheme +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** The real asynchronous host must return focus from its loading nav to the first loaded action. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class DiscoverLoadingFocusTest { + @get:Rule val rule = createAndroidComposeRule<ComponentActivity>() + + @Test fun firstOkAfterLoadingOpensTheHeroInsteadOfSelectingTheNav() { + val pending = CompletableDeferred<DiscoverFixture>() + val shell = ShellState(listOf(ShellPerson("justin", "Justin", "J", true)), "justin").apply { + pickerVisible = false + enterDestination("discover") + } + lateinit var current: DiscoverState + rule.setContent { + val state = rememberDiscoverState(shell, shell::enterDestination, { shell.enterDestination("discover") }, loadFixture = { pending.await() }) + current = state + WolfskinTheme { ShellSurface(shell) { DiscoverScreen(state, it) } } + } + rule.runOnIdle { + assertEquals(DiscoverStatus.Loading, current.snapshot.status) + assertTrue(shell.navFocused) + val json = rule.activity.assets.open("wolfskin-discover/fixture.json").bufferedReader().use { it.readText() } + pending.complete(DiscoverFixture(json) { "file:///android_asset/wolfskin-discover/$it" }) + } + rule.waitForIdle() + rule.runOnIdle { + assertEquals(DiscoverStatus.Ready, current.snapshot.status) + assertFalse(shell.navFocused) + assertEquals(0, current.focusedRow) + } + rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionCenter) } + rule.runOnIdle { + assertNotNull(current.title) + assertEquals("The Amazing Spider-Man", current.title!!.title) + } + } + + @Test fun finishingLoadPreservesANavTargetChosenWhileLoading() { + val pending = CompletableDeferred<DiscoverFixture>() + val shell = ShellState(listOf(ShellPerson("justin", "Justin", "J", true)), "justin").apply { + pickerVisible = false + enterDestination("discover") + } + lateinit var current: DiscoverState + rule.setContent { + val state = rememberDiscoverState(shell, shell::enterDestination, { shell.enterDestination("discover") }, loadFixture = { pending.await() }) + current = state + WolfskinTheme { ShellSurface(shell) { DiscoverScreen(state, it) } } + } + rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionRight) } + rule.runOnIdle { + assertEquals(DiscoverStatus.Loading, current.snapshot.status) + assertEquals(2, shell.navIndex) + val json = rule.activity.assets.open("wolfskin-discover/fixture.json").bufferedReader().use { it.readText() } + pending.complete(DiscoverFixture(json) { "file:///android_asset/wolfskin-discover/$it" }) + } + rule.waitForIdle() + rule.runOnIdle { + assertEquals(DiscoverStatus.Ready, current.snapshot.status) + assertTrue(shell.navFocused) + assertEquals(2, shell.navIndex) + } + rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionCenter) } + rule.runOnIdle { + assertEquals("movies", shell.route) + assertEquals(null, current.title) + } + } +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverSeedProjectionTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverSeedProjectionTest.kt new file mode 100644 index 00000000..0bd5e342 --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverSeedProjectionTest.kt @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.tv + +import kotlinx.coroutines.flow.last +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.* +import org.junit.Assert.* +import org.junit.Test +import org.justinvisser.wolfskin.conductarr.ConductarrClient +import org.justinvisser.wolfskin.conductarr.MemoryCacheStore + +/** Equal captions are not equal seed identities; repeated contributions union their evidence. */ +class DiscoverSeedProjectionTest { + @Test fun seedIdentitySurvivesTitleCollisionsAndWatchEvidenceAcrossCandidates() = runBlocking { + FakeConductarr().use { fake -> + val feed = fake.wire.getValue("feed").jsonObject + val candidate = feed.getValue("movies").jsonArray.first().jsonObject + val first = JsonObject(candidate + ("contributions" to Json.parseToJsonElement("""[ + {"media_type":"movie","tmdb_id":1,"title":"Same title","watched":true,"added":false}, + {"media_type":"tv","tmdb_id":1,"title":"Same title","watched":false,"added":true}, + {"media_type":"movie","tmdb_id":2,"watched":true,"added":false} + ]"""))) + val second = JsonObject(candidate + mapOf("tmdb_id" to JsonPrimitive(43), "contributions" to Json.parseToJsonElement("""[ + {"media_type":"movie","tmdb_id":1,"title":"Renamed seed","watched":false,"added":true} + ]"""))) + fake.feedBody = JsonObject(feed + ("movies" to JsonArray(listOf(first, second)))).toString() + ConductarrClient(fake.url, MINDEL_TOKEN, MemoryCacheStore()).use { client -> + val seeds = ConductarrProvider(client).discover("mindel").last().seeds + assertEquals(listOf("movie:1", "tv:1"), seeds.map { it.key }) + assertEquals(listOf(42L, 43L), seeds.first().items.map { it.tmdb }) + assertTrue(seeds.first().watched && seeds.first().added) + assertFalse(seeds.last().watched) + assertTrue(seeds.last().added) + } + } + } +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverSnapshotReadTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverSnapshotReadTest.kt new file mode 100644 index 00000000..cd7d4208 --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverSnapshotReadTest.kt @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.tv + +import android.app.Application +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import kotlinx.coroutines.runBlocking +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.justinvisser.wolfskin.conductarr.MemoryCacheStore +import org.justinvisser.wolfskin.design.* +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** No drawn screen may prewarm memory before the production Discover observer starts. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class) +class DiscoverSnapshotReadTest { + @get:Rule val rule = createAndroidComposeRule<ComponentActivity>() + + @Test fun signedInHostObservesFreshShellThroughTheRealAsyncProvider() { + FakeConductarr().use { fake -> + ConductarrSession(MemoryCacheStore()).use { session -> + session.select("server:shared", SHARED_TOKEN, fake.url) + runBlocking { session.authenticate() } + val identity = requireNotNull(session.identity.value) + assertTrue(identity.signedIn) + val shell = ShellState(listOf(ShellPerson("shared", "Shared", "S", true)), "shared") + lateinit var current: DiscoverState + rule.setContent { + current = rememberDiscoverState(shell, shell::enterDestination, {}, identity = identity, fixtures = false, + loadFixture = { error("Production observer must not load fixtures") }) + } + rule.waitUntil(30_000) { + org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle() + current.snapshot.status == DiscoverStatus.Ready + } + rule.waitForIdle() + rule.runOnIdle { + assertTrue(fake.calls.any { it.url.encodedPath.endsWith("/feed") }) + assertEquals(0, current.focusedRow) + assertEquals(0, current.column) + current.focus(current.firstShelf) + } + rule.waitForIdle() + rule.runOnIdle { + assertNotNull(current.focusedLane) + assertEquals(current.firstShelf, current.focusedRow) + assertEquals(0, current.column) + } + } + } + } +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverTitleTimingTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverTitleTimingTest.kt new file mode 100644 index 00000000..9fbbeaa0 --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverTitleTimingTest.kt @@ -0,0 +1,98 @@ +package org.justinvisser.wolfskin.tv + +import android.app.Application +import androidx.activity.ComponentActivity +import androidx.compose.runtime.* +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.takahirom.roborazzi.captureRoboImage +import java.io.File +import io.mockk.coEvery +import io.mockk.mockk +import java.util.concurrent.CountDownLatch +import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.justinvisser.wolfskin.conductarr.MemoryCacheStore +import org.justinvisser.wolfskin.design.* +import org.justinvisser.wolfskin.tv.read.TitleReadFacade +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** Catalogue details must reach the same late-filling cast/person/related surface as held titles. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class DiscoverTitleTimingTest { + @get:Rule val rule = createAndroidComposeRule<ComponentActivity>() + + @Test fun discoverDetailsFillCastAndRelatedAfterAsyncReplyAndBackRestoresDiscover() { + FakeConductarr().use { fake -> + fake.localArt = true + ConductarrSession(MemoryCacheStore()).use { session -> + session.select("server:shared", SHARED_TOKEN, fake.url) + runBlocking { session.authenticate() } + val identity = session.identity.value!! + val facade = mockk<TitleReadFacade>() + coEvery { facade.findHeld(any(), any()) } returns null + val navigation = NavigationManager().apply { replace(Destination.Wolfskin("discover")) } + val model = WolfskinTitleViewModel(rule.activity, facade, mockk(), navigation, session, mockk(relaxed = true)) + val shell = ShellState(listOf(ShellPerson("shared", "Shared", "S", false)), "shared").apply { + pickerVisible = false; pickerLaunch = false; enterDestination("discover") + } + lateinit var discover: DiscoverState + val release = CountDownLatch(1) + try { + rule.setContent { + val route = (navigation.backStack.last() as Destination.Wolfskin).route + LaunchedEffect(route) { shell.enterDestination(route) } + discover = rememberDiscoverState(shell, { navigation.navigateTo(Destination.Wolfskin(it)) }, navigation::goBack, identity = identity, fixtures = false) + WolfskinTheme { + if (route.startsWith("title/")) TitleDestination(route, shell, UserPreferences(AppPreferences.getDefaultInstance(), null), viewModel = model, fixtures = false) + else ShellSurface(shell) { DiscoverScreen(discover, it) } + } + } + rule.waitUntil(30_000) { discover.snapshot.status == DiscoverStatus.Ready } + rule.runOnIdle { + fake.beforeResponse = { if (it.endsWith("/cast")) release.await() } + discover.openTitle(DiscoverTitle(44, "tv", "Black Lightning")) + } + rule.waitForIdle() + assertTrue("Discover details must open the shared title destination, not its header-only preview", (navigation.backStack.last() as Destination.Wolfskin).route.startsWith("title/")) + rule.waitUntil(10_000) { fake.calls.any { it.url.encodedPath == "/catalogue/titles/44/tv/cast" } } + rule.runOnIdle { release.countDown() } + rule.waitUntil(30_000) { + org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle() + model.title?.data?.cast?.isNotEmpty() == true && model.title?.data?.related?.isNotEmpty() == true + } + rule.onRoot().performKeyInput { pressKey(Key.DirectionDown) } + assertEquals("cast", model.title!!.row) + rule.onNodeWithText("Sam Rockwell").assertIsDisplayed() + val captures = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "walk-fixes").apply { mkdirs() } + rule.onRoot().captureRoboImage(File(captures, "discover-title-cast.png").absolutePath) + rule.onRoot().performKeyInput { pressKey(Key.DirectionDown) } + assertEquals("related", model.title!!.row) + rule.onNodeWithText("More like this").assertIsDisplayed() + rule.onRoot().captureRoboImage(File(captures, "discover-title-related.png").absolutePath) + rule.onRoot().performKeyInput { pressKey(Key.Back) } + rule.waitForIdle() + assertNull(discover.title) + assertEquals("discover", shell.route) + } finally { + release.countDown() + model.viewModelScope.cancel() + } + } + } + } +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/FakeConductarr.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/FakeConductarr.kt new file mode 100644 index 00000000..647b0388 --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/FakeConductarr.kt @@ -0,0 +1,112 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.tv + +import java.io.Closeable +import java.util.concurrent.CopyOnWriteArrayList +import kotlinx.serialization.json.* +import mockwebserver3.Dispatcher +import mockwebserver3.MockResponse +import mockwebserver3.MockWebServer +import mockwebserver3.RecordedRequest + +internal const val SHARED_TOKEN = "0123456789abcdef0123456789abcdef" +internal const val MINDEL_TOKEN = "fedcba9876543210fedcba9876543210" +internal const val JUSTIN_TOKEN = "11111111111111111111111111111111" + +/** A loopback server supplies wire JSON, never app projections or live service state. */ +internal class FakeConductarr : Closeable { + val server = MockWebServer() + val calls = CopyOnWriteArrayList<RecordedRequest>() + val wire = Json.parseToJsonElement(javaClass.getResourceAsStream("/conductarr/wire.json")!!.bufferedReader().use { it.readText() }).jsonObject + @Volatile var status = 200 + @Volatile var postStatus = 200 + @Volatile var existingRequests = false + @Volatile var notModified = false + @Volatile var ratingsStatus = 200 + @Volatile var audiencesStatus = 200 + @Volatile var emptyAudiences = false + @Volatile var seasonTwoScore = 8.1 + @Volatile var localArt = false + @Volatile var beforeResponse: ((String) -> Unit)? = null + @Volatile var audiencesBody: String? = null + @Volatile var feedBody: String? = null + @Volatile var activityBody: String? = null + private val imageBytes = java.util.Base64.getDecoder().decode( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jYioAAAAASUVORK5CYII=") + + init { + server.dispatcher = object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse { + calls.add(request) + if (status != 200) return response("", status) + if (localArt && request.url.encodedPath == "/image") return MockResponse.Builder() + .body(okio.Buffer().write(imageBytes)).build() + val path = request.url.encodedPath + beforeResponse?.invoke(path) + if (request.method == "POST" && path == "/api/v1/requests") return response(wire.getValue("receipt").toString(), postStatus) + if (request.method == "POST") return response(wire.getValue("hide").toString()) + if (request.method == "DELETE") return response(wire.getValue("restore").toString()) + val key = when { + path == "/api/v1/operator" -> "operator" + path == "/api/v1/recommendations/profiles" -> "profiles" + path == "/curation/library-audiences" -> "audiences" + path.endsWith("/feed") -> "feed" + path.endsWith("/dismissals") -> "dismissals" + path == "/catalogue/options" -> "options" + path == "/catalogue/discover" -> "discover" + path.endsWith("/ratings") -> "ratings" + path.endsWith("/cast") -> "cast" + path.endsWith("/related") -> "discover" + path.endsWith("/enrichment") -> "enrichment" + path.endsWith("/display") -> "display" + path.contains("/seasons/") -> "season" + path.endsWith("/filmography") -> "filmography" + path.startsWith("/catalogue/performers/") -> "performer" + path.startsWith("/catalogue/titles/") -> "detail" + path.startsWith("/api/v1/activity/titles/") -> "readiness" + path.startsWith("/api/v1/requests/titles/") -> "requests" + path == "/api/v1/activity" -> "activity" + else -> return response("", 404) + } + if (key == "ratings" && ratingsStatus != 200) return response("", ratingsStatus) + if (key == "audiences" && audiencesStatus != 200) return response("", audiencesStatus) + if (key == "audiences") audiencesBody?.let { return response(it) } + if (key == "feed") feedBody?.let { return response(it) } + if (key == "activity") activityBody?.let { return response(it) } + if (notModified && request.headers["If-None-Match"] != null) return response("", 304) + if (key == "requests" && !existingRequests) return response("{\"requests\":[]}") + var body = wire.getValue(key).jsonObject + if (key == "audiences" && emptyAudiences) body = JsonObject(body + ("audiences" to JsonArray(emptyList()))) + if (key == "operator" && request.headers["Authorization"]?.contains(MINDEL_TOKEN) == true) { + body = JsonObject(body + mapOf("name" to JsonPrimitive("Mindel"), "requester" to JsonPrimitive("mindel"), "subject" to JsonPrimitive("jellyfin:mindel"), "watch_user" to JsonPrimitive("mindel"))) + } + if (key == "operator" && request.headers["Authorization"]?.contains(JUSTIN_TOKEN) == true) { + body = JsonObject(body + mapOf("name" to JsonPrimitive("Justin"), "requester" to JsonPrimitive("justin"), "subject" to JsonPrimitive("jellyfin:justin"), "watch_user" to JsonPrimitive("justin"))) + } + if (key in setOf("options", "discover") && request.url.queryParameter("media_type") == "tv") { + body = JsonObject(body + ("media_type" to JsonPrimitive("tv"))) + if (key == "discover") body = JsonObject(body + ("items" to JsonArray(listOf(JsonObject(body.getValue("items").jsonArray.first().jsonObject + mapOf("media_type" to JsonPrimitive("tv"), "tmdb_id" to JsonPrimitive(44), "title" to JsonPrimitive("Dark"))))))) + } + if (key == "discover" && request.url.queryParameter("page") == "2") body = JsonObject(body + ("page" to JsonPrimitive(2))) + if (key == "season" && path.endsWith("/2")) body = JsonObject(body + ("episodes" to JsonArray(body.getValue("episodes").jsonArray.map { + JsonObject(it.jsonObject + ("vote_average" to JsonPrimitive(seasonTwoScore))) + }))) + val responseBody = if (localArt) localizeImages(body).toString() else body.toString() + return response(responseBody) + } + } + server.start() + } + + val url: String get() = server.url("/").toString() + private fun localizeImages(element: JsonElement): JsonElement = when (element) { + is JsonObject -> JsonObject(element.mapValues { (key, value) -> + if (key in setOf("poster_url", "poster_path", "backdrop_url", "backdrop_path", "profile_url") && value is JsonPrimitive && value.contentOrNull != null) + JsonPrimitive(server.url("/image").toString()) else localizeImages(value) + }) + is JsonArray -> JsonArray(element.map(::localizeImages)) + else -> element + } + private fun response(body: String, code: Int = 200) = MockResponse.Builder().code(code).body(body).addHeader("ETag", "\"wire-v1\"").build() + override fun close() = server.close() +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/FirstSignInNavigationTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/FirstSignInNavigationTest.kt new file mode 100644 index 00000000..aa1faf95 --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/FirstSignInNavigationTest.kt @@ -0,0 +1,152 @@ +package org.justinvisser.wolfskin.tv + +import android.app.Application +import android.os.Looper +import androidx.activity.ComponentActivity +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.* +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.ui.successQueryResult +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import java.util.UUID +import kotlinx.coroutines.cancel +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.itemsApi +import org.jellyfin.sdk.api.client.extensions.userViewsApi +import org.jellyfin.sdk.api.operations.ItemsApi +import org.jellyfin.sdk.api.operations.UserViewsApi +import org.jellyfin.sdk.model.api.* +import org.junit.Assert.* +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.justinvisser.wolfskin.conductarr.MemoryCacheStore +import org.justinvisser.wolfskin.design.* +import org.justinvisser.wolfskin.tv.read.LibraryReadFacade +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows +import org.robolectric.annotation.Config + +/** Fresh login exercises the actual NavDisplay and renders user-prefixed Jellyfin libraries. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +class FirstSignInNavigationTest { + @get:Rule val rule = createAndroidComposeRule<ComponentActivity>() + private lateinit var previousUrl: String + private var previousFixtures = false + private var previousScreens = true + + @Before fun captureGlobalPreferences() { + ConductarrPreference.initialize(rule.activity) + WolfskinScreenPreference.initialize(rule.activity) + previousUrl = ConductarrPreference.baseUrl + previousFixtures = ConductarrPreference.fixtures + previousScreens = WolfskinScreenPreference.enabled + rule.mainClock.autoAdvance = true + } + + @After fun restoreGlobalPreferences() { + ConductarrPreference.updateBaseUrl(previousUrl) + ConductarrPreference.updateFixtures(previousFixtures) + WolfskinScreenPreference.enabled = previousScreens + rule.mainClock.autoAdvance = true + } + + @Test fun firstContinueThenMoviesRendersMindelsGridWithoutReenteringThePicker() { + ConductarrPreference.updateFixtures(false) + WolfskinScreenPreference.enabled = true + FakeConductarr().use { fake -> + fake.audiencesBody = """{"audiences":[{"is_anime":false,"libraries":["Mindel Movies"],"media_type":"movie","owner":"mindel","users":["mindel"]},{"is_anime":false,"libraries":["Mindel Shows"],"media_type":"tv","owner":"mindel","users":["mindel"]}]}""" + ConductarrPreference.updateBaseUrl(fake.url) + ConductarrSession(MemoryCacheStore()).use { conductarr -> + val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null) + val user = JellyfinUser(id = UUID.randomUUID(), name = "mindel", serverId = server.id, accessToken = MINDEL_TOKEN) + val repository = mockk<ServerRepository>(relaxed = true) + coEvery { repository.serverDao.getServer(server.id) } returns JellyfinServerUsers(server, listOf(user)) + val api = mockk<ApiClient>() + val views = mockk<UserViewsApi>() + val items = mockk<ItemsApi>() + every { api.userViewsApi } returns views + every { api.itemsApi } returns items + val movies = BaseItemDto(id = UUID.randomUUID(), name = "Mindel Movies", type = BaseItemKind.COLLECTION_FOLDER, collectionType = CollectionType.MOVIES) + val shows = BaseItemDto(id = UUID.randomUUID(), name = "Mindel Shows", type = BaseItemKind.COLLECTION_FOLDER, collectionType = CollectionType.TVSHOWS) + coEvery { views.getUserViews(userId = user.id) } returns successQueryResult(listOf(movies, shows)) + for ((library, kind, count) in listOf(Triple(movies, BaseItemKind.MOVIE, 6), Triple(shows, BaseItemKind.SERIES, 29))) { + coEvery { items.getItems(userId = user.id, parentId = library.id, recursive = true, + includeItemTypes = listOf(kind), fields = listOf(ItemFields.PROVIDER_IDS, ItemFields.SORT_NAME, ItemFields.DATE_CREATED), + sortBy = listOf(ItemSortBy.SORT_NAME), startIndex = 0, limit = 200) } returns successQueryResult( + List(count) { BaseItemDto(id = UUID.randomUUID(), name = "${library.name} title $it", type = kind) }) + } + val navigation = NavigationManager() + val session = WolfskinSession() + val model = WolfskinShellViewModel(rule.activity, session, repository, navigation, mockk(relaxed = true), mockk(relaxed = true), + LibraryReadFacade(api), WolfskinSeeAllLists(), conductarr, mockk(relaxed = true)) + val home = HomeSnapshot(listOf(HomeRow("resume", HomeRowKind.Hero, cards = listOf( + HomeTitle("episode", "Me and My Town", "episode", seriesTitle = "Desperate Housewives", season = 5, episode = 9)), heroKind = "resume"))) + try { + rule.setContent { + WolfskinApp(server, user, navigation, UserPreferences(AppPreferences.getDefaultInstance(), null), Modifier, model, + homeContent = { shell, _, _, modifier -> HomeScreen(shell, home, modifier) {} }) + } + rule.waitUntil(10_000) { + Shadows.shadowOf(Looper.getMainLooper()).idle() + conductarr.identity.value?.signedIn == true && model.libraries.audiences != null + } + rule.onNodeWithText("Mindel").assertIsDisplayed() + rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionCenter) } + rule.onNodeWithText("Desperate Housewives").assertIsDisplayed() + assertFalse("Continue must close the session's real picker, not a discarded launch shell", session.state!!.pickerVisible) + rule.onNodeWithTag("wolfskin-shell").performKeyInput { + pressKey(Key.DirectionUp) + pressKey(Key.DirectionRight) + pressKey(Key.DirectionRight) + pressKey(Key.DirectionCenter) + } + rule.waitForIdle() + rule.waitUntil(10_000) { + Shadows.shadowOf(Looper.getMainLooper()).idle() + rule.onAllNodesWithTag("library-grid").fetchSemanticsNodes().isNotEmpty() + } + rule.onNodeWithTag("library-grid").assertIsDisplayed() + rule.onAllNodesWithText("Mindel Movies title 0")[0].assertIsDisplayed() + rule.onNodeWithText("Desperate Housewives").assertDoesNotExist() + assertEquals(6, session.state!!.view.library!!.source.size) + assertFalse(session.state!!.person.hasAnime) + rule.onNodeWithText("Shows").performClick() + rule.waitForIdle() + try { + rule.waitUntil(timeoutMillis = 15_000) { + Shadows.shadowOf(Looper.getMainLooper()).idle() + rule.onAllNodes(hasTestTag("library-grid") and + hasAnyDescendant(hasText("Mindel Shows title 0"))).fetchSemanticsNodes().isNotEmpty() + } + } catch (timeout: ComposeTimeoutException) { + val shell = session.state!! + throw AssertionError("Active=${navigation.backStack.last()}, shell=${shell.route}, " + + "movies=${shell.rememberedView("movies").library?.source?.size}, " + + "shows=${shell.rememberedView("shows").library?.source?.size}/" + + "${shell.rememberedView("shows").library?.status}", timeout) + } + rule.onNodeWithTag("library-grid").assertIsDisplayed() + assertEquals(29, session.state!!.view.library!!.source.size) + rule.onAllNodesWithText("Mindel Shows title 0")[0].assertIsDisplayed() + rule.onNodeWithText("Home").performClick() + rule.waitForIdle() + rule.onNodeWithText("Desperate Housewives").assertIsDisplayed() + assertFalse("Returning Home must not reopen the picker", session.state!!.pickerVisible) + } finally { model.viewModelScope.cancel() } + } + } + } +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/HomeLibraryCacheTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/HomeLibraryCacheTest.kt new file mode 100644 index 00000000..49fc269f --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/HomeLibraryCacheTest.kt @@ -0,0 +1,29 @@ +package org.justinvisser.wolfskin.tv + +import android.app.Application +import androidx.test.core.app.ApplicationProvider +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.justinvisser.wolfskin.design.HomeLibrary +import org.justinvisser.wolfskin.design.HomeTitle +import org.justinvisser.wolfskin.tv.read.HomeLibraryCache + +/** A reopened Home cache preserves playable identity and episode/resume state without network reads. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class) +class HomeLibraryCacheTest { + @Test fun savedHomeReopensWithEpisodeMetadataAndFullShelfCount() { + val context = ApplicationProvider.getApplicationContext<Application>() + val episode = HomeTitle("episode-id", "The next episode", "episode", year = 2025, runtimeSeconds = 1800, overview = "Overview", poster = "poster", backdrop = "backdrop", seriesTitle = "A series", season = 2, episode = 3, resumeSeconds = 125.5, addedAt = "2026-11-05", genres = listOf("Drama"), held = true, tmdbId = 42) + val film = HomeTitle("film-id", "A film", "movie", year = 2024, runtimeSeconds = 5400, played = true, held = true, tmdbId = 43) + val library = HomeLibrary(listOf(episode), listOf(episode.copy(id = "next", resumeSeconds = 0.0)), listOf(film), mapOf("movies" to listOf(film)), mapOf("movies" to 84)) + HomeLibraryCache(context).write("server:person", library, 12345L) + val saved = HomeLibraryCache(context).read("server:person")!! + assertEquals(library, saved.first) + assertEquals(12345L, saved.second) + assertNull(HomeLibraryCache(context).read("server:someone-else")) + } +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/HomeRecoveryTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/HomeRecoveryTest.kt new file mode 100644 index 00000000..128dae1e --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/HomeRecoveryTest.kt @@ -0,0 +1,112 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.tv + +import android.app.Application +import androidx.test.core.app.ApplicationProvider +import com.github.damontecres.wholphin.data.model.JellyfinServer +import com.github.damontecres.wholphin.data.model.JellyfinUser +import com.github.damontecres.wholphin.services.NavigationManager +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import java.io.IOException +import java.util.UUID +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.runBlocking +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import org.justinvisser.wolfskin.design.* +import org.justinvisser.wolfskin.tv.read.HomeLibraryCache +import org.justinvisser.wolfskin.tv.read.HomeReadFacade +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** Saved Home projections survive outages but never survive an authenticated identity change. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class) +class HomeRecoveryTest { + private val context = ApplicationProvider.getApplicationContext<Application>() + private val server = JellyfinServer(UUID.fromString("00000000-0000-0000-0000-000000000001"), "Fixture", "https://example.invalid", null) + private val personA = JellyfinUser(id = UUID.fromString("00000000-0000-0000-0000-000000000002"), name = "Justin", serverId = server.id, accessToken = null) + private val personB = personA.copy(id = UUID.fromString("00000000-0000-0000-0000-000000000003"), name = "Mindel") + private val viewerA = ShellPerson("justin", "Justin", "J", false, personA.id.toString()) + private val viewerB = ShellPerson("mindel", "Mindel", "M", false, personB.id.toString()) + private val film = HomeTitle("private-film", "Person A film", "movie", held = true, runtimeSeconds = 5400) + private val library = HomeLibrary(resume = listOf(film)) + + @Test fun savedHomePaintsBeforeRefreshAndStaysAfterFailedRetry() = runBlocking { + val cache = HomeLibraryCache(context) + cache.write("${server.id}:${personA.id}", library, 1000L) + val started = CompletableDeferred<Unit>() + val response = CompletableDeferred<HomeLibrary>() + val facade = mockk<HomeReadFacade>() + coEvery { facade.readWatch(personA.id) } coAnswers { started.complete(Unit); response.await() } + coEvery { facade.readLibrary(any()) } returns HomeLibrary() + val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore())) + val refresh = async(Dispatchers.IO) { model.loadLibrary(server, personA, viewerA, retry = true) } + started.await() + assertEquals(film.id, model.snapshot.rows.first().cards.first().id) + assertFalse(model.snapshot.rows.first().watchReady) + response.completeExceptionally(IOException("fixture offline")) + refresh.await() + assertFalse(model.snapshot.jellyfinAvailable) + assertEquals(film.id, model.snapshot.rows.first().cards.first().id) + assertNotNull(model.snapshot.savedAt) + assertEquals(RecoveryMessage.StillUnreachable, model.toast) + } + + @Test fun personBSeesNeitherPersonAsSavedHomeNorTheirLateRefreshResult() = runBlocking { + val facade = mockk<HomeReadFacade>() + val cache = HomeLibraryCache(context) + cache.write("${server.id}:${personA.id}", library, 1000L) + val startedA = CompletableDeferred<Unit>() + val responseA = CompletableDeferred<HomeLibrary>() + coEvery { facade.readWatch(personA.id) } coAnswers { startedA.complete(Unit); responseA.await() } + coEvery { facade.readWatch(personB.id) } throws IOException("fixture offline") + coEvery { facade.readLibrary(any()) } returns HomeLibrary() + val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore())) + val requestA = async(Dispatchers.IO) { model.loadLibrary(server, personA, viewerA) } + startedA.await() + assertTrue(model.snapshot.rows.any { row -> row.cards.any { it.id == film.id } }) + model.loadLibrary(server, personB, viewerB) + assertFalse(model.snapshot.rows.any { row -> row.cards.any { it.id == film.id } }) + assertNull(model.snapshot.savedAt) + assertTrue(model.snapshot.rows.any { it.kind == HomeRowKind.State && it.focusable }) + responseA.complete(library) + requestA.await() + assertFalse(model.snapshot.rows.any { row -> row.cards.any { it.id == film.id } }) + assertFalse(model.snapshot.jellyfinAvailable) + } + + @Test fun readableJellyfinDoesNotBecomeAnOutageWhenLocalCacheCannotBeWritten() = runBlocking { + val facade = mockk<HomeReadFacade>() + coEvery { facade.readWatch(personA.id) } returns library + coEvery { facade.readLibrary(any()) } returns HomeLibrary() + val cache = mockk<HomeLibraryCache>() + every { cache.read(any()) } returns null + every { cache.write(any(), any(), any()) } throws IOException("fixture disk full") + val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore())) + model.loadLibrary(server, personA, viewerA) + assertTrue(model.snapshot.jellyfinAvailable) + assertEquals(film.id, model.snapshot.rows.first().cards.first().id) + assertNull(model.toast) + } + + @Test fun renamedHouseholdProfileKeepsItsOwnJellyfinLibraryWithoutAConductarrIdentity() = runBlocking { + val facade = mockk<HomeReadFacade>() + coEvery { facade.readWatch(personA.id) } returns library + coEvery { facade.readLibrary(any()) } returns HomeLibrary() + val cache = mockk<HomeLibraryCache>(relaxed = true) + every { cache.read(any()) } returns null + val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore())) + model.loadLibrary(server, personA, viewerA.copy(key = "household-profile", name = "Household display name")) + assertTrue(model.snapshot.jellyfinAvailable) + assertEquals(film.id, model.snapshot.rows.first().cards.first().id) + model.loadLibrary(server, personA, viewerB.copy(key = "justin")) + assertFalse(model.snapshot.jellyfinAvailable) + assertFalse(model.snapshot.rows.any { row -> row.cards.any { it.id == film.id } }) + } +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/HomeSeeAllNavigationTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/HomeSeeAllNavigationTest.kt new file mode 100644 index 00000000..f8322b5f --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/HomeSeeAllNavigationTest.kt @@ -0,0 +1,130 @@ +package org.justinvisser.wolfskin.tv + +import android.app.Application +import androidx.activity.ComponentActivity +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +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.JellyfinServer +import com.github.damontecres.wholphin.data.model.JellyfinUser +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.ui.nav.Destination +import io.mockk.mockk +import java.util.UUID +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Assert.assertSame +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.justinvisser.wolfskin.design.* +import org.justinvisser.wolfskin.tv.read.LibraryReadFacade +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** Home opens the complete shared grid and Back restores its original continuation card. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +class HomeSeeAllNavigationTest { + @get:Rule val rule = createAndroidComposeRule<ComponentActivity>() + + @Test fun personalMovieContinuationReachesTheEighteenthTitleAndReturnsToHome() = continuation("pk-m", 5) { grid -> + assertEquals(18, grid.items.size) + repeat(2) { press(Key.DirectionDown) } + repeat(5) { press(Key.DirectionRight) } + rule.onNodeWithText("Bleach the Movie: Fade to Black").assertIsDisplayed() + assertEquals("movie-bleach-the-movie-fade-to-black-2008", grid.focusedTitle?.id) + } + + @Test fun horizontallyScrolledContinuationRestoresTheExactHomeViewport() = continuation("trending", 20) { grid -> + press(Key.DirectionDown) + assertEquals(6, grid.index) + rule.onNodeWithTag("library-grid").assertIsDisplayed() + } + + private fun continuation(rowId: String, expectedColumn: Int, exerciseGrid: (LibraryGridState) -> Unit) { + rule.mainClock.autoAdvance = false + val snapshot = HomeFixture(rule.activity).snapshot("justin") + val row = snapshot.rows.first { it.id == rowId } + val cardTag = "home-card:$rowId:$expectedColumn" + val navigation = NavigationManager() + val lists = WolfskinSeeAllLists() + val shell = ShellState(listOf(ShellPerson("justin", "Justin", "J", true)), "justin") { + if (it == ShellAction.Pop) navigation.goBack() + }.apply { + pickerVisible = false + view.row = snapshot.rows.indexOf(row) + view.home.focusedKey = row.id + view.cards[row.id] = row.cards.size + } + val server = JellyfinServer(UUID.fromString("00000000-0000-0000-0000-000000000001"), "Fixture", "https://example.invalid", null) + val user = JellyfinUser(id = UUID.fromString("00000000-0000-0000-0000-000000000002"), name = "Justin", serverId = server.id, accessToken = null) + val model = WolfskinHomeViewModel(rule.activity, mockk(), mockk(), navigation, lists, ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore())) + val facade = mockk<LibraryReadFacade>() + rule.setContent { + val destination = navigation.backStack.last() + val route = (destination as? Destination.Wolfskin)?.route ?: "home" + LaunchedEffect(destination) { shell.enterDestination(route) } + WolfskinTheme { ShellSurface(shell) { modifier -> + if (destination is Destination.Home) HomeScreen(shell, snapshot, modifier) { model.act(it, shell, server, user) } + else WolfskinLibraryContent(rule.activity, route, shell, user, "fixture", facade, navigation, lists, modifier) + } } + } + settle() + rule.onNodeWithTag(cardTag).assertIsDisplayed() + val home = shell.view + val track = home.home.tracks.getValue(rowId) + val verticalIndex = home.listState.firstVisibleItemIndex + val verticalOffset = home.listState.firstVisibleItemScrollOffset + val rowOffset = home.listState.layoutInfo.visibleItemsInfo.first { it.key == rowId }.offset + val horizontalIndex = track.firstVisibleItemIndex + val horizontalOffset = track.firstVisibleItemScrollOffset + press(Key.DirectionCenter) + assertEquals("seeall/justin/$rowId", (navigation.backStack.last() as Destination.Wolfskin).route) + val grid = requireNotNull(lists.get("justin/$rowId")) + try { + rule.onNodeWithTag("library-grid").assertIsDisplayed() + exerciseGrid(grid) + press(Key.Back) + rule.waitForIdle() + assertTrue(navigation.backStack.last() is Destination.Home) + assertEquals(row.id, snapshot.rows[shell.view.row].id) + assertEquals(expectedColumn, shell.view.cards[row.id]) + assertSame("Home retains its original route memory", home, shell.view) + assertSame("Home retains its original horizontal state", track, home.home.tracks.getValue(rowId)) + assertEquals("Vertical first item", verticalIndex, home.listState.firstVisibleItemIndex) + assertEquals("Vertical pixel offset", verticalOffset, home.listState.firstVisibleItemScrollOffset) + assertEquals("Selected row pixel offset", rowOffset, home.listState.layoutInfo.visibleItemsInfo.first { it.key == rowId }.offset) + assertEquals("Horizontal first item", horizontalIndex, track.firstVisibleItemIndex) + assertEquals("Horizontal pixel offset", horizontalOffset, track.firstVisibleItemScrollOffset) + rule.onNodeWithTag(cardTag).assertIsDisplayed() + } catch (failure: AssertionError) { + throw AssertionError( + "Expected $rowId:$expectedColumn vertical=$verticalIndex/$verticalOffset row-offset=$rowOffset horizontal=$horizontalIndex/$horizontalOffset; " + + "actual route=${shell.route} row=${home.row} card=${home.cards[rowId]} nav=${shell.navFocused} handler=${shell.contentFocus?.javaClass?.simpleName} grid=${grid.index} " + + "vertical=${home.listState.firstVisibleItemIndex}/${home.listState.firstVisibleItemScrollOffset} horizontal=${track.firstVisibleItemIndex}/${track.firstVisibleItemScrollOffset} " + + "visible-rows=${home.listState.layoutInfo.visibleItemsInfo.map { it.key to it.offset }}", + failure, + ) + } + } + + private fun press(key: Key) { + rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(key) } + settle() + } + + private fun settle() { + repeat(3) { + rule.mainClock.advanceTimeByFrame() + rule.waitForIdle() + } + rule.mainClock.advanceTimeBy(1_000) + rule.waitForIdle() + } +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/LibraryProgressiveSurfaceTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/LibraryProgressiveSurfaceTest.kt new file mode 100644 index 00000000..976f9588 --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/LibraryProgressiveSurfaceTest.kt @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.tv + +import android.app.Application +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.pressKey +import androidx.compose.ui.input.key.Key +import com.github.damontecres.wholphin.data.model.JellyfinUser +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.takahirom.roborazzi.captureRoboImage +import io.mockk.every +import io.mockk.mockk +import java.io.File +import java.util.UUID +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.flow.flow +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.justinvisser.wolfskin.design.* +import org.justinvisser.wolfskin.tv.read.LibraryReadFacade +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** The real host exposes usable remote focus before the remaining library chunks arrive. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class LibraryProgressiveSurfaceTest { + @get:Rule val rule = createAndroidComposeRule<ComponentActivity>() + + @Test fun firstChunkPaintsAndAcceptsRemoteInputBeforeTheLastChunk() { + ConductarrPreference.initialize(rule.activity) + ConductarrPreference.updateFixtures(false) + val shell = ShellState(listOf(ShellPerson("justin", "Justin", "J", false)), "justin").apply { + pickerVisible = false + enterDestination("movies") + focusNav(currentTab) + } + val user = JellyfinUser(id = UUID(0, 1), name = "Justin", serverId = UUID(0, 2), accessToken = null) + val first = List(200) { LibraryTitle(UUID(0, it + 10L).toString(), "Film %03d".format(it), "movie") } + val release = CompletableDeferred<Unit>() + val facade = mockk<LibraryReadFacade>() + every { facade.audiences } returns null + every { facade.readLibrary(user.id, "movies") } returns flow { + emit(first) + release.await() + emit(first + LibraryTitle(UUID(0, 999).toString(), "Added film", "movie")) + } + val scope = "progressive:${UUID.randomUUID()}" + val navigation = NavigationManager() + val seeAll = WolfskinSeeAllLists() + try { + rule.setContent { WolfskinTheme { ShellSurface(shell) { modifier -> + WolfskinLibraryContent(rule.activity, "movies", shell, user, scope, facade, navigation, seeAll, modifier) + } } } + rule.waitUntil(timeoutMillis = 10_000) { + org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle() + shell.view.library?.source?.size == 200 + } + rule.mainClock.advanceTimeBy(500) + assertFalse(shell.navFocused) + rule.onRoot().performKeyInput { pressKey(Key.DirectionRight) } + assertEquals(first[1].id, shell.view.library!!.focusedTitle!!.id) + val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory"))).apply { mkdirs() } + rule.onRoot().captureRoboImage(File(directory, "library-first-chunk.png").absolutePath) + release.complete(Unit) + rule.waitUntil(timeoutMillis = 10_000) { + org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle() + shell.view.library?.source?.size == 201 + } + rule.runOnIdle { + assertEquals(first[1].id, shell.view.library!!.focusedTitle!!.id) + assertFalse(shell.navFocused) + } + } finally { release.complete(Unit) } + } +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/LibraryReadFacadeTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/LibraryReadFacadeTest.kt new file mode 100644 index 00000000..92afbe07 --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/LibraryReadFacadeTest.kt @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.tv + +import android.app.Application +import com.github.damontecres.wholphin.ui.successQueryResult +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import java.util.UUID +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.itemsApi +import org.jellyfin.sdk.api.client.extensions.userViewsApi +import org.jellyfin.sdk.api.operations.ItemsApi +import org.jellyfin.sdk.api.operations.UserViewsApi +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.api.ItemFields +import org.jellyfin.sdk.model.api.ItemSortBy +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import org.justinvisser.wolfskin.conductarr.MemoryCacheStore +import org.justinvisser.wolfskin.tv.read.LibraryReadFacade +import org.justinvisser.wolfskin.tv.read.LibrarySnapshot +import org.justinvisser.wolfskin.tv.read.LibrarySnapshotStore +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** Cold libraries publish usable chunks before later SDK reads finish; only complete copies reach disk. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class) +class LibraryReadFacadeTest { + @Test fun firstTwoHundredTitlesPublishWhileTheNextChunkIsSuspended() = runBlocking { + val api = mockk<ApiClient>() + val views = mockk<UserViewsApi>() + val items = mockk<ItemsApi>() + every { api.userViewsApi } returns views + every { api.itemsApi } returns items + val user = UUID(0, 1) + val library = BaseItemDto(id = UUID(1, 0), name = "Movies", type = BaseItemKind.COLLECTION_FOLDER, collectionType = CollectionType.MOVIES) + coEvery { views.getUserViews(userId = user) } returns successQueryResult(listOf(library)) + val firstChunk = List(200) { index -> BaseItemDto(id = UUID(0, index + 10L), name = "Film $index", type = BaseItemKind.MOVIE) } + val nextRead = CompletableDeferred<Unit>() + val release = CompletableDeferred<Unit>() + val offsets = mutableListOf<Int>() + coEvery { items.getItems(userId = user, parentId = library.id, recursive = true, + includeItemTypes = listOf(BaseItemKind.MOVIE), + fields = listOf(ItemFields.PROVIDER_IDS, ItemFields.SORT_NAME, ItemFields.DATE_CREATED), + sortBy = listOf(ItemSortBy.SORT_NAME), startIndex = capture(offsets), limit = 200) } coAnswers { + if (offsets.last() == 0) successQueryResult(firstChunk, 202) else { + assertEquals(200, offsets.last()) + nextRead.complete(Unit) + release.await() + successQueryResult(listOf(firstChunk.first().copy(name = "Updated film"), BaseItemDto(id = UUID(0, 999), name = "Last film", type = BaseItemKind.MOVIE)), 202) + } + } + val facade = LibraryReadFacade(api) + val store = LibrarySnapshotStore(MemoryCacheStore()) { 1000L } + val published = CompletableDeferred<LibrarySnapshot>() + val snapshots = mutableListOf<LibrarySnapshot>() + val job = launch { + store.read("server:justin", "movies") { + facade.readLibrary(user, "movies").catch { throw AssertionError("SDK fixture read failed", it) } + }.collect { + snapshots += it + if (!published.isCompleted) published.complete(it) + } + } + val initial = published.await() + assertEquals(firstChunk.map { it.id.toString() }, initial.items.map { it.id }) + nextRead.await() + assertTrue(job.isActive) + release.complete(Unit) + job.join() + val final = snapshots.last() + assertEquals(201, final.items.size) + assertEquals("Updated film", final.items.first().title) + assertEquals("Last film", final.items.last().title) + assertFalse(final.unreachable) + val saved = store.read("server:justin", "movies") { error("Complete fresh copy must not read") }.toList().single() + assertEquals(final.items, saved.items) + } +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/LibrarySnapshotStoreTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/LibrarySnapshotStoreTest.kt new file mode 100644 index 00000000..4e2917c3 --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/LibrarySnapshotStoreTest.kt @@ -0,0 +1,85 @@ +package org.justinvisser.wolfskin.tv + +import android.app.Application +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.runBlocking +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.justinvisser.wolfskin.conductarr.MemoryCacheStore +import org.justinvisser.wolfskin.design.LibraryTitle +import org.justinvisser.wolfskin.tv.read.LibrarySnapshotStore + +/** A stale library remains readable offline without crossing server/person boundaries. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class) +class LibrarySnapshotStoreTest { + @Test fun savedCopyArrivesBeforeFailedRefreshAndFreshCopySkipsNetwork() = runBlocking { + var now = 1000L + val store = LibrarySnapshotStore(MemoryCacheStore()) { now } + val items = listOf(LibraryTitle("a", "Film A", "movie", year = 2007, runtimeSeconds = 6000, played = true)) + store.read("server:justin", "movies") { flowOf(items) }.toList() + val fresh = store.read("server:justin", "movies") { error("Fresh snapshot must not read") }.toList() + assertEquals(items, fresh.single().items) + now += 11 * 60_000 + val offline = store.read("server:justin", "movies") { throw java.io.IOException("offline") }.toList() + assertEquals(2, offline.size) + assertFalse(offline.first().unreachable) + assertTrue(offline.last().unreachable) + assertEquals(1000L, offline.last().savedAtMillis) + assertEquals(items, offline.last().items) + for (scope in listOf("server:mindel", "other-server:justin")) { + val isolated = store.read(scope, "movies") { throw java.io.IOException("offline") }.toList().single() + assertNull(isolated.savedAtMillis) + assertTrue(isolated.items.isEmpty()) + } + } + + @Test fun explicitRecoveryRefreshesEvenFreshEmptyDestination() = runBlocking { + val store = LibrarySnapshotStore(MemoryCacheStore()) { 1000L } + store.read("server:justin", "movies") { flowOf(emptyList()) }.toList() + val current = listOf(LibraryTitle("b", "New Film", "movie")) + val reads = store.read("server:justin", "movies", force = true) { flowOf(current) }.toList() + assertTrue(reads.first().items.isEmpty()) + assertEquals(current, reads.last().items) + assertFalse(reads.last().unreachable) + } + + @Test fun acknowledgedPlaybackInvalidatesOnlyOlderWatchSnapshotsBeforeTheTtl() = runBlocking { + var now = 1000L + val store = LibrarySnapshotStore(MemoryCacheStore()) { now } + val old = listOf(LibraryTitle("film", "Film", "movie", progress = .1f)) + store.read("server:justin", "movies") { flowOf(old) }.toList() + store.read("server:mindel", "movies") { flowOf(old) }.toList() + now = 1002L + val updated = listOf(old.single().copy(progress = .6f)) + val reads = store.read("server:justin", "movies", invalidatedAtMillis = 1001L) { flowOf(updated) }.toList() + assertEquals(.1f, reads.first().items.single().progress) + assertEquals(.6f, reads.last().items.single().progress) + assertEquals(updated, store.read("server:justin", "movies", invalidatedAtMillis = 1001L) { error("Already revalidated") }.toList().single().items) + assertEquals(old, store.read("server:mindel", "movies") { error("Other viewer stays cached") }.toList().single().items) + } + + @Test fun failedLaterChunkKeepsTheCompleteSavedCopyOrTheColdPartialWithoutSavingIt() = runBlocking { + val partial = listOf(LibraryTitle("partial", "First film", "movie")) + val complete = partial + LibraryTitle("last", "Last film", "movie") + for (hasSaved in listOf(false, true)) { + val store = LibrarySnapshotStore(MemoryCacheStore()) { 1000L } + if (hasSaved) store.read("server:justin", "movies") { flowOf(complete) }.toList() + val reads = store.read("server:justin", "movies", force = true) { + kotlinx.coroutines.flow.flow { + emit(partial) + throw java.io.IOException("Later chunk failed") + } + }.toList() + assertEquals(if (hasSaved) complete else partial, reads.last().items) + assertTrue(reads.last().unreachable) + assertEquals(if (hasSaved) 1000L else null, reads.last().savedAtMillis) + val reopened = store.read("server:justin", "movies") { throw java.io.IOException("Offline") }.toList() + assertEquals(if (hasSaved) complete else emptyList<LibraryTitle>(), reopened.last().items) + } + } +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/NavBandOcclusionTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/NavBandOcclusionTest.kt new file mode 100644 index 00000000..5f2aa9e7 --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/NavBandOcclusionTest.kt @@ -0,0 +1,54 @@ +package org.justinvisser.wolfskin.tv + +import android.app.Application +import androidx.activity.ComponentActivity +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toPixelMap +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.justinvisser.wolfskin.design.* +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** Scrolled cards must be occluded across the entire persistent navigation band. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class NavBandOcclusionTest { + @get:Rule val rule = createAndroidComposeRule<ComponentActivity>() + + @Test fun scrolledRowsNeverShowThroughTheNavBand() { + val shell = ShellState(listOf(ShellPerson("shared", "Shared", "S", false)), "shared").apply { + pickerVisible = false; pickerLaunch = false; enterDestination("discover") + } + rule.setContent { WolfskinTheme { ShellSurface(shell) { modifier -> + LazyColumn(state = shell.view.listState, modifier = modifier.fillMaxSize()) { + items(20) { Box(Modifier.fillMaxSize().height(200.dp).background(Color.Red)) } + } + } } } + rule.runOnIdle { runBlocking { shell.view.listState.scrollToItem(2) } } + rule.waitForIdle() + val pixels = rule.onRoot().captureToImage().toPixelMap() + val background = WolfskinTokens.Color.bg + for (y in listOf(5, 50, 135)) { + val actual = pixels[20, y] + assertEquals("Nav red at y=$y", background.red, actual.red, 1f / 255f) + assertEquals("Nav green at y=$y", background.green, actual.green, 1f / 255f) + assertEquals("Nav blue at y=$y", background.blue, actual.blue, 1f / 255f) + } + } +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/PersonPickerSessionTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/PersonPickerSessionTest.kt new file mode 100644 index 00000000..8e13dca4 --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/PersonPickerSessionTest.kt @@ -0,0 +1,157 @@ +package org.justinvisser.wolfskin.tv + +import android.app.Application +import android.os.Looper +import androidx.activity.ComponentActivity +import androidx.compose.runtime.* +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.data.CurrentUser +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.* +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.SetupDestination +import com.github.damontecres.wholphin.services.SetupNavigationManager +import io.mockk.* +import java.util.UUID +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.justinvisser.wolfskin.conductarr.MemoryCacheStore +import org.justinvisser.wolfskin.design.* +import org.justinvisser.wolfskin.tv.read.LibraryReadFacade +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows +import org.robolectric.annotation.Config + +/** A selected identity gets a new app ViewModel without opening a second launch picker. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +class PersonPickerSessionTest { + @get:Rule val rule = createAndroidComposeRule<ComponentActivity>() + private lateinit var previousUrl: String + private var previousFixtures = false + private var previousScreens = true + private val models = mutableListOf<WolfskinShellViewModel>() + private lateinit var session: WolfskinSession + + @Before fun capturePreferences() { + ConductarrPreference.initialize(rule.activity) + WolfskinScreenPreference.initialize(rule.activity) + previousUrl = ConductarrPreference.baseUrl + previousFixtures = ConductarrPreference.fixtures + previousScreens = WolfskinScreenPreference.enabled + ConductarrPreference.updateFixtures(false) + WolfskinScreenPreference.enabled = true + } + + @After fun restorePreferences() { + models.forEach { it.viewModelScope.cancel() } + ConductarrPreference.updateBaseUrl(previousUrl) + ConductarrPreference.updateFixtures(previousFixtures) + WolfskinScreenPreference.enabled = previousScreens + } + + private fun select(initial: Int, selected: Int, fromAvatar: Boolean = false, fast: Boolean = false, moves: List<Key> = emptyList()) { + FakeConductarr().use { fake -> + ConductarrPreference.updateBaseUrl(fake.url) + ConductarrSession(MemoryCacheStore()).use { conductarr -> + val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null) + val users = listOf("Justin" to JUSTIN_TOKEN, "Mindel" to MINDEL_TOKEN, "Shared" to SHARED_TOKEN).map { (name, token) -> + JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = name, accessToken = token) + } + var current by mutableStateOf(CurrentUser(server, users[initial])) + val repository = mockk<ServerRepository>(relaxed = true) + coEvery { repository.serverDao.getServer(server.id) } returns JellyfinServerUsers(server, users) + coEvery { repository.changeUser(server, any()) } coAnswers { CurrentUser(server, secondArg()) } + val setup = mockk<SetupNavigationManager>(relaxed = true) + every { setup.navigateTo(any()) } answers { current = (firstArg() as SetupDestination.AppContent).current } + val libraries = mockk<LibraryReadFacade>(relaxed = true) + coEvery { libraries.readDestinations(any()) } returns emptyMap() + coEvery { libraries.hasAnimeTitles(any()) } returns false + session = WolfskinSession() + try { + rule.setContent { + key(current.user.id) { + val navigation = remember { NavigationManager() } + val model = remember { + WolfskinShellViewModel(rule.activity, session, repository, navigation, setup, mockk(relaxed = true), + libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true)).also { models.add(it) } + } + DisposableEffect(model) { onDispose { model.viewModelScope.cancel() } } + WolfskinApp(server, current.user, navigation, UserPreferences(AppPreferences.getDefaultInstance(), null), + viewModel = model, homeContent = { _, _, user, modifier -> + androidx.tv.material3.Text("Home for ${user.name}", modifier) + }) + } + } + awaitLoaded(users[initial]) + if (fromAvatar) { + press(listOf(Key.DirectionCenter)) + press(listOf(Key.DirectionUp, Key.DirectionLeft, Key.DirectionCenter)) + } + val actions = mutableListOf<ShellAction.Person>() + rule.runOnIdle { + val dispatch = session.onAction + session.onAction = { action -> + if (action is ShellAction.Person) actions.add(action) + dispatch(action) + } + } + if (fast) press(moves + Key.DirectionCenter) else { + press(moves) + press(listOf(Key.DirectionCenter)) + } + awaitLoaded(users[selected]) + rule.runOnIdle { + assertFalse("The replacement app must not reopen the picker after one OK", session.state!!.pickerVisible) + assertEquals("home", session.state!!.route) + assertEquals(users[selected].id.toString(), session.state!!.person.jellyfinUserId) + assertEquals(listOf(users[selected].id.toString()), actions.map { it.person.jellyfinUserId }) + assertEquals(if (initial == selected) 1 else 2, models.size) + } + rule.onNodeWithText("Home for ${users[selected].name}").assertIsDisplayed() + } finally { models.forEach { it.viewModelScope.cancel() } } + } + } + } + + private fun awaitLoaded(user: JellyfinUser) { + try { + rule.waitUntil(10_000) { + Shadows.shadowOf(Looper.getMainLooper()).idle() + models.lastOrNull()?.signedInPerson == user.name!!.lowercase() && + models.last().conductarr.identity.value?.signedIn == true && + models.last().conductarr.identity.value?.person?.endsWith(":${user.id}") == true && + session.state?.people?.size == 3 && + models.last().viewModelScope.coroutineContext[Job]!!.children.none { it.isActive } + } + } catch (timeout: ComposeTimeoutException) { + throw AssertionError("Expected ${user.name}; models=${models.size}, signedIn=${models.last().signedInPerson}, " + + "identity=${models.last().conductarr.identity.value?.person}, picker=${session.state?.pickerVisible}, " + + "person=${session.state?.person}, people=${session.state?.people?.size}", timeout) + } + rule.waitForIdle() + } + + private fun press(keys: List<Key>) { + rule.onNodeWithTag("wolfskin-shell").performKeyInput { keys.forEach { pressKey(it) } } + } + + @Test fun launchRightThenOneOkKeepsPickerClosed() = select(0, 1, moves = listOf(Key.DirectionRight)) + @Test fun launchLeftThenOneOkKeepsPickerClosed() = select(1, 0, moves = listOf(Key.DirectionLeft)) + @Test fun avatarRightThenOneOkKeepsPickerClosed() = select(0, 1, fromAvatar = true, moves = listOf(Key.DirectionRight)) + @Test fun avatarLeftThenOneOkKeepsPickerClosed() = select(1, 0, fromAvatar = true, moves = listOf(Key.DirectionLeft)) + @Test fun initialPersonNeedsOnlyOneOk() = select(0, 0) + @Test fun gearAndBackThenOneOkKeepsPickerClosed() = select(0, 2, moves = listOf(Key.DirectionRight, Key.DirectionRight, Key.DirectionDown, Key.DirectionUp)) + @Test fun fastLeftLeftOkKeepsPickerClosed() = select(2, 0, fast = true, moves = listOf(Key.DirectionLeft, Key.DirectionLeft)) +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/PersonSwitchThreadingTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/PersonSwitchThreadingTest.kt new file mode 100644 index 00000000..6a5936f8 --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/PersonSwitchThreadingTest.kt @@ -0,0 +1,253 @@ +package org.justinvisser.wolfskin.tv + +import android.app.Application +import android.os.Looper +import androidx.lifecycle.viewModelScope +import androidx.test.core.app.ApplicationProvider +import com.github.damontecres.wholphin.data.CurrentUser +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.* +import com.github.damontecres.wholphin.services.SetupDestination +import com.github.damontecres.wholphin.services.SetupNavigationManager +import io.mockk.* +import java.util.UUID +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.last +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.justinvisser.wolfskin.conductarr.* +import org.justinvisser.wolfskin.design.ShellKey +import org.justinvisser.wolfskin.tv.read.LibraryReadFacade +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** Picker changes invalidate identity immediately without closing sockets on the UI thread. */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class) +class PersonSwitchThreadingTest { + private val context = ApplicationProvider.getApplicationContext<Application>() + private lateinit var previousUrl: String + private var previousFixtures = false + + @Before fun setUp() { + Dispatchers.setMain(Dispatchers.Unconfined) + ConductarrPreference.initialize(context) + previousUrl = ConductarrPreference.baseUrl + previousFixtures = ConductarrPreference.fixtures + ConductarrPreference.updateFixtures(false) + } + + @After fun tearDown() { + ConductarrPreference.updateBaseUrl(previousUrl) + ConductarrPreference.updateFixtures(previousFixtures) + Dispatchers.resetMain() + unmockkConstructor(ConductarrClient::class) + } + + @Test fun pickerSwitchesKeepPersonDataAndDisposeEveryClientOffMainWithoutWaiting() = runBlocking { + assertSame(Looper.getMainLooper(), Looper.myLooper()) + val closeThreads = CopyOnWriteArrayList<Boolean>() + val releaseCloses = CountDownLatch(1) + val closed = CountDownLatch(4) + val disposalErrors = CopyOnWriteArrayList<Throwable>() + val disposalScope = CoroutineScope(SupervisorJob() + Dispatchers.IO + CoroutineExceptionHandler { _, error -> disposalErrors.add(error) }) + val failFirstClose = java.util.concurrent.atomic.AtomicBoolean(true) + mockkConstructor(ConductarrClient::class) + every { anyConstructed<ConductarrClient>().close() } answers { + val onMain = Looper.myLooper() == Looper.getMainLooper() + closeThreads.add(onMain) + check(!onMain) { "StrictMode: Conductarr socket close on main" } + check(releaseCloses.await(10, TimeUnit.SECONDS)) { "Picker waited for socket disposal" } + callOriginal() + closed.countDown() + if (failFirstClose.getAndSet(false)) throw java.io.IOException("Socket disposal fixture failure") + } + FakeConductarr().use { fake -> + ConductarrPreference.updateBaseUrl(fake.url) + val conductarr = ConductarrSession(MemoryCacheStore(), disposalScope) + val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null) + val justin = JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Justin", accessToken = JUSTIN_TOKEN) + val users = listOf(justin, justin.copy(id = UUID.randomUUID(), name = "Mindel", accessToken = MINDEL_TOKEN), + justin.copy(id = UUID.randomUUID(), name = "Shared", accessToken = SHARED_TOKEN)) + val repository = mockk<ServerRepository>(relaxed = true) + coEvery { repository.serverDao.getServer(server.id) } returns JellyfinServerUsers(server, users) + coEvery { repository.changeUser(server, any()) } coAnswers { CurrentUser(server, secondArg()) } + val setup = mockk<SetupNavigationManager>(relaxed = true) + val libraries = mockk<LibraryReadFacade>(relaxed = true) + coEvery { libraries.readDestinations(any()) } returns emptyMap() + val session = WolfskinSession() + val model = WolfskinShellViewModel(context, session, repository, mockk(relaxed = true), setup, mockk(relaxed = true), + libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true)) + val clients = mutableListOf<ConductarrClient>() + try { + model.load(server, justin) + requireNotNull(model.onForeground(server, justin)).join() + clients.add(conductarr.identity.value!!.client) + val shell = session.state!! + shell.key(ShellKey.Ok) + for (index in listOf(1, 2, 0)) { + val switched = CompletableDeferred<CurrentUser>() + every { setup.navigateTo(any()) } answers { switched.complete((firstArg() as SetupDestination.AppContent).current) } + shell.focusNav(shell.tabs.size) + shell.key(ShellKey.Ok) + shell.focusPerson(index) + shell.key(ShellKey.Ok) + assertEquals(users[index].id.toString(), shell.person.jellyfinUserId) + assertEquals("home", shell.route) + assertFalse(shell.pickerVisible) + assertNull("Old identity must not serve the newly selected person", conductarr.identity.value) + val current = withTimeout(5_000) { switched.await() } + assertEquals(users[index].id, current.user.id) + model.load(current.server, current.user) + requireNotNull(model.onForeground(server, current.user)).join() + assertEquals(users[index].name!!.lowercase(), conductarr.identity.value!!.operator!!.requester) + assertEquals(users[index].name!!.lowercase(), model.signedInPerson) + clients.add(conductarr.identity.value!!.client) + } + conductarr.close() + conductarr.close() + model.viewModelScope.cancel() + releaseCloses.countDown() + assertTrue("Every retired client must finish disposal", withContext(Dispatchers.IO) { closed.await(5, TimeUnit.SECONDS) }) + assertEquals(listOf(false, false, false, false), closeThreads.toList()) + for (client in clients) { + val result = client.read(TvRoutes.operator(), ReadTrigger.FORCE).last() + assertEquals("Retired clients cannot start another network read", ClientError.Unreachable, (result as ReadResult.Failure).error) + } + disposalScope.coroutineContext.job.children.toList().joinAll() + assertTrue("A failing socket close must be contained", disposalErrors.isEmpty()) + } finally { + releaseCloses.countDown() + model.viewModelScope.cancel() + withContext(Dispatchers.IO) { conductarr.close() } + disposalScope.coroutineContext.job.children.toList().joinAll() + disposalScope.cancel() + } + } + } + + @Test fun rapidPickerChoicesIgnoreLateSignInAndCanReturnToTheOriginalPerson() = runBlocking { + FakeConductarr().use { fake -> + ConductarrPreference.updateBaseUrl(fake.url) + ConductarrSession(MemoryCacheStore()).use { conductarr -> + val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null) + val justin = JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Justin", accessToken = JUSTIN_TOKEN) + val users = listOf(justin, justin.copy(id = UUID.randomUUID(), name = "Mindel", accessToken = MINDEL_TOKEN), + justin.copy(id = UUID.randomUUID(), name = "Shared", accessToken = SHARED_TOKEN)) + val repository = mockk<ServerRepository>(relaxed = true) + coEvery { repository.serverDao.getServer(server.id) } returns JellyfinServerUsers(server, users) + val pending = CompletableDeferred<Unit>() + val release = CompletableDeferred<Unit>() + coEvery { repository.changeUser(server, any()) } coAnswers { + val user = secondArg<JellyfinUser>() + if (user.id == users[1].id) { + pending.complete(Unit) + withContext(NonCancellable) { release.await() } + } + CurrentUser(server, user) + } + val switched = CompletableDeferred<CurrentUser>() + val setup = mockk<SetupNavigationManager>(relaxed = true) + every { setup.navigateTo(any()) } answers { switched.complete((firstArg() as SetupDestination.AppContent).current) } + val libraries = mockk<LibraryReadFacade>(relaxed = true) + coEvery { libraries.readDestinations(any()) } returns emptyMap() + val session = WolfskinSession() + val model = WolfskinShellViewModel(context, session, repository, mockk(relaxed = true), setup, mockk(relaxed = true), + libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true)) + try { + model.load(server, justin) + requireNotNull(model.onForeground(server, justin)).join() + val shell = session.state!! + shell.key(ShellKey.Ok) + for (index in listOf(1, 2, 0)) { + shell.focusNav(shell.tabs.size) + shell.key(ShellKey.Ok) + shell.focusPerson(index) + shell.key(ShellKey.Ok) + if (index == 1) withTimeout(5_000) { pending.await() } + } + release.complete(Unit) + val current = withTimeout(5_000) { switched.await() } + assertEquals(justin.id, current.user.id) + verify(exactly = 1) { setup.navigateTo(any()) } + model.load(server, current.user) + requireNotNull(model.onForeground(server, current.user)).join() + assertEquals(justin.id.toString(), shell.person.jellyfinUserId) + assertEquals("justin", model.signedInPerson) + assertEquals("justin", conductarr.identity.value!!.operator!!.requester) + } finally { + release.complete(Unit) + model.viewModelScope.cancel() + } + } + } + } + + @Test fun homeAndTitleRetriesReplaceSignedOutClientsWithoutMainThreadDisposal() = runBlocking { + val closeThreads = CopyOnWriteArrayList<Boolean>() + val closed = CountDownLatch(3) + val disposalScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + mockkConstructor(ConductarrClient::class) + every { anyConstructed<ConductarrClient>().close() } answers { + val onMain = Looper.myLooper() == Looper.getMainLooper() + closeThreads.add(onMain) + check(!onMain) { "StrictMode: retry socket close on main" } + callOriginal() + closed.countDown() + } + FakeConductarr().use { fake -> + ConductarrPreference.updateBaseUrl(fake.url) + val conductarr = ConductarrSession(MemoryCacheStore(), disposalScope) + val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null) + val user = JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Justin", accessToken = JUSTIN_TOKEN) + val repository = mockk<ServerRepository>() + every { repository.currentUser } returns user + every { repository.currentServer } returns server + val home = mockk<org.justinvisser.wolfskin.tv.read.HomeReadFacade>() + coEvery { home.readWatch(user.id) } returns org.justinvisser.wolfskin.design.HomeLibrary(resume = listOf( + org.justinvisser.wolfskin.design.HomeTitle("justin-film", "Justin film", "movie", held = true))) + coEvery { home.readLibrary(user.id) } returns org.justinvisser.wolfskin.design.HomeLibrary() + val cache = mockk<org.justinvisser.wolfskin.tv.read.HomeLibraryCache>(relaxed = true) + every { cache.read(any()) } returns null + val homeModel = WolfskinHomeViewModel(context, home, cache, mockk(relaxed = true), WolfskinSeeAllLists(), conductarr) + val titleModel = WolfskinTitleViewModel(context, mockk(), mockk(), mockk(relaxed = true), conductarr, repository) + try { + conductarr.select("${server.id}:${user.id}", user.accessToken, fake.url) + conductarr.authenticate() + fake.status = 401 + conductarr.authenticate() + assertFalse(conductarr.identity.value!!.signedIn) + fake.status = 200 + homeModel.load(server, user, org.justinvisser.wolfskin.design.ShellPerson("justin", "Justin", "J", false, user.id.toString()), retry = true) + withTimeout(5_000) { conductarr.identity.first { it?.signedIn == true } } + assertEquals("justin", conductarr.identity.value!!.operator!!.requester) + assertTrue(homeModel.snapshot.rows.any { row -> row.cards.any { it.id == "justin-film" } }) + fake.status = 401 + conductarr.authenticate() + assertFalse(conductarr.identity.value!!.signedIn) + fake.status = 200 + titleModel.retrySignIn() + assertEquals("justin", conductarr.identity.value!!.operator!!.requester) + assertTrue(conductarr.identity.value!!.signedIn) + } finally { + homeModel.viewModelScope.cancel() + titleModel.viewModelScope.cancel() + conductarr.close() + disposalScope.coroutineContext.job.children.toList().joinAll() + disposalScope.cancel() + } + assertEquals(0L, closed.count) + assertEquals(listOf(false, false, false), closeThreads.toList()) + } + } +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/PlaybackUserData.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/PlaybackUserData.kt new file mode 100644 index 00000000..b8e86ca0 --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/PlaybackUserData.kt @@ -0,0 +1,9 @@ +package org.justinvisser.wolfskin.tv + +import org.jellyfin.sdk.model.api.UserItemDataDto +import java.util.UUID + +/** Checkpoints use the SDK's required identity and watch fields. */ +internal fun playbackUserData(itemId: UUID, ticks: Long, played: Boolean = false, playCount: Int = 0): UserItemDataDto = + UserItemDataDto(playbackPositionTicks = ticks, playCount = playCount, isFavorite = false, + lastPlayedDate = null, played = played, key = "", itemId = itemId) diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/PlayerHandoffTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/PlayerHandoffTest.kt new file mode 100644 index 00000000..dca356a7 --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/PlayerHandoffTest.kt @@ -0,0 +1,116 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.tv + +import android.app.Application +import android.os.Looper +import android.view.View +import android.view.WindowManager +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import com.github.damontecres.wholphin.data.model.JellyfinServer +import com.github.damontecres.wholphin.data.model.JellyfinUser +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.takahirom.roborazzi.captureRoboImage +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import java.io.File +import java.io.IOException +import java.util.UUID +import org.junit.Assert.* +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Shadows.shadowOf +import org.justinvisser.wolfskin.design.* +import org.justinvisser.wolfskin.tv.read.HomeReadFacade +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** Actual Home actions either open upstream playback or leave focus and the saved Home intact. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class PlayerHandoffTest { + @get:Rule val rule = createAndroidComposeRule<ComponentActivity>() + private val server = JellyfinServer(UUID.fromString("00000000-0000-0000-0000-000000000001"), "Fixture", "https://example.invalid", null) + private val user = JellyfinUser(id = UUID.fromString("00000000-0000-0000-0000-000000000002"), name = "Mindel", serverId = server.id, accessToken = null) + private val navigation = NavigationManager() + private val facade = mockk<HomeReadFacade>() + private lateinit var model: WolfskinHomeViewModel + private lateinit var shell: ShellState + + @Before fun setup() { + rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN) + @Suppress("DEPRECATION") + rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY + shell = ShellState(listOf(ShellPerson("mindel", "Mindel", "M", false)), "mindel").apply { pickerVisible = false } + model = WolfskinHomeViewModel(rule.activity, facade, mockk(), navigation, WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore())) + } + + @Test fun failedPlaybackReadLeavesSavedHomeFocusedAndDrawsTheExactOfflineToast() { + val snapshot = HomeFixture(rule.activity).snapshot("mindel", "offline") + val title = snapshot.rows.first().cards.first() + coEvery { facade.preparePlayback(user.id, title) } throws IOException("fixture offline") + rule.setContent { WolfskinTheme { + ShellSurface(shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) { modifier -> + HomeScreen(shell, snapshot, modifier) { model.act(it, shell, server, user) } + model.toast?.let { RecoveryToast(it) } + } + } } + rule.mainClock.advanceTimeBy(1000) + rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionCenter) } + awaitModel { model.toast != null } + rule.onNodeWithText(RecoveryMessage.PlaybackUnavailable).assertIsDisplayed() + assertTrue(navigation.backStack.single() is Destination.Home) + assertFalse(shell.navFocused) + assertEquals("hero", snapshot.rows[shell.view.row].id) + assertEquals(0, shell.view.cards["hero"] ?: 0) + val directory = File(System.getProperty("wolfskin.captureDirectory"), "recovery").apply { mkdirs() } + rule.onRoot().captureRoboImage(File(directory, "st-090-integrated.png").absolutePath) + } + + @Test fun successfulPlaybackUsesTheRealFullscreenDestinationAndBackKeepsTheResumeCard() { + val title = HomeFixture(rule.activity).snapshot("mindel").rows.first().cards.first() + val id = UUID.fromString("00000000-0000-0000-0000-000000000003") + coEvery { facade.preparePlayback(user.id, title) } returns org.jellyfin.sdk.model.api.BaseItemDto( + id = id, type = org.jellyfin.sdk.model.api.BaseItemKind.EPISODE, + userData = playbackUserData(id, 11_362_740_000L, playCount = 2)) + val view = shell.view + view.cards["hero"] = 0 + rule.runOnIdle { model.act(HomeAction.Play(title), shell, server, user) } + awaitModel { navigation.backStack.last() is Destination.Playback } + val destination = navigation.backStack.last() as Destination.Playback + assertEquals(id, destination.itemId) + assertEquals(1_136_274L, destination.positionMs) + assertTrue(destination.fullScreen) + navigation.goBack() + assertTrue(navigation.backStack.single() is Destination.Home) + assertSame(view, shell.view) + assertEquals(0, shell.view.cards["hero"]) + assertNull(model.toast) + } + + @Test fun anotherPersonsSessionCannotResolveOrStartPlayback() { + val title = HomeFixture(rule.activity).snapshot("mindel").rows.first().cards.first() + val otherUser = user.copy(name = "Justin") + rule.runOnIdle { model.act(HomeAction.Play(title), shell, server, otherUser) } + awaitModel { model.toast != null } + assertEquals(RecoveryMessage.PlaybackUnavailable, model.toast) + assertTrue(navigation.backStack.single() is Destination.Home) + coVerify(exactly = 0) { facade.preparePlayback(any(), any()) } + } + private fun awaitModel(condition: () -> Boolean) { + rule.waitUntil(5000) { + shadowOf(Looper.getMainLooper()).idle() + condition() + } + } +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/RequestsRefreshTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/RequestsRefreshTest.kt new file mode 100644 index 00000000..a90bb4b1 --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/RequestsRefreshTest.kt @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.tv + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test +import org.justinvisser.wolfskin.design.ComingInRequest +import org.justinvisser.wolfskin.design.ComingInSnapshot +import org.justinvisser.wolfskin.design.ComingInStage +import org.justinvisser.wolfskin.design.ComingInStatus +import org.justinvisser.wolfskin.design.ShellPerson +import org.justinvisser.wolfskin.design.ShellState + +/** The fake clock checks refresh boundaries and cancellation without a server or a real wait. */ +@OptIn(ExperimentalCoroutinesApi::class) +class RequestsRefreshTest { + @Test fun foregroundOpenUnsettledSettledAndStoppedTransitions() = runTest { + val state = ShellState(listOf(ShellPerson("justin", "Justin", "J", false)), "justin") + val request = ComingInRequest("one", "movie", 1, "One", "justin", "Justin", "shared", "Shared", ComingInStage.Waiting) + state.requests.update(ComingInSnapshot(status = ComingInStatus.Ready, signedIn = true)) + var reads = 0 + val active = launch { refreshRequestsWhileStarted(state) { reads++ } } + runCurrent() + assertEquals(1, reads) + advanceTimeBy(30_000); runCurrent() + assertEquals("Closed and settled does not poll", 1, reads) + state.openRequests() + advanceTimeBy(29_999); runCurrent() + assertEquals(1, reads) + advanceTimeBy(1); runCurrent() + assertEquals("An open empty sheet still refreshes at 30 seconds", 2, reads) + state.closeRequests() + state.requests.update(ComingInSnapshot(listOf(request), ComingInStatus.Ready, signedIn = true)) + advanceTimeBy(30_000); runCurrent() + assertEquals("An unsettled request refreshes after the sheet closes", 3, reads) + state.requests.update(ComingInSnapshot(listOf(request.copy(stage = ComingInStage.Ready)), ComingInStatus.Ready, signedIn = true)) + advanceTimeBy(30_000); runCurrent() + assertEquals("Ready requests stop polling", 3, reads) + active.cancel(); runCurrent() + state.openRequests() + advanceTimeBy(60_000); runCurrent() + assertEquals("A stopped lifecycle cannot poll", 3, reads) + val resumed = launch { refreshRequestsWhileStarted(state) { reads++ } } + runCurrent() + assertEquals("Returning to the foreground refreshes immediately", 4, reads) + resumed.cancel() + } +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/SettingsHandOffCaptureTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/SettingsHandOffCaptureTest.kt new file mode 100644 index 00000000..dd5ee44c --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/SettingsHandOffCaptureTest.kt @@ -0,0 +1,76 @@ +package org.justinvisser.wolfskin.tv + +import android.app.Application +import android.view.View +import android.view.WindowManager +import androidx.activity.ComponentActivity +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onRoot +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.services.ImageUrlService +import com.github.damontecres.wholphin.services.SeerrRatingsService +import com.github.damontecres.wholphin.ui.LocalImageUrlService +import com.github.damontecres.wholphin.ui.components.LocalSeerrRatingsService +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.ui.main.settings.HomePageSettingsState +import com.github.damontecres.wholphin.ui.main.settings.HomeSettingsPage +import com.github.damontecres.wholphin.ui.main.settings.HomeSettingsViewModel +import com.github.damontecres.wholphin.util.LoadingState +import com.github.takahirom.roborazzi.captureRoboImage +import io.mockk.every +import io.mockk.mockk +import java.io.File +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.justinvisser.wolfskin.design.WolfskinTheme +import org.justinvisser.wolfskin.design.WolfskinTokens +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** The frozen st-094 state opens User profile settings, not the separate Playback press path. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class SettingsHandOffCaptureTest { + @get:Rule val rule = createAndroidComposeRule<ComponentActivity>() + + @Before fun fullscreen() { + rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN) + @Suppress("DEPRECATION") + rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY + } + + @Test fun st094_realUserProfileHomeSettingsHandOff() { + val model = mockk<HomeSettingsViewModel>(relaxed = true) + every { model.state } returns MutableStateFlow(HomePageSettingsState.EMPTY.copy(loading = LoadingState.Success)) + every { model.serverPluginActive } returns MutableStateFlow(false) + every { model.settingsChanged } returns MutableStateFlow(false) + val preferences = UserPreferences(AppPreferences.getDefaultInstance(), null) + val ratings = SeerrRatingsService(mockk(relaxed = true), mockk(relaxed = true)) + val images = mockk<ImageUrlService>(relaxed = true) + rule.setContent { + WolfskinTheme { + CompositionLocalProvider(LocalSeerrRatingsService provides ratings, LocalImageUrlService provides images) { + HomeSettingsPage(preferences, Modifier.fillMaxSize().background(WolfskinTokens.Color.bg), viewModel = model) + } + } + } + rule.mainClock.advanceTimeBy(1000) + rule.waitForIdle() + rule.onNodeWithText(rule.activity.getString(R.string.customize_home)).assertIsDisplayed() + val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory"))) + directory.mkdirs() + rule.onRoot().captureRoboImage(File(directory, "st-094.png").absolutePath) + } +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/ShellRoutingTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/ShellRoutingTest.kt new file mode 100644 index 00000000..a5b181f7 --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/ShellRoutingTest.kt @@ -0,0 +1,70 @@ +package org.justinvisser.wolfskin.tv + +import android.app.Application +import androidx.test.core.app.ApplicationProvider +import com.github.damontecres.wholphin.preferences.AppPreference +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption +import java.util.UUID +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.CollectionType +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** The actual Advanced preference persists its value and changes the shell's renderer policy. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class) +class ShellRoutingTest { + private val context: Application get() = ApplicationProvider.getApplicationContext() + private val movies = Destination.MediaItem(UUID.fromString("00000000-0000-0000-0000-000000000001"), BaseItemKind.COLLECTION_FOLDER, CollectionType.MOVIES) + private val libraries = mapOf("movies" to movies, "shows" to movies, "anime" to movies) + + @Before fun initialize() { WolfskinScreenPreference.initialize(context); WolfskinScreenPreference.enabled = true } + @After fun restore() { WolfskinScreenPreference.enabled = true } + + @Test fun advancedSwitchRestoresUpstreamHomeAndLibrariesAndPersistsAcrossReload() { + val preference = AppPreference.WolfskinScreens + val preferences = AppPreferences.getDefaultInstance() + assertNull(resolveUpstreamDestination(Destination.Home(), true, libraries)) + assertNull(resolveUpstreamDestination(Destination.Wolfskin("movies"), true, libraries)) + preference.setter(preferences, false) + WolfskinScreenPreference.initialize(context) + assertFalse(preference.getter(preferences)) + assertTrue(resolveUpstreamDestination(Destination.Home(), WolfskinScreenPreference.enabled, libraries) is Destination.Home) + listOf("movies", "shows", "anime").forEach { route -> + assertEquals(movies, resolveUpstreamDestination(Destination.Wolfskin(route), WolfskinScreenPreference.enabled, libraries)) + } + preference.setter(preferences, true) + assertNull(resolveUpstreamDestination(Destination.Home(), WolfskinScreenPreference.enabled, libraries)) + assertNull(resolveUpstreamDestination(Destination.Wolfskin("movies"), WolfskinScreenPreference.enabled, libraries)) + } + + @Test fun settingsAndPickerNeverBecomeUpstreamScreensButUnrelatedRoutesPassThrough() { + val settings = Destination.Wolfskin("settings") + val upstream = Destination.Settings(PreferenceScreenOption.ADVANCED) + for (enabled in listOf(false, true)) { + assertNull(resolveUpstreamDestination(settings, enabled, libraries)) + assertEquals(upstream, resolveUpstreamDestination(upstream, enabled, libraries)) + assertEquals(Destination.Search("name"), resolveUpstreamDestination(Destination.Search("name"), enabled, libraries)) + } + } + + @Test fun titleRolloutOwnsOnlyFilmSeriesEpisodeAndPerson() { + val id = UUID.fromString("00000000-0000-0000-0000-000000000001") + for (kind in listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES, BaseItemKind.EPISODE, BaseItemKind.PERSON)) { + val destination = Destination.MediaItem(id, kind) + assertNull(resolveUpstreamDestination(destination, true, libraries)) + assertEquals(destination, resolveUpstreamDestination(destination, false, libraries)) + } + for (kind in listOf(BaseItemKind.COLLECTION_FOLDER, BaseItemKind.AUDIO, BaseItemKind.MUSIC_ALBUM, BaseItemKind.BOX_SET)) { + val destination = Destination.MediaItem(id, kind) + assertEquals(destination, resolveUpstreamDestination(destination, true, libraries)) + } + } +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/ShellStartupTimingTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/ShellStartupTimingTest.kt new file mode 100644 index 00000000..dbcffdb4 --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/ShellStartupTimingTest.kt @@ -0,0 +1,164 @@ +package org.justinvisser.wolfskin.tv + +import android.app.Application +import androidx.lifecycle.viewModelScope +import androidx.test.core.app.ApplicationProvider +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.JellyfinServer +import com.github.damontecres.wholphin.data.model.JellyfinUser +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.ui.nav.Destination +import io.mockk.coEvery +import io.mockk.mockk +import io.mockk.verify +import java.util.UUID +import kotlinx.coroutines.* +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.flow.last +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.justinvisser.wolfskin.conductarr.MemoryCacheStore +import org.justinvisser.wolfskin.design.* +import org.justinvisser.wolfskin.tv.read.LibraryReadFacade +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** Saved-login I/O cannot leave an interactive but disconnected launch shell. */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class) +class ShellStartupTimingTest { + @Before fun setMain() { Dispatchers.setMain(Dispatchers.Unconfined) } + @After fun resetMain() { Dispatchers.resetMain() } + + @Test fun firstFramePickerAndEveryTabWorkBeforeSavedPeopleArrive() = runBlocking { + val context = ApplicationProvider.getApplicationContext<Application>() + ConductarrPreference.initialize(context) + ConductarrPreference.updateFixtures(false) + val previous = ConductarrPreference.baseUrl + FakeConductarr().use { fake -> + ConductarrPreference.updateBaseUrl(fake.url) + ConductarrSession(MemoryCacheStore()).use { conductarr -> + val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null) + val user = JellyfinUser(id = UUID.randomUUID(), name = "Shared", serverId = server.id, accessToken = SHARED_TOKEN) + conductarr.select("${server.id}:${user.id}", SHARED_TOKEN, fake.url) + conductarr.authenticate() + val activityEntered = CompletableDeferred<Unit>() + val releaseActivity = java.util.concurrent.CountDownLatch(1) + fake.beforeResponse = { + if (it == "/api/v1/activity") { + activityEntered.complete(Unit) + releaseActivity.await() + } + } + val activity = async(Dispatchers.IO) { conductarr.identity.value!!.provider.requests().last() } + withTimeout(5_000) { activityEntered.await() } + val savedPeople = CompletableDeferred<Unit>() + val savedPeopleStarted = CompletableDeferred<Unit>() + val repository = mockk<ServerRepository>(relaxed = true) + coEvery { repository.serverDao.getServer(server.id) } coAnswers { savedPeopleStarted.complete(Unit); savedPeople.await(); null } + val navigation = mockk<NavigationManager>(relaxed = true) + val libraries = mockk<LibraryReadFacade>(relaxed = true) + coEvery { libraries.readDestinations(user.id) } returns emptyMap() + val session = WolfskinSession() + val model = WolfskinShellViewModel(context, session, repository, navigation, mockk(), mockk(relaxed = true), + libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true)) + val load = launch { model.load(server, user) } + try { + withTimeout(5_000) { savedPeopleStarted.await() } + val shell = requireNotNull(session.state) { "The current Jellyfin person must paint before saved-login I/O" } + assertEquals(PeopleStatus.Ready, shell.peopleStatus) + assertEquals(user.id.toString(), shell.person.jellyfinUserId) + shell.key(ShellKey.Ok) + for (route in listOf("movies", "shows", "discover")) { + shell.focusNav(shell.tabs.indexOf(route)) + shell.key(ShellKey.Ok) + verify { navigation.replace(Destination.Wolfskin(route)) } + assertEquals(route, shell.route) + } + savedPeople.complete(Unit) + load.join() + assertSame(shell, session.state) + assertEquals("discover", shell.route) + assertFalse("Activity must still be pending while the picker and tabs are usable", activity.isCompleted) + } finally { + releaseActivity.countDown() + activity.cancelAndJoin() + savedPeople.complete(Unit) + load.cancelAndJoin() + model.viewModelScope.cancel() + } + } + } + ConductarrPreference.updateBaseUrl(previous) + } + + @Test fun switchingPersonDuringHouseholdReadKeepsTheNewIdentityAndReturnsHome() = runBlocking { + val context = ApplicationProvider.getApplicationContext<Application>() + ConductarrPreference.initialize(context) + ConductarrPreference.updateFixtures(false) + val previous = ConductarrPreference.baseUrl + FakeConductarr().use { fake -> + ConductarrPreference.updateBaseUrl(fake.url) + ConductarrSession(MemoryCacheStore()).use { conductarr -> + val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null) + val shared = JellyfinUser(id = UUID.randomUUID(), name = "Shared", serverId = server.id, accessToken = SHARED_TOKEN) + val mindel = shared.copy(id = UUID.randomUUID(), name = "Mindel", accessToken = MINDEL_TOKEN) + val repository = mockk<ServerRepository>(relaxed = true) + coEvery { repository.serverDao.getServer(server.id) } returns + com.github.damontecres.wholphin.data.model.JellyfinServerUsers(server, listOf(shared, mindel)) + val switched = CompletableDeferred<Unit>() + coEvery { repository.changeUser(server, mindel) } coAnswers { + switched.complete(Unit) + com.github.damontecres.wholphin.data.CurrentUser(server, mindel) + } + val navigation = mockk<NavigationManager>(relaxed = true) + val libraries = mockk<LibraryReadFacade>(relaxed = true) + coEvery { libraries.readDestinations(any()) } returns emptyMap() + val session = WolfskinSession() + val model = WolfskinShellViewModel(context, session, repository, navigation, mockk(relaxed = true), mockk(relaxed = true), + libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true)) + val pending = CompletableDeferred<Unit>() + val release = java.util.concurrent.CountDownLatch(1) + val firstAudience = java.util.concurrent.atomic.AtomicBoolean(true) + fake.beforeResponse = { + if (it == "/curation/library-audiences" && firstAudience.getAndSet(false)) { + pending.complete(Unit) + release.await() + } + } + try { + model.load(server, shared) + withTimeout(5_000) { pending.await() } + val shell = requireNotNull(session.state) + shell.key(ShellKey.Ok) + shell.enterDestination("discover") + shell.focusNav(shell.tabs.size) + shell.key(ShellKey.Ok) + shell.focusPerson(1) + shell.key(ShellKey.Ok) + assertEquals("home", shell.route) + verify { navigation.replace(Destination.Home()) } + withTimeout(5_000) { switched.await() } + model.load(server, mindel) + requireNotNull(model.onForeground(server, mindel)).join() + release.countDown() + assertSame(shell, session.state) + assertEquals("mindel", model.signedInPerson) + assertEquals(mindel.id.toString(), shell.person.jellyfinUserId) + assertEquals("Mindel", shell.person.name) + assertEquals("mindel", conductarr.identity.value!!.operator!!.requester) + assertEquals("home", shell.route) + } finally { + release.countDown() + model.viewModelScope.cancel() + } + } + } + ConductarrPreference.updateBaseUrl(previous) + } +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleAsyncFocusTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleAsyncFocusTest.kt new file mode 100644 index 00000000..9809967f --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleAsyncFocusTest.kt @@ -0,0 +1,170 @@ +package org.justinvisser.wolfskin.tv + +import android.app.Application +import android.os.Looper +import androidx.activity.ComponentActivity +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.pressKey +import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.services.NavigationManager +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.cancel +import org.junit.After +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.justinvisser.wolfskin.design.ShellPerson +import org.justinvisser.wolfskin.design.RecoveryMessage +import org.justinvisser.wolfskin.design.TitleConnection +import org.justinvisser.wolfskin.design.ShellState +import org.justinvisser.wolfskin.design.TitleItem +import org.justinvisser.wolfskin.design.TitlePageData +import org.justinvisser.wolfskin.design.TitleReadStatus +import org.justinvisser.wolfskin.design.TitleSheet +import org.justinvisser.wolfskin.design.TitleTrack +import org.justinvisser.wolfskin.design.TitleVersion +import org.justinvisser.wolfskin.design.WolfskinTheme +import org.justinvisser.wolfskin.tv.read.TitleReadFacade +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows +import org.robolectric.annotation.Config +import java.util.UUID + +/** A delayed service reply exercises the production destination, view-model and Android focus transfer. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +class TitleAsyncFocusTest { + @get:Rule val rule = createAndroidComposeRule<ComponentActivity>() + private lateinit var viewModel: WolfskinTitleViewModel + + @After fun dispose() { + if (::viewModel.isInitialized) viewModel.viewModelScope.cancel() + } + + @Test fun loadingToReadyRestoresRealKeyFocusAndSheetReturn() { + val id = UUID.fromString("00000000-0000-0000-0000-000000000012") + val ready = CompletableDeferred<TitlePageData>() + val facade = mockk<TitleReadFacade>() + coEvery { facade.readSaved(id, any()) } returns null + coEvery { facade.read(id, any(), any()) } coAnswers { ready.await() } + viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk<TitleFixtureProvider>(), mockk<NavigationManager>(relaxed = true), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), mockk<com.github.damontecres.wholphin.data.ServerRepository>(relaxed = true)) + val person = ShellPerson("justin", "Justin", "J", false) + val route = titleRoute(id.toString()) + val shell = ShellState(listOf(person), person.key).apply { + pickerVisible = false + pickerLaunch = false + enterDestination(route) + } + val preferences = UserPreferences(AppPreferences.getDefaultInstance(), null) + rule.setContent { WolfskinTheme { TitleDestination(route, shell, preferences, viewModel = viewModel) } } + rule.waitUntil { viewModel.title?.data?.status == TitleReadStatus.Loading } + rule.waitForIdle() + assertTrue(shell.navFocused) + press(Key.DirectionDown) + assertTrue("The initial skeleton cannot receive nav Down", shell.navFocused) + + val source = TitleVersion("first", "Original", "1080p", "", "", "MKV", listOf(TitleTrack(3, "English", true)), emptyList()) + rule.runOnIdle { + ready.complete(TitlePageData(TitleItem(id.toString(), "A held film", "movie", held = true), versions = listOf(source, source.copy(id = "second", name = "Alternate")))) + } + rule.waitUntil { viewModel.title?.data?.status == TitleReadStatus.Ready && !shell.navFocused } + press(Key.DirectionRight) + val state = requireNotNull(viewModel.title) + assertEquals("version", state.options[state.column].id) + press(Key.DirectionCenter) + assertEquals(TitleSheet.Version, state.sheet) + rule.runOnIdle { rule.activity.onBackPressedDispatcher.onBackPressed() } + assertNull(state.sheet) + press(Key.DirectionRight) + assertEquals("tracks", state.options[state.column].id) + assertFalse(shell.navFocused) + } + + @Test fun loadingPersonFocusesItsFirstCreditBeforeRealRemoteNavigation() { + val id = UUID.fromString("00000000-0000-0000-0000-000000000013") + val reply = CompletableDeferred<org.justinvisser.wolfskin.tv.read.PersonRead>() + val facade = mockk<TitleReadFacade>() + coEvery { facade.readPersonSaved(id) } returns null + coEvery { facade.readPerson(id, 0) } coAnswers { reply.await() } + viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk<TitleFixtureProvider>(), mockk<NavigationManager>(relaxed = true), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), mockk<com.github.damontecres.wholphin.data.ServerRepository>(relaxed = true)) + val viewer = ShellPerson("justin", "Justin", "J", false) + val route = personRoute(id.toString()) + val shell = ShellState(listOf(viewer), viewer.key).apply { + pickerVisible = false + pickerLaunch = false + enterDestination(route) + } + rule.setContent { WolfskinTheme { + TitleDestination(route, shell, UserPreferences(AppPreferences.getDefaultInstance(), null), viewModel = viewModel) + } } + rule.waitUntil { viewModel.title?.data?.status == TitleReadStatus.Loading } + rule.waitForIdle() + assertTrue(shell.navFocused) + press(Key.DirectionDown) + assertTrue(shell.navFocused) + rule.runOnIdle { + reply.complete(org.justinvisser.wolfskin.tv.read.PersonRead( + org.justinvisser.wolfskin.design.TitlePerson(id.toString(), "Actor"), + listOf(TitleItem("first-credit", "First credit", "movie"), TitleItem("second-credit", "Second credit", "movie")), + )) + } + rule.waitUntil(timeoutMillis = 5000) { viewModel.person != null || viewModel.title?.data?.status == TitleReadStatus.Unavailable } + assertNotNull("A real person reply must populate the performer destination", viewModel.person) + rule.waitForIdle() + assertFalse("The loaded person must transfer nav ownership to its first credit", shell.navFocused) + assertEquals(0, viewModel.person!!.index) + press(Key.DirectionRight) + assertEquals(1, viewModel.person!!.index) + assertFalse(shell.navFocused) + } + + @Test fun failedRetryKeepsThePersonsSavedTitleAndShowsSharedRecovery() { + val id = UUID.fromString("00000000-0000-0000-0000-000000000014") + val reads = java.util.concurrent.atomic.AtomicInteger() + val saved = TitlePageData(TitleItem(id.toString(), "Saved title", "movie"), + connection = TitleConnection.Offline, savedAt = "20:42") + val facade = mockk<TitleReadFacade>() + coEvery { facade.readSaved(id, any()) } returns saved + coEvery { facade.read(id, any(), any()) } coAnswers { + reads.incrementAndGet() + throw java.io.IOException("offline") + } + viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk<TitleFixtureProvider>(), mockk<NavigationManager>(relaxed = true), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), mockk<com.github.damontecres.wholphin.data.ServerRepository>(relaxed = true)) + val viewer = ShellPerson("justin", "Justin", "J", false) + val route = titleRoute(id.toString()) + val shell = ShellState(listOf(viewer), viewer.key).apply { + pickerVisible = false; pickerLaunch = false; enterDestination(route) + } + rule.setContent { WolfskinTheme { + TitleDestination(route, shell, UserPreferences(AppPreferences.getDefaultInstance(), null), viewModel = viewModel) + } } + rule.waitUntil(timeoutMillis = 5000) { reads.get() == 1 && viewModel.title?.toast != null } + rule.waitForIdle() + assertEquals("20:42", shell.navSavedAt) + assertEquals("retry", viewModel.title!!.options[viewModel.title!!.column].id) + rule.runOnIdle { viewModel.title!!.toast = null } + press(Key.DirectionCenter) + rule.waitUntil(timeoutMillis = 5000) { + Shadows.shadowOf(Looper.getMainLooper()).idle() + reads.get() == 2 && viewModel.title?.toast != null + } + rule.waitForIdle() + assertEquals("Saved title", viewModel.title!!.data.item.name) + assertEquals("20:42", shell.navSavedAt) + assertEquals(RecoveryMessage.StillUnreachable, viewModel.title!!.toast) + assertFalse(shell.navFocused) + rule.onNodeWithTag("recovery-toast").assertIsDisplayed() + } + + private fun press(key: Key) { rule.onRoot().performKeyInput { pressKey(key) } } +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleCaptureTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleCaptureTest.kt new file mode 100644 index 00000000..55f9d747 --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleCaptureTest.kt @@ -0,0 +1,219 @@ +package org.justinvisser.wolfskin.tv + +import android.app.Application +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.view.View +import android.view.WindowManager +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performSemanticsAction +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.graphics.toArgb +import com.github.takahirom.roborazzi.captureRoboImage +import org.json.JSONObject +import org.junit.Before +import org.junit.After +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.justinvisser.wolfskin.design.LocalTitleBitmaps +import org.justinvisser.wolfskin.design.PersonState +import org.justinvisser.wolfskin.design.PersonSurface +import org.justinvisser.wolfskin.design.ShellKey +import org.justinvisser.wolfskin.design.ShellState +import org.justinvisser.wolfskin.design.TitleConnection +import org.justinvisser.wolfskin.design.TitlePageData +import org.justinvisser.wolfskin.design.TitleReadStatus +import org.justinvisser.wolfskin.design.TitleState +import org.justinvisser.wolfskin.design.TitleSurface +import org.justinvisser.wolfskin.design.WolfskinTheme +import org.justinvisser.wolfskin.design.WolfskinTokens +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import java.io.File +import java.util.Locale + +/** Frozen local art feeds the shipped Compose surface; no test can call a live server. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class TitleCaptureTest { + @get:Rule val rule = createAndroidComposeRule<ComponentActivity>() + private lateinit var fixture: TitleFixture + private var previousLocale = Locale.getDefault() + + @Before fun fullscreen() { + previousLocale = Locale.getDefault() + Locale.setDefault(Locale.US) + rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN) + @Suppress("DEPRECATION") + rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY + fixture = TitleFixture(rule.activity.assets.open("wolfskin-titles.json").bufferedReader().use { JSONObject(it.readText()) }) + } + @After fun restoreLocale() { Locale.setDefault(previousLocale) } + + private fun capture(number: Int, title: String = "Locke", person: String = "justin", transform: (TitlePageData) -> TitlePageData = { it }, configure: (TitleState, ShellState) -> Unit = { _, _ -> }) { + val data = transform(fixture.title(title, person)) + val shell = ShellState(fixture.people, person).apply { pickerVisible = false; pickerLaunch = false; enterDestination("title/${data.item.id}") } + val state = TitleState(data, shell.person) {} + configure(state, shell) + val bitmaps = decodeArt(data) + rule.setContent { + WolfskinTheme { CompositionLocalProvider(LocalTitleBitmaps provides bitmaps) { + TitleSurface(state, shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) + } } + } + if (number == 55) rule.runOnIdle { + shell.view.listState.requestScrollToItem(1, -(WolfskinTokens.Layout.pinnedLine.value * 2).toInt()) + } + save(number) + } + + private fun decodeArt(data: TitlePageData): Map<String, androidx.compose.ui.graphics.ImageBitmap> { + val requests = buildList<Triple<String, Int, Int>> { + data.item.backdrop?.let { add(Triple(it, 1280, 720)) } + if (data.item.backdrop == null || data.item.isPosterBackdrop) data.item.poster?.let { add(Triple(it, 420, 630)) } + data.cast.forEach { person -> person.portrait?.let { add(Triple(it, 200, 200)) } } + data.related.forEach { item -> item.poster?.let { add(Triple(it, 256, 384)) } } + data.episodes.forEach { item -> + (if (item.isPosterBackdrop) item.poster else item.backdrop)?.let { add(Triple(it, (WolfskinTokens.Card.tileW.value * 2).toInt(), (WolfskinTokens.Card.tileH.value * 2).toInt())) } + } + } + return requests.distinctBy { it.first }.associate { (path, width, height) -> + val bitmap = rule.activity.assets.open(path.removePrefix("file:///android_asset/")).use { BitmapFactory.decodeStream(it)!! } + val scale = maxOf(width.toFloat() / bitmap.width, height.toFloat() / bitmap.height).coerceAtMost(1f) + val scaled = Bitmap.createScaledBitmap(bitmap, (bitmap.width * scale).toInt(), (bitmap.height * scale).toInt(), true) + if (scaled !== bitmap) bitmap.recycle() + path to scaled.asImageBitmap() + } + } + + private fun save(number: Int) { + rule.mainClock.advanceTimeBy(1000) + rule.waitForIdle() + val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory"))).apply { mkdirs() } + rule.onRoot().captureRoboImage(File(directory, "st-${number.toString().padStart(3, '0')}.png").absolutePath) + val heading = rule.onAllNodesWithTag("title-heading").fetchSemanticsNodes().singleOrNull() + if (heading != null) { + val layouts = mutableListOf<TextLayoutResult>() + rule.onNodeWithTag("title-heading").performSemanticsAction(SemanticsActions.GetTextLayoutResult) { action -> action(layouts) } + val layout = layouts.single() + val metrics = JSONObject().put("title", layout.layoutInput.text.text).put("lineCount", layout.lineCount) + .put("lineWidthsPx", org.json.JSONArray(List(layout.lineCount) { layout.getLineRight(it) - layout.getLineLeft(it) })) + .put("ellipsized", org.json.JSONArray(List(layout.lineCount) { layout.isLineEllipsized(it) })) + .put("maxWidthPx", layout.layoutInput.constraints.maxWidth).put("fontSizeSp", layout.layoutInput.style.fontSize.value) + .put("requestedWeight", layout.layoutInput.style.fontWeight?.weight) + .put("boundsPx", org.json.JSONArray(listOf(heading.boundsInRoot.left, heading.boundsInRoot.top, heading.boundsInRoot.right, heading.boundsInRoot.bottom))) + File(directory, "st-${number.toString().padStart(3, '0')}-layout.json").writeText(metrics.toString(2)) + } + recordTextMetrics(directory, number) + recordElements(directory, number) + } + + private fun recordTextMetrics(directory: File, number: Int) { + val elements = rule.onAllNodes(SemanticsMatcher.keyIsDefined(SemanticsActions.GetTextLayoutResult), useUnmergedTree = true) + val records = org.json.JSONArray() + elements.fetchSemanticsNodes().forEachIndexed { index, node -> + if (node.boundsInRoot.width > 0 && node.boundsInRoot.height > 0) { + val layouts = mutableListOf<TextLayoutResult>() + elements[index].performSemanticsAction(SemanticsActions.GetTextLayoutResult) { action -> action(layouts) } + val layout = layouts.single() + records.put(JSONObject().put("text", layout.layoutInput.text.text) + .put("fontSizeSp", layout.layoutInput.style.fontSize.value) + .put("weight", layout.layoutInput.style.fontWeight?.weight) + .put("colourArgb", Integer.toUnsignedString(layout.layoutInput.style.color.toArgb(), 16)) + .put("lineHeightSp", layout.layoutInput.style.lineHeight.value.takeIf { it.isFinite() } ?: JSONObject.NULL) + .put("lineCount", layout.lineCount) + .put("boundsPx", org.json.JSONArray(listOf(node.boundsInRoot.left, node.boundsInRoot.top, node.boundsInRoot.right, node.boundsInRoot.bottom))) + .put("spans", org.json.JSONArray(layout.layoutInput.text.spanStyles.map { span -> + JSONObject().put("start", span.start).put("end", span.end).put("weight", span.item.fontWeight?.weight) + }))) + } + } + File(directory, "st-${number.toString().padStart(3, '0')}-text.json").writeText(records.toString(2)) + } + + private fun recordElements(directory: File, number: Int) { + val tag = androidx.compose.ui.semantics.SemanticsProperties.TestTag + val description = androidx.compose.ui.semantics.SemanticsProperties.ContentDescription + val matcher = SemanticsMatcher.keyIsDefined(tag) or SemanticsMatcher.keyIsDefined(description) + val records = org.json.JSONArray() + rule.onAllNodes(matcher, useUnmergedTree = true).fetchSemanticsNodes().forEach { node -> + records.put(JSONObject() + .put("tag", if (node.config.contains(tag)) node.config[tag] else JSONObject.NULL) + .put("description", if (node.config.contains(description)) org.json.JSONArray(node.config[description]) else JSONObject.NULL) + .put("boundsPx", org.json.JSONArray(listOf(node.boundsInRoot.left, node.boundsInRoot.top, node.boundsInRoot.right, node.boundsInRoot.bottom)))) + } + File(directory, "st-${number.toString().padStart(3, '0')}-elements.json").writeText(records.toString(2)) + } + + private fun option(state: TitleState, id: String) { state.focus("header", state.options.indexOfFirst { it.id == id }) } + private fun open(state: TitleState, shell: ShellState, id: String) { + option(state, id) + state.key(ShellKey.Ok, true, 0, shell) + state.key(ShellKey.Ok, false, 0, shell) + } + + @Test fun st042_heldMovie() = capture(42, "The Girl Who Leapt Through Time") + @Test fun st043_options() = capture(43) { state, _ -> option(state, "version") } + @Test fun st044_versions() = capture(44) { state, shell -> open(state, shell, "version") } + @Test fun st045_rememberedVersion() = capture(45) { state, _ -> state.versionId = "v2"; option(state, "version") } + @Test fun st046_tracks() = capture(46) { state, shell -> open(state, shell, "tracks") } + @Test fun st047_mediaInfo() = capture(47) { state, shell -> open(state, shell, "more") } + @Test fun st048_trailerNoApp() = capture(48) { state, _ -> option(state, "trailer"); state.toast = "No YouTube app is available to play this trailer." } + @Test fun st049_watched() = capture(49, transform = { it.copy(watchTarget = it.watchTarget.copy(watched = true)) }) { state, _ -> option(state, "watched") } + @Test fun st050_seriesConfirm() = capture(50, "Andor") { state, shell -> open(state, shell, "watched") } + @Test fun st051_episodeRestart() = capture(51, "episode-sex-and-the-country-2001-s4e9", "mindel") { state, _ -> option(state, "restart") } + @Test fun st052_filmCast() = capture(52) { state, shell -> + state.focus("cast", 0) + state.key(ShellKey.Right, true, 0, shell) + state.key(ShellKey.Right, false, 0, shell) + } + @Test fun st053_seriesCast() = capture(53, "Andor") { state, _ -> state.focus("cast", 0) } + @Test fun st054_initialsCast() = capture(54, "Spirited Away", "shared") { state, _ -> state.focus("cast", 0) } + @Test fun st055_castLoading() = capture(55, transform = { it.copy(cast = emptyList(), castStatus = TitleReadStatus.Loading) }) + @Test fun st056_castUnavailable() = capture(56, transform = { it.copy(cast = emptyList(), castStatus = TitleReadStatus.Unavailable) }) { state, _ -> state.focus("related", 0) } + @Test fun st057_person() { + val (person, credits) = fixture.person("Tom Holland", "justin") + val shell = ShellState(fixture.people, "justin").apply { pickerVisible = false; enterDestination("person/Tom Holland") } + val state = PersonState(person, credits, {}) + val data = TitlePageData(credits.first(), cast = listOf(person), related = credits) + val art = decodeArt(data) + rule.setContent { WolfskinTheme { CompositionLocalProvider(LocalTitleBitmaps provides art) { + PersonSurface(state, shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) + } } } + save(57) + } + @Test fun st058_hiddenReceipt() = capture(58, "The Amazing Spider-Man") { state, shell -> open(state, shell, "hide") } + @Test fun st059_hiddenPick() = capture(59, "The Amazing Spider-Man", transform = { it.copy(hidden = true) }) { state, _ -> option(state, "unhide") } + @Test fun st060_titleNav() = capture(60) { _, shell -> shell.focusNav(0) } + @Test fun st061_loading() = capture(61, transform = { it.copy(status = TitleReadStatus.Loading) }) { _, shell -> shell.focusNav(0) } + @Test fun st062_error() = capture(62, transform = { it.copy(status = TitleReadStatus.Unavailable) }) + @Test fun st063_signedOut() = capture(63, "The Amazing Spider-Man", transform = { it.copy(connection = TitleConnection.SignedOut) }) + @Test fun st064_seriesNext() = capture(64, "Andor") + @Test fun st065_resumeEpisode() = capture(65, "episode-sex-and-the-country-2001-s4e9", "mindel") + @Test fun st066_notHeld() = capture(66, "The Amazing Spider-Man") + @Test fun st067_trailerFallback() = capture(67, "The Amazing Spider-Man") + @Test fun st068_requested() = capture(68, "The Amazing Spider-Man", transform = { it.copy(requestedStage = "Searching", requestActor = "Justin") }) + @Test fun st069_missingArt() = capture(69, "The Amazing Spider-Man", transform = { it.copy(item = it.item.copy(poster = null, backdrop = null)) }) + @Test fun st070_offline() = capture(70, "The Amazing Spider-Man", transform = { + it.copy(connection = TitleConnection.Offline, savedAt = "20:42", item = it.item.copy(ratings = emptyList())) + }) + @Test fun st104_relatedPlate() = capture(104, "The Girl Who Leapt Through Time") { state, _ -> state.focus("related", state.data.related.indexOfFirst { it.name == "INVINCIBLE" }.coerceAtLeast(0)) } + @Test fun st106_heldRatings() = capture(106, "American Gangster", "shared") + @Test fun st107_seriesEpisodeRatings() = capture(107, "Andor") + @Test fun st108_resumingEpisodeRatings() = capture(108, "episode-sex-and-the-country-2001-s4e9", "mindel") + @Test fun st109_noRatings() = capture(109, "The Amazing Spider-Man") + @Test fun spacingCastFirstFocused() = capture(114) { state, _ -> state.focus("cast", 0) } +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleKeysTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleKeysTest.kt new file mode 100644 index 00000000..0b6dac33 --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleKeysTest.kt @@ -0,0 +1,364 @@ +package org.justinvisser.wolfskin.tv + +import android.app.Application +import androidx.activity.ComponentActivity +import androidx.activity.compose.BackHandler +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.pressKey +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performMouseInput +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasClickAction +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.performSemanticsAction +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.text.TextLayoutResult +import org.justinvisser.wolfskin.design.WolfskinTokens +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Assert.* +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.justinvisser.wolfskin.design.ShellAction +import org.justinvisser.wolfskin.design.ShellState +import org.justinvisser.wolfskin.design.TitleAction +import org.justinvisser.wolfskin.design.TitleConnection +import org.justinvisser.wolfskin.design.TitleReadStatus +import org.justinvisser.wolfskin.design.TitleSheet +import org.justinvisser.wolfskin.design.TitleState +import org.justinvisser.wolfskin.design.TitleSurface +import org.justinvisser.wolfskin.design.WolfskinTheme +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** Approved title-entry suffixes replay Android keys through the rendered title and shared shell. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +class TitleKeysTest { + @get:Rule val rule = createAndroidComposeRule<ComponentActivity>() + private lateinit var fixture: TitleFixture + private val effects = mutableListOf<TitleAction>() + private val shellEffects = mutableListOf<ShellAction>() + private lateinit var state: TitleState + private lateinit var shell: ShellState + private lateinit var paths: List<JSONObject> + + @Before fun fixtures() { + fixture = TitleFixture(rule.activity.assets.open("wolfskin-titles.json").bufferedReader().use { JSONObject(it.readText()) }) + val data = javaClass.getResourceAsStream("/title-press-paths.json")!!.bufferedReader().use { JSONArray(it.readText()) } + paths = List(data.length()) { data.getJSONObject(it) } + } + + private fun render(title: String = "Locke", transform: (org.justinvisser.wolfskin.design.TitlePageData) -> org.justinvisser.wolfskin.design.TitlePageData = { it }) { + shell = ShellState(fixture.people, "justin", shellEffects::add).apply { pickerVisible = false; pickerLaunch = false; enterDestination(titleRoute(title)) } + state = TitleState(transform(fixture.title(title, "justin")), shell.person, effects::add) + rule.setContent { WolfskinTheme { + BackHandler { state.back(shell) } + TitleSurface(state, shell) + } } + } + + @Test fun downFocusesThePlayableLaterSeasonAndScrollsItIntoViewWithoutLosingBrowsingMemory() { + render("Andor") { page -> + page.copy(item = page.item.copy(seasons = 30), seasonNumbers = (1..30).toList(), selectedSeason = 29, + playTarget = page.playTarget!!.copy(season = 29)) + } + press(Key.DirectionDown) + assertEquals("seasons", state.row) + assertEquals(28, state.column) + rule.onNode(hasText("Season 29") and hasClickAction()).assertIsDisplayed() + press(Key.DirectionLeft) + assertEquals(27, state.column) + rule.onNode(hasText("Season 28") and hasClickAction()).assertIsDisplayed() + press(Key.DirectionUp) + press(Key.DirectionDown) + assertEquals(27, state.column) + press(Key.DirectionCenter) + assertEquals(28, state.season) + assertEquals(28, state.requestedSeason) + assertEquals(TitleAction.Season(28), effects.last()) + } + + private fun press(key: Key) { rule.onRoot().performKeyInput { pressKey(key) } } + private fun replay(end: String): Int { + val path = paths.single { it.getString("end") == end } + render(path.getString("title")) + val keys = path.getJSONArray("keys") + var presses = 0 + for (index in path.getInt("entryPresses") until keys.length()) { + val key = when (keys.getString(index)) { + "UP" -> Key.DirectionUp + "DOWN" -> Key.DirectionDown + "LEFT" -> Key.DirectionLeft + "RIGHT" -> Key.DirectionRight + "OK" -> Key.DirectionCenter + else -> error("Unknown replay key") + } + press(key) + presses++ + } + return presses + } + + @Test fun titleToNavOnePressFromEntry() { + assertEquals(1, replay("nav")) + assertTrue(shell.navFocused) + assertEquals(shell.currentTab, shell.navIndex) + } + + @Test fun switchPersonFivePressesFromTitleEntry() { + assertEquals(5, replay("person-mindel")) + assertEquals("mindel", shell.person.key) + assertEquals("home", shell.route) + assertFalse(shell.pickerVisible) + assertTrue(shellEffects.last() is ShellAction.Person) + } + + @Test fun lockeSecondVersionSixPressesFromTitleEntry() { + assertEquals(6, replay("play-version-2")) + assertEquals("v2", state.versionId) + assertNull(state.sheet) + assertEquals("play", state.options[state.column].id) + assertTrue(effects.last() is TitleAction.Play) + assertEquals("Locke", (effects.last() as TitleAction.Play).item.name) + } + + @Test fun lockeCastTwoPressesFromTitleEntry() { + assertEquals(2, replay("person-Tom Hardy")) + assertEquals("cast", state.row) + assertEquals("Tom Hardy", (effects.single() as TitleAction.Person).person.name) + } + + @Test fun sheetBackRestoresItsOpeningOptionAndChosenVersion() { + render() + press(Key.DirectionRight) + press(Key.DirectionCenter) + assertEquals(TitleSheet.Version, state.sheet) + press(Key.DirectionDown) + press(Key.DirectionCenter) + assertEquals("v2", state.versionId) + assertEquals("Version", state.options[state.column].label) + press(Key.DirectionCenter) + assertEquals(1, state.sheetIndex) + press(Key.Back) + assertNull(state.sheet) + assertEquals("Version", state.options[state.column].label) + assertFalse(shell.navFocused) + } + + @Test fun trackChoiceStaysInSheetAndOffRemainsAnExplicitChoice() { + render() + press(Key.DirectionRight); press(Key.DirectionRight); press(Key.DirectionCenter) + assertEquals(TitleSheet.Tracks, state.sheet) + press(Key.DirectionDown); press(Key.DirectionCenter) + assertEquals(1, state.audioIndex) + assertEquals(TitleSheet.Tracks, state.sheet) + press(Key.DirectionDown); press(Key.DirectionDown); press(Key.DirectionCenter) + assertEquals(2, state.subtitleIndex) + press(Key.DirectionUp); press(Key.DirectionCenter) + assertEquals(-1, state.subtitleIndex) + press(Key.Back) + assertNull(state.sheet) + assertEquals("tracks", state.options[state.column].id) + } + + @Test fun castLoadingAndUnavailableAreNeverFocusable() { + render(transform = { it.copy(cast = emptyList(), castStatus = TitleReadStatus.Loading) }) + press(Key.DirectionDown) + assertEquals("related", state.row) + assertFalse(state.rows.contains("cast")) + } + + @Test fun wholeSeriesRequiresConfirmationButEpisodesDoNot() { + render("Andor") + rule.runOnIdle { state.focus("header", state.options.indexOfFirst { it.id == "watched" }) } + press(Key.DirectionCenter) + assertEquals(TitleSheet.ConfirmWatched, state.sheet) + assertFalse(state.watched) + press(Key.DirectionRight); press(Key.DirectionCenter) + assertFalse(state.watched) + assertTrue(effects.none { it is TitleAction.Watched }) + press(Key.DirectionCenter); press(Key.DirectionCenter) + assertTrue(state.watched) + assertTrue(effects.last() is TitleAction.Watched) + } + + @Test fun titlesNobodyHoldsHaveNoTrailerUntilTheCatalogueFieldExists() { + render("The Amazing Spider-Man") + assertTrue(state.options.none { it.id == "trailer" }) + assertEquals("Request", state.options.first().label) + rule.runOnIdle { state.update(state.data.copy(connection = TitleConnection.SignedOut)) } + assertEquals(listOf("retry"), state.options.map { it.id }) + } + + @Test fun hiddenReceiptUndoRestoresTitleAndOptionInPlace() { + render("The Amazing Spider-Man") + press(Key.DirectionRight); press(Key.DirectionRight); press(Key.DirectionCenter) + assertTrue(state.data.hidden) + assertEquals(TitleSheet.HiddenReceipt, state.sheet) + press(Key.DirectionCenter) + assertFalse(state.data.hidden) + assertNull(state.sheet) + assertEquals("hide", state.options[state.column].id) + } + + @Test fun heldOkCannotChooseTheVersionInTheSheetItOpened() { + render() + press(Key.DirectionRight) + rule.runOnIdle { + rule.activity.dispatchKeyEvent(android.view.KeyEvent(0, 0, android.view.KeyEvent.ACTION_DOWN, android.view.KeyEvent.KEYCODE_DPAD_CENTER, 0)) + repeat(5) { index -> rule.activity.dispatchKeyEvent(android.view.KeyEvent(0, 0, android.view.KeyEvent.ACTION_DOWN, android.view.KeyEvent.KEYCODE_DPAD_CENTER, index + 1)) } + rule.activity.dispatchKeyEvent(android.view.KeyEvent(0, 0, android.view.KeyEvent.ACTION_UP, android.view.KeyEvent.KEYCODE_DPAD_CENTER, 0)) + } + assertEquals(TitleSheet.Version, state.sheet) + assertTrue(effects.none { it is TitleAction.Version }) + } + + @Test fun episodeWatchedToggleKeepsItsIdentityAndDoesNotAskAboutTheSeries() { + val episode = requireNotNull(fixture.title("Andor", "justin").playTarget) + render(episode.id) + rule.runOnIdle { state.focus("header", state.options.indexOfFirst { it.id == "watched" }) } + press(Key.DirectionCenter) + assertNull(state.sheet) + val change = effects.single() as TitleAction.Watched + assertEquals(episode.id, change.item.id) + assertEquals(!episode.watched, change.watched) + } + + @Test fun aPendingSeasonSkipsItsEpisodeSkeletons() { + render("Andor") + rule.runOnIdle { state.showSeasonLoading() } + press(Key.DirectionDown); press(Key.DirectionDown) + assertEquals("cast", state.row) + rule.runOnIdle { state.restoreSeason() } + press(Key.DirectionUp) + assertEquals("episodes", state.row) + } + + @Test fun aFailedOlderVersionWriteCannotUndoTheNewerChoice() { + render() + press(Key.DirectionRight); press(Key.DirectionCenter); press(Key.DirectionDown); press(Key.DirectionCenter) + val first = effects.last() as TitleAction.Version + press(Key.DirectionCenter); press(Key.DirectionUp); press(Key.DirectionCenter) + val second = effects.last() as TitleAction.Version + rule.runOnIdle { state.rejectPlayback(first); state.confirmPlayback(second) } + assertEquals("v1", state.versionId) + press(Key.DirectionCenter); press(Key.DirectionDown); press(Key.DirectionCenter) + val failed = effects.last() as TitleAction.Version + rule.runOnIdle { state.rejectPlayback(failed) } + assertEquals("v1", state.versionId) + } + + @Test fun aNewRowUsesTheNearestHorizontalCardBeforeItsOwnRememberedCard() { + render() + repeat(4) { press(Key.DirectionRight) } + val x = rule.onNodeWithTag("title-option-trailer").fetchSemanticsNode().boundsInRoot.center.x + val cast = state.data.cast.take(WolfskinTokens.Card.personSlots) + val nearest = cast.indices.minBy { index -> + kotlin.math.abs(rule.onNodeWithTag("title-cast-${cast[index].id}").fetchSemanticsNode().boundsInRoot.center.x - x) + } + press(Key.DirectionDown) + assertEquals(nearest, state.column) + press(Key.DirectionRight) + val remembered = state.column + press(Key.DirectionUp); press(Key.DirectionDown) + assertEquals(remembered, state.column) + } + + @Test fun wheelThenRemoteLandsOnTheFirstVisibleFocusableRow() { + render() + rule.onNodeWithTag("title-page").performMouseInput { scroll(800f) } + val expected = rule.runOnIdle { + shell.view.listState.layoutInfo.visibleItemsInfo.first { + it.key in state.rows && it.offset + it.size > WolfskinTokens.Layout.pinnedLine.value * 2 + }.key as String + } + assertNotEquals("header", expected) + press(Key.DirectionDown) + assertEquals(expected, state.row) + } + @Test fun seriesOptionsKeepTheCompleteHouseholdActionLabel() { + render("Andor") + val layouts = mutableListOf<TextLayoutResult>() + rule.onNodeWithText("Add for someone", useUnmergedTree = true) + .performSemanticsAction(SemanticsActions.GetTextLayoutResult) { action -> action(layouts) } + val layout = layouts.single() + assertFalse("The final pill must not shrink to its icon or a clipped label", layout.didOverflowWidth) + assertEquals(1, layout.lineCount) + } + + @Test fun anInitialLoadNeverExposesStaleCastBelowItsSkeleton() { + render(transform = { it.copy(status = TitleReadStatus.Loading) }) + rule.onNodeWithText("Cast").assertDoesNotExist() + press(Key.DirectionDown) + assertTrue(shell.navFocused) + assertEquals("header", state.row) + rule.runOnIdle { rule.activity.onBackPressedDispatcher.onBackPressed() } + assertEquals(listOf(TitleAction.Pop), effects) + } + + + @Test fun unratedFactsDoNotReserveAnEmptyRatingsSlot() { + render("The Amazing Spider-Man", transform = { + it.copy(item = it.item.copy(ratings = emptyList(), genres = listOf("Drama"), runtimeSeconds = 0), + cast = emptyList(), related = emptyList()) + }) + val year = rule.onNodeWithText("2012", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot + val genre = rule.onNodeWithText("Drama", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot + assertEquals(WolfskinTokens.Title.factsGap.value * 2, genre.left - year.right, 1f) + } + + @GraphicsMode(GraphicsMode.Mode.NATIVE) + @Test fun mediaInfoRowsPreserveTheirDeclaredLineBoxes() { + render() + rule.runOnIdle { state.focus("header", state.options.indexOfFirst { it.id == "more" }) } + press(Key.DirectionCenter) + val container = rule.onNodeWithText("Container", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot + val size = rule.onNodeWithText("Size", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot + val expectedPitch = (WolfskinTokens.Title.infoKey.lineHeight.value + WolfskinTokens.Title.infoRowGap.value) * 2 + assertEquals("Stacked facts retain the CSS line-box rhythm", expectedPitch, size.top - container.top, 1f) + val lastSubtitle = rule.onNodeWithText("Dutch", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot + assertEquals("Fourteen stacked lines do not accumulate native pixel rounding", 526.2f, lastSubtitle.top - container.top, 1f) + } + + @GraphicsMode(GraphicsMode.Mode.NATIVE) + @Test fun filmHeaderKeepsItsFixedCssStack() { + render() + assertEquals(358f, rule.onNodeWithTag("title-facts").fetchSemanticsNode().boundsInRoot.top, 1f) + assertEquals(410f, rule.onNodeWithTag("title-overview").fetchSemanticsNode().boundsInRoot.top, 1f) + assertEquals(553f, rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot.top, 1f) + } + + + @Test fun everyLongTrackChoiceAndDoneRemainInsideTheVisibleSheet() { + render(transform = { data -> + val source = data.versions.first().copy( + audio = List(3) { org.justinvisser.wolfskin.design.TitleTrack(it + 3, "Audio ${it + 1}") }, + subtitles = List(15) { org.justinvisser.wolfskin.design.TitleTrack(it + 20, "Subtitle ${it + 1}") }, + ) + data.copy(versions = listOf(source)) + }) + rule.runOnIdle { state.focus("header", state.options.indexOfFirst { it.id == "tracks" }) } + press(Key.DirectionCenter) + val bottom = rule.onRoot().fetchSemanticsNode().boundsInRoot.bottom + val labels = (1..3).map { "Audio $it" } + "Off" + (1..15).map { "Subtitle $it" } + "Done" + labels.forEachIndexed { index, label -> + if (index > 0) press(Key.DirectionDown) + rule.waitForIdle() + val bounds = rule.onNodeWithText(label, useUnmergedTree = true).fetchSemanticsNode().boundsInRoot + assertTrue("$label must have visible height: $bounds", bounds.height > 20) + assertTrue("$label stays above the viewport bottom: $bounds / $bottom", bounds.bottom <= bottom) + assertTrue("$label stays below the viewport top: $bounds", bounds.top >= 0) + } + press(Key.DirectionCenter) + assertNull(state.sheet) + assertFalse(shell.navFocused) + } +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleLaunchKeysTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleLaunchKeysTest.kt new file mode 100644 index 00000000..07636e5a --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleLaunchKeysTest.kt @@ -0,0 +1,132 @@ +package org.justinvisser.wolfskin.tv + +import android.app.Application +import androidx.activity.ComponentActivity +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.pressKey +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Assert.* +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.justinvisser.wolfskin.design.* +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** Full launch paths render the real picker, Home hero/shelves, title and person surfaces. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +class TitleLaunchKeysTest { + @get:Rule val rule = createAndroidComposeRule<ComponentActivity>() + private lateinit var fixture: TitleFixture + private lateinit var home: HomeFixture + private lateinit var shell: ShellState + private var title by mutableStateOf<TitleState?>(null) + private var person by mutableStateOf<PersonState?>(null) + private val actions = mutableListOf<TitleAction>() + private val shellActions = mutableListOf<ShellAction>() + private lateinit var paths: List<JSONObject> + + @Before fun launch() { + fixture = TitleFixture(rule.activity.assets.open("wolfskin-titles.json").bufferedReader().use { JSONObject(it.readText()) }) + home = HomeFixture(rule.activity) + val source = javaClass.getResourceAsStream("/title-press-paths.json")!!.bufferedReader().use { JSONArray(it.readText()) } + paths = List(source.length()) { source.getJSONObject(it) } + shell = ShellState(fixture.people, "justin") { action -> + shellActions.add(action) + if (action is ShellAction.Person) { title = null; person = null } + } + rule.setContent { WolfskinTheme { + val performer = person + val page = title + val snapshot = remember(shell.person.key) { home.snapshot(shell.person.key) } + when { + performer != null -> PersonSurface(performer, shell) + page != null -> TitleSurface(page, shell) + else -> ShellSurface(shell) { modifier -> + HomeScreen(shell, snapshot, modifier) { action -> + if (action is HomeAction.Details) openTitle(action.title.id) + } + } + } + } } + } + + private fun openTitle(id: String) { + val data = fixture.title(id, shell.person.key) + title = TitleState(data, shell.person, ::act) + person = null + shell.enterDestination(titleRoute(id)) + } + + private fun act(action: TitleAction) { + actions.add(action) + when (action) { + is TitleAction.Person -> { + val (actor, credits) = fixture.person(action.person.id, shell.person.key) + person = PersonState(actor, credits, ::act) + shell.enterDestination(personRoute(actor.id)) + } + is TitleAction.OpenTitle -> openTitle(action.item.id) + else -> Unit + } + } + + private fun replay(end: String, expectedPresses: Int) { + val path = paths.single { it.getString("end") == end } + val keys = path.getJSONArray("keys") + assertEquals(expectedPresses, path.getInt("presses")) + assertEquals(expectedPresses, keys.length()) + for (index in 0 until keys.length()) { + val key = when (keys.getString(index)) { + "UP" -> Key.DirectionUp; "DOWN" -> Key.DirectionDown + "LEFT" -> Key.DirectionLeft; "RIGHT" -> Key.DirectionRight + "OK" -> Key.DirectionCenter + else -> error("Unknown launch replay key") + } + rule.onRoot().performKeyInput { pressKey(key) } + } + } + + @Test fun titleToNavTakesFourPressesFromTheRealHomeHero() { + replay("nav", 4) + assertNotNull(title) + assertTrue(shell.navFocused) + assertEquals(shell.currentTab, shell.navIndex) + } + + @Test fun switchingPersonFromATitleTakesEightLaunchPresses() { + replay("person-mindel", 8) + assertEquals("mindel", shell.person.key) + assertFalse(shell.pickerVisible) + assertEquals("home", shell.route) + assertTrue(shellActions.last() is ShellAction.Person) + } + + @Test fun lockeVersionTwoAndPlayTakesFifteenLaunchPresses() { + replay("play-version-2", 15) + val page = requireNotNull(title) + assertEquals("Locke", page.data.item.name) + assertEquals("v2", page.versionId) + assertNull(page.sheet) + assertEquals("play", page.options[page.column].id) + assertEquals("Locke", (actions.last() as TitleAction.Play).item.name) + } + + @Test fun lockeToTomHardyTakesElevenLaunchPresses() { + replay("person-Tom Hardy", 11) + assertEquals("Tom Hardy", requireNotNull(person).person.name) + assertEquals(0, person!!.index) + assertFalse(shell.navFocused) + assertTrue(actions.last() is TitleAction.Person) + } +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleLocaleTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleLocaleTest.kt new file mode 100644 index 00000000..e9462bff --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleLocaleTest.kt @@ -0,0 +1,57 @@ +package org.justinvisser.wolfskin.tv + +import android.app.Application +import androidx.activity.ComponentActivity +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onLast +import androidx.compose.ui.test.performSemanticsAction +import androidx.compose.ui.text.TextLayoutResult +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.justinvisser.wolfskin.design.ShellPerson +import org.justinvisser.wolfskin.design.ShellState +import org.justinvisser.wolfskin.design.TitleConnection +import org.justinvisser.wolfskin.design.TitleItem +import org.justinvisser.wolfskin.design.TitlePageData +import org.justinvisser.wolfskin.design.LibraryRating +import org.justinvisser.wolfskin.design.TitleState +import org.justinvisser.wolfskin.design.TitleSurface +import org.justinvisser.wolfskin.design.WolfskinTheme +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import java.util.Locale + +/** Native text layout proves locale formatting still fits the focused poster's one-line plate. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class TitleLocaleTest { + @get:Rule val rule = createAndroidComposeRule<ComponentActivity>() + + private fun render(locale: Locale, expected: String) { + val previous = Locale.getDefault() + try { + Locale.setDefault(locale) + val person = ShellPerson("justin", "Justin", "J", false) + val item = TitleItem("movie:1", "Title", "movie", ratings = listOf(LibraryRating("tmdb", 7.8))) + val shell = ShellState(listOf(person), person.key).apply { pickerVisible = false; enterDestination("title/movie:1") } + val state = TitleState(TitlePageData(item, related = listOf(item.copy(id = "movie:2")), connection = TitleConnection.SignedIn), person) {} + state.focus("related", 0) + rule.setContent { WolfskinTheme { TitleSurface(state, shell) } } + rule.mainClock.advanceTimeBy(1000) + val layouts = mutableListOf<TextLayoutResult>() + rule.onAllNodesWithText(expected, useUnmergedTree = true).onLast().performSemanticsAction(SemanticsActions.GetTextLayoutResult) { get -> get(layouts) } + assertEquals(1, layouts.single().lineCount) + assertFalse(layouts.single().didOverflowWidth) + } finally { Locale.setDefault(previous) } + } + + @Test fun dutchScoreUsesCommaOnTheNativePlate() = render(Locale.forLanguageTag("nl-NL"), "7,8") + @Test fun englishScoreUsesPointOnTheNativePlate() = render(Locale.US, "7.8") +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitlePlaybackNavigationTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitlePlaybackNavigationTest.kt new file mode 100644 index 00000000..75c92ed5 --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitlePlaybackNavigationTest.kt @@ -0,0 +1,136 @@ +package org.justinvisser.wolfskin.tv + +import android.app.Application +import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.ui.nav.Destination +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.BaseItemKind +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import org.justinvisser.wolfskin.conductarr.MemoryCacheStore +import org.justinvisser.wolfskin.design.* +import org.justinvisser.wolfskin.tv.read.TitleReadFacade +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import java.util.UUID + +/** Production title actions preserve parents and resolve live playback instead of cached offsets. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class) +@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) +class TitlePlaybackNavigationTest { + private val seriesId = UUID.fromString("00000000-0000-0000-0000-000000000001") + private val firstId = UUID.fromString("00000000-0000-0000-0000-000000000002") + private val secondId = UUID.fromString("00000000-0000-0000-0000-000000000003") + private val person = ShellPerson("justin", "Justin", "J", false) + private val preferences = UserPreferences(AppPreferences.getDefaultInstance(), null) + private val series = TitleItem(seriesId.toString(), "Series", "tv", seasons = 5, held = true) + private val first = TitleItem(firstId.toString(), "First episode", "episode", held = true, season = 3, episode = 1, resumeSeconds = 12) + private val second = first.copy(id = secondId.toString(), name = "Second episode", episode = 2) + + @Test fun repeatedEpisodeChoicesReplaceOnlyEpisodesAndBackPreservesTheirSeriesParent() = runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + val navigation = NavigationManager() + val facade = mockk<TitleReadFacade>() + coEvery { facade.readSaved(any(), any()) } returns null + coEvery { facade.read(any(), any(), any()) } coAnswers { + val id = firstArg<UUID>() + TitlePageData(series, playTarget = first, watchTarget = if (id == seriesId) series else first.copy(id = id.toString()), + episodes = listOf(first, second), related = listOf(series), cast = listOf(TitlePerson("actor", "Actor"))) + } + val session = ConductarrSession(MemoryCacheStore()) + val model = WolfskinTitleViewModel(RuntimeEnvironment.getApplication(), facade, mockk(), navigation, session, mockk<ServerRepository>(relaxed = true)) + fun open(id: UUID) = model.load(titleRoute(id.toString()), person, preferences, fixtures = false, force = true) + try { + navigation.navigateTo(Destination.Wolfskin(titleRoute(seriesId.toString()))) + open(seriesId) + advanceUntilIdle() + model.title!!.click("episodes", 0) + assertEquals(3, navigation.backStack.size) + assertEquals(firstId, (navigation.backStack.last() as Destination.MediaItem).itemId) + open(firstId) + advanceUntilIdle() + for (id in listOf(secondId, firstId, secondId)) { + model.title!!.click("episodes", if (id == firstId) 0 else 1) + assertEquals(3, navigation.backStack.size) + assertEquals(id, (navigation.backStack.last() as Destination.MediaItem).itemId) + open(id) + advanceUntilIdle() + } + model.title!!.click("related", 0) + assertEquals(4, navigation.backStack.size) + navigation.goBack() + model.title!!.click("cast", 0) + assertEquals(4, navigation.backStack.size) + navigation.goBack() + model.title!!.back(ShellState(listOf(person), person.key).apply { pickerVisible = false; pickerLaunch = false }) + assertEquals(listOf(Destination.Home(), Destination.Wolfskin(titleRoute(seriesId.toString()))), navigation.backStack.toList()) + navigation.goBack() + assertEquals(listOf(Destination.Home()), navigation.backStack.toList()) + navigation.navigateTo(Destination.Wolfskin(titleRoute(firstId.toString()))) + open(firstId) + advanceUntilIdle() + model.title!!.click("episodes", 1) + navigation.goBack() + assertEquals(listOf(Destination.Home()), navigation.backStack.toList()) + } finally { + model.viewModelScope.cancel() + session.close() + Dispatchers.resetMain() + } + } + + @Test fun primarySeriesActionReResolvesNextUpButRestartAndExplicitEpisodesKeepTheirIdentity() = runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + val navigation = NavigationManager() + val facade = mockk<TitleReadFacade>() + coEvery { facade.readSaved(any(), any()) } returns null + coEvery { facade.read(any(), any(), any()) } coAnswers { + TitlePageData(series, first, watchTarget = if (firstArg<UUID>() == seriesId) series else first) + } + coEvery { facade.preparePlayback(seriesId) } returns BaseItemDto(id = secondId, type = BaseItemKind.EPISODE, + userData = playbackUserData(secondId, 11_362_740_000L, playCount = 2)) + coEvery { facade.preparePlayback(firstId) } returns BaseItemDto(id = firstId, type = BaseItemKind.EPISODE, + userData = playbackUserData(firstId, 960_780_000L, playCount = 1)) + val session = ConductarrSession(MemoryCacheStore()) + val model = WolfskinTitleViewModel(RuntimeEnvironment.getApplication(), facade, mockk(), navigation, session, mockk<ServerRepository>(relaxed = true)) + try { + model.load(titleRoute(seriesId.toString()), person, preferences, fixtures = false) + advanceUntilIdle() + model.title!!.click("header", 0) + advanceUntilIdle() + assertEquals(Destination.Playback(secondId, 1_136_274L), navigation.backStack.last()) + navigation.goBack() + model.title!!.click("header", model.title!!.options.indexOfFirst { it.id == "restart" }) + advanceUntilIdle() + assertEquals(Destination.Playback(firstId, 0L), navigation.backStack.last()) + navigation.goBack() + model.load(titleRoute(firstId.toString()), person, preferences, fixtures = false) + advanceUntilIdle() + model.title!!.click("header", 0) + advanceUntilIdle() + assertEquals(Destination.Playback(firstId, 96_078L), navigation.backStack.last()) + coVerify(exactly = 1) { facade.preparePlayback(seriesId) } + } finally { + model.viewModelScope.cancel() + session.close() + Dispatchers.resetMain() + } + } +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleReadFacadeTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleReadFacadeTest.kt new file mode 100644 index 00000000..fb7bf06e --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleReadFacadeTest.kt @@ -0,0 +1,240 @@ +package org.justinvisser.wolfskin.tv + +import android.app.Application +import android.content.Context +import com.github.damontecres.wholphin.WholphinApplication +import com.github.damontecres.wholphin.data.ItemPlaybackRepository +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.JellyfinUser +import com.github.damontecres.wholphin.data.model.JellyfinServer +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.services.FavoriteWatchManager +import com.github.damontecres.wholphin.services.ImageUrlService +import com.github.damontecres.wholphin.ui.successQueryResult +import com.github.damontecres.wholphin.ui.successResponse +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkObject +import kotlinx.coroutines.test.runTest +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.itemsApi +import org.jellyfin.sdk.api.client.extensions.tvShowsApi +import org.jellyfin.sdk.api.client.extensions.userLibraryApi +import org.jellyfin.sdk.api.operations.ItemsApi +import org.jellyfin.sdk.api.operations.TvShowsApi +import org.jellyfin.sdk.api.operations.UserLibraryApi +import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.BaseItemKind +import com.github.damontecres.wholphin.test.TestTracks +import org.jellyfin.sdk.model.api.MediaStreamType +import org.jellyfin.sdk.model.api.request.GetEpisodesRequest +import org.jellyfin.sdk.model.api.request.GetItemsRequest +import org.junit.Assert.* +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.justinvisser.wolfskin.design.ShellPerson +import org.justinvisser.wolfskin.design.TitleConnection +import org.justinvisser.wolfskin.design.TitleState +import org.justinvisser.wolfskin.tv.read.TitleReadFacade +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.RuntimeEnvironment +import java.util.UUID + +/** SDK replies stay in-process; disk reload and identity isolation exercise the real facade. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class) +class TitleReadFacadeTest { + @get:Rule val directory = TemporaryFolder() + private val library = mockk<UserLibraryApi>() + private val items = mockk<ItemsApi>() + private val shows = mockk<TvShowsApi>() + private val api = mockk<ApiClient>() + private val playback = mockk<ItemPlaybackRepository>() + private val repository = mockk<ServerRepository>() + private val preferences = UserPreferences(AppPreferences.getDefaultInstance(), null) + private lateinit var facade: TitleReadFacade + private val firstId = UUID.fromString("00000000-0000-0000-0000-000000000001") + private val secondId = UUID.fromString("00000000-0000-0000-0000-000000000002") + private val seriesId = UUID.fromString("00000000-0000-0000-0000-000000000003") + private val seasonId = UUID.fromString("00000000-0000-0000-0000-000000000004") + private val sourceId = UUID.fromString("00000000-0000-0000-0000-000000000005").toString().replace("-", "") + + @Before fun setup() { + // BaseItem formats runtime using application resources; no Hilt/network application is started. + val app = mockk<WholphinApplication> { + every { resources } returns RuntimeEnvironment.getApplication().resources + } + mockkObject(WholphinApplication.Companion) + every { WholphinApplication.instance } returns app + every { api.userLibraryApi } returns library + every { api.itemsApi } returns items + every { api.tvShowsApi } returns shows + every { repository.currentServer } returns null + every { repository.currentUser } returns null + coEvery { playback.getSelectedTracks(any(), any(), any()) } returns null + coEvery { items.getItems(any<GetItemsRequest>()) } returns successQueryResult(emptyList()) + val context = mockk<Context> { every { filesDir } returns directory.root } + facade = TitleReadFacade(api, mockk<ImageUrlService>(relaxed = true), playback, repository, mockk<FavoriteWatchManager>(), context) + } + + @After fun teardown() { + unmockkObject(WholphinApplication.Companion) + } + + private fun file(id: UUID, kind: BaseItemKind = BaseItemKind.MOVIE): BaseItemDto { + val source = TestTracks.Builder().addVideo().addAudio().addSubtitle().buildMediaSourceInfo() + val streams = source.mediaStreams.orEmpty().map { stream -> + when (stream.type) { + MediaStreamType.VIDEO -> stream.copy(index = 0, displayTitle = "1080p") + MediaStreamType.AUDIO -> stream.copy(index = 3, displayTitle = "English", isDefault = true) + MediaStreamType.SUBTITLE -> stream.copy(index = 8, displayTitle = "Dutch") + else -> error("Unexpected track in the SDK fixture builder") + } + } + return BaseItemDto(id = id, name = "A real file", type = kind, runTimeTicks = 6_000_000_000, + mediaSources = listOf(source.copy(id = sourceId, name = "Original", container = "mkv", mediaStreams = streams))) + } + + @Test fun heldMovieResolvesCatalogueIdentityWhenTheDetailOmitsProviderIds() = runTest { + coEvery { library.getItem(firstId) } returns successResponse(file(firstId)) + coEvery { items.getItems(any<GetItemsRequest>()) } returns successQueryResult( + listOf(file(firstId).copy(providerIds = mapOf("Tmdb" to "42")))) + val page = facade.read(firstId, null, preferences) + assertEquals(42L, page.item.tmdbId) + assertEquals(firstId.toString(), page.watchTarget.id) + assertEquals(firstId.toString(), TitleState(page, ShellPerson("shared", "Shared", "S", false)) {}.target?.id) + FakeConductarr().use { fake -> + ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()).use { session -> + session.select("server:shared", SHARED_TOKEN, fake.url) + session.authenticate() + val screen = TitleState(page, ShellPerson("shared", "Shared", "S", false)) {} + ConductarrTitlePages(session.identity.value!!).title(requireNotNull(page.item.tmdbId), + org.justinvisser.wolfskin.conductarr.MediaType.MOVIE, page).collect { screen.update(it) } + assertEquals(listOf("header", "cast", "related"), screen.rows) + assertEquals("Sam Rockwell", screen.data.cast.single().name) + assertEquals(firstId.toString(), screen.target?.id) + assertTrue(screen.data.item.held) + } + } + } + + @Test fun diskCopyPreservesFileAndNonContiguousStreamIndexesWithoutRatingsBeforeSignIn() = runTest { + coEvery { library.getItem(firstId) } returns successResponse(file(firstId).copy(userData = playbackUserData(firstId, 200_000_000L))) + facade.read(firstId, null, preferences) + coEvery { library.getItem(any()) } throws java.io.IOException("offline") + val saved = facade.readSaved(firstId, preferences)!! + val screen = TitleState(saved, ShellPerson("justin", "Justin", "J", false)) {} + assertEquals("MKV", saved.versions.single().container) + assertEquals(3, screen.audio?.index) + assertEquals(listOf(8), saved.versions.single().subtitles.map { it.index }) + assertEquals(TitleConnection.SignedOut, saved.connection) + assertTrue(saved.item.ratings.isEmpty()) + assertEquals(firstId.toString(), saved.watchTarget.id) + assertFalse(screen.options.any { it.id == "play" || it.id == "restart" }) + } + + @Test fun diskCopiesNeverCrossViewerIdentity() = runTest { + val userA = mockk<JellyfinUser> { every { id } returns firstId } + val userB = mockk<JellyfinUser> { every { id } returns secondId } + every { repository.currentUser } returns userA + coEvery { library.getItem(firstId) } returns successResponse(file(firstId)) + facade.read(firstId, null, preferences) + every { repository.currentUser } returns userB + assertNull(facade.readSaved(firstId, preferences)) + every { repository.currentUser } returns userA + assertEquals("A real file", facade.readSaved(firstId, preferences)?.item?.name) + } + + @Test fun personBiographyAndContinuousCreditsRemainReadableFromDiskWithoutAService() = runTest { + val actor = BaseItemDto(id = firstId, type = BaseItemKind.PERSON, name = "Actor", overview = "Actual biography") + coEvery { library.getItem(firstId) } returns successResponse(actor) + coEvery { items.getItems(any<GetItemsRequest>()) } returns successQueryResult(listOf(file(secondId))) + facade.readPerson(firstId, 0) + coEvery { library.getItem(any()) } throws java.io.IOException("offline") + coEvery { items.getItems(any<GetItemsRequest>()) } throws java.io.IOException("offline") + val saved = facade.readPersonSaved(firstId)!! + assertEquals("Actual biography", saved.person.biography) + assertEquals(secondId.toString(), saved.credits.single().id) + assertEquals("A real file", saved.credits.single().name) + } + + @Test fun personSavedCopiesNeverCrossServerOrViewerIdentity() = runTest { + val userA = mockk<JellyfinUser> { every { id } returns firstId } + val userB = mockk<JellyfinUser> { every { id } returns secondId } + val serverA = mockk<JellyfinServer> { every { id } returns seriesId } + val serverB = mockk<JellyfinServer> { every { id } returns seasonId } + every { repository.currentUser } returns userA + every { repository.currentServer } returns serverA + coEvery { library.getItem(firstId) } returns successResponse( + BaseItemDto(id = firstId, type = BaseItemKind.PERSON, name = "Saved actor", overview = "Private biography")) + coEvery { items.getItems(any<GetItemsRequest>()) } returns successQueryResult(listOf(file(secondId))) + facade.readPerson(firstId, 0) + every { repository.currentUser } returns userB + assertNull(facade.readPersonSaved(firstId)) + every { repository.currentUser } returns userA + every { repository.currentServer } returns serverB + assertNull(facade.readPersonSaved(firstId)) + every { repository.currentServer } returns serverA + val saved = requireNotNull(facade.readPersonSaved(firstId)) + assertEquals("Private biography", saved.person.biography) + assertEquals(secondId.toString(), saved.credits.single().id) + } + + @Test fun cataloguePlaybackLookupRejectsAnotherMediaTypeOrTmdbIdentity() = runTest { + var payload = """{"Items":[{"Id":"$firstId","Type":"Series","ProviderIds":{"Tmdb":"42"}}]}""" + coEvery { api.request(any(), any(), any(), any(), any()) } coAnswers { + org.jellyfin.sdk.api.client.RawResponse(org.json.JSONObject(payload).put("TotalRecordCount", 1).put("StartIndex", 0).toString().toByteArray(), 200, emptyMap()) + } + assertNull(facade.findHeld(42, "movie")) + payload = """{"Items":[{"Id":"$firstId","Type":"Movie","ProviderIds":{"Tmdb":"43"}}]}""" + assertNull(facade.findHeld(42, "movie")) + payload = """{"Items":[{"Id":"$firstId","Type":"Movie","ProviderIds":{"Tmdb":"42"}}]}""" + assertEquals(firstId, facade.findHeld(42, "movie")) + } + + @Test fun explicitEpisodeStaysThePlayAndWatchTargetAcrossDifferentSeasonSelection() = runTest { + val episode = file(firstId, BaseItemKind.EPISODE).copy(seriesId = seriesId, parentIndexNumber = 4, indexNumber = 9) + val other = file(secondId, BaseItemKind.EPISODE).copy(seriesId = seriesId, parentIndexNumber = 2, indexNumber = 1) + val series = BaseItemDto(id = seriesId, name = "Series", type = BaseItemKind.SERIES) + val secondSeason = BaseItemDto(id = seasonId, type = BaseItemKind.SEASON, indexNumber = 2) + coEvery { library.getItem(firstId) } returns successResponse(episode) + coEvery { library.getItem(seriesId) } returns successResponse(series) + coEvery { items.getItems(any<GetItemsRequest>()) } returns successQueryResult(listOf(secondSeason)) + coEvery { shows.getEpisodes(any<GetEpisodesRequest>()) } returns successQueryResult(listOf(other)) + val data = facade.read(firstId, 2, preferences) + assertEquals(seriesId.toString(), data.item.id) + assertEquals(firstId.toString(), data.playTarget?.id) + assertEquals(firstId.toString(), data.watchTarget.id) + assertEquals(listOf(secondId.toString()), data.episodes.map { it.id }) + assertEquals(listOf(2), data.seasonNumbers) + assertEquals(2, TitleState(data, ShellPerson("justin", "Justin", "J", false)) {}.season) + } + + @Test fun compactJellyfinSourceIdUsesTheSameVersionPersistenceKey() = runTest { + coEvery { library.getItem(firstId) } returns successResponse(file(firstId)) + coEvery { playback.savePlayVersion(any(), any()) } returns null + val data = facade.read(firstId, null, preferences) + facade.apply(org.justinvisser.wolfskin.design.TitleAction.Version(data.watchTarget, data.versions.single())) + coVerify(exactly = 1) { playback.savePlayVersion(firstId, UUID.fromString("00000000-0000-0000-0000-000000000005")) } + } + @Test fun subtitleOffDisablesPlaybackRatherThanRestoringThePreferredLanguage() = runTest { + coEvery { library.getItem(firstId) } returns successResponse(file(firstId)) + coEvery { playback.saveTrackSelection(any(), any(), any(), MediaStreamType.SUBTITLE) } returns + com.github.damontecres.wholphin.data.model.ItemPlayback(userId = 1, itemId = firstId) + val data = facade.read(firstId, null, preferences) + facade.apply(org.justinvisser.wolfskin.design.TitleAction.Track(data.watchTarget, data.versions.single(), audio = null, subtitle = -1)) + coVerify(exactly = 1) { playback.saveTrackSelection(any(), any(), com.github.damontecres.wholphin.data.model.TrackIndex.DISABLED, MediaStreamType.SUBTITLE) } + coVerify(exactly = 0) { playback.saveTrackSelection(any(), any(), com.github.damontecres.wholphin.data.model.TrackIndex.UNSPECIFIED, MediaStreamType.SUBTITLE) } + facade.apply(org.justinvisser.wolfskin.design.TitleAction.Track(data.watchTarget, data.versions.single(), audio = null, subtitle = 8)) + coVerify(exactly = 1) { playback.saveTrackSelection(any(), any(), 8, MediaStreamType.SUBTITLE) } + } +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleTrailerTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleTrailerTest.kt new file mode 100644 index 00000000..afa137ee --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleTrailerTest.kt @@ -0,0 +1,51 @@ +package org.justinvisser.wolfskin.tv + +import android.app.Application +import android.content.Context +import android.content.Intent +import android.content.pm.ActivityInfo +import android.content.pm.ResolveInfo +import android.net.Uri +import androidx.test.core.app.ApplicationProvider +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +/** Browser handlers must never become a fallback for a missing YouTube app. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class) +class TitleTrailerTest { + private val context = ApplicationProvider.getApplicationContext<Context>() + private val url = "https://www.youtube.com/watch?v=abcdefghijk" + + @Test fun missingYoutubeHandlerStaysOnTheTitleEvenWhenABrowserExists() { + register(Intent(Intent.ACTION_VIEW, Uri.parse(url)), "test.browser") + assertFalse(TitleTrailer(context).open(url)) + assertNull(shadowOf(context as Application).nextStartedActivity) + } + + @Test fun installedYoutubeHandlerReceivesAnExplicitPackageAndNoBrowserUrl() { + register(Intent(Intent.ACTION_VIEW, Uri.parse("vnd.youtube:abcdefghijk")), "test.youtube") + assertTrue(TitleTrailer(context).open(url)) + val launched = shadowOf(context as Application).nextStartedActivity + assertEquals("test.youtube", launched.`package`) + assertEquals("vnd.youtube", launched.data?.scheme) + assertEquals("abcdefghijk", launched.data?.schemeSpecificPart) + } + + @Test fun nonYoutubeTrailerNeverOpensAGeneralViewHandler() { + register(Intent(Intent.ACTION_VIEW, Uri.parse("https://trailers.example/video")), "test.browser") + assertFalse(TitleTrailer(context).open("https://trailers.example/video")) + assertNull(shadowOf(context as Application).nextStartedActivity) + } + + private fun register(intent: Intent, name: String) { + val info = ResolveInfo().apply { + activityInfo = ActivityInfo().apply { packageName = name; this.name = "$name.Player"; exported = true } + } + shadowOf(context.packageManager).addResolveInfoForIntent(intent, info) + } +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/UpstreamContentFocusTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/UpstreamContentFocusTest.kt new file mode 100644 index 00000000..5c55f1fc --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/UpstreamContentFocusTest.kt @@ -0,0 +1,145 @@ +package org.justinvisser.wolfskin.tv + +import android.app.Application +import android.view.View +import android.view.WindowManager +import androidx.activity.ComponentActivity +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toPixelMap +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsFocused +import androidx.compose.ui.test.assertIsNotFocused +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.pressKey +import androidx.compose.ui.unit.dp +import com.github.damontecres.wholphin.data.model.JellyfinServer +import com.github.damontecres.wholphin.data.model.JellyfinUser +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.BackdropStyle +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.services.BackdropResult +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.takahirom.roborazzi.captureRoboImage +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import java.io.File +import java.util.UUID +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.After +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.justinvisser.wolfskin.design.ShellPerson +import org.justinvisser.wolfskin.design.ShellState +import org.justinvisser.wolfskin.design.ShellSurface +import org.justinvisser.wolfskin.design.WolfskinTheme +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** The rollout-off path must preserve native content focus and its backdrop beneath the shell. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class UpstreamContentFocusTest { + @get:Rule val rule = createAndroidComposeRule<ComponentActivity>() + + @Before fun prepare() { + WolfskinScreenPreference.initialize(rule.activity) + WolfskinScreenPreference.enabled = false + rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN) + @Suppress("DEPRECATION") + rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY + } + + @After fun restore() { WolfskinScreenPreference.enabled = true } + + private fun shellState() = ShellState(listOf(ShellPerson("justin", "Justin", "J", true)), "justin").apply { + pickerVisible = false + navFocused = false + } + + @Test fun switchOffUpMovesBetweenContentRowsBeforeEnteringNav() { + val state = shellState() + val first = FocusRequester() + val second = FocusRequester() + rule.setContent { + val upstream = resolveUpstreamDestination(Destination.Home(), WolfskinScreenPreference.enabled, emptyMap()) + WolfskinTheme { + ShellSurface(state, handlesContentKeys = upstream == null) { contentModifier -> + Column(contentModifier.padding(start = 100.dp, top = 150.dp), verticalArrangement = Arrangement.spacedBy(32.dp)) { + Box(Modifier.size(200.dp, 60.dp).testTag("upstream-first").focusRequester(first).focusable()) + Box(Modifier.size(200.dp, 60.dp).testTag("upstream-second").focusRequester(second).focusable()) + } + } + } + } + rule.runOnIdle { second.requestFocus() } + rule.onNodeWithTag("upstream-second").assertIsFocused() + rule.onNodeWithTag("upstream-second").performKeyInput { pressKey(Key.DirectionUp) } + rule.onNodeWithTag("upstream-first").assertIsFocused() + rule.runOnIdle { assertFalse("Up from the second row must not enter nav", state.navFocused) } + rule.onNodeWithTag("upstream-first").performKeyInput { pressKey(Key.DirectionUp) } + rule.runOnIdle { assertTrue("Only the first row exits upward to nav", state.navFocused) } + rule.onNodeWithTag("upstream-first").assertIsNotFocused() + rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionDown) } + rule.onNodeWithTag("upstream-first").assertIsFocused() + } + + @Test fun switchOffShowsRealUpstreamBackdropAndSwitchOnRestoresOwnedBackground() { + val state = shellState() + val model = mockk<WolfskinShellViewModel>(relaxed = true) + every { model.session } returns WolfskinSession().also { it.state = state } + every { model.conductarr } returns ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()) + every { model.libraryDestinations } returns emptyMap() + // An unavailable saved identity uses the real sign-in placeholder, avoiding any server reads. + every { model.signedInPerson } returns "unavailable" + every { model.backdropFlow } returns MutableStateFlow(BackdropResult("fixture", null, Color.Red, Color.Red, Color.Red)) + coEvery { model.load(any(), any()) } returns Unit + val server = JellyfinServer(UUID.fromString("00000000-0000-0000-0000-000000000001"), "Fixture", "https://example.invalid", null) + val user = JellyfinUser(id = UUID.fromString("00000000-0000-0000-0000-000000000002"), name = "Justin", serverId = server.id, accessToken = null) + val defaults = AppPreferences.getDefaultInstance() + val preferences = UserPreferences(defaults.toBuilder().setInterfacePreferences(defaults.interfacePreferences.toBuilder().setBackdropStyle(BackdropStyle.BACKDROP_DYNAMIC_COLOR)).build(), null) + val navigation = NavigationManager() + rule.setContent { WolfskinApp(server, user, navigation, preferences, Modifier.fillMaxSize(), model) } + rule.mainClock.advanceTimeBy(1500) + rule.waitForIdle() + val off = rule.onRoot().captureToImage().toPixelMap()[1700, 900] + assertTrue("The real dynamic backdrop must remain visible through native content", off.red > .4f && off.green < .1f && off.blue < .1f) + val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory"))) + directory.mkdirs() + rule.onRoot().captureRoboImage(File(directory, "review-upstream-off.png").absolutePath) + rule.runOnIdle { WolfskinScreenPreference.enabled = true } + rule.mainClock.advanceTimeBy(1500) + rule.waitForIdle() + rule.onNodeWithText("Sign in as Justin to open their Jellyfin library").assertIsDisplayed() + val on = rule.onRoot().captureToImage().toPixelMap()[1700, 900] + val background = org.justinvisser.wolfskin.design.WolfskinTokens.Color.bg + org.junit.Assert.assertEquals("Owned background red", background.red, on.red, 1f / 255f) + org.junit.Assert.assertEquals("Owned background green", background.green, on.green, 1f / 255f) + org.junit.Assert.assertEquals("Owned background blue", background.blue, on.blue, 1f / 255f) + rule.onRoot().captureRoboImage(File(directory, "review-upstream-on.png").absolutePath) + } +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/WatchReadTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/WatchReadTest.kt new file mode 100644 index 00000000..a7901727 --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/WatchReadTest.kt @@ -0,0 +1,135 @@ +package org.justinvisser.wolfskin.tv + +import android.app.Application +import android.content.Context +import com.github.damontecres.wholphin.WholphinApplication +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.services.ImageUrlService +import com.github.damontecres.wholphin.ui.successQueryResult +import com.github.damontecres.wholphin.ui.successResponse +import io.mockk.* +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.* +import kotlinx.serialization.json.Json +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.itemsApi +import org.jellyfin.sdk.api.client.extensions.tvShowsApi +import org.jellyfin.sdk.api.client.extensions.userLibraryApi +import org.jellyfin.sdk.api.operations.ItemsApi +import org.jellyfin.sdk.api.operations.TvShowsApi +import org.jellyfin.sdk.api.operations.UserLibraryApi +import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult +import org.jellyfin.sdk.model.api.BaseItemKind +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.justinvisser.wolfskin.conductarr.MemoryCacheStore +import org.justinvisser.wolfskin.design.* +import org.justinvisser.wolfskin.tv.read.* +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.data.model.JellyfinServer +import com.github.damontecres.wholphin.data.model.JellyfinUser +import com.github.damontecres.wholphin.services.NavigationManager +import java.util.UUID +import com.github.damontecres.wholphin.util.WholphinDispatchers +import com.github.damontecres.wholphin.util.configure +import com.github.damontecres.wholphin.util.reset + +/** Recorded Jellyfin watch shapes exercise newer checkpoints and independent Home publication. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class) +@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) +class WatchReadTest { + private val api = mockk<ApiClient>() + private val library = mockk<UserLibraryApi>() + private val shows = mockk<TvShowsApi>() + private val items = mockk<ItemsApi>() + private val images = mockk<ImageUrlService>(relaxed = true) + private val userId = UUID.fromString("167649f8-00c9-44b5-a86a-f371eccc2d2e") + private val seriesId = UUID.fromString("00000000-0000-0000-0000-000000000003") + private val json = Json { ignoreUnknownKeys = true } + // Selected fields from the read-only Resume and NextUp replies, without credentials. + private val resumeWire = """{"Items":[{"Id":"a8ed46755a50a5dfb443dd5cdd7125a3","Name":"Sex and the Country","Type":"Episode","ParentIndexNumber":4,"IndexNumber":9,"RunTimeTicks":17507200000,"UserData":{"PlaybackPositionTicks":960780000,"Played":false,"PlayCount":1,"IsFavorite":false,"LastPlayedDate":null,"Key":"","ItemId":"a8ed46755a50a5dfb443dd5cdd7125a3"}}],"TotalRecordCount":1,"StartIndex":0}""" + private val nextWire = """{"Items":[{"Id":"d6a9588e91a4a22ecb9ae279eb7e09d6","Name":"Me and My Town","Type":"Episode","SeriesName":"Desperate Housewives","ParentIndexNumber":5,"IndexNumber":9,"RunTimeTicks":25368960000,"UserData":{"PlaybackPositionTicks":11362740000,"Played":false,"PlayCount":2,"IsFavorite":false,"LastPlayedDate":"2026-11-06T10:39:27.1772471Z","Key":"","ItemId":"d6a9588e91a4a22ecb9ae279eb7e09d6"}}],"TotalRecordCount":1,"StartIndex":0}""" + private fun decode(wire: String) = json.decodeFromString(BaseItemDtoQueryResult.serializer(), wire).items.first() + + @Before fun setup() { + every { api.userLibraryApi } returns library + every { api.tvShowsApi } returns shows + every { api.itemsApi } returns items + mockkObject(WholphinApplication.Companion) + every { WholphinApplication.instance } returns mockk { + every { resources } returns RuntimeEnvironment.getApplication().resources + } + } + + @After fun teardown() { unmockkObject(WholphinApplication.Companion) } + + @Test fun bothFacadesUseTheLiveCheckpointAndSeriesNextUpWhileExplicitEpisodeKeepsItsIdentity() = runTest { + val resume = decode(resumeWire) + val next = decode(nextWire) + val series = BaseItemDto(id = seriesId, type = BaseItemKind.SERIES) + coEvery { library.getItem(itemId = seriesId, userId = userId) } returns successResponse(series) + coEvery { library.getItem(itemId = resume.id, userId = userId) } returns successResponse(resume) + coEvery { library.getItem(itemId = next.id, userId = userId) } returns successResponse(next) + coEvery { shows.getNextUp(userId = userId, seriesId = seriesId, limit = 1, enableResumable = true, enableUserData = true) } returns successQueryResult(listOf(next)) + val home = HomeReadFacade(api, images, mockk()) + val repository = mockk<ServerRepository> { + every { currentUser } returns mockk { every { id } returns userId } + } + val title = TitleReadFacade(api, images, mockk(), repository, mockk(), mockk<Context>()) + val cached = HomeTitle(resume.id.toString(), "Sex and the Country", "episode", resumeSeconds = 10.0) + assertEquals(96_078L, home.preparePlayback(userId, cached)!!.playbackPositionMs()) + assertEquals(96_078L, title.preparePlayback(resume.id)!!.playbackPositionMs()) + assertEquals(next.id, home.preparePlayback(userId, HomeTitle(seriesId.toString(), "Series", "tv"))!!.id) + val selected = title.preparePlayback(seriesId)!! + assertEquals(next.id, selected.id) + assertEquals(1_136_274L, selected.playbackPositionMs()) + coVerify(exactly = 2) { shows.getNextUp(userId = userId, seriesId = seriesId, limit = 1, enableResumable = true, enableUserData = true) } + val completed = resume.copy(userData = resume.userData!!.copy(playbackPositionTicks = 0, played = true)) + coEvery { library.getItem(itemId = resume.id, userId = userId) } returns successResponse(completed) + assertEquals(0L, home.preparePlayback(userId, cached)!!.playbackPositionMs()) + assertEquals(true, title.preparePlayback(resume.id)!!.userData?.played) + } + + @Test fun freshResumeAndNextUpPaintBeforeSlowLibraryAndSurviveItsFailure() = runTest { + WholphinDispatchers.configure(StandardTestDispatcher(testScheduler)) + val server = JellyfinServer(seriesId, "Fixture", "https://example.invalid", null) + val user = JellyfinUser(id = userId, serverId = server.id, name = "Mindel", accessToken = null) + val person = ShellPerson("mindel", "Mindel", "M", false, userId.toString()) + val resume = decode(resumeWire) + val next = decode(nextWire) + coEvery { items.getResumeItems(userId = userId, fields = any(), mediaTypes = any(), limit = 20) } returns successQueryResult(listOf(resume)) + coEvery { shows.getNextUp(userId = userId, fields = any(), limit = 20, enableUserData = true) } returns successQueryResult(listOf(next)) + val facade = spyk(HomeReadFacade(api, images, mockk())) + val releaseLibrary = CompletableDeferred<Unit>() + coEvery { facade.readLibrary(userId) } coAnswers { releaseLibrary.await(); throw java.io.IOException("Library failed") } + val cache = mockk<HomeLibraryCache>(relaxed = true) + every { cache.read(any()) } returns (HomeLibrary(resume = listOf(HomeTitle(resume.id.toString(), "Cached episode", "episode", resumeSeconds = 10.0))) to 1L) + val session = ConductarrSession(MemoryCacheStore()) + val model = WolfskinHomeViewModel(RuntimeEnvironment.getApplication(), facade, cache, NavigationManager(), WolfskinSeeAllLists(), session) + try { + val loading = launch { model.loadLibrary(server, user, person) } + runCurrent() + assertFalse(loading.isCompleted) + assertTrue(model.snapshot.rows.first().watchReady) + assertEquals(1136.274, model.snapshot.rows.single { it.id == "nu" }.cards.single().resumeSeconds, .0001) + releaseLibrary.complete(Unit) + loading.join() + assertEquals(96.078, model.snapshot.rows.first().cards.first().resumeSeconds, .0001) + assertTrue(model.snapshot.jellyfinAvailable) + } finally { + model.viewModelScope.cancel() + session.close() + WholphinDispatchers.reset() + } + } +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/WatchReturnTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/WatchReturnTest.kt new file mode 100644 index 00000000..494c8412 --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/WatchReturnTest.kt @@ -0,0 +1,77 @@ +package org.justinvisser.wolfskin.tv + +import android.app.Application +import android.os.Looper +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.data.model.JellyfinServer +import com.github.damontecres.wholphin.data.model.JellyfinUser +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.util.PlaybackWatchState +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.cancel +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.justinvisser.wolfskin.conductarr.MemoryCacheStore +import org.justinvisser.wolfskin.design.* +import org.justinvisser.wolfskin.tv.read.HomeLibraryCache +import org.justinvisser.wolfskin.tv.read.HomeReadFacade +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import java.util.UUID +import java.util.concurrent.atomic.AtomicReference + +/** A still-composed Home must paint the checkpoint acknowledged after its first return read. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +class WatchReturnTest { + @get:Rule val rule = createAndroidComposeRule<ComponentActivity>() + + @Test fun acknowledgedStopRefreshesVisibleHeroWithoutRecreatingHomeOrLosingFocus() { + val server = JellyfinServer(UUID.randomUUID(), "Fixture", "https://example.invalid", null) + val user = JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Mindel", accessToken = null) + val person = ShellPerson("mindel", "Mindel", "M", false, user.id.toString()) + val shell = ShellState(listOf(person), person.key).apply { pickerVisible = false; pickerLaunch = false } + val episode = HomeTitle("episode", "Me and My Town", "episode", runtimeSeconds = 2536, resumeSeconds = 10.0, held = true, seriesTitle = "Desperate Housewives", season = 5, episode = 9) + val current = AtomicReference(episode) + val facade = mockk<HomeReadFacade>() + every { facade.audiences } returns null + coEvery { facade.readWatch(user.id) } coAnswers { HomeLibrary(resume = listOf(current.get())) } + coEvery { facade.readLibrary(user.id) } returns HomeLibrary() + val cache = mockk<HomeLibraryCache>(relaxed = true) + every { cache.read(any()) } returns null + val session = ConductarrSession(MemoryCacheStore()) + val model = WolfskinHomeViewModel(rule.activity, facade, cache, NavigationManager(), WolfskinSeeAllLists(), session) + val oldFixtures = ConductarrPreference.fixtures + ConductarrPreference.updateFixtures(false) + try { + rule.setContent { WolfskinTheme { + ShellSurface(shell) { HomeDestination(shell, server, user, it, model) } + } } + rule.waitUntil(5000) { + shadowOf(Looper.getMainLooper()).idle() + model.snapshot.rows.firstOrNull()?.cards?.firstOrNull()?.resumeSeconds == 10.0 + } + val view = shell.rememberedView("home") + rule.runOnIdle { shell.navFocused = false; view.cards["hero"] = 0; current.set(episode.copy(resumeSeconds = 1136.274)) } + PlaybackWatchState.stopped(rule.activity, "${server.id}:${user.id}") + rule.waitUntil(5000) { + shadowOf(Looper.getMainLooper()).idle() + model.snapshot.rows.firstOrNull()?.cards?.firstOrNull()?.resumeSeconds == 1136.274 + } + assertSame(view, shell.rememberedView("home")) + assertEquals(0, view.cards["hero"]) + assertTrue(model.snapshot.rows.first().watchReady) + } finally { + model.viewModelScope.cancel() + session.close() + ConductarrPreference.updateFixtures(oldFixtures) + } + } +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinArtworkTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinArtworkTest.kt new file mode 100644 index 00000000..9e4d1118 --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinArtworkTest.kt @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.tv + +import android.app.Application +import android.graphics.Bitmap +import androidx.activity.ComponentActivity +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import coil3.request.SuccessResult +import coil3.imageLoader +import coil3.SingletonImageLoader +import coil3.request.CachePolicy +import coil3.request.ImageRequest +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.JellyfinServer +import com.github.damontecres.wholphin.ui.CoilConfig +import io.mockk.every +import io.mockk.mockk +import java.io.ByteArrayOutputStream +import java.util.UUID +import java.util.concurrent.CopyOnWriteArrayList +import mockwebserver3.MockResponse +import mockwebserver3.MockWebServer +import okhttp3.OkHttpClient +import okio.Buffer +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.justinvisser.wolfskin.design.WolfskinImages +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** The shipped loader decodes both hosts, but only Jellyfin receives the injected credential. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class WolfskinArtworkTest { + @get:Rule val rule = createAndroidComposeRule<ComponentActivity>() + + @OptIn(coil3.annotation.DelicateCoilApi::class) + @Test fun inheritedAuthenticatedFetcherCannotSendTheJellyfinTokenToAnotherArtworkHost() { + MockWebServer().use { jellyfin -> + MockWebServer().use { artwork -> + jellyfin.start(); artwork.start() + val context = rule.activity + val credential = "MediaBrowser Client=\"Wolfskin test\", Token=\"$SHARED_TOKEN\"" + val authenticated = OkHttpClient.Builder().addInterceptor { chain -> + chain.proceed(chain.request().newBuilder().addHeader("Authorization", credential).build()) + }.build() + val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", jellyfin.url("/").toString(), null) + val repository = mockk<ServerRepository> { every { currentServer } returns server } + val network = WolfskinArtwork(authenticated, repository) + val bytes = ByteArrayOutputStream().also { + Bitmap.createBitmap(2, 2, Bitmap.Config.ARGB_8888).compress(Bitmap.CompressFormat.PNG, 100, it) + }.toByteArray() + jellyfin.enqueue(MockResponse.Builder().body(Buffer().write(bytes)).build()) + artwork.enqueue(MockResponse.Builder().body(Buffer().write(bytes)).build()) + artwork.enqueue(MockResponse.Builder().body(Buffer().write(bytes)).build()) + val urls = listOf(jellyfin.url("/jellyfin.png"), + artwork.url("/public.png").newBuilder().host("127.0.0.1").build(), + artwork.url("/another-port.png")) + val results = CopyOnWriteArrayList<coil3.request.ImageResult>() + val previousParent = context.applicationContext.imageLoader + try { + // Other screen tests may already have created Coil's process singleton. + SingletonImageLoader.reset() + rule.setContent { + CoilConfig(1024 * 1024L, authenticated, debugLogging = false, enableCache = false) + LaunchedEffect(network) { + // Force the upstream authenticated parent to exist before configuring Wolfskin. + context.applicationContext.imageLoader + network.configure(context) + val loader = WolfskinImages.loader(context) + for (url in urls) results.add(loader.execute(ImageRequest.Builder(context).data(url.toString()) + .memoryCachePolicy(CachePolicy.DISABLED).diskCachePolicy(CachePolicy.DISABLED).build())) + } + } + rule.waitUntil(30_000) { + org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle() + results.size == urls.size + } + results.forEach { result -> assertEquals(2, (result as SuccessResult).image.width) } + assertEquals(credential, jellyfin.takeRequest().headers["Authorization"]) + assertNull(artwork.takeRequest().headers["Authorization"]) + assertNull(artwork.takeRequest().headers["Authorization"]) + } finally { + WolfskinImages.loader(context).shutdown() + val testParent = context.applicationContext.imageLoader + SingletonImageLoader.setUnsafe(previousParent) + testParent.shutdown() + authenticated.dispatcher.executorService.shutdown() + authenticated.connectionPool.evictAll() + } + } + } + } +} diff --git a/app/src/test/resources/conductarr/activity-history.json b/app/src/test/resources/conductarr/activity-history.json new file mode 100644 index 00000000..69a29b75 --- /dev/null +++ b/app/src/test/resources/conductarr/activity-history.json @@ -0,0 +1,2568 @@ +{ + "$schema": "https://wolfskin-tv.justinvisser.org/schemas/ActivityFeed.json", + "requester": "all", + "items": [ + { + "id": "request-11", + "tmdb_id": 14069, + "media_type": "movie", + "title": "The Girl Who Leapt Through Time", + "requester": { + "key": "shared", + "name": "shared" + }, + "actor": { + "name": "justin", + "role": "household", + "requester": "justin" + }, + "on_behalf": true, + "filed_by_you": true, + "seasons": [], + "requested_at": "2026-11-06T15:33:09Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "No Seerr request was needed: the media was already held.", + "source": "native request record", + "observed_at": "2026-11-06T15:33:10Z" + }, + { + "name": "acquisition", + "state": "done", + "summary": "Dispatched; the Arr has represented files.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-11-06T18:46:45Z" + }, + { + "name": "visibility", + "state": "done", + "summary": "shared-movies: Jellyfin shows the represented files to the viewer.", + "source": "layer visibility observation", + "observed_at": "2026-11-06T18:46:45Z" + }, + { + "name": "playback", + "state": "done", + "summary": "Jellyfin admits server playback of this movie for the viewer. This is not a media byte-read or phone compatibility check.", + "source": "authenticated Jellyfin PlaybackInfo for the viewer", + "observed_at": "2026-11-06T15:41:46Z" + } + ] + }, + { + "id": "request-10", + "tmdb_id": 290720, + "media_type": "tv", + "title": "The Final Problem", + "requester": { + "key": "shared", + "name": "shared" + }, + "actor": { + "name": "Mindel Voogd", + "role": "household", + "requester": "mindel" + }, + "on_behalf": true, + "filed_by_you": false, + "seasons": [ + 1 + ], + "requested_at": "2026-11-04T18:55:20Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Filed in Seerr as request 72.", + "source": "native request record", + "observed_at": "2026-11-04T18:55:21Z" + }, + { + "name": "acquisition", + "state": "unknown", + "summary": "Dispatched; the Arr has represented files, but which seasons they cover is not established here; open readiness for per-season coverage.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-11-06T18:31:21Z" + }, + { + "name": "visibility", + "state": "done", + "summary": "shared-tv: Jellyfin shows the represented files to the viewer.", + "source": "layer visibility observation", + "observed_at": "2026-11-06T18:31:21Z" + }, + { + "name": "playback", + "state": "partial", + "summary": "Jellyfin admits server playback for sampled episodes S01E01, S01E02, S01E03, not every requested episode. This is not a media byte-read or phone compatibility check.", + "source": "authenticated Jellyfin PlaybackInfo for the viewer", + "observed_at": "2026-11-05T23:14:52Z" + } + ], + "blocking_stage": "acquisition" + }, + { + "id": "request-9", + "tmdb_id": 289517, + "media_type": "tv", + "title": "Dead-End Job", + "requester": { + "key": "shared", + "name": "shared" + }, + "actor": { + "name": "Mindel Voogd", + "role": "household", + "requester": "mindel" + }, + "on_behalf": true, + "filed_by_you": false, + "seasons": [ + 1 + ], + "requested_at": "2026-11-04T18:54:18Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Filed in Seerr as request 71.", + "source": "native request record", + "observed_at": "2026-11-04T18:54:19Z" + }, + { + "name": "acquisition", + "state": "unknown", + "summary": "Dispatched; the Arr has represented files, but which seasons they cover is not established here; open readiness for per-season coverage.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-11-06T18:31:21Z" + }, + { + "name": "visibility", + "state": "done", + "summary": "shared-tv: Jellyfin shows the represented files to the viewer.", + "source": "layer visibility observation", + "observed_at": "2026-11-06T18:31:21Z" + }, + { + "name": "playback", + "state": "partial", + "summary": "Jellyfin admits server playback for sampled episodes S01E01, S01E02, S01E03, not every requested episode. This is not a media byte-read or phone compatibility check.", + "source": "authenticated Jellyfin PlaybackInfo for the viewer", + "observed_at": "2026-11-05T23:14:51Z" + } + ], + "blocking_stage": "acquisition" + }, + { + "id": "request-8", + "tmdb_id": 110173, + "media_type": "tv", + "title": "Partition - The Day India Burned", + "requester": { + "key": "shared", + "name": "shared" + }, + "actor": { + "name": "Justin Visser", + "role": "admin", + "requester": "justin" + }, + "on_behalf": true, + "filed_by_you": true, + "seasons": [ + 1 + ], + "requested_at": "2026-11-01T14:14:54Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Filed in Seerr as request 70.", + "source": "native request record", + "observed_at": "2026-11-01T14:14:55Z" + }, + { + "name": "acquisition", + "state": "in_progress", + "summary": "Dispatched; waiting for the Arr to import a file.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-11-06T18:46:45Z" + }, + { + "name": "visibility", + "state": "none", + "summary": "shared-tv: There is no file to show yet.", + "source": "layer visibility observation", + "observed_at": "2026-11-06T18:46:45Z" + }, + { + "name": "playback", + "state": "unknown", + "summary": "No playback record or current server playback evidence is available for this viewer.", + "source": "viewer watch record and media-health ledger" + } + ], + "blocking_stage": "acquisition" + }, + { + "id": "request-7", + "tmdb_id": 93290, + "media_type": "tv", + "title": "Breeders", + "requester": { + "key": "shared", + "name": "shared" + }, + "actor": { + "name": "Mindel Voogd", + "role": "household", + "requester": "mindel" + }, + "on_behalf": true, + "filed_by_you": false, + "seasons": [ + 1, + 2, + 3, + 4 + ], + "requested_at": "2026-10-28T18:36:56Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Filed in Seerr as request 69.", + "source": "native request record", + "observed_at": "2026-10-28T18:36:56Z" + }, + { + "name": "acquisition", + "state": "unknown", + "summary": "Dispatched; the Arr has represented files, but which seasons they cover is not established here; open readiness for per-season coverage.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-11-06T18:15:52Z" + }, + { + "name": "visibility", + "state": "done", + "summary": "shared-tv: Jellyfin shows the represented files to the viewer.", + "source": "layer visibility observation", + "observed_at": "2026-11-06T18:15:52Z" + }, + { + "name": "playback", + "state": "partial", + "summary": "Jellyfin admits server playback for sampled episodes S01E01, S02E01, S03E01, not every requested episode. This is not a media byte-read or phone compatibility check.", + "source": "authenticated Jellyfin PlaybackInfo for the viewer", + "observed_at": "2026-11-05T23:46:10Z" + } + ], + "blocking_stage": "acquisition" + }, + { + "id": "request-6", + "tmdb_id": 243316, + "media_type": "tv", + "title": "The Four Seasons (2025)", + "requester": { + "key": "shared", + "name": "shared" + }, + "actor": { + "name": "Mindel Voogd", + "role": "household", + "requester": "mindel" + }, + "on_behalf": true, + "filed_by_you": false, + "seasons": [ + 1, + 2 + ], + "requested_at": "2026-10-28T18:34:56Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Filed in Seerr as request 68.", + "source": "native request record", + "observed_at": "2026-10-28T18:34:56Z" + }, + { + "name": "acquisition", + "state": "unknown", + "summary": "Dispatched; the Arr has represented files, but which seasons they cover is not established here; open readiness for per-season coverage.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-11-06T18:00:28Z" + }, + { + "name": "visibility", + "state": "done", + "summary": "shared-tv: Jellyfin shows the represented files to the viewer.", + "source": "layer visibility observation", + "observed_at": "2026-11-06T18:00:28Z" + }, + { + "name": "playback", + "state": "partial", + "summary": "Jellyfin admits server playback for sampled episodes S01E01, S02E01, S01E02, not every requested episode. This is not a media byte-read or phone compatibility check.", + "source": "authenticated Jellyfin PlaybackInfo for the viewer", + "observed_at": "2026-11-05T22:43:54Z" + } + ], + "blocking_stage": "acquisition" + }, + { + "id": "request-5", + "tmdb_id": 260458, + "media_type": "tv", + "title": "Adults", + "requester": { + "key": "shared", + "name": "shared" + }, + "actor": { + "name": "Mindel Voogd", + "role": "household", + "requester": "mindel" + }, + "on_behalf": true, + "filed_by_you": false, + "seasons": [ + 1, + 2 + ], + "requested_at": "2026-10-23T20:55:49Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Filed in Seerr as request 67.", + "source": "native request record", + "observed_at": "2026-10-23T20:55:49Z" + }, + { + "name": "acquisition", + "state": "unknown", + "summary": "Dispatched; the Arr has represented files, but which seasons they cover is not established here; open readiness for per-season coverage.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-11-06T18:15:52Z" + }, + { + "name": "visibility", + "state": "done", + "summary": "shared-tv: Jellyfin shows the represented files to the viewer.", + "source": "layer visibility observation", + "observed_at": "2026-11-06T18:15:52Z" + }, + { + "name": "playback", + "state": "partial", + "summary": "Jellyfin admits server playback for sampled episodes S01E01, S02E01, S01E02, not every requested episode. This is not a media byte-read or phone compatibility check.", + "source": "authenticated Jellyfin PlaybackInfo for the viewer", + "observed_at": "2026-11-05T22:59:25Z" + } + ], + "blocking_stage": "acquisition" + }, + { + "id": "request-4", + "tmdb_id": 445629, + "media_type": "movie", + "title": "Fighting with My Family", + "requester": { + "key": "shared", + "name": "shared" + }, + "actor": { + "name": "Mindel Voogd", + "role": "household", + "requester": "mindel" + }, + "on_behalf": true, + "filed_by_you": false, + "seasons": [], + "requested_at": "2026-10-22T21:41:58Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Filed in Seerr as request 66.", + "source": "native request record", + "observed_at": "2026-10-22T21:41:59Z" + }, + { + "name": "acquisition", + "state": "done", + "summary": "Dispatched; the Arr has represented files.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-11-06T18:31:21Z" + }, + { + "name": "visibility", + "state": "done", + "summary": "shared-movies: Jellyfin shows the represented files to the viewer.", + "source": "layer visibility observation", + "observed_at": "2026-11-06T18:31:21Z" + }, + { + "name": "playback", + "state": "done", + "summary": "Jellyfin admits server playback of this movie for the viewer. This is not a media byte-read or phone compatibility check.", + "source": "authenticated Jellyfin PlaybackInfo for the viewer", + "observed_at": "2026-11-05T23:14:53Z" + } + ] + }, + { + "id": "request-3", + "tmdb_id": 399057, + "media_type": "movie", + "title": "The Killing of a Sacred Deer", + "requester": { + "key": "mindel", + "name": "mindel" + }, + "actor": { + "name": "Mindel Voogd", + "role": "household", + "requester": "mindel" + }, + "on_behalf": false, + "filed_by_you": false, + "seasons": [], + "requested_at": "2026-10-22T21:38:52Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Filed in Seerr as request 65.", + "source": "native request record", + "observed_at": "2026-10-22T21:38:53Z" + }, + { + "name": "acquisition", + "state": "done", + "summary": "Dispatched; the Arr has represented files.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-11-06T18:31:21Z" + }, + { + "name": "visibility", + "state": "done", + "summary": "mindel-movies: Jellyfin shows the represented files to the viewer.", + "source": "layer visibility observation", + "observed_at": "2026-11-06T18:31:21Z" + }, + { + "name": "playback", + "state": "done", + "summary": "Jellyfin admits server playback of this movie for the viewer. This is not a media byte-read or phone compatibility check.", + "source": "authenticated Jellyfin PlaybackInfo for the viewer", + "observed_at": "2026-11-05T23:14:52Z" + } + ] + }, + { + "id": "request-2", + "tmdb_id": 225745, + "media_type": "movie", + "title": "Steins;Gate: The Movie - Load Region of D\u00e9j\u00e0 Vu", + "requester": { + "key": "justin", + "name": "justin" + }, + "actor": { + "name": "Justin Visser", + "role": "admin", + "requester": "justin" + }, + "on_behalf": false, + "filed_by_you": true, + "seasons": [], + "requested_at": "2026-10-22T14:55:41Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Filed in Seerr as request 64.", + "source": "native request record", + "observed_at": "2026-10-22T14:55:41Z" + }, + { + "name": "acquisition", + "state": "done", + "summary": "Dispatched; the Arr has represented files.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-11-06T18:00:28Z" + }, + { + "name": "visibility", + "state": "failed", + "summary": "justin-movies: Jellyfin visibility needs attention.", + "source": "layer visibility observation", + "observed_at": "2026-11-06T18:00:28Z" + }, + { + "name": "playback", + "state": "unknown", + "summary": "No playback record or current server playback evidence is available for this viewer.", + "source": "viewer watch record and media-health ledger" + } + ], + "blocking_stage": "visibility" + }, + { + "id": "request-1", + "tmdb_id": 30669, + "media_type": "tv", + "title": "Yu Yu Hakusho", + "requester": { + "key": "justin", + "name": "justin" + }, + "actor": { + "name": "Justin Visser", + "role": "admin", + "requester": "justin" + }, + "on_behalf": false, + "filed_by_you": true, + "seasons": [ + 1, + 2, + 3, + 4 + ], + "requested_at": "2026-10-19T21:39:29Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Filed in Seerr as request 63.", + "source": "native request record", + "observed_at": "2026-10-19T21:39:29Z" + }, + { + "name": "acquisition", + "state": "unknown", + "summary": "Dispatched; the Arr has represented files, but which seasons they cover is not established here; open readiness for per-season coverage.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-11-06T18:00:28Z" + }, + { + "name": "visibility", + "state": "failed", + "summary": "justin-anime: Jellyfin visibility needs attention.", + "source": "layer visibility observation", + "observed_at": "2026-11-06T18:00:28Z" + }, + { + "name": "playback", + "state": "partial", + "summary": "Jellyfin records the viewer playing 5 episode(s). That is a client playback record, not a server probe.", + "source": "viewer watch record and media-health ledger", + "observed_at": "2026-11-06T16:13:21Z" + } + ], + "blocking_stage": "acquisition" + }, + { + "id": "dispatch-765", + "tmdb_id": 37735, + "media_type": "movie", + "title": "Easy A", + "requester": { + "key": "shared", + "name": "shared" + }, + "on_behalf": false, + "filed_by_you": false, + "seasons": [], + "requested_at": "2026-10-17T19:57:25Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Seerr approved a request for this viewer. It was filed outside the cockpit, so who filed it is not recorded.", + "source": "dispatch log", + "observed_at": "2026-10-17T19:57:25Z" + }, + { + "name": "acquisition", + "state": "done", + "summary": "Dispatched; the Arr has represented files.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-11-06T18:00:28Z" + }, + { + "name": "visibility", + "state": "done", + "summary": "shared-movies: Jellyfin shows the represented files to the viewer.", + "source": "layer visibility observation", + "observed_at": "2026-11-06T18:00:28Z" + }, + { + "name": "playback", + "state": "done", + "summary": "Jellyfin admits server playback of this movie for the viewer. This is not a media byte-read or phone compatibility check.", + "source": "authenticated Jellyfin PlaybackInfo for the viewer", + "observed_at": "2026-11-05T23:30:33Z" + } + ] + }, + { + "id": "dispatch-764", + "tmdb_id": 329865, + "media_type": "movie", + "title": "Arrival", + "requester": { + "key": "shared", + "name": "shared" + }, + "on_behalf": false, + "filed_by_you": false, + "seasons": [], + "requested_at": "2026-10-14T19:51:06Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Seerr approved a request for this viewer. It was filed outside the cockpit, so who filed it is not recorded.", + "source": "dispatch log", + "observed_at": "2026-10-14T19:51:06Z" + }, + { + "name": "acquisition", + "state": "done", + "summary": "Dispatched; the Arr has represented files.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-11-06T17:45:10Z" + }, + { + "name": "visibility", + "state": "done", + "summary": "shared-movies: Jellyfin shows the represented files to the viewer.", + "source": "layer visibility observation", + "observed_at": "2026-11-06T17:45:10Z" + }, + { + "name": "playback", + "state": "done", + "summary": "Jellyfin admits server playback of this movie for the viewer. This is not a media byte-read or phone compatibility check.", + "source": "authenticated Jellyfin PlaybackInfo for the viewer", + "observed_at": "2026-11-05T22:28:30Z" + } + ] + }, + { + "id": "dispatch-763", + "tmdb_id": 977942, + "media_type": "movie", + "title": "The Uprising", + "requester": { + "key": "shared", + "name": "shared" + }, + "on_behalf": false, + "filed_by_you": false, + "seasons": [], + "requested_at": "2026-10-11T17:34:21Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Seerr approved a request for this viewer. It was filed outside the cockpit, so who filed it is not recorded.", + "source": "dispatch log", + "observed_at": "2026-10-11T17:34:21Z" + }, + { + "name": "acquisition", + "state": "done", + "summary": "Dispatched; the Arr has represented files.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-11-06T18:31:21Z" + }, + { + "name": "visibility", + "state": "done", + "summary": "shared-movies: Jellyfin shows the represented files to the viewer.", + "source": "layer visibility observation", + "observed_at": "2026-11-06T18:31:21Z" + }, + { + "name": "playback", + "state": "done", + "summary": "Jellyfin admits server playback of this movie for the viewer. This is not a media byte-read or phone compatibility check.", + "source": "authenticated Jellyfin PlaybackInfo for the viewer", + "observed_at": "2026-11-05T23:14:53Z" + } + ] + }, + { + "id": "dispatch-762", + "tmdb_id": 316152, + "media_type": "movie", + "title": "Free State of Jones", + "requester": { + "key": "shared", + "name": "shared" + }, + "on_behalf": false, + "filed_by_you": false, + "seasons": [], + "requested_at": "2026-10-11T17:33:20Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Seerr approved a request for this viewer. It was filed outside the cockpit, so who filed it is not recorded.", + "source": "dispatch log", + "observed_at": "2026-10-11T17:33:20Z" + }, + { + "name": "acquisition", + "state": "done", + "summary": "Dispatched; the Arr has represented files.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-11-06T18:31:21Z" + }, + { + "name": "visibility", + "state": "done", + "summary": "shared-movies: Jellyfin shows the represented files to the viewer.", + "source": "layer visibility observation", + "observed_at": "2026-11-06T18:31:21Z" + }, + { + "name": "playback", + "state": "done", + "summary": "Jellyfin admits server playback of this movie for the viewer. This is not a media byte-read or phone compatibility check.", + "source": "authenticated Jellyfin PlaybackInfo for the viewer", + "observed_at": "2026-11-05T23:14:52Z" + } + ] + }, + { + "id": "dispatch-761", + "tmdb_id": 9040, + "media_type": "movie", + "title": "Serpico", + "requester": { + "key": "shared", + "name": "shared" + }, + "on_behalf": false, + "filed_by_you": false, + "seasons": [], + "requested_at": "2026-10-10T20:35:14Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Seerr approved a request for this viewer. It was filed outside the cockpit, so who filed it is not recorded.", + "source": "dispatch log", + "observed_at": "2026-10-10T20:35:14Z" + }, + { + "name": "acquisition", + "state": "done", + "summary": "Dispatched; the Arr has represented files.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-11-06T18:46:45Z" + }, + { + "name": "visibility", + "state": "done", + "summary": "shared-movies: Jellyfin shows the represented files to the viewer.", + "source": "layer visibility observation", + "observed_at": "2026-11-06T18:46:45Z" + }, + { + "name": "playback", + "state": "done", + "summary": "Jellyfin admits server playback of this movie for the viewer. This is not a media byte-read or phone compatibility check.", + "source": "authenticated Jellyfin PlaybackInfo for the viewer", + "observed_at": "2026-11-05T22:13:15Z" + } + ] + }, + { + "id": "dispatch-760", + "tmdb_id": 479, + "media_type": "movie", + "title": "Shaft", + "requester": { + "key": "shared", + "name": "shared" + }, + "on_behalf": false, + "filed_by_you": false, + "seasons": [], + "requested_at": "2026-10-10T20:35:14Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Seerr approved a request for this viewer. It was filed outside the cockpit, so who filed it is not recorded.", + "source": "dispatch log", + "observed_at": "2026-10-10T20:35:14Z" + }, + { + "name": "acquisition", + "state": "done", + "summary": "Dispatched; the Arr has represented files.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-11-06T18:46:45Z" + }, + { + "name": "visibility", + "state": "done", + "summary": "shared-movies: Jellyfin shows the represented files to the viewer.", + "source": "layer visibility observation", + "observed_at": "2026-11-06T18:46:45Z" + }, + { + "name": "playback", + "state": "done", + "summary": "Jellyfin admits server playback of this movie for the viewer. This is not a media byte-read or phone compatibility check.", + "source": "authenticated Jellyfin PlaybackInfo for the viewer", + "observed_at": "2026-11-05T22:13:04Z" + } + ] + }, + { + "id": "dispatch-759", + "tmdb_id": 197, + "media_type": "movie", + "title": "Braveheart", + "requester": { + "key": "shared", + "name": "shared" + }, + "on_behalf": false, + "filed_by_you": false, + "seasons": [], + "requested_at": "2026-10-10T20:35:14Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Seerr approved a request for this viewer. It was filed outside the cockpit, so who filed it is not recorded.", + "source": "dispatch log", + "observed_at": "2026-10-10T20:35:14Z" + }, + { + "name": "acquisition", + "state": "done", + "summary": "Dispatched; the Arr has represented files.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-11-06T18:46:45Z" + }, + { + "name": "visibility", + "state": "done", + "summary": "shared-movies: Jellyfin shows the represented files to the viewer.", + "source": "layer visibility observation", + "observed_at": "2026-11-06T18:46:45Z" + }, + { + "name": "playback", + "state": "done", + "summary": "Jellyfin admits server playback of this movie for the viewer. This is not a media byte-read or phone compatibility check.", + "source": "authenticated Jellyfin PlaybackInfo for the viewer", + "observed_at": "2026-11-05T22:13:02Z" + } + ] + }, + { + "id": "dispatch-758", + "tmdb_id": 9361, + "media_type": "movie", + "title": "The Last of the Mohicans", + "requester": { + "key": "shared", + "name": "shared" + }, + "on_behalf": false, + "filed_by_you": false, + "seasons": [], + "requested_at": "2026-10-10T20:34:14Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Seerr approved a request for this viewer. It was filed outside the cockpit, so who filed it is not recorded.", + "source": "dispatch log", + "observed_at": "2026-10-10T20:34:14Z" + }, + { + "name": "acquisition", + "state": "done", + "summary": "Dispatched; the Arr has represented files.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-11-06T18:46:45Z" + }, + { + "name": "visibility", + "state": "done", + "summary": "shared-movies: Jellyfin shows the represented files to the viewer.", + "source": "layer visibility observation", + "observed_at": "2026-11-06T18:46:45Z" + }, + { + "name": "playback", + "state": "done", + "summary": "Jellyfin admits server playback of this movie for the viewer. This is not a media byte-read or phone compatibility check.", + "source": "authenticated Jellyfin PlaybackInfo for the viewer", + "observed_at": "2026-11-05T22:13:15Z" + } + ] + }, + { + "id": "dispatch-757", + "tmdb_id": 2024, + "media_type": "movie", + "title": "The Patriot", + "requester": { + "key": "shared", + "name": "shared" + }, + "on_behalf": false, + "filed_by_you": false, + "seasons": [], + "requested_at": "2026-10-10T20:34:13Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Seerr approved a request for this viewer. It was filed outside the cockpit, so who filed it is not recorded.", + "source": "dispatch log", + "observed_at": "2026-10-10T20:34:13Z" + }, + { + "name": "acquisition", + "state": "done", + "summary": "Dispatched; the Arr has represented files.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-11-06T18:46:45Z" + }, + { + "name": "visibility", + "state": "done", + "summary": "shared-movies: Jellyfin shows the represented files to the viewer.", + "source": "layer visibility observation", + "observed_at": "2026-11-06T18:46:45Z" + }, + { + "name": "playback", + "state": "done", + "summary": "Jellyfin admits server playback of this movie for the viewer. This is not a media byte-read or phone compatibility check.", + "source": "authenticated Jellyfin PlaybackInfo for the viewer", + "observed_at": "2026-11-05T22:13:07Z" + } + ] + }, + { + "id": "dispatch-756", + "tmdb_id": 715931, + "media_type": "movie", + "title": "Emancipation", + "requester": { + "key": "shared", + "name": "shared" + }, + "on_behalf": false, + "filed_by_you": false, + "seasons": [], + "requested_at": "2026-10-10T20:33:13Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Seerr approved a request for this viewer. It was filed outside the cockpit, so who filed it is not recorded.", + "source": "dispatch log", + "observed_at": "2026-10-10T20:33:13Z" + }, + { + "name": "acquisition", + "state": "done", + "summary": "Dispatched; the Arr has represented files.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-11-06T18:00:28Z" + }, + { + "name": "visibility", + "state": "done", + "summary": "shared-movies: Jellyfin shows the represented files to the viewer.", + "source": "layer visibility observation", + "observed_at": "2026-11-06T18:00:28Z" + }, + { + "name": "playback", + "state": "done", + "summary": "Jellyfin admits server playback of this movie for the viewer. This is not a media byte-read or phone compatibility check.", + "source": "authenticated Jellyfin PlaybackInfo for the viewer", + "observed_at": "2026-11-05T22:43:55Z" + } + ] + }, + { + "id": "dispatch-755", + "tmdb_id": 290750, + "media_type": "tv", + "title": "Love is Blind: Netherlands", + "requester": { + "key": "mindel", + "name": "mindel" + }, + "on_behalf": false, + "filed_by_you": false, + "seasons": [], + "requested_at": "2026-10-10T16:15:28Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Seerr approved a request for this viewer. It was filed outside the cockpit, so who filed it is not recorded.", + "source": "dispatch log", + "observed_at": "2026-10-10T16:15:28Z" + }, + { + "name": "acquisition", + "state": "in_progress", + "summary": "Dispatched; waiting for the Arr to import a file.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-11-06T18:46:45Z" + }, + { + "name": "visibility", + "state": "none", + "summary": "mindel-tv: There is no file to show yet.", + "source": "layer visibility observation", + "observed_at": "2026-11-06T18:46:45Z" + }, + { + "name": "playback", + "state": "unknown", + "summary": "No playback record or current server playback evidence is available for this viewer.", + "source": "viewer watch record and media-health ledger" + } + ], + "blocking_stage": "acquisition" + }, + { + "id": "dispatch-750", + "tmdb_id": 315635, + "media_type": "movie", + "title": "Spider-Man: Homecoming", + "requester": { + "key": "justin", + "name": "justin" + }, + "on_behalf": false, + "filed_by_you": false, + "seasons": [], + "requested_at": "2026-10-10T14:05:49Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Seerr approved a request for this viewer. It was filed outside the cockpit, so who filed it is not recorded.", + "source": "dispatch log", + "observed_at": "2026-10-10T14:05:49Z" + }, + { + "name": "acquisition", + "state": "done", + "summary": "Dispatched; the Arr has represented files.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-11-06T18:00:28Z" + }, + { + "name": "visibility", + "state": "none", + "summary": "The title is only in libraries this viewer does not see: shared-movies.", + "source": "layer visibility observation" + }, + { + "name": "playback", + "state": "unknown", + "summary": "No playback record or current server playback evidence is available for this viewer.", + "source": "viewer watch record and media-health ledger" + } + ], + "blocking_stage": "visibility" + }, + { + "id": "dispatch-749", + "tmdb_id": 429617, + "media_type": "movie", + "title": "Spider-Man: Far From Home", + "requester": { + "key": "justin", + "name": "justin" + }, + "on_behalf": false, + "filed_by_you": false, + "seasons": [], + "requested_at": "2026-10-10T14:05:32Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Seerr approved a request for this viewer. It was filed outside the cockpit, so who filed it is not recorded.", + "source": "dispatch log", + "observed_at": "2026-10-10T14:05:32Z" + }, + { + "name": "acquisition", + "state": "done", + "summary": "Dispatched; the Arr has represented files.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-11-06T18:00:28Z" + }, + { + "name": "visibility", + "state": "none", + "summary": "The title is only in libraries this viewer does not see: shared-movies.", + "source": "layer visibility observation" + }, + { + "name": "playback", + "state": "unknown", + "summary": "No playback record or current server playback evidence is available for this viewer.", + "source": "viewer watch record and media-health ledger" + } + ], + "blocking_stage": "visibility" + }, + { + "id": "dispatch-748", + "tmdb_id": 634649, + "media_type": "movie", + "title": "Spider-Man: No Way Home", + "requester": { + "key": "justin", + "name": "justin" + }, + "on_behalf": false, + "filed_by_you": false, + "seasons": [], + "requested_at": "2026-10-10T14:04:54Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Seerr approved a request for this viewer. It was filed outside the cockpit, so who filed it is not recorded.", + "source": "dispatch log", + "observed_at": "2026-10-10T14:04:54Z" + }, + { + "name": "acquisition", + "state": "done", + "summary": "Dispatched; the Arr has represented files.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-11-06T18:00:28Z" + }, + { + "name": "visibility", + "state": "none", + "summary": "The title is only in libraries this viewer does not see: shared-movies.", + "source": "layer visibility observation" + }, + { + "name": "playback", + "state": "unknown", + "summary": "No playback record or current server playback evidence is available for this viewer.", + "source": "viewer watch record and media-health ledger" + } + ], + "blocking_stage": "visibility" + }, + { + "id": "dispatch-747", + "tmdb_id": 969681, + "media_type": "movie", + "title": "Spider-Man: Brand New Day", + "requester": { + "key": "justin", + "name": "justin" + }, + "on_behalf": false, + "filed_by_you": false, + "seasons": [], + "requested_at": "2026-10-10T14:04:33Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Seerr approved a request for this viewer. It was filed outside the cockpit, so who filed it is not recorded.", + "source": "dispatch log", + "observed_at": "2026-10-10T14:04:33Z" + }, + { + "name": "acquisition", + "state": "done", + "summary": "Dispatched; the Arr has represented files.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-11-06T18:00:28Z" + }, + { + "name": "visibility", + "state": "none", + "summary": "The title is only in libraries this viewer does not see: shared-movies.", + "source": "layer visibility observation" + }, + { + "name": "playback", + "state": "unknown", + "summary": "No playback record or current server playback evidence is available for this viewer.", + "source": "viewer watch record and media-health ledger" + } + ], + "blocking_stage": "visibility" + }, + { + "id": "dispatch-746", + "tmdb_id": 258165, + "media_type": "tv", + "title": "East of Eden (2026)", + "requester": { + "key": "mindel", + "name": "mindel" + }, + "on_behalf": false, + "filed_by_you": false, + "seasons": [], + "requested_at": "2026-10-04T09:22:33Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Seerr approved a request for this viewer. It was filed outside the cockpit, so who filed it is not recorded.", + "source": "dispatch log", + "observed_at": "2026-10-04T09:22:33Z" + }, + { + "name": "acquisition", + "state": "unknown", + "summary": "Dispatched; the Arr has represented files, but which seasons they cover is not established here; open readiness for per-season coverage.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-11-06T18:00:28Z" + }, + { + "name": "visibility", + "state": "none", + "summary": "The title is only in libraries this viewer does not see: shared-tv.", + "source": "layer visibility observation" + }, + { + "name": "playback", + "state": "unknown", + "summary": "No playback record or current server playback evidence is available for this viewer.", + "source": "viewer watch record and media-health ledger" + } + ], + "blocking_stage": "acquisition" + }, + { + "id": "dispatch-745", + "tmdb_id": 76148, + "media_type": "tv", + "title": "Derry Girls", + "requester": { + "key": "mindel", + "name": "mindel" + }, + "on_behalf": false, + "filed_by_you": false, + "seasons": [], + "requested_at": "2026-10-02T19:58:53Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Seerr approved a request for this viewer. It was filed outside the cockpit, so who filed it is not recorded.", + "source": "dispatch log", + "observed_at": "2026-10-02T19:58:53Z" + }, + { + "name": "acquisition", + "state": "unknown", + "summary": "Dispatched; the Arr has represented files, but which seasons they cover is not established here; open readiness for per-season coverage.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-11-06T18:15:52Z" + }, + { + "name": "visibility", + "state": "done", + "summary": "mindel-tv: Jellyfin shows the represented files to the viewer.", + "source": "layer visibility observation", + "observed_at": "2026-11-06T18:15:52Z" + }, + { + "name": "playback", + "state": "partial", + "summary": "Jellyfin admits server playback for sampled episodes S01E01, S02E01, S03E01, not every requested episode. This is not a media byte-read or phone compatibility check.", + "source": "authenticated Jellyfin PlaybackInfo for the viewer", + "observed_at": "2026-11-05T23:46:06Z" + } + ], + "blocking_stage": "acquisition" + }, + { + "id": "dispatch-744", + "tmdb_id": 42994, + "media_type": "movie", + "title": "Memories", + "requester": { + "key": "justin", + "name": "justin" + }, + "on_behalf": false, + "filed_by_you": false, + "seasons": [], + "requested_at": "2026-09-30T21:35:31Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Seerr approved a request for this viewer. It was filed outside the cockpit, so who filed it is not recorded.", + "source": "dispatch log", + "observed_at": "2026-09-30T21:35:31Z" + }, + { + "name": "acquisition", + "state": "done", + "summary": "Dispatched; the Arr has represented files.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-11-06T18:15:52Z" + }, + { + "name": "visibility", + "state": "done", + "summary": "justin-movies: Jellyfin shows the represented files to the viewer.", + "source": "layer visibility observation", + "observed_at": "2026-11-06T18:15:52Z" + }, + { + "name": "playback", + "state": "done", + "summary": "Jellyfin admits server playback of this movie for the viewer. This is not a media byte-read or phone compatibility check.", + "source": "authenticated Jellyfin PlaybackInfo for the viewer", + "observed_at": "2026-11-05T23:46:05Z" + } + ] + }, + { + "id": "dispatch-743", + "tmdb_id": 616, + "media_type": "movie", + "title": "The Last Samurai", + "requester": { + "key": "shared", + "name": "shared" + }, + "on_behalf": false, + "filed_by_you": false, + "seasons": [], + "requested_at": "2026-09-25T20:11:11Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Seerr approved a request for this viewer. It was filed outside the cockpit, so who filed it is not recorded.", + "source": "dispatch log", + "observed_at": "2026-09-25T20:11:11Z" + }, + { + "name": "acquisition", + "state": "done", + "summary": "Dispatched; the Arr has represented files.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-11-06T18:00:28Z" + }, + { + "name": "visibility", + "state": "done", + "summary": "shared-movies: Jellyfin shows the represented files to the viewer.", + "source": "layer visibility observation", + "observed_at": "2026-11-06T18:00:28Z" + }, + { + "name": "playback", + "state": "done", + "summary": "Jellyfin admits server playback of this movie for the viewer. This is not a media byte-read or phone compatibility check.", + "source": "authenticated Jellyfin PlaybackInfo for the viewer", + "observed_at": "2026-11-05T22:28:31Z" + } + ] + }, + { + "id": "dispatch-742", + "tmdb_id": 1203329, + "media_type": "movie", + "title": "The Witcher: Sirens of the Deep", + "requester": { + "key": "shared", + "name": "shared" + }, + "on_behalf": false, + "filed_by_you": false, + "seasons": [], + "requested_at": "2026-09-25T19:37:11Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Seerr approved a request for this viewer. It was filed outside the cockpit, so who filed it is not recorded.", + "source": "dispatch log", + "observed_at": "2026-09-25T19:37:11Z" + }, + { + "name": "acquisition", + "state": "done", + "summary": "Dispatched; the Arr has represented files.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-11-06T18:31:21Z" + }, + { + "name": "visibility", + "state": "done", + "summary": "shared-movies: Jellyfin shows the represented files to the viewer.", + "source": "layer visibility observation", + "observed_at": "2026-11-06T18:31:21Z" + }, + { + "name": "playback", + "state": "done", + "summary": "Jellyfin admits server playback of this movie for the viewer. This is not a media byte-read or phone compatibility check.", + "source": "authenticated Jellyfin PlaybackInfo for the viewer", + "observed_at": "2026-11-05T23:14:53Z" + } + ] + }, + { + "id": "dispatch-741", + "tmdb_id": 666243, + "media_type": "movie", + "title": "The Witcher: Nightmare of the Wolf", + "requester": { + "key": "shared", + "name": "shared" + }, + "on_behalf": false, + "filed_by_you": false, + "seasons": [], + "requested_at": "2026-09-25T19:37:05Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Seerr approved a request for this viewer. It was filed outside the cockpit, so who filed it is not recorded.", + "source": "dispatch log", + "observed_at": "2026-09-25T19:37:05Z" + }, + { + "name": "acquisition", + "state": "done", + "summary": "Dispatched; the Arr has represented files.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-11-06T18:15:52Z" + }, + { + "name": "visibility", + "state": "done", + "summary": "shared-movies: Jellyfin shows the represented files to the viewer.", + "source": "layer visibility observation", + "observed_at": "2026-11-06T18:15:52Z" + }, + { + "name": "playback", + "state": "done", + "summary": "Jellyfin admits server playback of this movie for the viewer. This is not a media byte-read or phone compatibility check.", + "source": "authenticated Jellyfin PlaybackInfo for the viewer", + "observed_at": "2026-11-05T22:59:26Z" + } + ] + }, + { + "id": "dispatch-740", + "tmdb_id": 1274, + "media_type": "tv", + "title": "Six Feet Under", + "requester": { + "key": "shared", + "name": "shared" + }, + "on_behalf": false, + "filed_by_you": false, + "seasons": [], + "requested_at": "2026-09-25T19:36:06Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Seerr approved a request for this viewer. It was filed outside the cockpit, so who filed it is not recorded.", + "source": "dispatch log", + "observed_at": "2026-09-25T19:36:06Z" + }, + { + "name": "acquisition", + "state": "unknown", + "summary": "Dispatched; the Arr has represented files, but which seasons they cover is not established here; open readiness for per-season coverage.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-11-06T18:15:52Z" + }, + { + "name": "visibility", + "state": "done", + "summary": "shared-tv: Jellyfin shows the represented files to the viewer.", + "source": "layer visibility observation", + "observed_at": "2026-11-06T18:15:52Z" + }, + { + "name": "playback", + "state": "partial", + "summary": "Jellyfin admits server playback for sampled episodes S01E01, S02E01, S03E01, not every requested episode. This is not a media byte-read or phone compatibility check.", + "source": "authenticated Jellyfin PlaybackInfo for the viewer", + "observed_at": "2026-11-05T22:43:59Z" + } + ], + "blocking_stage": "acquisition" + }, + { + "id": "dispatch-739", + "tmdb_id": 96488, + "media_type": "tv", + "title": "Interrogation", + "requester": { + "key": "shared", + "name": "shared" + }, + "on_behalf": false, + "filed_by_you": false, + "seasons": [], + "requested_at": "2026-09-25T19:35:05Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Seerr approved a request for this viewer. It was filed outside the cockpit, so who filed it is not recorded.", + "source": "dispatch log", + "observed_at": "2026-09-25T19:35:05Z" + }, + { + "name": "acquisition", + "state": "unknown", + "summary": "Dispatched; the Arr has represented files, but which seasons they cover is not established here; open readiness for per-season coverage.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-11-06T18:15:52Z" + }, + { + "name": "visibility", + "state": "done", + "summary": "shared-tv: Jellyfin shows the represented files to the viewer.", + "source": "layer visibility observation", + "observed_at": "2026-11-06T18:15:52Z" + }, + { + "name": "playback", + "state": "partial", + "summary": "Jellyfin admits server playback for sampled episodes S01E01, S01E02, S01E03, not every requested episode. This is not a media byte-read or phone compatibility check.", + "source": "authenticated Jellyfin PlaybackInfo for the viewer", + "observed_at": "2026-11-05T23:46:10Z" + } + ], + "blocking_stage": "acquisition" + }, + { + "id": "dispatch-738", + "tmdb_id": 47665, + "media_type": "tv", + "title": "Black Sails", + "requester": { + "key": "shared", + "name": "shared" + }, + "on_behalf": false, + "filed_by_you": false, + "seasons": [], + "requested_at": "2026-09-25T19:34:04Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Seerr approved a request for this viewer. It was filed outside the cockpit, so who filed it is not recorded.", + "source": "dispatch log", + "observed_at": "2026-09-25T19:34:04Z" + }, + { + "name": "acquisition", + "state": "none", + "summary": "The title was deleted after it was dispatched.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-09-25T19:34:04Z" + }, + { + "name": "visibility", + "state": "none", + "summary": "The title is not in a library this viewer sees.", + "source": "layer visibility observation" + }, + { + "name": "playback", + "state": "partial", + "summary": "Jellyfin records the viewer playing 1 episode(s). That is a client playback record, not a server probe.", + "source": "viewer watch record and media-health ledger", + "observed_at": "2026-10-12T16:24:20Z" + } + ], + "blocking_stage": "acquisition" + }, + { + "id": "dispatch-737", + "tmdb_id": 2207, + "media_type": "tv", + "title": "Fawlty Towers", + "requester": { + "key": "justin", + "name": "justin" + }, + "on_behalf": false, + "filed_by_you": false, + "seasons": [], + "requested_at": "2026-09-23T12:41:28Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Seerr approved a request for this viewer. It was filed outside the cockpit, so who filed it is not recorded.", + "source": "dispatch log", + "observed_at": "2026-09-23T12:41:28Z" + }, + { + "name": "acquisition", + "state": "unknown", + "summary": "Dispatched; the Arr has represented files, but which seasons they cover is not established here; open readiness for per-season coverage.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-11-06T18:31:21Z" + }, + { + "name": "visibility", + "state": "none", + "summary": "The title is only in libraries this viewer does not see: shared-tv.", + "source": "layer visibility observation" + }, + { + "name": "playback", + "state": "unknown", + "summary": "No playback record or current server playback evidence is available for this viewer.", + "source": "viewer watch record and media-health ledger" + } + ], + "blocking_stage": "acquisition" + }, + { + "id": "dispatch-735", + "tmdb_id": 133908, + "media_type": "tv", + "title": "SkyMed", + "requester": { + "key": "mindel", + "name": "mindel" + }, + "on_behalf": false, + "filed_by_you": false, + "seasons": [], + "requested_at": "2026-09-22T17:33:55Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Seerr approved a request for this viewer. It was filed outside the cockpit, so who filed it is not recorded.", + "source": "dispatch log", + "observed_at": "2026-09-22T17:33:55Z" + }, + { + "name": "acquisition", + "state": "unknown", + "summary": "Dispatched; the Arr has represented files, but which seasons they cover is not established here; open readiness for per-season coverage.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-11-06T18:31:21Z" + }, + { + "name": "visibility", + "state": "done", + "summary": "mindel-tv: Jellyfin shows the represented files to the viewer.", + "source": "layer visibility observation", + "observed_at": "2026-11-06T18:31:21Z" + }, + { + "name": "playback", + "state": "partial", + "summary": "Jellyfin admits server playback for sampled episodes S01E01, S02E01, S03E01, not every requested episode. This is not a media byte-read or phone compatibility check.", + "source": "authenticated Jellyfin PlaybackInfo for the viewer", + "observed_at": "2026-11-06T00:01:41Z" + } + ], + "blocking_stage": "acquisition" + }, + { + "id": "dispatch-734", + "tmdb_id": 32504, + "media_type": "tv", + "title": "Hellcats", + "requester": { + "key": "mindel", + "name": "mindel" + }, + "on_behalf": false, + "filed_by_you": false, + "seasons": [], + "requested_at": "2026-09-22T17:31:54Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Seerr approved a request for this viewer. It was filed outside the cockpit, so who filed it is not recorded.", + "source": "dispatch log", + "observed_at": "2026-09-22T17:31:54Z" + }, + { + "name": "acquisition", + "state": "unknown", + "summary": "Dispatched; the Arr has represented files, but which seasons they cover is not established here; open readiness for per-season coverage.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-11-06T18:46:45Z" + }, + { + "name": "visibility", + "state": "done", + "summary": "mindel-tv: Jellyfin shows the represented files to the viewer.", + "source": "layer visibility observation", + "observed_at": "2026-11-06T18:46:45Z" + }, + { + "name": "playback", + "state": "partial", + "summary": "Jellyfin admits server playback for sampled episodes S01E01, S01E02, S01E03, not every requested episode. This is not a media byte-read or phone compatibility check.", + "source": "authenticated Jellyfin PlaybackInfo for the viewer", + "observed_at": "2026-11-05T22:13:18Z" + } + ], + "blocking_stage": "acquisition" + }, + { + "id": "dispatch-733", + "tmdb_id": 40231, + "media_type": "tv", + "title": "So You Think You Can Dance (BE/NL)", + "requester": { + "key": "mindel", + "name": "mindel" + }, + "on_behalf": false, + "filed_by_you": false, + "seasons": [], + "requested_at": "2026-09-22T17:03:09Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Seerr approved a request for this viewer. It was filed outside the cockpit, so who filed it is not recorded.", + "source": "dispatch log", + "observed_at": "2026-09-22T17:03:09Z" + }, + { + "name": "acquisition", + "state": "unknown", + "summary": "Dispatched; the Arr has represented files, but which seasons they cover is not established here; open readiness for per-season coverage.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-11-06T18:15:52Z" + }, + { + "name": "visibility", + "state": "done", + "summary": "mindel-tv: Jellyfin shows the represented files to the viewer.", + "source": "layer visibility observation", + "observed_at": "2026-11-06T18:15:52Z" + }, + { + "name": "playback", + "state": "partial", + "summary": "Jellyfin admits server playback for sampled episodes S08E01, S08E02, S08E03, not every requested episode. This is not a media byte-read or phone compatibility check.", + "source": "authenticated Jellyfin PlaybackInfo for the viewer", + "observed_at": "2026-11-05T23:46:05Z" + } + ], + "blocking_stage": "acquisition" + }, + { + "id": "dispatch-732", + "tmdb_id": 99353, + "media_type": "tv", + "title": "Love is Blind", + "requester": { + "key": "mindel", + "name": "mindel" + }, + "on_behalf": false, + "filed_by_you": false, + "seasons": [], + "requested_at": "2026-09-22T17:02:58Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Seerr approved a request for this viewer. It was filed outside the cockpit, so who filed it is not recorded.", + "source": "dispatch log", + "observed_at": "2026-09-22T17:02:58Z" + }, + { + "name": "acquisition", + "state": "unknown", + "summary": "Dispatched; the Arr has represented files, but which seasons they cover is not established here; open readiness for per-season coverage.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-11-06T18:15:52Z" + }, + { + "name": "visibility", + "state": "failed", + "summary": "mindel-tv: Jellyfin visibility needs attention.", + "source": "layer visibility observation", + "observed_at": "2026-11-06T18:15:52Z" + }, + { + "name": "playback", + "state": "unknown", + "summary": "No playback record or current server playback evidence is available for this viewer.", + "source": "viewer watch record and media-health ledger" + } + ], + "blocking_stage": "acquisition" + }, + { + "id": "dispatch-731", + "tmdb_id": 90228, + "media_type": "tv", + "title": "Dune: Prophecy", + "requester": { + "key": "justin", + "name": "justin" + }, + "on_behalf": false, + "filed_by_you": false, + "seasons": [], + "requested_at": "2026-09-20T20:43:46Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Seerr approved a request for this viewer. It was filed outside the cockpit, so who filed it is not recorded.", + "source": "dispatch log", + "observed_at": "2026-09-20T20:43:46Z" + }, + { + "name": "acquisition", + "state": "unknown", + "summary": "Dispatched; the Arr has represented files, but which seasons they cover is not established here; open readiness for per-season coverage.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-11-06T18:00:28Z" + }, + { + "name": "visibility", + "state": "none", + "summary": "The title is only in libraries this viewer does not see: shared-tv.", + "source": "layer visibility observation" + }, + { + "name": "playback", + "state": "partial", + "summary": "Jellyfin records the viewer playing 1 episode(s). That is a client playback record, not a server probe.", + "source": "viewer watch record and media-health ledger", + "observed_at": "2026-11-04T18:22:43Z" + } + ], + "blocking_stage": "acquisition" + }, + { + "id": "dispatch-728", + "tmdb_id": 291507, + "media_type": "tv", + "title": "The Body (2026)", + "requester": { + "key": "mindel", + "name": "mindel" + }, + "on_behalf": false, + "filed_by_you": false, + "seasons": [], + "requested_at": "2026-09-20T16:07:00Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Seerr approved a request for this viewer. It was filed outside the cockpit, so who filed it is not recorded.", + "source": "dispatch log", + "observed_at": "2026-09-20T16:07:00Z" + }, + { + "name": "acquisition", + "state": "none", + "summary": "The title is not released yet; there is nothing to acquire.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-11-06T18:15:52Z" + }, + { + "name": "visibility", + "state": "none", + "summary": "mindel-tv: There is no file to show yet.", + "source": "layer visibility observation", + "observed_at": "2026-11-06T18:15:52Z" + }, + { + "name": "playback", + "state": "unknown", + "summary": "No playback record or current server playback evidence is available for this viewer.", + "source": "viewer watch record and media-health ledger" + } + ], + "blocking_stage": "acquisition" + }, + { + "id": "dispatch-727", + "tmdb_id": 1437, + "media_type": "tv", + "title": "Firefly", + "requester": { + "key": "justin", + "name": "justin" + }, + "on_behalf": false, + "filed_by_you": false, + "seasons": [], + "requested_at": "2026-09-20T11:33:21Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Seerr approved a request for this viewer. It was filed outside the cockpit, so who filed it is not recorded.", + "source": "dispatch log", + "observed_at": "2026-09-20T11:33:21Z" + }, + { + "name": "acquisition", + "state": "unknown", + "summary": "Dispatched; the Arr has represented files, but which seasons they cover is not established here; open readiness for per-season coverage.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-11-06T18:31:21Z" + }, + { + "name": "visibility", + "state": "done", + "summary": "justin-tv: Jellyfin shows the represented files to the viewer.", + "source": "layer visibility observation", + "observed_at": "2026-11-06T18:31:21Z" + }, + { + "name": "playback", + "state": "partial", + "summary": "Jellyfin admits server playback for sampled episodes S01E01, S01E02, S01E03, not every requested episode. This is not a media byte-read or phone compatibility check.", + "source": "authenticated Jellyfin PlaybackInfo for the viewer", + "observed_at": "2026-11-05T22:59:33Z" + } + ], + "blocking_stage": "acquisition" + }, + { + "id": "dispatch-726", + "tmdb_id": 86831, + "media_type": "tv", + "title": "Love, Death & Robots", + "requester": { + "key": "justin", + "name": "justin" + }, + "on_behalf": false, + "filed_by_you": false, + "seasons": [], + "requested_at": "2026-09-20T11:30:20Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Seerr approved a request for this viewer. It was filed outside the cockpit, so who filed it is not recorded.", + "source": "dispatch log", + "observed_at": "2026-09-20T11:30:20Z" + }, + { + "name": "acquisition", + "state": "unknown", + "summary": "Dispatched; the Arr has represented files, but which seasons they cover is not established here; open readiness for per-season coverage.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-11-06T18:31:21Z" + }, + { + "name": "visibility", + "state": "done", + "summary": "justin-tv: Jellyfin shows the represented files to the viewer.", + "source": "layer visibility observation", + "observed_at": "2026-11-06T18:31:21Z" + }, + { + "name": "playback", + "state": "partial", + "summary": "Jellyfin admits server playback for sampled episodes S01E01, S02E01, S03E01, not every requested episode. This is not a media byte-read or phone compatibility check.", + "source": "authenticated Jellyfin PlaybackInfo for the viewer", + "observed_at": "2026-11-06T00:01:36Z" + } + ], + "blocking_stage": "acquisition" + }, + { + "id": "dispatch-725", + "tmdb_id": 9357, + "media_type": "movie", + "title": "One Hour Photo", + "requester": { + "key": "justin", + "name": "justin" + }, + "on_behalf": false, + "filed_by_you": false, + "seasons": [], + "requested_at": "2026-09-20T11:29:20Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Seerr approved a request for this viewer. It was filed outside the cockpit, so who filed it is not recorded.", + "source": "dispatch log", + "observed_at": "2026-09-20T11:29:20Z" + }, + { + "name": "acquisition", + "state": "none", + "summary": "The title was deleted after it was dispatched.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-09-20T11:29:20Z" + }, + { + "name": "visibility", + "state": "none", + "summary": "The title is not in a library this viewer sees.", + "source": "layer visibility observation" + }, + { + "name": "playback", + "state": "unknown", + "summary": "No playback record or current server playback evidence is available for this viewer.", + "source": "viewer watch record and media-health ledger" + } + ], + "blocking_stage": "acquisition" + }, + { + "id": "dispatch-724", + "tmdb_id": 65315, + "media_type": "tv", + "title": "Flaked", + "requester": { + "key": "mindel", + "name": "mindel" + }, + "on_behalf": false, + "filed_by_you": false, + "seasons": [], + "requested_at": "2026-09-20T11:25:20Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Seerr approved a request for this viewer. It was filed outside the cockpit, so who filed it is not recorded.", + "source": "dispatch log", + "observed_at": "2026-09-20T11:25:20Z" + }, + { + "name": "acquisition", + "state": "none", + "summary": "The title was deleted after it was dispatched.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-09-20T11:25:20Z" + }, + { + "name": "visibility", + "state": "none", + "summary": "The title is not in a library this viewer sees.", + "source": "layer visibility observation" + }, + { + "name": "playback", + "state": "partial", + "summary": "Jellyfin records the viewer playing 1 episode(s). That is a client playback record, not a server probe.", + "source": "viewer watch record and media-health ledger", + "observed_at": "2026-09-20T12:15:36Z" + } + ], + "blocking_stage": "acquisition" + }, + { + "id": "dispatch-723", + "tmdb_id": 9357, + "media_type": "movie", + "title": "One Hour Photo", + "requester": { + "key": "mindel", + "name": "mindel" + }, + "on_behalf": false, + "filed_by_you": false, + "seasons": [], + "requested_at": "2026-09-20T11:22:19Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Seerr approved a request for this viewer. It was filed outside the cockpit, so who filed it is not recorded.", + "source": "dispatch log", + "observed_at": "2026-09-20T11:22:19Z" + }, + { + "name": "acquisition", + "state": "none", + "summary": "The title was deleted after it was dispatched.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-09-20T11:29:20Z" + }, + { + "name": "visibility", + "state": "none", + "summary": "The title is not in a library this viewer sees.", + "source": "layer visibility observation" + }, + { + "name": "playback", + "state": "done", + "summary": "Jellyfin records the viewer playing this movie. That is a client playback record, not a server probe.", + "source": "viewer watch record and media-health ledger", + "observed_at": "2026-09-20T12:15:36Z" + } + ], + "blocking_stage": "acquisition" + }, + { + "id": "dispatch-722", + "tmdb_id": 111803, + "media_type": "tv", + "title": "The White Lotus", + "requester": { + "key": "mindel", + "name": "mindel" + }, + "on_behalf": false, + "filed_by_you": false, + "seasons": [], + "requested_at": "2026-09-20T00:15:57Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Seerr approved a request for this viewer. It was filed outside the cockpit, so who filed it is not recorded.", + "source": "dispatch log", + "observed_at": "2026-09-20T00:15:57Z" + }, + { + "name": "acquisition", + "state": "unknown", + "summary": "Dispatched; the Arr has represented files, but which seasons they cover is not established here; open readiness for per-season coverage.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-11-06T18:46:45Z" + }, + { + "name": "visibility", + "state": "done", + "summary": "mindel-tv: Jellyfin shows the represented files to the viewer.", + "source": "layer visibility observation", + "observed_at": "2026-11-06T18:31:21Z" + }, + { + "name": "playback", + "state": "partial", + "summary": "Jellyfin admits server playback for sampled episodes S01E01, S02E01, S03E01, not every requested episode. This is not a media byte-read or phone compatibility check.", + "source": "authenticated Jellyfin PlaybackInfo for the viewer", + "observed_at": "2026-11-06T00:01:39Z" + } + ], + "blocking_stage": "acquisition" + }, + { + "id": "dispatch-721", + "tmdb_id": 246, + "media_type": "tv", + "title": "Avatar: The Last Airbender", + "requester": { + "key": "justin", + "name": "justin" + }, + "on_behalf": false, + "filed_by_you": false, + "seasons": [], + "requested_at": "2026-09-20T00:15:57Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Seerr approved a request for this viewer. It was filed outside the cockpit, so who filed it is not recorded.", + "source": "dispatch log", + "observed_at": "2026-09-20T00:15:57Z" + }, + { + "name": "acquisition", + "state": "unknown", + "summary": "Dispatched; the Arr has represented files, but which seasons they cover is not established here; open readiness for per-season coverage.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-11-06T18:46:45Z" + }, + { + "name": "visibility", + "state": "none", + "summary": "The title is only in libraries this viewer does not see: shared-tv.", + "source": "layer visibility observation" + }, + { + "name": "playback", + "state": "failed", + "summary": "A file is unreadable and has not been repaired.", + "source": "viewer watch record and media-health ledger" + } + ], + "blocking_stage": "acquisition" + }, + { + "id": "dispatch-720", + "tmdb_id": 33880, + "media_type": "tv", + "title": "The Legend of Korra", + "requester": { + "key": "justin", + "name": "justin" + }, + "on_behalf": false, + "filed_by_you": false, + "seasons": [], + "requested_at": "2026-09-20T00:15:56Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Seerr approved a request for this viewer. It was filed outside the cockpit, so who filed it is not recorded.", + "source": "dispatch log", + "observed_at": "2026-09-20T00:15:56Z" + }, + { + "name": "acquisition", + "state": "unknown", + "summary": "Dispatched; the Arr has represented files, but which seasons they cover is not established here; open readiness for per-season coverage.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-11-06T18:00:28Z" + }, + { + "name": "visibility", + "state": "done", + "summary": "justin-tv: Jellyfin shows the represented files to the viewer.", + "source": "layer visibility observation", + "observed_at": "2026-11-06T18:00:28Z" + }, + { + "name": "playback", + "state": "partial", + "summary": "Jellyfin admits server playback for sampled episodes S01E01, S02E01, S03E01, not every requested episode. This is not a media byte-read or phone compatibility check.", + "source": "authenticated Jellyfin PlaybackInfo for the viewer", + "observed_at": "2026-11-05T23:30:33Z" + } + ], + "blocking_stage": "acquisition" + }, + { + "id": "dispatch-719", + "tmdb_id": 95557, + "media_type": "tv", + "title": "INVINCIBLE (2021)", + "requester": { + "key": "shared", + "name": "shared" + }, + "on_behalf": false, + "filed_by_you": false, + "seasons": [], + "requested_at": "2026-09-20T00:15:56Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Seerr approved a request for this viewer. It was filed outside the cockpit, so who filed it is not recorded.", + "source": "dispatch log", + "observed_at": "2026-09-20T00:15:56Z" + }, + { + "name": "acquisition", + "state": "unknown", + "summary": "Dispatched; the Arr has represented files, but which seasons they cover is not established here; open readiness for per-season coverage.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-11-06T18:00:28Z" + }, + { + "name": "visibility", + "state": "none", + "summary": "The title is only in libraries this viewer does not see: justin-tv.", + "source": "layer visibility observation" + }, + { + "name": "playback", + "state": "partial", + "summary": "Jellyfin records the viewer playing 1 episode(s). That is a client playback record, not a server probe.", + "source": "viewer watch record and media-health ledger", + "observed_at": "2026-11-04T20:23:49Z" + } + ], + "blocking_stage": "acquisition" + }, + { + "id": "dispatch-716", + "tmdb_id": 285404, + "media_type": "tv", + "title": "Maximum Pleasure Guaranteed", + "requester": { + "key": "mindel", + "name": "mindel" + }, + "on_behalf": false, + "filed_by_you": false, + "seasons": [], + "requested_at": "2026-09-07T20:58:52Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Seerr approved a request for this viewer. It was filed outside the cockpit, so who filed it is not recorded.", + "source": "dispatch log", + "observed_at": "2026-09-07T20:58:52Z" + }, + { + "name": "acquisition", + "state": "unknown", + "summary": "Dispatched; the Arr has represented files, but which seasons they cover is not established here; open readiness for per-season coverage.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-11-06T18:31:21Z" + }, + { + "name": "visibility", + "state": "done", + "summary": "mindel-tv: Jellyfin shows the represented files to the viewer.", + "source": "layer visibility observation", + "observed_at": "2026-11-06T18:31:21Z" + }, + { + "name": "playback", + "state": "partial", + "summary": "Jellyfin admits server playback for sampled episodes S01E01, S01E02, S01E03, not every requested episode. This is not a media byte-read or phone compatibility check.", + "source": "authenticated Jellyfin PlaybackInfo for the viewer", + "observed_at": "2026-11-05T23:14:51Z" + } + ], + "blocking_stage": "acquisition" + }, + { + "id": "dispatch-526", + "tmdb_id": 124428, + "media_type": "tv", + "title": "B&B Vol Liefde", + "requester": { + "key": "justin", + "name": "justin" + }, + "on_behalf": false, + "filed_by_you": false, + "seasons": [], + "requested_at": "2026-07-31T15:40:26Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Seerr approved a request for this viewer. It was filed outside the cockpit, so who filed it is not recorded.", + "source": "dispatch log", + "observed_at": "2026-07-31T15:40:26Z" + }, + { + "name": "acquisition", + "state": "unknown", + "summary": "Dispatched; the Arr has represented files, but which seasons they cover is not established here; open readiness for per-season coverage.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-11-06T18:31:21Z" + }, + { + "name": "visibility", + "state": "none", + "summary": "The title is only in libraries this viewer does not see: shared-tv.", + "source": "layer visibility observation" + }, + { + "name": "playback", + "state": "unknown", + "summary": "No playback record or current server playback evidence is available for this viewer.", + "source": "viewer watch record and media-health ledger" + } + ], + "blocking_stage": "acquisition" + }, + { + "id": "dispatch-519", + "tmdb_id": 78941, + "media_type": "tv", + "title": "Aggretsuko", + "requester": { + "key": "shared", + "name": "shared" + }, + "on_behalf": false, + "filed_by_you": false, + "seasons": [], + "requested_at": "2026-07-17T17:38:47Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Seerr approved a request for this viewer. It was filed outside the cockpit, so who filed it is not recorded.", + "source": "dispatch log", + "observed_at": "2026-07-17T17:38:47Z" + }, + { + "name": "acquisition", + "state": "none", + "summary": "The title was deleted after it was dispatched.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-07-17T17:38:47Z" + }, + { + "name": "visibility", + "state": "none", + "summary": "The title is not in a library this viewer sees.", + "source": "layer visibility observation" + }, + { + "name": "playback", + "state": "unknown", + "summary": "No playback record or current server playback evidence is available for this viewer.", + "source": "viewer watch record and media-health ledger" + } + ], + "blocking_stage": "acquisition" + }, + { + "id": "dispatch-518", + "tmdb_id": 486947, + "media_type": "movie", + "title": "The Guilty", + "requester": { + "key": "shared", + "name": "shared" + }, + "on_behalf": false, + "filed_by_you": false, + "seasons": [], + "requested_at": "2026-07-17T17:38:47Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Seerr approved a request for this viewer. It was filed outside the cockpit, so who filed it is not recorded.", + "source": "dispatch log", + "observed_at": "2026-07-17T17:38:47Z" + }, + { + "name": "acquisition", + "state": "none", + "summary": "The title was deleted after it was dispatched.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-07-17T17:38:47Z" + }, + { + "name": "visibility", + "state": "none", + "summary": "The title is not in a library this viewer sees.", + "source": "layer visibility observation" + }, + { + "name": "playback", + "state": "unknown", + "summary": "No playback record or current server playback evidence is available for this viewer.", + "source": "viewer watch record and media-health ledger" + } + ], + "blocking_stage": "acquisition" + }, + { + "id": "dispatch-514", + "tmdb_id": 220289, + "media_type": "movie", + "title": "Coherence", + "requester": { + "key": "mindel", + "name": "mindel" + }, + "on_behalf": false, + "filed_by_you": false, + "seasons": [], + "requested_at": "2026-07-17T17:38:46Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Seerr approved a request for this viewer. It was filed outside the cockpit, so who filed it is not recorded.", + "source": "dispatch log", + "observed_at": "2026-07-17T17:38:46Z" + }, + { + "name": "acquisition", + "state": "done", + "summary": "Dispatched; the Arr has represented files.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-11-06T18:15:52Z" + }, + { + "name": "visibility", + "state": "done", + "summary": "mindel-movies: Jellyfin shows the represented files to the viewer.", + "source": "layer visibility observation", + "observed_at": "2026-11-06T18:15:52Z" + }, + { + "name": "playback", + "state": "done", + "summary": "Jellyfin admits server playback of this movie for the viewer. This is not a media byte-read or phone compatibility check.", + "source": "authenticated Jellyfin PlaybackInfo for the viewer", + "observed_at": "2026-11-05T22:59:24Z" + } + ] + } + ], + "truncated": false, + "sources": [ + { + "name": "native requests", + "state": "observed" + }, + { + "name": "dispatch log", + "state": "observed" + }, + { + "name": "library routes", + "state": "observed" + } + ] +} diff --git a/app/src/test/resources/conductarr/mindel-feed.json b/app/src/test/resources/conductarr/mindel-feed.json new file mode 100644 index 00000000..ffb957ab --- /dev/null +++ b/app/src/test/resources/conductarr/mindel-feed.json @@ -0,0 +1,8143 @@ +{ + "$schema": "https://wolfskin-tv.justinvisser.org/schemas/RecommendationFeed.json", + "profile": { + "key": "mindel", + "name": "mindel" + }, + "state": "current", + "outcome": "ranked", + "attempted_at": "2026-11-06T16:13:56.797980746Z", + "observed_at": "2026-11-06T16:14:14.67322323Z", + "expires_at": "2026-11-06T22:14:14.67322323Z", + "materialization": "current", + "movies": [ + { + "media_type": "movie", + "tmdb_id": 235271, + "tvdb_id": 0, + "title": "Addicted", + "overview": "A gallerist risks her family and flourishing career when she enters into an affair with a talented painter and slowly loses control of her life.", + "poster_path": "/fAERrD6vvmznDoL99wlgjhSfPcH.jpg", + "backdrop_path": "/aqqoDJs8vYp2EZ74aQ1UMY0sjM7.jpg", + "year": 2014, + "score": 1.0710949439563022, + "genre_ids": [ + 18, + 53 + ], + "contributions": [ + { + "media_type": "movie", + "tmdb_id": 210479, + "watched": true, + "added": true, + "title": "Locke" + }, + { + "media_type": "movie", + "tmdb_id": 9357, + "watched": true, + "added": false, + "title": "One Hour Photo" + } + ] + }, + { + "media_type": "movie", + "tmdb_id": 8999, + "tvdb_id": 0, + "title": "Derailed", + "overview": "When two married business executives having an affair are blackmailed by a violent criminal, they are forced to turn the tables on him to save their families.", + "poster_path": "/bcCoOkwEBHUS5Y7Rm1u1oTLUHdb.jpg", + "backdrop_path": "/yhx8DtHySbvIm19wurVttyXCTTe.jpg", + "year": 2005, + "score": 1, + "genre_ids": [ + 18, + 53 + ], + "contributions": [ + { + "media_type": "movie", + "tmdb_id": 210479, + "watched": true, + "added": true, + "title": "Locke" + } + ] + }, + { + "media_type": "movie", + "tmdb_id": 317744, + "tvdb_id": 0, + "title": "Careful What You Wish For", + "overview": "A guy gets more than he bargained for after entering into an affair with the wife of an investment banker. Soon, a suspicious death and substantial life insurance policy embroil him in a scandal.", + "poster_path": "/alRvln4lzGjdSExC37vr1spSTF7.jpg", + "backdrop_path": "/kyx15eUlLQy7HHm8HRuvi9QagO8.jpg", + "year": 2015, + "score": 0.745920536874323, + "genre_ids": [ + 18, + 53 + ], + "contributions": [ + { + "media_type": "movie", + "tmdb_id": 210479, + "watched": true, + "added": true, + "title": "Locke" + }, + { + "media_type": "movie", + "tmdb_id": 9357, + "watched": true, + "added": false, + "title": "One Hour Photo" + } + ] + }, + { + "media_type": "movie", + "tmdb_id": 13250, + "tvdb_id": 0, + "title": "Butterfly on a Wheel", + "overview": "A sociopathic kidnapper methodically pushes a desperate pair of parents to their absolute breaking point.", + "poster_path": "/qcjjYcMaaSKD2C8xn0hy6o6yj2L.jpg", + "backdrop_path": "/7al64Hp3RPaMNB5AWTdFH7RggnG.jpg", + "year": 2007, + "score": 0.5848757652954433, + "genre_ids": [ + 80, + 18, + 53 + ], + "contributions": [ + { + "media_type": "movie", + "tmdb_id": 210479, + "watched": true, + "added": true, + "title": "Locke" + }, + { + "media_type": "movie", + "tmdb_id": 9357, + "watched": true, + "added": false, + "title": "One Hour Photo" + }, + { + "media_type": "movie", + "tmdb_id": 399057, + "watched": false, + "added": true, + "title": "The Killing of a Sacred Deer" + } + ] + }, + { + "media_type": "movie", + "tmdb_id": 376004, + "tvdb_id": 0, + "title": "Adulterers", + "overview": "On a steamy New Orleans afternoon, Sam Deuprey decides to surprise his beautiful wife, Ashley, for their anniversary and comes home early. But Ashley is not alone in the bedroom. She and her lover, Damien are about to learn how far a jealous husband will go.", + "poster_path": "/7weaBxcimjsN9zi89Dfk4fZFCUE.jpg", + "backdrop_path": "/8q6AJYqJzZPrNSbttcwslXx7QgO.jpg", + "year": 2016, + "score": 0.48362143005756547, + "genre_ids": [ + 53, + 18 + ], + "contributions": [ + { + "media_type": "movie", + "tmdb_id": 9357, + "watched": true, + "added": false, + "title": "One Hour Photo" + }, + { + "media_type": "movie", + "tmdb_id": 399057, + "watched": false, + "added": true, + "title": "The Killing of a Sacred Deer" + } + ] + }, + { + "media_type": "movie", + "tmdb_id": 13483, + "tvdb_id": 0, + "title": "Awake", + "overview": "While undergoing heart surgery, a man experiences a phenomenon called \u2018anesthetic awareness\u2019, which leaves him awake but paralyzed throughout the operation. As various obstacles present themselves, his wife must make life-altering decisions while wrestling with her own personal drama.", + "poster_path": "/xqooV6lR1Kdx4Q52Sim0aGbDTYP.jpg", + "backdrop_path": "/c5TiPhbggc1LWckfd93p8ZFu16X.jpg", + "year": 2007, + "score": 0.45896249898432395, + "genre_ids": [ + 53, + 80, + 9648 + ], + "contributions": [ + { + "media_type": "movie", + "tmdb_id": 399057, + "watched": false, + "added": true, + "title": "The Killing of a Sacred Deer" + } + ] + }, + { + "media_type": "movie", + "tmdb_id": 18190, + "tvdb_id": 0, + "title": "Dream Lover", + "overview": "Not long after they cross paths at an art gallery, architect Ray Reardon and hypnotically sensual Lena are married with children. But as strange incidents occur, Ray begins to realize he may not really know the woman he married.", + "poster_path": "/epl5pprGt04gAJI8lzbKy83XkbE.jpg", + "backdrop_path": "/83hLixhpxMw2WpG72IzFYE28b3v.jpg", + "year": 1994, + "score": 0.30017994059534503, + "genre_ids": [ + 18, + 9648, + 53 + ], + "contributions": [ + { + "media_type": "movie", + "tmdb_id": 210479, + "watched": true, + "added": true, + "title": "Locke" + }, + { + "media_type": "movie", + "tmdb_id": 9357, + "watched": true, + "added": false, + "title": "One Hour Photo" + }, + { + "media_type": "movie", + "tmdb_id": 399057, + "watched": false, + "added": true, + "title": "The Killing of a Sacred Deer" + } + ] + }, + { + "media_type": "movie", + "tmdb_id": 210577, + "tvdb_id": 0, + "title": "Gone Girl", + "overview": "With his wife's disappearance having become the focus of an intense media circus, a man sees the spotlight turned on him when it's suspected that he may not be innocent.", + "poster_path": "/ts996lKsxvjkO2yiYG0ht4qAicO.jpg", + "backdrop_path": "/iWak7wT0j6ycCc8lKr4NBz9c7n5.jpg", + "year": 2014, + "score": 0.2353903390452386, + "genre_ids": [ + 9648, + 53, + 18 + ], + "contributions": [ + { + "media_type": "movie", + "tmdb_id": 210479, + "watched": true, + "added": true, + "title": "Locke" + }, + { + "media_type": "movie", + "tmdb_id": 9357, + "watched": true, + "added": false, + "title": "One Hour Photo" + }, + { + "media_type": "movie", + "tmdb_id": 399057, + "watched": false, + "added": true, + "title": "The Killing of a Sacred Deer" + } + ] + }, + { + "media_type": "movie", + "tmdb_id": 619979, + "tvdb_id": 0, + "title": "Deep Water", + "overview": "A well-to-do husband who allows his wife to have affairs in order to avoid a divorce becomes a prime suspect in the disappearance of her lovers.", + "poster_path": "/6yRMyWwjuhKg6IU66uiZIGhaSc8.jpg", + "backdrop_path": "/lcITbesmy0IBO6TXTAgQHJE6Asx.jpg", + "year": 2022, + "score": 0.23033665255417463, + "genre_ids": [ + 18, + 9648, + 53 + ], + "contributions": [ + { + "media_type": "movie", + "tmdb_id": 9357, + "watched": true, + "added": false, + "title": "One Hour Photo" + }, + { + "media_type": "movie", + "tmdb_id": 399057, + "watched": false, + "added": true, + "title": "The Killing of a Sacred Deer" + } + ] + }, + { + "media_type": "movie", + "tmdb_id": 1317288, + "tvdb_id": 0, + "title": "Marty Supreme", + "overview": "Marty Mauser, a young man with a dream no one respects, goes to hell and back in pursuit of greatness.", + "poster_path": "/lYWEXbQgRTR4ZQleSXAgRbxAjvq.jpg", + "backdrop_path": "/6h7w8R4BA7vubEmEEW5vYEjuoz6.jpg", + "year": 2025, + "score": 0.1659867561457205, + "genre_ids": [ + 18, + 53 + ], + "contributions": [ + { + "media_type": "movie", + "tmdb_id": 210479, + "watched": true, + "added": true, + "title": "Locke" + }, + { + "media_type": "movie", + "tmdb_id": 9357, + "watched": true, + "added": false, + "title": "One Hour Photo" + } + ] + }, + { + "media_type": "movie", + "tmdb_id": 582014, + "tvdb_id": 0, + "title": "Promising Young Woman", + "overview": "A young woman, traumatized by a tragic event in her past, seeks out vengeance against those who crossed her path.", + "poster_path": "/2h4EkRxy36zSsBDkijXSUMJFDaz.jpg", + "backdrop_path": "/gESm6yc2MZpSXakgqLF0OlLaRn1.jpg", + "year": 2020, + "score": 0.15298749966144132, + "genre_ids": [ + 53, + 80, + 18 + ], + "contributions": [ + { + "media_type": "movie", + "tmdb_id": 399057, + "watched": false, + "added": true, + "title": "The Killing of a Sacred Deer" + } + ] + }, + { + "media_type": "movie", + "tmdb_id": 549514, + "tvdb_id": 0, + "title": "Fatale", + "overview": "After a wild one-night stand, successful sports agent Derrick Tyler watches his perfect life slowly disappear when he discovers the sexy and mysterious woman he risked everything for is determined police detective Valerie Quinlan who entangles him in her latest investigation. As he desperately tries to put the pieces together, he falls deeper into her trap, risking his family, his career, and even his life.", + "poster_path": "/tGFXEyESXIuei5tZWXRkSRSFSxi.jpg", + "backdrop_path": "/tljkeU2y1dCvT51Sv4ciNf0VfmV.jpg", + "year": 2020, + "score": 0.1475523221245938, + "genre_ids": [ + 18, + 53 + ], + "contributions": [ + { + "media_type": "movie", + "tmdb_id": 9357, + "watched": true, + "added": false, + "title": "One Hour Photo" + } + ] + }, + { + "media_type": "movie", + "tmdb_id": 59231, + "tvdb_id": 0, + "title": "The Rain People", + "overview": "When a housewife finds out she is pregnant, she runs out of town looking for freedom to reevaluate her life decisions.", + "poster_path": "/dDg4I79yb2VNdM3Vkh9oDOUROTc.jpg", + "backdrop_path": "/k5VMD0fpCB8CunTjzpHdjOp6Lqv.jpg", + "year": 1969, + "score": 0.14285714285714285, + "genre_ids": [ + 18 + ], + "contributions": [ + { + "media_type": "movie", + "tmdb_id": 210479, + "watched": true, + "added": true, + "title": "Locke" + } + ] + }, + { + "media_type": "movie", + "tmdb_id": 1249213, + "tvdb_id": 0, + "title": "Drop", + "overview": "Violet, a widowed mother on her first date in years, arrives at an upscale restaurant where she is relieved that her date, Henry, is more charming and handsome than she expected. But their chemistry begins to curdle as Violet begins being irritated and then terrorized by a series of anonymous drops to her phone.", + "poster_path": "/hSQPSW8aLjsMBfwqGjgJ6HozTkp.jpg", + "backdrop_path": "/akBGSguhkAj1TXOdZUcL11e1hpH.jpg", + "year": 2025, + "score": 0.11516832627708731, + "genre_ids": [ + 9648, + 53 + ], + "contributions": [ + { + "media_type": "movie", + "tmdb_id": 9357, + "watched": true, + "added": false, + "title": "One Hour Photo" + }, + { + "media_type": "movie", + "tmdb_id": 399057, + "watched": false, + "added": true, + "title": "The Killing of a Sacred Deer" + } + ] + }, + { + "media_type": "movie", + "tmdb_id": 624788, + "tvdb_id": 0, + "title": "Black Bear", + "overview": "At a remote lake house in the Adirondack Mountains, a couple entertains an out-of-town guest looking for inspiration in her filmmaking. The group quickly falls into a calculated game of desire, manipulation, and jealousy, unaware of how dangerously intertwined their lives will soon become.", + "poster_path": "/nqZFjQRWfW6PjFTc17pzJ5LRAAv.jpg", + "backdrop_path": "/o87ErHJHx99C5B8IiW9RYK1uCdU.jpg", + "year": 2020, + "score": 0.1111111111111111, + "genre_ids": [ + 18, + 53 + ], + "contributions": [ + { + "media_type": "movie", + "tmdb_id": 210479, + "watched": true, + "added": true, + "title": "Locke" + } + ] + }, + { + "media_type": "movie", + "tmdb_id": 2252, + "tvdb_id": 0, + "title": "Eastern Promises", + "overview": "A Russian teenager living in London dies during childbirth but leaves clues in her diary that could tie her child to a rape involving a violent Russian mob family.", + "poster_path": "/nLwv1pxRCRZucQaqs6oPkq7HH3K.jpg", + "backdrop_path": "/1ckHUJ6ztq5XJRKM2nIAW0UjJNH.jpg", + "year": 2007, + "score": 0.1, + "genre_ids": [ + 53, + 80, + 9648 + ], + "contributions": [ + { + "media_type": "movie", + "tmdb_id": 210479, + "watched": true, + "added": true, + "title": "Locke" + } + ] + }, + { + "media_type": "movie", + "tmdb_id": 311667, + "tvdb_id": 0, + "title": "Manhattan Night", + "overview": "Porter Wren is a Manhattan tabloid writer with an appetite for scandal. On the beat he sells murder, tragedy, and anything that passes for the truth. At home, he is a dedicated husband and father. But when Caroline, a seductive stranger asks him to dig into the unsolved murder of her filmmaker husband Simon, he is drawn into a very nasty case of sexual obsession and blackmail--one that threatens his job, his marriage, and his life.", + "poster_path": "/d40J0y6Ha1eakPrmfyEHoKFQrSB.jpg", + "backdrop_path": "/1eM6CsDTwRmxgnigzz8pitLlUKJ.jpg", + "year": 2016, + "score": 0.09678494183275951, + "genre_ids": [ + 18, + 9648, + 53 + ], + "contributions": [ + { + "media_type": "movie", + "tmdb_id": 9357, + "watched": true, + "added": false, + "title": "One Hour Photo" + }, + { + "media_type": "movie", + "tmdb_id": 399057, + "watched": false, + "added": true, + "title": "The Killing of a Sacred Deer" + } + ] + }, + { + "media_type": "movie", + "tmdb_id": 2990, + "tvdb_id": 0, + "title": "Pacific Heights", + "overview": "A couple works hard to renovate their dream house and become landlords to pay for it. Unfortunately one of their tenants has plans of his own.", + "poster_path": "/9iHD6xIrGSuuMEFWhwSVGXSddY2.jpg", + "backdrop_path": "/oifQqVSpK5fOqgCrYIEbLGkcIiS.jpg", + "year": 1990, + "score": 0.09619987082688097, + "genre_ids": [ + 53, + 18, + 9648 + ], + "contributions": [ + { + "media_type": "movie", + "tmdb_id": 9357, + "watched": true, + "added": false, + "title": "One Hour Photo" + }, + { + "media_type": "movie", + "tmdb_id": 399057, + "watched": false, + "added": true, + "title": "The Killing of a Sacred Deer" + } + ] + }, + { + "media_type": "movie", + "tmdb_id": 1965, + "tvdb_id": 0, + "title": "A Perfect Murder", + "overview": "Millionaire industrialist Steven Taylor is a man who has everything but what he craves most: the love and fidelity of his wife. A hugely successful player in the New York financial world, he considers her to be his most treasured acquisition. But she needs more than simply the role of dazzling accessory.", + "poster_path": "/wC0ax12N9GQ8vMXPEs4nES5AAiB.jpg", + "backdrop_path": "/dNW7ftcqEAvKmDkuRPaLC4gnhwJ.jpg", + "year": 1998, + "score": 0.09122515832853577, + "genre_ids": [ + 80, + 53, + 18 + ], + "contributions": [ + { + "media_type": "movie", + "tmdb_id": 9357, + "watched": true, + "added": false, + "title": "One Hour Photo" + }, + { + "media_type": "movie", + "tmdb_id": 399057, + "watched": false, + "added": true, + "title": "The Killing of a Sacred Deer" + } + ] + }, + { + "media_type": "movie", + "tmdb_id": 63493, + "tvdb_id": 0, + "title": "The Ledge", + "overview": "A thriller in which a battle of philosophies between a fundamentalist Christian and an atheist who shares an apartment with a gay friend escalates into a lethal battle of wills.", + "poster_path": "/cTaDS6CTIfZ5MH189ub6oE1FATk.jpg", + "backdrop_path": "/fWOfr0L1ISpP3KF8qY2qG1qrxMi.jpg", + "year": 2011, + "score": 0.09090909090909091, + "genre_ids": [ + 18, + 53 + ], + "contributions": [ + { + "media_type": "movie", + "tmdb_id": 210479, + "watched": true, + "added": true, + "title": "Locke" + } + ] + }, + { + "media_type": "movie", + "tmdb_id": 451156, + "tvdb_id": 0, + "title": "Infidelity in Suburbia", + "overview": "With her husband away at work, and her son at school, Laura dreams of a home-renovation project to supplant her restless days. Elliott, her irresistibly hot private contractor, soon fulfills her more forbidden dreams.", + "poster_path": "/gKyvJ8u2US61pHR56C2DoVbwnu.jpg", + "backdrop_path": "/wjnewOLmAYEfh1jeoXme7FO77K2.jpg", + "year": 2017, + "score": 0.08333333333333333, + "genre_ids": [ + 53, + 10770 + ], + "contributions": [ + { + "media_type": "movie", + "tmdb_id": 210479, + "watched": true, + "added": true, + "title": "Locke" + } + ] + }, + { + "media_type": "movie", + "tmdb_id": 1920, + "tvdb_id": 0, + "title": "Crime of Passion", + "overview": "Kathy leaves the newspaper business to marry homicide detective Bill, but is frustrated by his lack of ambition and the banality of life in the suburbs. Her drive to advance Bill's career soon takes her down a dangerous path.", + "poster_path": "/dDpstkYlTAE8n9jqcTYt3jJVNXm.jpg", + "backdrop_path": "/r3Ddxw4v780GzOZdjAT2CMgo0Gn.jpg", + "year": 1956, + "score": 0.0737761610622969, + "genre_ids": [ + 18, + 80, + 53 + ], + "contributions": [ + { + "media_type": "movie", + "tmdb_id": 9357, + "watched": true, + "added": false, + "title": "One Hour Photo" + } + ] + }, + { + "media_type": "movie", + "tmdb_id": 1033148, + "tvdb_id": 0, + "title": "Die My Love", + "overview": "After inheriting a remote Montana house, Jackson moves there from New York with his partner Grace, and the couple soon welcome a child. As Jackson becomes increasingly absent and rural isolation sets in, Grace struggles with loneliness, creative frustration, and unresolved emotional wounds. What begins as an attempt at renewal gradually turns into an intense psychological descent, placing strain on their relationship and exposing the fragile balance between love, identity, and motherhood.", + "poster_path": "/kajpShbFhOdpl6yCrLezMrr9tB4.jpg", + "backdrop_path": "/dEkFlZzj2cb5F7SShvqkL54E4lr.jpg", + "year": 2025, + "score": 0.07142857142857142, + "genre_ids": [ + 18 + ], + "contributions": [ + { + "media_type": "movie", + "tmdb_id": 210479, + "watched": true, + "added": true, + "title": "Locke" + } + ] + }, + { + "media_type": "movie", + "tmdb_id": 7442, + "tvdb_id": 0, + "title": "Sleeping with the Enemy", + "overview": "A young woman fakes her own death in an attempt to escape her nightmarish marriage, but discovers it is impossible to elude her controlling husband.", + "poster_path": "/zaUkndJP8IClB1gMsbw13PoVeLS.jpg", + "backdrop_path": "/apUbdi4nGrD5VGIaxErtpsn7ST8.jpg", + "year": 1991, + "score": 0.07039553585925253, + "genre_ids": [ + 18, + 53 + ], + "contributions": [ + { + "media_type": "movie", + "tmdb_id": 9357, + "watched": true, + "added": false, + "title": "One Hour Photo" + }, + { + "media_type": "movie", + "tmdb_id": 399057, + "watched": false, + "added": true, + "title": "The Killing of a Sacred Deer" + } + ] + }, + { + "media_type": "movie", + "tmdb_id": 8884, + "tvdb_id": 0, + "title": "Franklyn", + "overview": "Set between the parallel worlds of contemporary London and the futuristic faith dominated metropolis of Meanwhile City, Franklyn weaves a tale of four souls, whose lives are intertwined by fate, romance and tragedy. As these worlds collide, a single bullet determines the destiny of these four characters.", + "poster_path": "/5yns10om8I19j4OHy1zoVRilAnw.jpg", + "backdrop_path": "/6hQgTpZVNHrLafE6IeSAoThQwkH.jpg", + "year": 2008, + "score": 0.06666666666666667, + "genre_ids": [ + 14, + 53, + 18 + ], + "contributions": [ + { + "media_type": "movie", + "tmdb_id": 210479, + "watched": true, + "added": true, + "title": "Locke" + } + ] + }, + { + "media_type": "movie", + "tmdb_id": 14698, + "tvdb_id": 0, + "title": "Suddenly, Last Summer", + "overview": "The only son of wealthy widow Violet Venable dies while on vacation with his cousin Catherine. What the girl saw was so horrible that she went insane; now Mrs. Venable wants Catherine lobotomized to cover up the truth.", + "poster_path": "/igma0zn9J5srRvOMDRnxyUDQXJL.jpg", + "backdrop_path": "/pMIIOovLyyCr8W5JFOqsSO8gK1r.jpg", + "year": 1959, + "score": 0.06556607128347484, + "genre_ids": [ + 53, + 18, + 9648 + ], + "contributions": [ + { + "media_type": "movie", + "tmdb_id": 399057, + "watched": false, + "added": true, + "title": "The Killing of a Sacred Deer" + } + ] + }, + { + "media_type": "movie", + "tmdb_id": 45938, + "tvdb_id": 0, + "title": "Sunday Bloody Sunday", + "overview": "Recently divorced career woman Alex Greville begins a romantic relationship with glamorous mod artist Bob Elkin, fully aware that he's also intimately involved with middle-aged doctor Daniel Hirsh. For both Alex and Daniel, the younger man represents a break with their repressive pasts, and though both know that Bob is seeing both of them, neither is willing to let go of the youth and vitality he brings to their otherwise stable lives.", + "poster_path": "/7q1h3M1vDlsHDKRhoPOzKjmFCVq.jpg", + "backdrop_path": "/xkIaVZo98JFCteffdXRgZfcW5A.jpg", + "year": 1971, + "score": 0.0625, + "genre_ids": [ + 18 + ], + "contributions": [ + { + "media_type": "movie", + "tmdb_id": 210479, + "watched": true, + "added": true, + "title": "Locke" + } + ] + }, + { + "media_type": "movie", + "tmdb_id": 16933, + "tvdb_id": 0, + "title": "They Drive by Night", + "overview": "Joe and Paul Fabrini are Wildcat, or independent, truck drivers who have their own small one-truck business. The Fabrini boys constantly battle distributors, rivals and loan collectors, while trying to make a success of their transport company.", + "poster_path": "/uF0X3IMhPIFgbwzlbayUsj4I6Tc.jpg", + "backdrop_path": "/4rd9SUlamdxZlLkCBl6A1z24L4e.jpg", + "year": 1940, + "score": 0.058823529411764705, + "genre_ids": [ + 18, + 80, + 53 + ], + "contributions": [ + { + "media_type": "movie", + "tmdb_id": 210479, + "watched": true, + "added": true, + "title": "Locke" + } + ] + }, + { + "media_type": "movie", + "tmdb_id": 7183, + "tvdb_id": 0, + "title": "Perfect Stranger", + "overview": "A journalist goes undercover to ferret out businessman Harrison Hill as her best friend's killer. Posing as one of his temps, she enters into a game of online cat-and-mouse.", + "poster_path": "/i7D5QqQ0q5EXU1UuZVY9KwIxoYR.jpg", + "backdrop_path": "/sNhYDvDbEwf9AUGip2fGRwP7x8r.jpg", + "year": 2007, + "score": 0.056750893124843764, + "genre_ids": [ + 53, + 80, + 9648 + ], + "contributions": [ + { + "media_type": "movie", + "tmdb_id": 9357, + "watched": true, + "added": false, + "title": "One Hour Photo" + } + ] + }, + { + "media_type": "movie", + "tmdb_id": 893086, + "tvdb_id": 0, + "title": "The Good Neighbor", + "overview": "A nightmarish evening unfolds for neighbors David and Robert when they accidentally hit a woman on her bike and flee the scene. While David is increasingly plagued by feelings of guilt, Robert shows no remorse and becomes overbearing and possessive.", + "poster_path": "/mXJ2xI7B0Z9WxDjyv18anPsOxeE.jpg", + "backdrop_path": "/AnmfRV7X0kwEn96pg0lAVopV8YK.jpg", + "year": 2022, + "score": 0.05555555555555555, + "genre_ids": [ + 53, + 18 + ], + "contributions": [ + { + "media_type": "movie", + "tmdb_id": 210479, + "watched": true, + "added": true, + "title": "Locke" + } + ] + }, + { + "media_type": "movie", + "tmdb_id": 1213, + "tvdb_id": 0, + "title": "The Talented Mr. Ripley", + "overview": "Tom Ripley is a calculating young man who believes it's better to be a fake somebody than a real nobody. Opportunity knocks in the form of a wealthy U.S. shipbuilder who hires Tom to travel to Italy to bring back his playboy son, Dickie. Ripley worms his way into the idyllic lives of Dickie and his girlfriend, plunging into a daring scheme of duplicity, lies and murder.", + "poster_path": "/6ojHgqtIR41O2qLKa7LFUVj0cZa.jpg", + "backdrop_path": "/51UDa3IuGyiKGe2MgMcLGvJE2Fm.jpg", + "year": 1999, + "score": 0.05269725790164064, + "genre_ids": [ + 53, + 80, + 18 + ], + "contributions": [ + { + "media_type": "movie", + "tmdb_id": 9357, + "watched": true, + "added": false, + "title": "One Hour Photo" + } + ] + }, + { + "media_type": "movie", + "tmdb_id": 1317088, + "tvdb_id": 0, + "title": "The Assessment", + "overview": "In a climate change-ravaged world, a utopian society optimizes life, including parenthood assessments. A successful couple faces scrutiny by an evaluator over seven days to determine their fitness for childbearing.", + "poster_path": "/o1K7be85FAauSbVPzCQ0GjOXUqu.jpg", + "backdrop_path": "/96w2p3xKIgvuSTJsNVnvNFqOhPJ.jpg", + "year": 2025, + "score": 0.05263157894736842, + "genre_ids": [ + 878, + 18, + 53 + ], + "contributions": [ + { + "media_type": "movie", + "tmdb_id": 210479, + "watched": true, + "added": true, + "title": "Locke" + } + ] + }, + { + "media_type": "movie", + "tmdb_id": 50875, + "tvdb_id": 0, + "title": "Higher Ground", + "overview": "Inspired by screenwriter Carolyn S. Briggs' memoir This Dark World, a tight-knit spiritual community is thrown off-kilter when one of their own begins to question her faith, love, and trust - in human relationships as well as in God.", + "poster_path": "/xFvN6OVYGwPokUnkZlKxd1coxbS.jpg", + "backdrop_path": "/evC47eVO3jBNb1iaXyc5TdnDMgl.jpg", + "year": 2011, + "score": 0.05, + "genre_ids": [ + 18, + 53 + ], + "contributions": [ + { + "media_type": "movie", + "tmdb_id": 210479, + "watched": true, + "added": true, + "title": "Locke" + } + ] + }, + { + "media_type": "movie", + "tmdb_id": 11660, + "tvdb_id": 0, + "title": "Following", + "overview": "Bill, an idle, unemployed aspiring writer, walks the crowded streets of London following randomly chosen strangers, a seemingly innocent entertainment that becomes dangerous when he crosses paths with a mysterious character.", + "poster_path": "/3bX6VVSMf0dvzk5pMT4ALG5A92d.jpg", + "backdrop_path": "/yf5I5VHllK4h5VdbxHfQ40RBTe8.jpg", + "year": 1999, + "score": 0.04611010066393556, + "genre_ids": [ + 18, + 53 + ], + "contributions": [ + { + "media_type": "movie", + "tmdb_id": 9357, + "watched": true, + "added": false, + "title": "One Hour Photo" + } + ] + }, + { + "media_type": "movie", + "tmdb_id": 65650, + "tvdb_id": 0, + "title": "The Good Doctor", + "overview": "Dr. Martin Blake, who has spent his life looking for respect, meets an 18-year-old patient named Diane, suffering from a kidney infection, and gets a much-needed boost of self-esteem. However, when her health starts improving, Martin fears losing her, so he begins tampering with her treatment, keeping Diane sick and in the hospital right next to him.", + "poster_path": "/fJSdqHzpMeCkXMrILYt1SdGN2sL.jpg", + "backdrop_path": "/jPlyFmmh9rHQqIuRtEv0gCsOVPK.jpg", + "year": 2011, + "score": 0.04172386354402945, + "genre_ids": [ + 18, + 53, + 9648 + ], + "contributions": [ + { + "media_type": "movie", + "tmdb_id": 399057, + "watched": false, + "added": true, + "title": "The Killing of a Sacred Deer" + } + ] + }, + { + "media_type": "movie", + "tmdb_id": 241, + "tvdb_id": 0, + "title": "Natural Born Killers", + "overview": "Two victims of traumatized childhoods become lovers and serial murderers irresponsibly glorified by the mass media.", + "poster_path": "/fEKZwT91gxvkAoyPgpNXo8W5fu0.jpg", + "backdrop_path": "/k3TOt50cHvTLXWYPnXP0szhiXB8.jpg", + "year": 1994, + "score": 0.03824687491536033, + "genre_ids": [ + 80, + 53, + 18 + ], + "contributions": [ + { + "media_type": "movie", + "tmdb_id": 399057, + "watched": false, + "added": true, + "title": "The Killing of a Sacred Deer" + } + ] + }, + { + "media_type": "movie", + "tmdb_id": 645710, + "tvdb_id": 0, + "title": "The Voyeurs", + "overview": "When Pippa and Thomas move into their dream apartment, they notice that their windows look directly into the apartment opposite \u2013 inviting them to witness the volatile relationship of the attractive couple across the street. But what starts as a simple curiosity turns into full-blown obsession with increasingly dangerous consequences.", + "poster_path": "/8Y4XOIWhpOvSOEn8XrxbkH9yAXO.jpg", + "backdrop_path": "/5KGrBXvE0cYDwqfEaymvNBVspkJ.jpg", + "year": 2021, + "score": 0.03688808053114845, + "genre_ids": [ + 53 + ], + "contributions": [ + { + "media_type": "movie", + "tmdb_id": 9357, + "watched": true, + "added": false, + "title": "One Hour Photo" + } + ] + }, + { + "media_type": "movie", + "tmdb_id": 69668, + "tvdb_id": 0, + "title": "Dream House", + "overview": "Publisher Will Atenton quits a lucrative job in New York to relocate his wife, Libby, and their daughters to a quaint town in New England. However, as they settle into their home the Atentons discover that a woman and her children were murdered there, and the surviving husband is the town's prime suspect. With help from a neighbor who was close to the murdered family, Will pieces together a horrifying chain of events.", + "poster_path": "/uQFbnBEYguc6EWYKG0Hgq4LVdc2.jpg", + "backdrop_path": "/6eFU4wRgnL4YEvDagEianumeGpc.jpg", + "year": 2011, + "score": 0.03278303564173742, + "genre_ids": [ + 9648, + 18, + 53 + ], + "contributions": [ + { + "media_type": "movie", + "tmdb_id": 399057, + "watched": false, + "added": true, + "title": "The Killing of a Sacred Deer" + } + ] + }, + { + "media_type": "movie", + "tmdb_id": 33195, + "tvdb_id": 0, + "title": "Triage", + "overview": "The wife of a photojournalist sets out to discover why he came home from a recent assignment without his colleague.", + "poster_path": "/qH3QzgkqJYcTVs5QoH9SOE80B8q.jpg", + "backdrop_path": "/yLqD4mKGLR4pZRP42DxGqPlIG1t.jpg", + "year": 2009, + "score": 0.030597499932288264, + "genre_ids": [ + 18, + 53, + 9648 + ], + "contributions": [ + { + "media_type": "movie", + "tmdb_id": 399057, + "watched": false, + "added": true, + "title": "The Killing of a Sacred Deer" + } + ] + }, + { + "media_type": "movie", + "tmdb_id": 4547, + "tvdb_id": 0, + "title": "Panic Room", + "overview": "Trapped in their New York brownstone's panic room, a hidden chamber built as a sanctuary in the event of break-ins, newly divorced Meg Altman and her young daughter Sarah play a deadly game of cat-and-mouse with three intruders - Burnham, Raoul and Junior - during a brutal home invasion. But the room itself is the focal point because what the intruders really want is inside it.", + "poster_path": "/hANYbvfwxmkC9E4yY6YyJxYxlSJ.jpg", + "backdrop_path": "/gJfd2SzUsvY1q1iszthJ3ryuvEA.jpg", + "year": 2002, + "score": 0.028685156186520247, + "genre_ids": [ + 80, + 18, + 53 + ], + "contributions": [ + { + "media_type": "movie", + "tmdb_id": 399057, + "watched": false, + "added": true, + "title": "The Killing of a Sacred Deer" + } + ] + }, + { + "media_type": "movie", + "tmdb_id": 74998, + "tvdb_id": 0, + "title": "Seeking Justice", + "overview": "After his wife is assaulted, a husband enlists the services of a vigilante group to help him settle the score.", + "poster_path": "/fJCBxeeKLmxOG2TS4ewhalSb9ur.jpg", + "backdrop_path": "/3luwjhBo76a7swVxqfjcOh3e8tR.jpg", + "year": 2011, + "score": 0.02549791661024022, + "genre_ids": [ + 28, + 18, + 53 + ], + "contributions": [ + { + "media_type": "movie", + "tmdb_id": 399057, + "watched": false, + "added": true, + "title": "The Killing of a Sacred Deer" + } + ] + } + ], + "shows": [ + { + "media_type": "tv", + "tmdb_id": 73552, + "tvdb_id": 341235, + "title": "Heathers", + "overview": "A pitch-black comedy anthology set in the present day, as our heroine, Veronica Sawyer, deals with a very different but equally vicious group of Heathers. Based on the 1988 cult classic film.", + "poster_path": "/pbK7GvYCrkqUevhGLcNuyja3ngm.jpg", + "backdrop_path": "/bsazBvyJQlor6PvA5pS6X3mRWmX.jpg", + "year": 2018, + "score": 1.0824883110875794, + "genre_ids": [ + 35, + 80 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 218347, + "watched": true, + "added": false + }, + { + "media_type": "tv", + "tmdb_id": 107113, + "watched": false, + "added": true, + "title": "Only Murders in the Building" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 2327, + "tvdb_id": 72584, + "title": "Dawson's Creek", + "overview": "Dawson's Creek is an American teen drama that portrays the fictional lives of a close-knit group of teenagers through high school and college.", + "poster_path": "/arT93dBemanftfWTcf9I0JRIlxU.jpg", + "backdrop_path": "/ofiNBOL7gpuzqXEqWBprF90CtY7.jpg", + "year": 1998, + "score": 1.030931929109617, + "genre_ids": [ + 18, + 10766 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 4586, + "watched": true, + "added": false, + "title": "Gilmore Girls" + }, + { + "media_type": "tv", + "tmdb_id": 202851, + "watched": false, + "added": true, + "title": "Big Boys" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 578, + "tvdb_id": 73918, + "title": "Numb3rs", + "overview": "Inspired by actual cases and experiences, Numb3rs depicts the confluence of police work and mathematics in solving crime as an FBI agent recruits his mathematical genius brother to help solve a wide range of challenging crimes in Los Angeles from a very different perspective.", + "poster_path": "/wOzbxJmpccPA5MYvBSGbor3WKh3.jpg", + "backdrop_path": "/oYwaOeQmE0tuBKy8kt43NFNI4bE.jpg", + "year": 2005, + "score": 1, + "genre_ids": [ + 80, + 18, + 9648 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 226637, + "watched": true, + "added": false, + "title": "High Potential" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 900, + "tvdb_id": 79773, + "title": "Skins", + "overview": "Irreverent comedy drama which follows the messy lives, loves, delirious highs and inevitable lows of a group of raucous teenage friends in Bristol.", + "poster_path": "/bf4L6bB9YOGyrPeLdlpz3FlMZQg.jpg", + "backdrop_path": "/saBnI34QTYTjG9sj8DsembO4PBK.jpg", + "year": 2007, + "score": 1, + "genre_ids": [ + 18, + 35 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 76148, + "watched": true, + "added": true, + "title": "Derry Girls" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 56570, + "tvdb_id": 270408, + "title": "Outlander", + "overview": "The story of Claire Randall, a married combat nurse from 1945 who is mysteriously swept back in time to 1743, where she is immediately thrown into an unknown world where her life is threatened. When she is forced to marry Jamie, a chivalrous and romantic young Scottish warrior, a passionate affair is ignited that tears Claire's heart between two vastly different men in two irreconcilable lives.", + "poster_path": "/oftZNfyTVNU7IfOqoGLoT8MGvNs.jpg", + "backdrop_path": "/nf3Vlxm3C9U1aKUUQHmKFZmxPSc.jpg", + "year": 2014, + "score": 1, + "genre_ids": [ + 18, + 10765 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 207484, + "watched": true, + "added": false, + "title": "Outlander: Blood of My Blood" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 62117, + "tvdb_id": 280706, + "title": "Younger", + "overview": "Liza Miller, a suddenly single stay-at-home mother, tries to get back into the working world, only to find it\u2019s nearly impossible to start at the bottom at 40-year old. When a chance encounter convinces her she looks younger than she is, Liza tries to pass herself off as 26 and lands a job as an assistant at Empirical Press. Now she just has to make sure no one finds out the secret only she and her best friend Maggie share.", + "poster_path": "/feWw6i09zaX2apzRqd7QbKEAsx9.jpg", + "backdrop_path": "/wCdhtaLMNtQ37j4OEch4dSKXyoE.jpg", + "year": 2015, + "score": 1, + "genre_ids": [ + 18, + 35 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 105, + "watched": true, + "added": false, + "title": "Sex and the City" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 87428, + "tvdb_id": 365069, + "title": "Why Women Kill", + "overview": "Three women living in three different decades: a housewife in the '60s, a socialite in the '80s and a lawyer in 2018, deal with infidelity in their marriages.", + "poster_path": "/fI3E1G5N8oimMeyYn5UzWRT2cST.jpg", + "backdrop_path": "/61YttrsiP6B4sJo98HFMuHKPV3G.jpg", + "year": 2019, + "score": 1, + "genre_ids": [ + 35, + 80, + 18 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 693, + "watched": true, + "added": false, + "title": "Desperate Housewives" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 88989, + "tvdb_id": 376815, + "title": "Nine Perfect Strangers", + "overview": "At a boutique health-and-wellness resort that promises healing and transformation, nine stressed city dwellers try to get on a path to a better way of living. Watching over them during this 10-day retreat is the resort's director, Masha, a woman on a mission to reinvigorate their tired minds and bodies. However, these nine \"perfect\" strangers have no idea what is about to hit them.", + "poster_path": "/oPcqsZIFHrv7HoGgFQnsGDiPulD.jpg", + "backdrop_path": "/RzCw9LQyAcoa9mn4xkEAyuvhgr.jpg", + "year": 2021, + "score": 1, + "genre_ids": [ + 18, + 9648 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 111803, + "watched": true, + "added": true, + "title": "The White Lotus" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 106454, + "tvdb_id": 384495, + "title": "The Afterparty", + "overview": "Nothing is as it seems in this hilarious murder mystery where each suspect's story is told through a different film genre.", + "poster_path": "/9iAxAxeX5RjB39ckhxPMDCJPpG9.jpg", + "backdrop_path": "/kbehRFMpW7VHsoLB0FM2wsdZsyd.jpg", + "year": 2022, + "score": 0.9711203427159524, + "genre_ids": [ + 9648, + 35, + 80 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 693, + "watched": true, + "added": false, + "title": "Desperate Housewives" + }, + { + "media_type": "tv", + "tmdb_id": 218347, + "watched": true, + "added": false + }, + { + "media_type": "tv", + "tmdb_id": 107113, + "watched": false, + "added": true, + "title": "Only Murders in the Building" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 71199, + "tvdb_id": 321149, + "title": "Claws", + "overview": "Follow the life and crimes of five diverse and treacherous manicurists working at the Nail Artisan of Manatee County salon, where there is a lot more going on than silk wraps and pedicures.", + "poster_path": "/3XtQTSWYpmv9NpC9MJt96nJ9fbf.jpg", + "backdrop_path": "/ooCJReGvCw8UcHQeP5gmNFSInQp.jpg", + "year": 2017, + "score": 0.9248318554229322, + "genre_ids": [ + 35, + 18 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 76148, + "watched": true, + "added": true, + "title": "Derry Girls" + }, + { + "media_type": "tv", + "tmdb_id": 111803, + "watched": true, + "added": true, + "title": "The White Lotus" + }, + { + "media_type": "tv", + "tmdb_id": 218347, + "watched": true, + "added": false + }, + { + "media_type": "tv", + "tmdb_id": 65315, + "watched": true, + "added": false, + "title": "Flaked" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 100883, + "tvdb_id": 378746, + "title": "Never Have I Ever", + "overview": "After a traumatic year, all an Indian-American teen wants is to go from pariah to popular -- but friends, family and feeling won't make it easy on her.", + "poster_path": "/hd5fnBixab6IzfUwjC5wfdbX3eM.jpg", + "backdrop_path": "/umVYLVZ7T85TkIHudxK799lPnLQ.jpg", + "year": 2020, + "score": 0.8999999999999999, + "genre_ids": [ + 35, + 18 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 105, + "watched": true, + "added": false, + "title": "Sex and the City" + }, + { + "media_type": "tv", + "tmdb_id": 76148, + "watched": true, + "added": true, + "title": "Derry Girls" + }, + { + "media_type": "tv", + "tmdb_id": 117581, + "watched": true, + "added": false, + "title": "Ginny & Georgia" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 42282, + "tvdb_id": 220411, + "title": "Girls", + "overview": "The assorted humiliations, disasters and rare triumphs of four very different twenty-something girls: Hannah, an aspiring writer; Marnie, an art gallery assistant and cousins Jessa and Shoshanna.", + "poster_path": "/ikyT3hnELofnYRsPIqVGgUhDbOT.jpg", + "backdrop_path": "/9O2k1lfAMes2UgrWzHVvpMJHjw.jpg", + "year": 2012, + "score": 0.8666666666666667, + "genre_ids": [ + 18, + 35 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 105, + "watched": true, + "added": false, + "title": "Sex and the City" + }, + { + "media_type": "tv", + "tmdb_id": 76148, + "watched": true, + "added": true, + "title": "Derry Girls" + }, + { + "media_type": "tv", + "tmdb_id": 117581, + "watched": true, + "added": false, + "title": "Ginny & Georgia" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 243861, + "tvdb_id": 444846, + "title": "The Vince Staples Show", + "overview": "Kind of famous and sort of rich, rapper and actor Vince Staples navigates the challenges and surprises of everyday life in his hometown of The Beach.", + "poster_path": "/gyUKo6ILKVwuiVq4EWv1avEXpBp.jpg", + "backdrop_path": "/saDz8sl3IjuKpPgD3uqkzJ952tJ.jpg", + "year": 2024, + "score": 0.7377724130921047, + "genre_ids": [ + 18, + 35 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 65315, + "watched": true, + "added": false, + "title": "Flaked" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 94617, + "tvdb_id": 415488, + "title": "Grease: Rise of the Pink Ladies", + "overview": "In 1954, a time before rock \u2018n\u2019 roll ruled, before the T-Birds were the coolest in the school, four fed-up outcasts dare to have fun on their own terms, sparking a moral panic that will change Rydell High forever.", + "poster_path": "/dGu8u5F9Cjn9Ij2aMYtGmKHp5m1.jpg", + "backdrop_path": "/aWXGFJyhEvI1Y7NWk2A3oriHMfb.jpg", + "year": 2023, + "score": 0.6669665360344916, + "genre_ids": [ + 18 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 254420, + "watched": true, + "added": false + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 62823, + "tvdb_id": 294417, + "title": "Scream: The TV Series", + "overview": "What starts as a YouTube video going viral, soon leads to problems for the teenagers of Lakewood and serves as the catalyst for a murder that opens up a window to the town's troubled past. Everyone has secrets. Everyone tells lies. Everyone is fair game.", + "poster_path": "/cb2WD75HjajBmFtPzfU5ukGqfeo.jpg", + "backdrop_path": "/xCXyhbr7T4vx82QUqtN8ZszYIqx.jpg", + "year": 2015, + "score": 0.666741634008623, + "genre_ids": [ + 18, + 80, + 9648 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 226637, + "watched": true, + "added": false, + "title": "High Potential" + }, + { + "media_type": "tv", + "tmdb_id": 254420, + "watched": true, + "added": false + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 18856, + "tvdb_id": 95741, + "title": "Hawthorne", + "overview": "Christina Hawthorne is a compassionate and headstrong Chief Nursing Officer heading up a group of dedicated nurses at Richmond Trinity Hospital on the hospital's front lines. She does everything in her power to help her patients. When necessary, she takes on doctors and administrators who are overworked, distracted or just unable to see the human being behind the hospital chart. But the long days at the hospital and Hawthorne\u2019s intense focus on helping others take a toll on her personal life.", + "poster_path": "/bZuFnSA7RtgEQJUBdHqbhtlbpmp.jpg", + "backdrop_path": "/rvlQ6u2ZR8phyOtQSmaOOP9pOPv.jpg", + "year": 2009, + "score": 0.6224984087250857, + "genre_ids": [ + 18 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 39793, + "watched": true, + "added": false, + "title": "Call the Midwife" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 1803, + "tvdb_id": 76130, + "title": "The Facts of Life", + "overview": "Mrs. Edna Garrett, housemother and dietitian at the Eastland School, teaches a group of girls in her charge how to solve those problems that every teenager has to face.", + "poster_path": "/45i9VujUbQITmHl3QE3eNs9ZUv3.jpg", + "backdrop_path": "/nxqRVJ3QP76CZBaunWl42hQVK0.jpg", + "year": 1979, + "score": 0.598852635575991, + "genre_ids": [ + 18, + 35 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 4586, + "watched": true, + "added": false, + "title": "Gilmore Girls" + }, + { + "media_type": "tv", + "tmdb_id": 254420, + "watched": true, + "added": false + }, + { + "media_type": "tv", + "tmdb_id": 108255, + "watched": false, + "added": true, + "title": "All Creatures Great & Small (2020)" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 116450, + "tvdb_id": 394924, + "title": "And Just Like That\u2026", + "overview": "This new chapter of \u201cSex and the City\u201d follows Carrie, Miranda and Charlotte as they navigate the journey from the complicated reality of life and friendship in their 30s to the even more complicated reality of life and friendship in their 50s.", + "poster_path": "/2b6E0pN19IGsmxSv6HyTKydKKdb.jpg", + "backdrop_path": "/sHvTsXSWxuW7hoXAqWM4lBLJI50.jpg", + "year": 2021, + "score": 0.5808878159517542, + "genre_ids": [ + 35, + 18 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 105, + "watched": true, + "added": false, + "title": "Sex and the City" + }, + { + "media_type": "tv", + "tmdb_id": 693, + "watched": true, + "added": false, + "title": "Desperate Housewives" + }, + { + "media_type": "tv", + "tmdb_id": 117581, + "watched": true, + "added": false, + "title": "Ginny & Georgia" + }, + { + "media_type": "tv", + "tmdb_id": 65315, + "watched": true, + "added": false, + "title": "Flaked" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 199001, + "tvdb_id": 405817, + "title": "My Life with the Walter Boys", + "overview": "When a tragedy disrupts her life, a teen moves in with her guardian's big family in a small town and learns new lessons about love, hope and friendship.", + "poster_path": "/dQOwpTpBQEqRUcev4423LrU32G6.jpg", + "backdrop_path": "/ntsEP1jUbLxz62MZqxmVfM5TNfX.jpg", + "year": 2023, + "score": 0.5398411880684697, + "genre_ids": [ + 18 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 117581, + "watched": true, + "added": false, + "title": "Ginny & Georgia" + }, + { + "media_type": "tv", + "tmdb_id": 207484, + "watched": true, + "added": false, + "title": "Outlander: Blood of My Blood" + }, + { + "media_type": "tv", + "tmdb_id": 258165, + "watched": false, + "added": true, + "title": "East of Eden (2026)" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 64254, + "tvdb_id": 298901, + "title": "Master of None", + "overview": "30-year-old New York actor Dev takes on such pillars of maturity as the first big job, a serious relationship, and busting sex offenders on the subway.", + "poster_path": "/AcJM86PhgHAfbrF4dMKBaqO3cHV.jpg", + "backdrop_path": "/yYnxld9hgTLCOFORequmOK6paj2.jpg", + "year": 2015, + "score": 0.5, + "genre_ids": [ + 35, + 18 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 105, + "watched": true, + "added": false, + "title": "Sex and the City" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 196454, + "tvdb_id": 425204, + "title": "Queen Charlotte: A Bridgerton Story", + "overview": "Young Queen Charlotte's marriage to King George of England sparks an epic love story and transforms high society in this \"Bridgerton\" universe prequel.", + "poster_path": "/kFSaVIlBRycGNfrLzJZX7dgSQ1o.jpg", + "backdrop_path": "/92R2R75gIiVo32dIm0JUaIytpij.jpg", + "year": 2023, + "score": 0.5, + "genre_ids": [ + 18 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 207484, + "watched": true, + "added": false, + "title": "Outlander: Blood of My Blood" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 206829, + "tvdb_id": 422776, + "title": "The Regime", + "overview": "The story of one year within the walls of the palace of a modern European regime as it begins to unravel.", + "poster_path": "/ztqPixNyezY6pWSNIP2AlhwPMO0.jpg", + "backdrop_path": "/lp6Rsgid52BIlzYxWDURFSZZU6m.jpg", + "year": 2024, + "score": 0.5, + "genre_ids": [ + 18, + 35 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 111803, + "watched": true, + "added": true, + "title": "The White Lotus" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 99248, + "tvdb_id": 376879, + "title": "Survive", + "overview": "Jane's plane crashes on a remote snow-covered mountain and she, along with Paul, the only remaining survivors, must pull themselves out of the wreckage and fight for their lives. Together they embark on a harrowing journey out of the wilderness, battling brutal conditions and personal traumas.", + "poster_path": "/stere2OYFYoqj6LTGm0XXWUcKYy.jpg", + "backdrop_path": "/A9h3YUFbnQ7ReXwvnaQvJf5uNjC.jpg", + "year": 2020, + "score": 0.49493359594934455, + "genre_ids": [ + 18 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 117488, + "watched": false, + "added": true, + "title": "Yellowjackets" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 124834, + "tvdb_id": 395494, + "title": "Heartstopper", + "overview": "Teens Charlie and Nick discover their unlikely friendship might be something more as they navigate school and young love.", + "poster_path": "/dQc0QbDiHjGmWxTfKtBgYtS4bj5.jpg", + "backdrop_path": "/8cpXau1LjYMBjiaHUS75JmlgGsU.jpg", + "year": 2022, + "score": 0.4949108657538713, + "genre_ids": [ + 18 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 202851, + "watched": false, + "added": true, + "title": "Big Boys" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 7406, + "tvdb_id": 78289, + "title": "All Creatures Great and Small", + "overview": "The trials and misadventures of the staff at a country veterinary office in Yorkshire. James Herriot, a young animal surgeon, moves to a small Yorkshire town to begin his first job.", + "poster_path": "/inl6wqdRPoLEDXttC33x9PBoMbN.jpg", + "backdrop_path": "/kqpOg91jYda1G3RU7MpjdHllAGy.jpg", + "year": 1978, + "score": 0.49490485366539394, + "genre_ids": [ + 35, + 18 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 108255, + "watched": false, + "added": true, + "title": "All Creatures Great & Small (2020)" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 69470, + "tvdb_id": 320450, + "title": "Santa Clarita Diet", + "overview": "They're ordinary husband and wife realtors until she undergoes a dramatic change that sends them down a road of death and destruction. In a good way.", + "poster_path": "/5rBjNm2sRubaXKBnU904NInFi7P.jpg", + "backdrop_path": "/zPCDfLwOEtEhddaT2xeO5ChErsV.jpg", + "year": 2017, + "score": 0.45706579996470237, + "genre_ids": [ + 35, + 9648, + 80 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 693, + "watched": true, + "added": false, + "title": "Desperate Housewives" + }, + { + "media_type": "tv", + "tmdb_id": 107113, + "watched": false, + "added": true, + "title": "Only Murders in the Building" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 62705, + "tvdb_id": 295729, + "title": "Married at First Sight", + "overview": "Working with leading relationship experts, eight Australian singles are carefully match-made into four married couples, who each meet each other - for the very first time - at their wedding. We'll follow them as they marry, honeymoon, meet the in-laws and set up home, all the while getting to know one another more and more deeply, to see if the matchmakers have got it right and they will have a future together.", + "poster_path": "/2F0ADv2beVB4tRG0qx7YlWMvnDN.jpg", + "backdrop_path": "/pLa2pcYnKA9xxWiWh1C08TXcDTY.jpg", + "year": 2015, + "score": 0.42500246169008526, + "genre_ids": [ + 10764 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 290750, + "watched": false, + "added": true, + "title": "Love is Blind: Netherlands" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 253376, + "tvdb_id": 449706, + "title": "The Girlfriend", + "overview": "Laura has it all: a glittering career, a loving husband, and her precious son, Daniel. But her perfect life unravels when Daniel brings home Cherry, a girlfriend who changes everything. After a tense introduction, Laura becomes convinced Cherry is hiding something. Is she a manipulative social climber, or is Laura just paranoid? The truth is a matter of perspective.", + "poster_path": "/ktrp3tdZ5IQpFux4nLy8z70bt3n.jpg", + "backdrop_path": "/lnVWmFvLv2pTZGsuAJytiqKu541.jpg", + "year": 2025, + "score": 0.41000601893271116, + "genre_ids": [ + 18, + 9648 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 693, + "watched": true, + "added": false, + "title": "Desperate Housewives" + }, + { + "media_type": "tv", + "tmdb_id": 207484, + "watched": true, + "added": false, + "title": "Outlander: Blood of My Blood" + }, + { + "media_type": "tv", + "tmdb_id": 117488, + "watched": false, + "added": true, + "title": "Yellowjackets" + }, + { + "media_type": "tv", + "tmdb_id": 258165, + "watched": false, + "added": true, + "title": "East of Eden (2026)" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 39468, + "tvdb_id": 211681, + "title": "Mildred Pierce", + "overview": "Mildred Pierce depicts an overprotective, self-sacrificing mother during the Great Depression who finds herself separated from her husband, opening a restaurant of her own and falling in love with a man, all the while trying to earn her spoiled, narcissistic daughter's love and respect.", + "poster_path": "/eZ7TUocetldADAXZfnMSEVl7SbJ.jpg", + "backdrop_path": "/nYGVRE2VVJZ71gqsm1AYAjO0C1G.jpg", + "year": 2011, + "score": 0.407539273675682, + "genre_ids": [ + 18 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 258165, + "watched": false, + "added": true, + "title": "East of Eden (2026)" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 126914, + "tvdb_id": 404298, + "title": "With Love", + "overview": "The Diaz siblings, Lily and Jorge, are on a mission to find love and purpose. They cross paths with seemingly unrelated residents during some of the most heightened days of the year\u2014the holidays.", + "poster_path": "/H7UoEyxRPKdwgdy4kt7UbvkAlX.jpg", + "backdrop_path": "/tlamrEuFPeXBjUNW4ynmJaAemV3.jpg", + "year": 2021, + "score": 0.4007003182322874, + "genre_ids": [ + 35, + 18 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 105, + "watched": true, + "added": false, + "title": "Sex and the City" + }, + { + "media_type": "tv", + "tmdb_id": 111803, + "watched": true, + "added": true, + "title": "The White Lotus" + }, + { + "media_type": "tv", + "tmdb_id": 65315, + "watched": true, + "added": false, + "title": "Flaked" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 5721, + "tvdb_id": 82422, + "title": "The Secret Life of the American Teenager", + "overview": "Amy and her friends at Grant High learn to define themselves while they navigate the perilous waters of contemporary adolescence. Between their love triangles, secrets, drama, accusations, gossip, confusion, and scandalous rumors, there's never a dull moment.", + "poster_path": "/k2XTj9D0tzca3dSgvVojU9xk8F5.jpg", + "backdrop_path": "/bH9Sf6wzJqYeBzhOsqGXwcLSUgR.jpg", + "year": 2008, + "score": 0.38888888888888884, + "genre_ids": [ + 18, + 35 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 76148, + "watched": true, + "added": true, + "title": "Derry Girls" + }, + { + "media_type": "tv", + "tmdb_id": 117581, + "watched": true, + "added": false, + "title": "Ginny & Georgia" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 21762, + "tvdb_id": 94551, + "title": "Parenthood", + "overview": "The trials and tribulations of the very large, colorful and imperfect Braverman family.", + "poster_path": "/9KjKLJrrUI1sU9bNsQaQ5H8kTyY.jpg", + "backdrop_path": "/72zLfNDaL8JpfLDwmQ2b8DPbeHc.jpg", + "year": 2010, + "score": 0.3781500494089771, + "genre_ids": [ + 18 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 33155, + "watched": true, + "added": false, + "title": "Offspring" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 80587, + "tvdb_id": 342059, + "title": "Big City Greens", + "overview": "The offbeat adventures of 10-year-old Cricket Green, a mischievous and optimistic country boy who moves to the big city with his wildly out of place family \u2013 older sister Tilly, father Bill and Gramma Alice.", + "poster_path": "/buRk2Vrz8UhF5nZUOADSRZLEU1f.jpg", + "backdrop_path": "/vVGmv5mle6lCrgnx8lNv5Q1vZPw.jpg", + "year": 2018, + "score": 0.3781500494089771, + "genre_ids": [ + 10751, + 16, + 35 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 66134, + "watched": true, + "added": false, + "title": "The Durrells" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 39859, + "tvdb_id": 249882, + "title": "Awkward.", + "overview": "An irreverent look at the conflict, chaos and humor that defines teenage life through the eyes of 15-year-old Jenna Hamilton whose life begins to change when a simple accident becomes an epic misunderstanding and is blown way out of proportion. Narration in the first-person voice of Jenna's blog posts captures the humor within the struggles and experiences everyone can relate to from their formative years.", + "poster_path": "/7PLO6PdGmfKiV3KER2Rd08Ga8kM.jpg", + "backdrop_path": "/yFFW6MtYRjBHppYWskmG81pR7xV.jpg", + "year": 2011, + "score": 0.37155333461369744, + "genre_ids": [ + 35, + 18 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 254420, + "watched": true, + "added": false + }, + { + "media_type": "tv", + "tmdb_id": 202851, + "watched": false, + "added": true, + "title": "Big Boys" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 210805, + "tvdb_id": 414550, + "title": "Tyler Perry's Zatima", + "overview": "Zac and Fatima take a huge step to strengthen their bond, but new friends and past actions interfere with their blossoming relationship.", + "poster_path": "/mccoy3vzuTHucJXkBL27SqcyAvv.jpg", + "backdrop_path": "/6Vtd3EMdOuoT3GIUKDbkDzSFp0Q.jpg", + "year": 2022, + "score": 0.36888620654605236, + "genre_ids": [ + 18, + 35 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 65315, + "watched": true, + "added": false, + "title": "Flaked" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 4177, + "tvdb_id": 70589, + "title": "Perry Mason", + "overview": "The cases of master criminal defense attorney Perry Mason and his staff who handled the most difficult of cases in the aid of the innocent.", + "poster_path": "/sK6JvgNFP1ClT4aF4nPAZiDpAdk.jpg", + "backdrop_path": "/iq4BjyVsDxzg24XnjoGP0Xi2Tz5.jpg", + "year": 1957, + "score": 0.3333333333333333, + "genre_ids": [ + 9648, + 18, + 80 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 226637, + "watched": true, + "added": false, + "title": "High Potential" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 46028, + "tvdb_id": 267777, + "title": "Girl Meets World", + "overview": "The adventures of relatable and adventurous Riley Matthews, the tween daughter of Cory and Topanga Matthews, and her bold best friend Maya as they traverse the twists and turns of teenage years at Manhattan's John Quincy Adams Middle School where Riley's dad is their History teacher.", + "poster_path": "/8E1i8j6GVT7wMzw8CtqzifRpr5R.jpg", + "backdrop_path": "/pJvLprQwTVEOSBPs25y4rM214ch.jpg", + "year": 2014, + "score": 0.3333333333333333, + "genre_ids": [ + 35, + 10751 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 4586, + "watched": true, + "added": false, + "title": "Gilmore Girls" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 92621, + "tvdb_id": 351960, + "title": "Brassic", + "overview": "A group of working-class friends finding unconventional ways to win at life in suburban northern England. These lads have dealt, scammed, bribed and conned their way through adolescence, but now, their dealing and stealing is catching up with them and a whole load of trouble is heading their way.", + "poster_path": "/tnIq3yiLMZJpaJyRs8FNV7WJs6V.jpg", + "backdrop_path": "/hEJE1WsZZqjJsq0Ltpom5qZzWEE.jpg", + "year": 2019, + "score": 0.325, + "genre_ids": [ + 35, + 18, + 80 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 76148, + "watched": true, + "added": true, + "title": "Derry Girls" + }, + { + "media_type": "tv", + "tmdb_id": 218347, + "watched": true, + "added": false + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 1395, + "tvdb_id": 80547, + "title": "Gossip Girl", + "overview": "An exclusive group of privileged teens from a posh prep school on Manhattan's Upper East Side whose lives revolve around the blog of the all-knowing albeit ultra-secretive Gossip Girl.", + "poster_path": "/ep03M6KKZGeljOizPEbt3Geja5n.jpg", + "backdrop_path": "/5RWAjCYhu3D8Ai2QBYP7GjaxFb3.jpg", + "year": 2007, + "score": 0.3166966536034492, + "genre_ids": [ + 18 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 4586, + "watched": true, + "added": false, + "title": "Gilmore Girls" + }, + { + "media_type": "tv", + "tmdb_id": 254420, + "watched": true, + "added": false + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 6127, + "tvdb_id": 78895, + "title": "Agatha Christie's Marple", + "overview": "The adventures of Miss Jane Marple, an elderly spinster living in the quiet little village of St Mary Mead. During her many visits to friends and relatives in other villages, Miss Marple often stumbles upon mysterious murders which she helps solve. Although the police are sometimes reluctant to accept Miss Marple's help, her reputation and unparalleled powers of observation eventually win them over.", + "poster_path": "/nVswrZQpqQND9TgwgbWgz18KgAo.jpg", + "backdrop_path": "/1lBEfsnzUkxtxzvFwimENpM6bEP.jpg", + "year": 2004, + "score": 0.31224984087250857, + "genre_ids": [ + 18, + 80, + 9648 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 226637, + "watched": true, + "added": false, + "title": "High Potential" + }, + { + "media_type": "tv", + "tmdb_id": 39793, + "watched": true, + "added": false, + "title": "Call the Midwife" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 1021, + "tvdb_id": 71756, + "title": "Casualty", + "overview": "Drama series about the staff and patients at Holby City Hospital's emergency department, charting the ups and downs in their personal and professional lives.", + "poster_path": "/ahqMUNIUeGIBQSkShEV32085IFZ.jpg", + "backdrop_path": "/wnyMAP1Xep5BUwTqMq2KBHjRHws.jpg", + "year": 1986, + "score": 0.31124920436254283, + "genre_ids": [ + 18, + 10766 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 39793, + "watched": true, + "added": false, + "title": "Call the Midwife" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 211, + "tvdb_id": 79636, + "title": "The Game", + "overview": "When her boyfriend Derwin Davis is chosen as the new third-string wide receiver for the San Diego Sabers, Melanie Barnett decides to attend a local college so she can be with him. While Derwin worries about the plays on the field, Melanie adjusts to her new lifestyle. She gets a play-by-play account of the lives and relationships among NFL wives, girlfriends and mom/managers who use their best game to help their men stay on the field and on their arm.", + "poster_path": "/zmvRaCRcmi6lsriYxZmAR6uWHPS.jpg", + "backdrop_path": "/lW3RCsfXXVGIBXyupMGT7dAYcze.jpg", + "year": 2006, + "score": 0.30110976993969285, + "genre_ids": [ + 18, + 35 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 105, + "watched": true, + "added": false, + "title": "Sex and the City" + }, + { + "media_type": "tv", + "tmdb_id": 117581, + "watched": true, + "added": false, + "title": "Ginny & Georgia" + }, + { + "media_type": "tv", + "tmdb_id": 65315, + "watched": true, + "added": false, + "title": "Flaked" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 61118, + "tvdb_id": 281776, + "title": "You're the Worst", + "overview": "Narcissistic, brash, and self-destructive Jimmy Shive-Overly thinks all relationships are doomed. Cynical, people-pleasing, and stubborn Gretchen Cutler knows that relationships aren't for her. So when they meet at a wedding, it's only natural that the two of them go home together.", + "poster_path": "/whXfP89yXz4AwQ9dzWCTGcl9YvV.jpg", + "backdrop_path": "/3gqDstdBLUbJ2jY3hl953iS97D4.jpg", + "year": 2014, + "score": 0.3005952380952381, + "genre_ids": [ + 35, + 18 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 105, + "watched": true, + "added": false, + "title": "Sex and the City" + }, + { + "media_type": "tv", + "tmdb_id": 693, + "watched": true, + "added": false, + "title": "Desperate Housewives" + }, + { + "media_type": "tv", + "tmdb_id": 111803, + "watched": true, + "added": true, + "title": "The White Lotus" + }, + { + "media_type": "tv", + "tmdb_id": 117581, + "watched": true, + "added": false, + "title": "Ginny & Georgia" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 62704, + "tvdb_id": 281714, + "title": "Ballers", + "overview": "Looking at the lives of former and current football players, the show follows former superstar Spencer Strasmore as he gets his life on track in retirement while mentoring other current and former players through the daily grind of the business of football.", + "poster_path": "/WyIazPyjmcFMC42nWEpI0jr56x.jpg", + "backdrop_path": "/gyPWbM1lq7Ufd9cENSl5Phsrb0a.jpg", + "year": 2015, + "score": 0.29592413769736825, + "genre_ids": [ + 18, + 35 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 76148, + "watched": true, + "added": true, + "title": "Derry Girls" + }, + { + "media_type": "tv", + "tmdb_id": 65315, + "watched": true, + "added": false, + "title": "Flaked" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 39525, + "tvdb_id": 239761, + "title": "Wilfred", + "overview": "Everyone else sees Wilfred as just a dog, but Ryan sees a crude and somewhat surly, yet irrepressibly brave and honest Australian bloke in a cheap dog suit. While leading him through a series of comedic and existential adventures, Wilfred the dog shows Ryan the man how to overcome his fears and joyfully embrace the unpredictability and insanity of the world around him.", + "poster_path": "/2cUYCkmHEWsxaNiVTQnFqxyq5Ij.jpg", + "backdrop_path": "/f43EKMX1QHJhiieDfhRgh5M1fZj.jpg", + "year": 2011, + "score": 0.2920702193720095, + "genre_ids": [ + 35, + 18, + 9648 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 693, + "watched": true, + "added": false, + "title": "Desperate Housewives" + }, + { + "media_type": "tv", + "tmdb_id": 111803, + "watched": true, + "added": true, + "title": "The White Lotus" + }, + { + "media_type": "tv", + "tmdb_id": 65315, + "watched": true, + "added": false, + "title": "Flaked" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 111838, + "tvdb_id": 390537, + "title": "The Woman in the House Across the Street from the Girl in the Window", + "overview": "For Anna, every day is the same. She sits with her wine, staring out the window, watching life go by without her. But when a handsome neighbor moves in across the street, Anna starts to see a light at the end of the tunnel. That is until she witnesses a gruesome murder\u2026 Or did she?", + "poster_path": "/riSoMsWYkwqed4ul5PgHOnbIVFp.jpg", + "backdrop_path": "/7I81Iey8vEk6IOfSXqulSckcCsZ.jpg", + "year": 2022, + "score": 0.2656526399717619, + "genre_ids": [ + 9648, + 80, + 35 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 693, + "watched": true, + "added": false, + "title": "Desperate Housewives" + }, + { + "media_type": "tv", + "tmdb_id": 107113, + "watched": false, + "added": true, + "title": "Only Murders in the Building" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 124010, + "tvdb_id": 401513, + "title": "How I Met Your Father", + "overview": "In the near future, Sophie tells her son the story of how she met his father: a story that catapults us back to the year 2021 where Sophie and her close-knit group of friends are in the midst of figuring out who they are, what they want out of life, and how to fall in love in the age of dating apps and limitless options.", + "poster_path": "/8psLGtFzTMYCXxUNMpMOCB6doPn.jpg", + "backdrop_path": "/40JJpWnIkxMpKGHCjt9uE6kzJHU.jpg", + "year": 2022, + "score": 0.255580544669541, + "genre_ids": [ + 35, + 18 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 4586, + "watched": true, + "added": false, + "title": "Gilmore Girls" + }, + { + "media_type": "tv", + "tmdb_id": 254420, + "watched": true, + "added": false + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 275139, + "tvdb_id": 455844, + "title": "Phony", + "overview": "After waking up from a freak car accident, Sonny discovers that his mother has been replaced by an impostor.", + "poster_path": "/iCyQc8cSSHkxUHbkvzbC77owALt.jpg", + "backdrop_path": "/roUD5m2nj6k88azMFsiqBae29cr.jpg", + "year": 2026, + "score": 0.25263157894736843, + "genre_ids": [ + 35, + 18, + 9648 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 693, + "watched": true, + "added": false, + "title": "Desperate Housewives" + }, + { + "media_type": "tv", + "tmdb_id": 111803, + "watched": true, + "added": true, + "title": "The White Lotus" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 69050, + "tvdb_id": 311954, + "title": "Riverdale", + "overview": "Set in the present, the series offers a bold, subversive take on Archie, Betty, Veronica and their friends, exploring the surreality of small-town life, the darkness and weirdness bubbling beneath Riverdale\u2019s wholesome facade.", + "poster_path": "/d8mmn9thQ5dBk2qbv6BCqGUXWK3.jpg", + "backdrop_path": "/kb9XvtEnDhBVVit4eeKygqll4EA.jpg", + "year": 2017, + "score": 0.25, + "genre_ids": [ + 80, + 18, + 9648 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 117581, + "watched": true, + "added": false, + "title": "Ginny & Georgia" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 70796, + "tvdb_id": 326791, + "title": "The Marvelous Mrs. Maisel", + "overview": "It\u2019s 1958 Manhattan and Miriam \u201cMidge\u201d Maisel has everything she\u2019s ever wanted - the perfect husband, kids, and Upper West Side apartment. But when her life suddenly takes a turn and Midge must start over, she discovers a previously unknown talent - one that will take her all the way from the comedy clubs of Greenwich Village to a spot on Johnny Carson\u2019s couch.", + "poster_path": "/zS7fQiOZiKCVH2vlYSiIsFWW8hh.jpg", + "backdrop_path": "/vQ77kC1amsZECKIxkUIsMJCtBVp.jpg", + "year": 2017, + "score": 0.25, + "genre_ids": [ + 35, + 18 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 105, + "watched": true, + "added": false, + "title": "Sex and the City" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 135959, + "tvdb_id": 411173, + "title": "That '90s Show", + "overview": "Hello, Wisconsin! It's 1995 and Leia Forman, daughter of Eric and Donna, is visiting her grandparents for the summer where she bonds with a new generation of Point Place kids under the watchful eye of Kitty and the stern glare of Red. Sex, drugs and rock 'n roll never dies, it just changes clothes.", + "poster_path": "/nZcufEuqZqNYMx6mNjlh2rmjDqx.jpg", + "backdrop_path": "/23WUgvjfnQFNwSPSSG9CdGFnauN.jpg", + "year": 2023, + "score": 0.25, + "genre_ids": [ + 35 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 76148, + "watched": true, + "added": true, + "title": "Derry Girls" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 241372, + "tvdb_id": 443312, + "title": "56 Days", + "overview": "When an unidentified body is found in a luxury apartment linked to Oliver Kennedy and his girlfriend Ciara Wyse, Detectives Lee Reardon and Karl Connolly reconstruct the couple's deadly romance across the past 56 days.", + "poster_path": "/sdJLbucggwGj8NdX0UN2zt1Vwmn.jpg", + "backdrop_path": "/dnEljhBkw53ra7tEl2JNKGrI0wk.jpg", + "year": 2026, + "score": 0.25, + "genre_ids": [ + 18, + 80, + 9648 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 218347, + "watched": true, + "added": false + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 250504, + "tvdb_id": 448245, + "title": "The Beast in Me", + "overview": "A famous author is pulled into a twisted mind game with her rich, powerful new neighbor \u2014 who might be a murderer.", + "poster_path": "/jxf7JCfIMvGONWTNUIJ068sFixK.jpg", + "backdrop_path": "/ylW0m5UztyyL4YFu8EC6usUHLF.jpg", + "year": 2025, + "score": 0.25, + "genre_ids": [ + 18, + 9648 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 693, + "watched": true, + "added": false, + "title": "Desperate Housewives" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 258190, + "tvdb_id": 451659, + "title": "Coldwater", + "overview": "A man in the midst of a mid-life crisis moves his family to a remote Scottish village, only to find himself entangled in a dangerous friendship with a sinister neighbour.", + "poster_path": "/oguBhjxw7hwwCyq2G4ujFRy6kbX.jpg", + "backdrop_path": "/yufA0LfNVYYFApA07cgs3V45yJK.jpg", + "year": 2025, + "score": 0.25, + "genre_ids": [ + 18, + 80 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 207484, + "watched": true, + "added": false, + "title": "Outlander: Blood of My Blood" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 120998, + "tvdb_id": 398822, + "title": "Poker Face", + "overview": "Follow Charlie Cale, a woman with an extraordinary ability to tell when someone is lying, as she hits the road and, at every stop, encounters a new cast of characters and crimes she can't help but solve.", + "poster_path": "/8Xm5dyMQC9whMJKnGdFugUAW73C.jpg", + "backdrop_path": "/ccOzCmrglAjRGtqv5ClTaBEsWt8.jpg", + "year": 2023, + "score": 0.2474649332627381, + "genre_ids": [ + 80, + 9648 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 107113, + "watched": false, + "added": true, + "title": "Only Murders in the Building" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 4454, + "tvdb_id": 71814, + "title": "Will & Grace", + "overview": "Will Truman and Grace Adler are best friends living in New York, and when Grace's engagement falls apart, she moves in with Will. Together, along with their friends, they go through the trials of dating, sex, relationships and their careers, butting heads at times but ultimately supporting one another while exchanging plenty of witty banter along the way.", + "poster_path": "/aEaYdyHcpHN3fIXHhXHtXOs8Rtk.jpg", + "backdrop_path": "/pIPMPMEZErCxTvQG9HDt4AsnAfn.jpg", + "year": 1998, + "score": 0.24745543287693564, + "genre_ids": [ + 35 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 202851, + "watched": false, + "added": true, + "title": "Big Boys" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 2585, + "tvdb_id": 74684, + "title": "Heartbeat", + "overview": "Set during the 1960s in the fictional North Yorkshire village of Aidensfield, this enduringly popular series interweaves crime and medical storylines.", + "poster_path": "/naMIm2WuIFcBk2yNWAQcRAhZFyU.jpg", + "backdrop_path": "/dRVToyhldkgjzGcrhPlZf4IuK0y.jpg", + "year": 1992, + "score": 0.24745242683269697, + "genre_ids": [ + 80, + 18, + 35 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 108255, + "watched": false, + "added": true, + "title": "All Creatures Great & Small (2020)" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 269, + "tvdb_id": 72158, + "title": "One Tree Hill", + "overview": "In Tree Hill, North Carolina two half brothers share a last name and nothing else. Brooding, blue-collar Lucas is a talented street-side basketball player, but his skills are appreciated only by his friends at the river court. Popular, affluent Nathan basks in the hero-worship of the town, as the star of his high school team. And both boys are the son of former college ball player Dan Scott whose long ago choice to abandon Lucas and his mother Karen, will haunt him long into his life with wife Deb and their son Nathan.", + "poster_path": "/sOrelBaAhp7DZbPTivDwKEyPslC.jpg", + "backdrop_path": "/gJOpHgHa37qnglH3dL9g63iztky.jpg", + "year": 2003, + "score": 0.22683789670264065, + "genre_ids": [ + 18 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 76148, + "watched": true, + "added": true, + "title": "Derry Girls" + }, + { + "media_type": "tv", + "tmdb_id": 117581, + "watched": true, + "added": false, + "title": "Ginny & Georgia" + }, + { + "media_type": "tv", + "tmdb_id": 258165, + "watched": false, + "added": true, + "title": "East of Eden (2026)" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 95004, + "tvdb_id": 367658, + "title": "Tyler Perry's The Oval", + "overview": "A seemingly perfect interracial first family becomes the White House's newest residents. But behind closed doors they unleash a torrent of lies, cheating and corruption.", + "poster_path": "/l0u0L1ekz6VVBvbZDWM1XizNEes.jpg", + "backdrop_path": "/LdcSeybOK0xoWTkDjyy1P49KvA.jpg", + "year": 2019, + "score": 0.22345230192348647, + "genre_ids": [ + 18, + 10751 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 33155, + "watched": true, + "added": false, + "title": "Offspring" + }, + { + "media_type": "tv", + "tmdb_id": 66134, + "watched": true, + "added": false, + "title": "The Durrells" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 4107, + "tvdb_id": 74112, + "title": "The Steve Harvey Show", + "overview": "Down on his luck and out of money, former R&B star Steve Hightower lands a music teacher gig at an inner-city Chicago school. Showing who's in charge with his unorthodox approach, Steve discovers a new groove at Washington High School.", + "poster_path": "/iGUWv21kgKc79bpYxImk2nTIPin.jpg", + "backdrop_path": "/2y3q6BzERpZRZrutdKrMHrBnbDb.jpg", + "year": 1996, + "score": 0.22232217867816387, + "genre_ids": [ + 35, + 18 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 254420, + "watched": true, + "added": false + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 70785, + "tvdb_id": 322971, + "title": "Anne with an E", + "overview": "A coming-of-age story about an outsider who, against all odds and numerous challenges, fights for love and acceptance and for her place in the world. The series centers on a young orphaned girl in the late 1890s, who, after an abusive childhood spent in orphanages and the homes of strangers, is mistakenly sent to live with an elderly woman and her aging brother. Over time, 13-year-old Anne will transform their lives and eventually the small town in which they live with her unique spirit, fierce intellect and brilliant imagination.", + "poster_path": "/6P6tXhjT5tK3qOXzxF9OMLlG7iz.jpg", + "backdrop_path": "/ywQtHG17LZhAFZyZtBflhtFMtJ7.jpg", + "year": 2017, + "score": 0.21646710032483785, + "genre_ids": [ + 18, + 10751 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 39793, + "watched": true, + "added": false, + "title": "Call the Midwife" + }, + { + "media_type": "tv", + "tmdb_id": 108255, + "watched": false, + "added": true, + "title": "All Creatures Great & Small (2020)" + }, + { + "media_type": "tv", + "tmdb_id": 66134, + "watched": true, + "added": false, + "title": "The Durrells" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 4607, + "tvdb_id": 73739, + "title": "Lost", + "overview": "Stripped of everything, the survivors of a horrific plane crash must work together to stay alive. But the island holds many secrets.", + "poster_path": "/og6S0aTZU6YUJAbqxeKjCa3kY1E.jpg", + "backdrop_path": "/yUOFocKDW7MCC5isx4FK8A68QFp.jpg", + "year": 2004, + "score": 0.2149778653164482, + "genre_ids": [ + 9648, + 10759, + 18 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 111803, + "watched": true, + "added": true, + "title": "The White Lotus" + }, + { + "media_type": "tv", + "tmdb_id": 117488, + "watched": false, + "added": true, + "title": "Yellowjackets" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 73319, + "tvdb_id": 332747, + "title": "90 Day Fianc\u00e9: Before the 90 Days", + "overview": "Follow couples who have an existing relationship online, but haven't met in person. We'll experience their journey as they travel to the other's foreign country for the first time in an attempt to establish an in-person relationship and start the K-1 visa process.", + "poster_path": "/miG0k1MGyLovjKETzhqbKs9kIdt.jpg", + "backdrop_path": "/5cq5nn8L9wxK4GBPfZU7KnTnrFE.jpg", + "year": 2017, + "score": 0.21250123084504263, + "genre_ids": [ + 10764 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 290750, + "watched": false, + "added": true, + "title": "Love is Blind: Netherlands" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 2398, + "tvdb_id": 74631, + "title": "Girlfriends", + "overview": "A friend group of black women face life's tests and triumphs together. From dating to divorce, and friends to family to relationships, Joan, Maya, Lynn and Toni support each other despite their differing backgrounds.", + "poster_path": "/buFnsyWEg4pPR7dco5Vbsm6HCIY.jpg", + "backdrop_path": "/lJEItmoc7bdqvA0yS5quuBIwW0N.jpg", + "year": 2000, + "score": 0.20647286761979436, + "genre_ids": [ + 18, + 35 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 4586, + "watched": true, + "added": false, + "title": "Gilmore Girls" + }, + { + "media_type": "tv", + "tmdb_id": 202851, + "watched": false, + "added": true, + "title": "Big Boys" + }, + { + "media_type": "tv", + "tmdb_id": 108255, + "watched": false, + "added": true, + "title": "All Creatures Great & Small (2020)" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 186, + "tvdb_id": 74845, + "title": "Weeds", + "overview": "After the unexpected death of her husband, a suburban mom resorts to selling weed to support her family.", + "poster_path": "/gKUsBTa5b0GY7U4iWBseeBalfjO.jpg", + "backdrop_path": "/6k6vAUa0Fm73HB0QBOJbFGTgPq5.jpg", + "year": 2005, + "score": 0.20535714285714285, + "genre_ids": [ + 35, + 80, + 18 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 693, + "watched": true, + "added": false, + "title": "Desperate Housewives" + }, + { + "media_type": "tv", + "tmdb_id": 111803, + "watched": true, + "added": true, + "title": "The White Lotus" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 31917, + "tvdb_id": 146711, + "title": "Pretty Little Liars", + "overview": "Based on the Pretty Little Liars series of young adult novels by Sara Shepard, the series follows the lives of four girls \u2014 Spencer, Hanna, Aria, and Emily \u2014 whose clique falls apart after the disappearance of their queen bee, Alison. One year later, they begin receiving messages from someone using the name \"A\" who threatens to expose their secrets \u2014 including long-hidden ones they thought only Alison knew.", + "poster_path": "/aUPbHiLS3hCHKjtLsncFa9g0viV.jpg", + "backdrop_path": "/6NuvpA24GbWrnkKsYrNyr2RiRbp.jpg", + "year": 2010, + "score": 0.20473848173969317, + "genre_ids": [ + 18, + 9648 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 117581, + "watched": true, + "added": false, + "title": "Ginny & Georgia" + }, + { + "media_type": "tv", + "tmdb_id": 117488, + "watched": false, + "added": true, + "title": "Yellowjackets" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 88324, + "tvdb_id": 371972, + "title": "Virgin River", + "overview": "After seeing an ad for a midwife, a recently divorced big-city nurse moves to the redwood forests of California, where she meets an intriguing man.", + "poster_path": "/oHEqfmSWR4Ex4V4AJyUn8HGHHnt.jpg", + "backdrop_path": "/m1nuMHBCiNy6pMGzRAdH5frNesN.jpg", + "year": 2019, + "score": 0.203769636837841, + "genre_ids": [ + 18 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 258165, + "watched": false, + "added": true, + "title": "East of Eden (2026)" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 4582, + "tvdb_id": 78677, + "title": "Ironside", + "overview": "When an assassin's bullet confines him to a wheelchair for life ending his career as Chief of Detectives, Robert T. Ironside becomes a consultant to the police department. Detective Sergeant Ed Brown and policewoman Eve Whitfield join with him to crack varied and fascinating cases. Ex-con Mark Sanger is employed by the chief as home help but eventually becomes a fully fledged member of the team also. Officer Whitfield leaves after 4 years service, and is replaced by Officer Fran Belding.", + "poster_path": "/Fu9Sv0pQ0s89uWwwXcaG0kiNW7.jpg", + "backdrop_path": "/7lYBvjfgrguOUgnS9NrpysHtaCq.jpg", + "year": 1967, + "score": 0.2, + "genre_ids": [ + 80, + 18, + 9648 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 226637, + "watched": true, + "added": false, + "title": "High Potential" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 67697, + "tvdb_id": 309523, + "title": "Search Party", + "overview": "A dark comedy about four self-absorbed twenty-somethings who become entangled in an ominous mystery when a former college acquaintance suddenly disappears.", + "poster_path": "/8ldHjc3Hx29NKK0bfgaYZkIwQWN.jpg", + "backdrop_path": "/kK2bP2ZWF2up3Y2uzBJupHCaMos.jpg", + "year": 2016, + "score": 0.2, + "genre_ids": [ + 35, + 18, + 80 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 218347, + "watched": true, + "added": false + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 130842, + "tvdb_id": 393813, + "title": "The Newsreader", + "overview": "An unconventional relationship in a world on the cusp of change; a star newsreader and an ambitious bisexual reporter join forces in a ruthless 1986 newsroom, as events unfold that will change their lives.", + "poster_path": "/aHl6HgHcFLfe68E7uiq8NRDCXOv.jpg", + "backdrop_path": "/aGLAJddCYwKhnGZeJdtOEhBGYsr.jpg", + "year": 2021, + "score": 0.2, + "genre_ids": [ + 18 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 117581, + "watched": true, + "added": false, + "title": "Ginny & Georgia" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 153723, + "tvdb_id": 396645, + "title": "All American: Homecoming", + "overview": "Follow the journeys of Simone Hicks, a tennis hopeful from Beverly Hills, and elite baseball player Damon Sims from Chicago, as they navigate life at the prestigious HBCU Bringston University.", + "poster_path": "/97988mOprohvBB61LgdsSdeMGa3.jpg", + "backdrop_path": "/9An9RjsIGLnRmZDbeUVCWDUcuWB.jpg", + "year": 2022, + "score": 0.2, + "genre_ids": [ + 18 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 207484, + "watched": true, + "added": false, + "title": "Outlander: Blood of My Blood" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 41875, + "tvdb_id": 251405, + "title": "Chemistry", + "overview": "The scandalous lives of Liz and Michael, a cop and a lawyer who meet in a near-fatal coincidence. Drama and comedy with inevitable and powerful chemistry between a man and woman that completely alter a destined life plan.", + "poster_path": "/6YL87vk4mdRv2o0v04X6FjSd0XO.jpg", + "backdrop_path": "/4bUFGOe6AxBqBPx6rNMsWVJeKsW.jpg", + "year": 2011, + "score": 0.1875, + "genre_ids": [ + 35, + 18 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 105, + "watched": true, + "added": false, + "title": "Sex and the City" + }, + { + "media_type": "tv", + "tmdb_id": 111803, + "watched": true, + "added": true, + "title": "The White Lotus" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 60705, + "tvdb_id": 277169, + "title": "Faking It", + "overview": "A romantic comedy about two best friends who love each other -- in slightly different ways. After numerous failed attempts to become popular, the girls are mistakenly outed as lesbians, which launches them to instant celebrity status. Seduced by their newfound fame, Karma and Amy decide to keep up their romantic ruse.", + "poster_path": "/vb9J7q977HB3AmvLETpmWIyIyTP.jpg", + "backdrop_path": "/nIMog8nl1FB2uE9NvbL7YOKCcP3.jpg", + "year": 2014, + "score": 0.18436103789614888, + "genre_ids": [ + 35, + 18 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 254420, + "watched": true, + "added": false + }, + { + "media_type": "tv", + "tmdb_id": 202851, + "watched": false, + "added": true, + "title": "Big Boys" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 236235, + "tvdb_id": 426845, + "title": "The Gentlemen", + "overview": "When aristocratic Eddie inherits the family estate, he discovers that it's home to an enormous weed empire \u2014 and its proprietors aren't going anywhere.", + "poster_path": "/tw3tzfXaSpmUZIB8ZNqNEGzMBCy.jpg", + "backdrop_path": "/yG1wltFmkX5c5ocACKfpX0tp3SY.jpg", + "year": 2024, + "score": 0.17692307692307693, + "genre_ids": [ + 35, + 18, + 80 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 111803, + "watched": true, + "added": true, + "title": "The White Lotus" + }, + { + "media_type": "tv", + "tmdb_id": 218347, + "watched": true, + "added": false + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 81357, + "tvdb_id": 361857, + "title": "Dead to Me", + "overview": "A hotheaded widow searching for the hit-and-run driver who mowed down her husband befriends an eccentric optimist who isn't quite what she seems.", + "poster_path": "/mAj18PHeACxqcrEus4pD8pksRmB.jpg", + "backdrop_path": "/j4TXQ7OJsLx1Ba6z8XA7is0DcRZ.jpg", + "year": 2019, + "score": 0.16783216783216784, + "genre_ids": [ + 18, + 35, + 80 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 693, + "watched": true, + "added": false, + "title": "Desperate Housewives" + }, + { + "media_type": "tv", + "tmdb_id": 117581, + "watched": true, + "added": false, + "title": "Ginny & Georgia" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 1088, + "tvdb_id": 71436, + "title": "Dream On", + "overview": "The family life, romantic life, and career of Martin Tupper, a divorced New York City book editor. The show distinctively interjected clips from older black and white television series to punctuate Tupper's feelings or thoughts.", + "poster_path": "/8aiG4OUBXj5yMFrBIpYIXMtxCJv.jpg", + "backdrop_path": "/oLrKqDLFdAdbNe2KurNBDDtrE6d.jpg", + "year": 1990, + "score": 0.16666666666666666, + "genre_ids": [ + 35 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 105, + "watched": true, + "added": false, + "title": "Sex and the City" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 3004, + "tvdb_id": 76896, + "title": "Newhart", + "overview": "Dick Loudon and his wife Joanna decide to leave life in New York City and buy a little inn in Vermont. Dick is a how-to book writer, who eventually becomes a local TV celebrity as host of \"Vermont Today.\" George Utley is the handyman at the inn and Leslie Vanderkellen is the maid, with ambitions of being an Olympic Ski champion; she is later replaced by her cousin Stephanie, an heiress who hates her job. Her boyfriend is Dick's yuppie TV producer, Michael Harris. There are many other quirky characters in this fictional little town, including Dick's neighbors Larry, Darryl, and Darryl...three brothers who buy the Minuteman Cafe from Kirk Devane. Besides sharing a name, Darryl and Darryl never speak.", + "poster_path": "/zBI5YcdsftGE9F6zY6UwYVybsdY.jpg", + "backdrop_path": "/pCUfyiLZ1bT7JkffXZdDT0PgbGz.jpg", + "year": 1982, + "score": 0.16666666666666666, + "genre_ids": [ + 35, + 18 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 4586, + "watched": true, + "added": false, + "title": "Gilmore Girls" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 3216, + "tvdb_id": 71928, + "title": "Baretta", + "overview": "Baretta is an American detective television series which ran on ABC from 1975 to 1978. The show was a milder version of a successful 1973\u201374 ABC series, Toma, starring Tony Musante as chameleon-like, real-life New Jersey police officer David Toma. While popular, Toma received intense criticism at the time for its realistic and frequent depiction of police and criminal violence. When Musante left the series after a single season, the concept was retooled as Baretta, with Robert Blake in the title role.", + "poster_path": "/xayGSRT2GbcFTwNIqt6l355v29F.jpg", + "backdrop_path": "/wixjwQv8aPfIwbc7euj0fPPsMjT.jpg", + "year": 1975, + "score": 0.16666666666666666, + "genre_ids": [ + 80, + 18, + 9648 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 226637, + "watched": true, + "added": false, + "title": "High Potential" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 80213, + "tvdb_id": 349661, + "title": "The Purge", + "overview": "Set in a dystopian America ruled by a totalitarian political party, the series follows several seemingly unrelated characters living in a small city. Tying them all together is a mysterious savior who\u2019s impeccably equipped for everything the night throws at them. As the clock winds down with their fates hanging in the balance, each character is forced to reckon with their pasts as they discover how far they will go to survive the night.", + "poster_path": "/9CaS2XFd0Db42grzzVBnWcSkrbg.jpg", + "backdrop_path": "/9PARvL1FQtyjNhLF6AT6Oanqg1U.jpg", + "year": 2018, + "score": 0.16666666666666666, + "genre_ids": [ + 9648, + 80, + 18 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 693, + "watched": true, + "added": false, + "title": "Desperate Housewives" + }, + { + "media_type": "tv", + "tmdb_id": 218347, + "watched": true, + "added": false + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 81358, + "tvdb_id": 349743, + "title": "Mr Inbetween", + "overview": "Ray Shoesmith is a father, ex-husband, boyfriend and best friend: tough roles to juggle in the modern age. Even harder when you\u2019re a criminal for hire.", + "poster_path": "/1FkONly4j7hxi6Pz5CmuhRN1Ops.jpg", + "backdrop_path": "/2INMEKxee4z0mVYpHCS4AdPxgOb.jpg", + "year": 2018, + "score": 0.16666666666666666, + "genre_ids": [ + 80, + 18 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 218347, + "watched": true, + "added": false + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 153312, + "tvdb_id": 413215, + "title": "Tulsa King", + "overview": "New York mafia capo Dwight \"The General\" Manfredi is released from prison after 25 years and exiled by his boss to set up shop in Tulsa, Oklahoma. Realizing that his mob family may not have his best interests in mind, Dwight slowly builds a crew.", + "poster_path": "/y13WOeZ2rz2Ezpws0RJ0I8sQpG4.jpg", + "backdrop_path": "/mNHRGO1gFpR2CYZdANe72kcKq7G.jpg", + "year": 2022, + "score": 0.16666666666666666, + "genre_ids": [ + 80, + 18, + 35 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 111803, + "watched": true, + "added": true, + "title": "The White Lotus" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 242054, + "tvdb_id": 417552, + "title": "The Residence", + "overview": "A brilliant, eccentric detective must solve a murder in the White House residence \u2014 where the staff and guests at a state dinner are all suspects.", + "poster_path": "/cJZbg425I9LF3SlJVQDOg4nJEvq.jpg", + "backdrop_path": "/rhwN3mIN89zlDveR0gv0VkrspXa.jpg", + "year": 2025, + "score": 0.16497662217515874, + "genre_ids": [ + 35, + 80, + 9648 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 107113, + "watched": false, + "added": true, + "title": "Only Murders in the Building" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 749, + "tvdb_id": 77152, + "title": "Cold Feet", + "overview": "Follow the lives and loves of a group of thirtysomethings in a heart-warming comic drama as they try to find true love - or at least keep their relationships on track.\u00a0In this wry and funny look at a generation which is as confused as it is liberated by the choices it faces, will the chill in their feet put out the passion that burns in their hearts?", + "poster_path": "/mSPjuYGUpxr3bwP2VB97Ry0oJA0.jpg", + "backdrop_path": "/u6VRUfHyrh4tsIwucbs39P7HghH.jpg", + "year": 1998, + "score": 0.16497028858462376, + "genre_ids": [ + 35, + 18 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 202851, + "watched": false, + "added": true, + "title": "Big Boys" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 2215, + "tvdb_id": 77589, + "title": "Monarch of the Glen", + "overview": "Archie MacDonald, a young restaurateur is called back to his childhood home of Glenbogle where he is told he is the new Laird of Glenbogle.", + "poster_path": "/jeMhROvJWKTQuwSip5z5RsNPyfU.jpg", + "backdrop_path": "/ecxni5kM5664x1cy4Hdi2WB1Zdy.jpg", + "year": 2000, + "score": 0.16496828455513132, + "genre_ids": [ + 18, + 35 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 108255, + "watched": false, + "added": true, + "title": "All Creatures Great & Small (2020)" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 87049, + "tvdb_id": 384248, + "title": "Firefly Lane", + "overview": "For decades, childhood best friends Kate and Tully have weathered life's storms together -- until a betrayal threatens to break them apart for good.", + "poster_path": "/2AavbPEEXzyy7Smyusozyz48T1n.jpg", + "backdrop_path": "/zvdnqNYYjW1NvULZbToh50pdts4.jpg", + "year": 2021, + "score": 0.15744037397447605, + "genre_ids": [ + 18 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 76148, + "watched": true, + "added": true, + "title": "Derry Girls" + }, + { + "media_type": "tv", + "tmdb_id": 258165, + "watched": false, + "added": true, + "title": "East of Eden (2026)" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 95386, + "tvdb_id": 372160, + "title": "Nurses", + "overview": "Follow five young nurses working on the frontlines of a busy hospital in downtown Toronto, dedicating their lives to helping others, while struggling to help themselves.", + "poster_path": "/hBhiT42BLVuWwl0B79zYA5bJ0ES.jpg", + "backdrop_path": "/tqhsaVtN3zvmCDxut1DvI2sq5pe.jpg", + "year": 2020, + "score": 0.15562460218127142, + "genre_ids": [ + 18 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 39793, + "watched": true, + "added": false, + "title": "Call the Midwife" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 136044, + "tvdb_id": 378995, + "title": "The Outlaws", + "overview": "Seven strangers from different walks of life - people who would never normally interact - are forced to work together to renovate a derelict community centre. They resent the menial physical labour and they resent each other. But when one of their number gets dragged into a dangerous world of organised crime, they unite in ways none of them thought possible.", + "poster_path": "/s8vp0S0iz2nPb7TpOpV0rHbm1er.jpg", + "backdrop_path": "/qQcI42QcZk9Eo4IqexsUwaRhusx.jpg", + "year": 2021, + "score": 0.14766081499309897, + "genre_ids": [ + 35, + 80, + 18 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 218347, + "watched": true, + "added": false + }, + { + "media_type": "tv", + "tmdb_id": 65315, + "watched": true, + "added": false, + "title": "Flaked" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 195670, + "tvdb_id": 412285, + "title": "XO, Kitty", + "overview": "Teen matchmaker Kitty Song Covey thinks she knows everything there is to know about love. But when she moves halfway across the world to reunite with her long-distance boyfriend, she'll soon realise that relationships are a lot more complicated when it's your heart on the line.", + "poster_path": "/hxvTdKAwv27PUfpXOQp6AwWr6V.jpg", + "backdrop_path": "/h0CQ36rm9cSLznve5ocCDtYlnwN.jpg", + "year": 2023, + "score": 0.1466226400103808, + "genre_ids": [ + 35, + 18 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 254420, + "watched": true, + "added": false + }, + { + "media_type": "tv", + "tmdb_id": 202851, + "watched": false, + "added": true, + "title": "Big Boys" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 102904, + "tvdb_id": 381065, + "title": "Sweet Magnolias", + "overview": "Lifelong friends Maddie, Helen and Dana Sue lift each other up as they juggle relationships, family and careers in the small Southern town of Serenity.", + "poster_path": "/n1WDSFOnCCg3cdHHGBywZtYIzf9.jpg", + "backdrop_path": "/wt60Q4doM2vrubiCsutwaeWncLL.jpg", + "year": 2020, + "score": 0.14528214151952024, + "genre_ids": [ + 18 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 207484, + "watched": true, + "added": false, + "title": "Outlander: Blood of My Blood" + }, + { + "media_type": "tv", + "tmdb_id": 258165, + "watched": false, + "added": true, + "title": "East of Eden (2026)" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 45194, + "tvdb_id": 77570, + "title": "Dragnet", + "overview": "Police Detective Sgt. Joe Friday and his partners investigate crimes in Los Angeles.", + "poster_path": "/tvozcbojiCAHIFar7kdaGfwSWew.jpg", + "backdrop_path": "/k7w3VSy1F4pW5HeoZjk2wn73AXS.jpg", + "year": 1967, + "score": 0.14285714285714285, + "genre_ids": [ + 80, + 18, + 9648 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 226637, + "watched": true, + "added": false, + "title": "High Potential" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 69630, + "tvdb_id": 323385, + "title": "The Bold Type", + "overview": "A glimpse into the outrageous lives of Jane, Kat and Sutton, who are working at the nation's top women's magazine, Scarlet, while navigating their careers, identities and individual voices.", + "poster_path": "/cKloQpl6zE7kdFf8SUeYU2qPT9H.jpg", + "backdrop_path": "/ppBiYWnUn2STU8YUdlct4Klfyu4.jpg", + "year": 2017, + "score": 0.14285714285714285, + "genre_ids": [ + 18, + 35 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 105, + "watched": true, + "added": false, + "title": "Sex and the City" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 91239, + "tvdb_id": 366668, + "title": "Bridgerton", + "overview": "Wealth, lust, and betrayal set in the backdrop of Regency era England, seen through the eyes of the powerful Bridgerton family.", + "poster_path": "/uXTg565ahu9RwonCX1V2Hex1NU6.jpg", + "backdrop_path": "/6umsRLI7t0ydFwCl0JNEIO0q2LH.jpg", + "year": 2020, + "score": 0.14285714285714285, + "genre_ids": [ + 18 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 207484, + "watched": true, + "added": false, + "title": "Outlander: Blood of My Blood" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 100412, + "tvdb_id": 376698, + "title": "The Baby-Sitters Club", + "overview": "In this contemporary take on the beloved book series, five best friends launch a baby-sitting business that's big on fun and adventure.", + "poster_path": "/afyG6cPl0jeV3SwNwyZxRG0Q9wd.jpg", + "backdrop_path": "/lhk5vgXN4Mnz4YWDw5RDqTaqPLd.jpg", + "year": 2020, + "score": 0.14285714285714285, + "genre_ids": [ + 10762, + 18 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 4586, + "watched": true, + "added": false, + "title": "Gilmore Girls" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 284558, + "tvdb_id": 460247, + "title": "Kill Jackie", + "overview": "Jackie Price has been living a wealthy, luxurious life for the past 20 years after escaping a dangerous past as an international cocaine dealer. But just as life begins to feel a little boring, she discovers that The Seven Demons, a group of the world\u2019s most terrifying hitmen, have been hired to kill her.", + "poster_path": "/3tjZspkqquE24iK1rc0nvnJwdEZ.jpg", + "backdrop_path": "/twc2YYBXTjdv52TShVumB1voT3Q.jpg", + "year": 2026, + "score": 0.14285714285714285, + "genre_ids": [ + 18, + 80 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 218347, + "watched": true, + "added": false + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 90046, + "tvdb_id": 363769, + "title": "90 Day Fianc\u00e9: The Other Way", + "overview": "Americans move to a foreign country all in the name of love. Will uprooting their lives in the United States pay off, or will they be packing up and moving back to America brokenhearted?", + "poster_path": "/jw4KDjNmpBnMd7fEjbz9qTUmKJS.jpg", + "backdrop_path": "/9jmxvWOt5xTjrJ03FNmViZCuZ5f.jpg", + "year": 2019, + "score": 0.14166748723002842, + "genre_ids": [ + 10764 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 290750, + "watched": false, + "added": true, + "title": "Love is Blind: Netherlands" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 259702, + "tvdb_id": 452532, + "title": "The House of the Spirits", + "overview": "The epic tale of a proud and passionate family, secret loves, and bloody revolution. The Trueba family's passions, struggles, and secrets span a century of violent social change, culminating in a crisis that hurls the proud, tyrannical patriarch and his beloved granddaughter towards opposite sides of the fence.", + "poster_path": "/yb682O0KCZlS5qRydxOytO5A6co.jpg", + "backdrop_path": "/u2nBtDrTOIi9u78c02bL0A3lo7Y.jpg", + "year": 2026, + "score": 0.13584642455856066, + "genre_ids": [ + 18 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 258165, + "watched": false, + "added": true, + "title": "East of Eden (2026)" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 2382, + "tvdb_id": 76321, + "title": "Freaks and Geeks", + "overview": "High school mathlete Lindsay Weir rebels and begins hanging out with a crowd of burnouts (the \"freaks\"), while her brother Sam Weir navigates a different part of the social universe with his nerdy friends (the \"geeks\").", + "poster_path": "/tPqV63zcW6ZV0Hd48DMGb5UzQIG.jpg", + "backdrop_path": "/1lOGkpGa9SjZ2uP1PxfxDkSXRNs.jpg", + "year": 1999, + "score": 0.13339330720689832, + "genre_ids": [ + 18, + 35 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 254420, + "watched": true, + "added": false + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 10860, + "tvdb_id": 74481, + "title": "Road to Avonlea", + "overview": "In the fictional small town of Avonlea, Prince Edward Island, in the early 20th century, 10-year-old Montreal heiress Sara Stanley is sent by her wealthy father to live with her two maiden aunts, Hetty and Olivia King, to be near her late mother's side of the family.", + "poster_path": "/2392PwVEfsMOMgYD5q0KaRWbjhP.jpg", + "backdrop_path": "/fM7OxNnULTgN19JChMPIv2OSiNx.jpg", + "year": 1990, + "score": 0.12605001646965905, + "genre_ids": [ + 18, + 10751 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 66134, + "watched": true, + "added": false, + "title": "The Durrells" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 211564, + "tvdb_id": 420865, + "title": "Sullivan's Crossing", + "overview": "After Neurosurgeon Maggie Sullivan\u2019s personal and professional life are thrown into turmoil she returns home to Sullivan\u2019s Crossing. While there, Maggie is forced to navigate her complicated present while confronting the painful past she has chosen to ignore for years.", + "poster_path": "/nYRnuQNR13nEYaXkOZaY0qIWl2K.jpg", + "backdrop_path": "/coh0nl5ov1o42fy7f74MDMvme2I.jpg", + "year": 2023, + "score": 0.12605001646965905, + "genre_ids": [ + 18 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 33155, + "watched": true, + "added": false, + "title": "Offspring" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 197373, + "tvdb_id": 418934, + "title": "Based on a True Story", + "overview": "The lives of a realtor, a plumber and a former tennis star unexpectedly collide, exposing America\u2019s obsession with true crime, murder and the slow-close toilet seat.", + "poster_path": "/5xPLk78TFStBi1dQAlG2mnoSK60.jpg", + "backdrop_path": "/yVWlvUHX02ARZte4aBep6GPkkck.jpg", + "year": 2023, + "score": 0.12549019607843137, + "genre_ids": [ + 35, + 18 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 693, + "watched": true, + "added": false, + "title": "Desperate Housewives" + }, + { + "media_type": "tv", + "tmdb_id": 218347, + "watched": true, + "added": false + } + ] + } + ], + "projection": { + "media_type": "all", + "genre_id": 0, + "service_id": 0, + "coverage": { + "total": 141, + "matched": 141, + "excluded": 0, + "unresolved": 0, + "complete": true + }, + "facts": [ + { + "metadata": { + "tmdb_id": 235271, + "media_type": "movie", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 18, + "name": "Drama" + }, + { + "id": 53, + "name": "Thriller" + } + ], + "providers": [ + { + "id": 2358, + "name": "Lionsgate+ Amazon Channel", + "logo_url": "https://image.tmdb.org/t/p/w92/1356slxu4QrHOMrEVDKHJXRo5sW.png" + } + ], + "genres_observed_at": "2026-11-06T10:43:00.643180289Z", + "providers_observed_at": "2026-11-06T10:43:00.643180289Z", + "checked_at": "2026-11-06T10:43:00.643180289Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 8999, + "media_type": "movie", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 18, + "name": "Drama" + }, + { + "id": 53, + "name": "Thriller" + } + ], + "providers": [], + "genres_observed_at": "2026-11-06T15:44:52.599309363Z", + "providers_observed_at": "2026-11-06T15:44:52.599309363Z", + "checked_at": "2026-11-06T15:44:52.599309363Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 317744, + "media_type": "movie", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 18, + "name": "Drama" + }, + { + "id": 53, + "name": "Thriller" + } + ], + "providers": [], + "genres_observed_at": "2026-11-06T15:44:17.038050781Z", + "checked_at": "2026-11-06T15:44:17.038050781Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 13250, + "media_type": "movie", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 80, + "name": "Crime" + }, + { + "id": 18, + "name": "Drama" + }, + { + "id": 53, + "name": "Thriller" + } + ], + "providers": [], + "genres_observed_at": "2026-11-06T15:44:18.812047665Z", + "checked_at": "2026-11-06T15:44:18.812047665Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 376004, + "media_type": "movie", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 53, + "name": "Thriller" + }, + { + "id": 18, + "name": "Drama" + } + ], + "providers": [], + "genres_observed_at": "2026-11-06T15:44:20.081486476Z", + "checked_at": "2026-11-06T15:44:20.081486476Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 13483, + "media_type": "movie", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 53, + "name": "Thriller" + }, + { + "id": 80, + "name": "Crime" + }, + { + "id": 9648, + "name": "Mystery" + } + ], + "providers": [ + { + "id": 2358, + "name": "Lionsgate+ Amazon Channel", + "logo_url": "https://image.tmdb.org/t/p/w92/1356slxu4QrHOMrEVDKHJXRo5sW.png" + } + ], + "genres_observed_at": "2026-11-06T00:21:02.623137183Z", + "providers_observed_at": "2026-11-06T00:21:02.623137183Z", + "checked_at": "2026-11-06T00:21:02.623137183Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 18190, + "media_type": "movie", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 18, + "name": "Drama" + }, + { + "id": 9648, + "name": "Mystery" + }, + { + "id": 53, + "name": "Thriller" + } + ], + "providers": [], + "genres_observed_at": "2026-11-06T15:44:56.168073579Z", + "checked_at": "2026-11-06T15:44:56.168073579Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 210577, + "media_type": "movie", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 9648, + "name": "Mystery" + }, + { + "id": 53, + "name": "Thriller" + }, + { + "id": 18, + "name": "Drama" + } + ], + "providers": [ + { + "id": 119, + "name": "Amazon Prime Video", + "logo_url": "https://image.tmdb.org/t/p/w92/gMZdpavHmxFNnLpMHwVxfqeux2g.png" + }, + { + "id": 337, + "name": "Disney Plus", + "logo_url": "https://image.tmdb.org/t/p/w92/5eZ872CghnHFLB1j8grszbrx0dx.png" + } + ], + "genres_observed_at": "2026-11-05T21:25:31.995556322Z", + "providers_observed_at": "2026-11-05T21:25:31.995556322Z", + "checked_at": "2026-11-05T21:25:31.995556322Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 619979, + "media_type": "movie", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 18, + "name": "Drama" + }, + { + "id": 9648, + "name": "Mystery" + }, + { + "id": 53, + "name": "Thriller" + } + ], + "providers": [ + { + "id": 119, + "name": "Amazon Prime Video", + "logo_url": "https://image.tmdb.org/t/p/w92/gMZdpavHmxFNnLpMHwVxfqeux2g.png" + } + ], + "genres_observed_at": "2026-11-05T21:23:46.870483061Z", + "providers_observed_at": "2026-11-05T21:23:46.870483061Z", + "checked_at": "2026-11-05T21:23:46.870483061Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 1317288, + "media_type": "movie", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 18, + "name": "Drama" + }, + { + "id": 53, + "name": "Thriller" + } + ], + "providers": [ + { + "id": 1899, + "name": "HBO Max", + "logo_url": "https://image.tmdb.org/t/p/w92/skypuy7SXuugIQeYg0IglmzoKaS.png" + }, + { + "id": 1825, + "name": "HBO Max Amazon Channel", + "logo_url": "https://image.tmdb.org/t/p/w92/64fLWeSyZ1KQZhIdvTdy4QHeWty.png" + } + ], + "genres_observed_at": "2026-11-06T12:21:37.342438175Z", + "providers_observed_at": "2026-11-06T12:21:37.342438175Z", + "checked_at": "2026-11-06T12:21:37.342438175Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 582014, + "media_type": "movie", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 53, + "name": "Thriller" + }, + { + "id": 80, + "name": "Crime" + }, + { + "id": 18, + "name": "Drama" + } + ], + "providers": [ + { + "id": 119, + "name": "Amazon Prime Video", + "logo_url": "https://image.tmdb.org/t/p/w92/gMZdpavHmxFNnLpMHwVxfqeux2g.png" + } + ], + "genres_observed_at": "2026-11-06T00:21:16.848833721Z", + "providers_observed_at": "2026-11-06T00:21:16.848833721Z", + "checked_at": "2026-11-06T00:21:16.848833721Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 549514, + "media_type": "movie", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 18, + "name": "Drama" + }, + { + "id": 53, + "name": "Thriller" + } + ], + "providers": [ + { + "id": 119, + "name": "Amazon Prime Video", + "logo_url": "https://image.tmdb.org/t/p/w92/gMZdpavHmxFNnLpMHwVxfqeux2g.png" + }, + { + "id": 297, + "name": "Ziggo TV", + "logo_url": "https://image.tmdb.org/t/p/w92/96DBWDlJw9X0EnP22cOahM8Fdpn.png" + }, + { + "id": 1773, + "name": "SkyShowtime", + "logo_url": "https://image.tmdb.org/t/p/w92/ube1pVpP3FklRkeyQjlDkkC3tL2.png" + } + ], + "genres_observed_at": "2026-11-05T21:24:51.510424868Z", + "providers_observed_at": "2026-11-05T21:24:51.510424868Z", + "checked_at": "2026-11-05T21:24:51.510424868Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 59231, + "media_type": "movie", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 18, + "name": "Drama" + } + ], + "providers": [], + "genres_observed_at": "2026-11-06T15:45:24.19081339Z", + "checked_at": "2026-11-06T15:45:24.19081339Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 1249213, + "media_type": "movie", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 9648, + "name": "Mystery" + }, + { + "id": 53, + "name": "Thriller" + } + ], + "providers": [ + { + "id": 8, + "name": "Netflix", + "logo_url": "https://image.tmdb.org/t/p/w92/rK1KljqmbvO9HQa1PBFLILWah72.png" + } + ], + "genres_observed_at": "2026-11-06T15:45:03.503769553Z", + "providers_observed_at": "2026-11-06T15:45:03.503769553Z", + "checked_at": "2026-11-06T15:45:03.503769553Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 624788, + "media_type": "movie", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 18, + "name": "Drama" + }, + { + "id": 53, + "name": "Thriller" + } + ], + "providers": [], + "genres_observed_at": "2026-11-05T21:34:19.075907378Z", + "checked_at": "2026-11-05T21:34:19.075907378Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 2252, + "media_type": "movie", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 53, + "name": "Thriller" + }, + { + "id": 80, + "name": "Crime" + }, + { + "id": 9648, + "name": "Mystery" + } + ], + "providers": [], + "genres_observed_at": "2026-11-05T21:24:35.282458128Z", + "providers_observed_at": "2026-11-05T21:24:35.282458128Z", + "checked_at": "2026-11-05T21:24:35.282458128Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 311667, + "media_type": "movie", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 18, + "name": "Drama" + }, + { + "id": 9648, + "name": "Mystery" + }, + { + "id": 53, + "name": "Thriller" + } + ], + "providers": [ + { + "id": 119, + "name": "Amazon Prime Video", + "logo_url": "https://image.tmdb.org/t/p/w92/gMZdpavHmxFNnLpMHwVxfqeux2g.png" + }, + { + "id": 639, + "name": "CineMember", + "logo_url": "https://image.tmdb.org/t/p/w92/gd0zMEph8WdBDaRd7LTqfCG53vI.png" + } + ], + "genres_observed_at": "2026-11-05T21:27:25.057305529Z", + "providers_observed_at": "2026-11-05T21:27:25.057305529Z", + "checked_at": "2026-11-05T21:27:25.057305529Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 2990, + "media_type": "movie", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 53, + "name": "Thriller" + }, + { + "id": 18, + "name": "Drama" + }, + { + "id": 9648, + "name": "Mystery" + } + ], + "providers": [], + "genres_observed_at": "2026-11-06T00:21:14.82897145Z", + "checked_at": "2026-11-06T00:21:14.82897145Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 1965, + "media_type": "movie", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 80, + "name": "Crime" + }, + { + "id": 53, + "name": "Thriller" + }, + { + "id": 18, + "name": "Drama" + } + ], + "providers": [], + "genres_observed_at": "2026-11-06T15:45:02.873240161Z", + "providers_observed_at": "2026-11-06T15:45:02.873240161Z", + "checked_at": "2026-11-06T15:45:02.873240161Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 63493, + "media_type": "movie", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 18, + "name": "Drama" + }, + { + "id": 53, + "name": "Thriller" + } + ], + "providers": [], + "genres_observed_at": "2026-11-06T15:45:00.513271626Z", + "checked_at": "2026-11-06T15:45:00.513271626Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 451156, + "media_type": "movie", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 53, + "name": "Thriller" + }, + { + "id": 10770, + "name": "TV Movie" + } + ], + "providers": [], + "genres_observed_at": "2026-11-05T21:26:30.493325244Z", + "checked_at": "2026-11-05T21:26:30.493325244Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 1920, + "media_type": "movie", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 18, + "name": "Drama" + }, + { + "id": 80, + "name": "Crime" + }, + { + "id": 53, + "name": "Thriller" + } + ], + "providers": [], + "genres_observed_at": "2026-11-06T15:44:58.412956949Z", + "checked_at": "2026-11-06T15:44:58.412956949Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 1033148, + "media_type": "movie", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 18, + "name": "Drama" + } + ], + "providers": [ + { + "id": 11, + "name": "MUBI", + "logo_url": "https://image.tmdb.org/t/p/w92/k7iSlvgWzZuO4zU5PcBjhABMuia.png" + }, + { + "id": 201, + "name": "MUBI Amazon Channel", + "logo_url": "https://image.tmdb.org/t/p/w92/w1hl8HszsPf5aAGT0mVY0O6LYP4.png" + } + ], + "genres_observed_at": "2026-11-05T21:24:05.016873731Z", + "providers_observed_at": "2026-11-05T21:24:05.016873731Z", + "checked_at": "2026-11-05T21:24:05.016873731Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 7442, + "media_type": "movie", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 18, + "name": "Drama" + }, + { + "id": 53, + "name": "Thriller" + } + ], + "providers": [ + { + "id": 337, + "name": "Disney Plus", + "logo_url": "https://image.tmdb.org/t/p/w92/5eZ872CghnHFLB1j8grszbrx0dx.png" + } + ], + "genres_observed_at": "2026-11-06T12:26:41.975535551Z", + "providers_observed_at": "2026-11-06T12:26:41.975535551Z", + "checked_at": "2026-11-06T12:26:41.975535551Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 8884, + "media_type": "movie", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 14, + "name": "Fantasy" + }, + { + "id": 53, + "name": "Thriller" + }, + { + "id": 18, + "name": "Drama" + } + ], + "providers": [], + "genres_observed_at": "2026-11-05T21:25:13.822417756Z", + "checked_at": "2026-11-05T21:25:13.822417756Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 14698, + "media_type": "movie", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 53, + "name": "Thriller" + }, + { + "id": 18, + "name": "Drama" + }, + { + "id": 9648, + "name": "Mystery" + } + ], + "providers": [], + "genres_observed_at": "2026-11-06T00:21:20.880053202Z", + "providers_observed_at": "2026-11-06T00:21:20.880053202Z", + "checked_at": "2026-11-06T00:21:20.880053202Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 45938, + "media_type": "movie", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 18, + "name": "Drama" + } + ], + "providers": [], + "genres_observed_at": "2026-11-06T12:35:54.548175409Z", + "checked_at": "2026-11-06T12:35:54.548175409Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 16933, + "media_type": "movie", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 18, + "name": "Drama" + }, + { + "id": 80, + "name": "Crime" + }, + { + "id": 53, + "name": "Thriller" + } + ], + "providers": [], + "genres_observed_at": "2026-11-06T13:23:01.142473058Z", + "providers_observed_at": "2026-11-06T13:23:01.142473058Z", + "checked_at": "2026-11-06T13:23:01.142473058Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 7183, + "media_type": "movie", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 53, + "name": "Thriller" + }, + { + "id": 80, + "name": "Crime" + }, + { + "id": 9648, + "name": "Mystery" + } + ], + "providers": [], + "genres_observed_at": "2026-11-05T21:29:08.281292709Z", + "providers_observed_at": "2026-11-05T21:29:08.281292709Z", + "checked_at": "2026-11-05T21:29:08.281292709Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 893086, + "media_type": "movie", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 53, + "name": "Thriller" + }, + { + "id": 18, + "name": "Drama" + } + ], + "providers": [ + { + "id": 119, + "name": "Amazon Prime Video", + "logo_url": "https://image.tmdb.org/t/p/w92/gMZdpavHmxFNnLpMHwVxfqeux2g.png" + } + ], + "genres_observed_at": "2026-11-06T17:51:19.878708528Z", + "providers_observed_at": "2026-11-06T17:51:19.878708528Z", + "checked_at": "2026-11-06T17:51:19.878708528Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 1213, + "media_type": "movie", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 53, + "name": "Thriller" + }, + { + "id": 80, + "name": "Crime" + }, + { + "id": 18, + "name": "Drama" + } + ], + "providers": [ + { + "id": 119, + "name": "Amazon Prime Video", + "logo_url": "https://image.tmdb.org/t/p/w92/gMZdpavHmxFNnLpMHwVxfqeux2g.png" + } + ], + "genres_observed_at": "2026-11-05T21:33:16.269467208Z", + "providers_observed_at": "2026-11-05T21:33:16.269467208Z", + "checked_at": "2026-11-05T21:33:16.269467208Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 1317088, + "media_type": "movie", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 878, + "name": "Science Fiction" + }, + { + "id": 18, + "name": "Drama" + }, + { + "id": 53, + "name": "Thriller" + } + ], + "providers": [ + { + "id": 119, + "name": "Amazon Prime Video", + "logo_url": "https://image.tmdb.org/t/p/w92/gMZdpavHmxFNnLpMHwVxfqeux2g.png" + } + ], + "genres_observed_at": "2026-11-06T13:23:05.185284868Z", + "providers_observed_at": "2026-11-06T13:23:05.185284868Z", + "checked_at": "2026-11-06T13:23:05.185284868Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 50875, + "media_type": "movie", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 18, + "name": "Drama" + }, + { + "id": 53, + "name": "Thriller" + } + ], + "providers": [], + "genres_observed_at": "2026-11-06T13:22:59.015768824Z", + "checked_at": "2026-11-06T13:22:59.015768824Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 11660, + "media_type": "movie", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 18, + "name": "Drama" + }, + { + "id": 53, + "name": "Thriller" + } + ], + "providers": [], + "genres_observed_at": "2026-11-05T21:25:09.782964078Z", + "checked_at": "2026-11-05T21:25:09.782964078Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 65650, + "media_type": "movie", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 18, + "name": "Drama" + }, + { + "id": 53, + "name": "Thriller" + }, + { + "id": 9648, + "name": "Mystery" + } + ], + "providers": [], + "genres_observed_at": "2026-11-06T00:21:23.004963735Z", + "checked_at": "2026-11-06T00:21:23.004963735Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 241, + "media_type": "movie", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 80, + "name": "Crime" + }, + { + "id": 53, + "name": "Thriller" + }, + { + "id": 18, + "name": "Drama" + } + ], + "providers": [ + { + "id": 337, + "name": "Disney Plus", + "logo_url": "https://image.tmdb.org/t/p/w92/5eZ872CghnHFLB1j8grszbrx0dx.png" + } + ], + "genres_observed_at": "2026-11-06T00:21:12.685828539Z", + "providers_observed_at": "2026-11-06T00:21:12.685828539Z", + "checked_at": "2026-11-06T00:21:12.685828539Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 645710, + "media_type": "movie", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 53, + "name": "Thriller" + } + ], + "providers": [ + { + "id": 119, + "name": "Amazon Prime Video", + "logo_url": "https://image.tmdb.org/t/p/w92/gMZdpavHmxFNnLpMHwVxfqeux2g.png" + } + ], + "genres_observed_at": "2026-11-06T16:14:14.986254896Z", + "providers_observed_at": "2026-11-06T16:14:14.986254896Z", + "checked_at": "2026-11-06T16:14:14.986254896Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 69668, + "media_type": "movie", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 9648, + "name": "Mystery" + }, + { + "id": 18, + "name": "Drama" + }, + { + "id": 53, + "name": "Thriller" + } + ], + "providers": [], + "genres_observed_at": "2026-11-06T15:45:07.983781006Z", + "checked_at": "2026-11-06T15:45:07.983781006Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 33195, + "media_type": "movie", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 18, + "name": "Drama" + }, + { + "id": 53, + "name": "Thriller" + }, + { + "id": 9648, + "name": "Mystery" + } + ], + "providers": [], + "genres_observed_at": "2026-11-06T00:21:25.163153867Z", + "checked_at": "2026-11-06T00:21:25.163153867Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 4547, + "media_type": "movie", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 80, + "name": "Crime" + }, + { + "id": 18, + "name": "Drama" + }, + { + "id": 53, + "name": "Thriller" + } + ], + "providers": [], + "genres_observed_at": "2026-11-05T21:16:43.34835158Z", + "providers_observed_at": "2026-11-05T21:16:43.34835158Z", + "checked_at": "2026-11-05T21:16:43.34835158Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 74998, + "media_type": "movie", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 28, + "name": "Action" + }, + { + "id": 18, + "name": "Drama" + }, + { + "id": 53, + "name": "Thriller" + } + ], + "providers": [], + "genres_observed_at": "2026-11-06T17:48:57.705507001Z", + "providers_observed_at": "2026-10-30T17:48:50.333477339Z", + "checked_at": "2026-11-06T17:48:57.705507001Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 73552, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 35, + "name": "Comedy" + }, + { + "id": 80, + "name": "Crime" + } + ], + "providers": [], + "genres_observed_at": "2026-11-06T15:44:50.276092275Z", + "checked_at": "2026-11-06T15:44:50.276092275Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 2327, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 18, + "name": "Drama" + }, + { + "id": 10766, + "name": "Soap" + } + ], + "providers": [ + { + "id": 119, + "name": "Amazon Prime Video", + "logo_url": "https://image.tmdb.org/t/p/w92/gMZdpavHmxFNnLpMHwVxfqeux2g.png" + } + ], + "genres_observed_at": "2026-11-06T15:44:51.398997957Z", + "providers_observed_at": "2026-11-06T15:44:51.398997957Z", + "checked_at": "2026-11-06T15:44:51.398997957Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 578, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 80, + "name": "Crime" + }, + { + "id": 18, + "name": "Drama" + }, + { + "id": 9648, + "name": "Mystery" + } + ], + "providers": [], + "genres_observed_at": "2026-11-06T15:44:54.217033174Z", + "checked_at": "2026-11-06T15:44:54.217033174Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 900, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 18, + "name": "Drama" + }, + { + "id": 35, + "name": "Comedy" + } + ], + "providers": [], + "genres_observed_at": "2026-11-06T16:12:55.902717952Z", + "checked_at": "2026-11-06T16:12:55.902717952Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 56570, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 18, + "name": "Drama" + }, + { + "id": 10765, + "name": "Sci-Fi & Fantasy" + } + ], + "providers": [ + { + "id": 8, + "name": "Netflix", + "logo_url": "https://image.tmdb.org/t/p/w92/rK1KljqmbvO9HQa1PBFLILWah72.png" + }, + { + "id": 297, + "name": "Ziggo TV", + "logo_url": "https://image.tmdb.org/t/p/w92/96DBWDlJw9X0EnP22cOahM8Fdpn.png" + } + ], + "genres_observed_at": "2026-11-05T21:28:53.93026257Z", + "providers_observed_at": "2026-11-05T21:28:53.93026257Z", + "checked_at": "2026-11-05T21:28:53.93026257Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 62117, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 18, + "name": "Drama" + }, + { + "id": 35, + "name": "Comedy" + } + ], + "providers": [ + { + "id": 8, + "name": "Netflix", + "logo_url": "https://image.tmdb.org/t/p/w92/rK1KljqmbvO9HQa1PBFLILWah72.png" + } + ], + "genres_observed_at": "2026-11-05T21:34:59.469454878Z", + "providers_observed_at": "2026-11-05T21:34:59.469454878Z", + "checked_at": "2026-11-05T21:34:59.469454878Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 87428, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 35, + "name": "Comedy" + }, + { + "id": 80, + "name": "Crime" + }, + { + "id": 18, + "name": "Drama" + } + ], + "providers": [ + { + "id": 1773, + "name": "SkyShowtime", + "logo_url": "https://image.tmdb.org/t/p/w92/ube1pVpP3FklRkeyQjlDkkC3tL2.png" + } + ], + "genres_observed_at": "2026-11-06T15:44:56.822294671Z", + "providers_observed_at": "2026-11-06T15:44:56.822294671Z", + "checked_at": "2026-11-06T15:44:56.822294671Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 88989, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 18, + "name": "Drama" + }, + { + "id": 9648, + "name": "Mystery" + } + ], + "providers": [ + { + "id": 119, + "name": "Amazon Prime Video", + "logo_url": "https://image.tmdb.org/t/p/w92/gMZdpavHmxFNnLpMHwVxfqeux2g.png" + }, + { + "id": 72, + "name": "Videoland", + "logo_url": "https://image.tmdb.org/t/p/w92/3IdfSLRjSoDoo9lrpvX4oxZmnVi.png" + } + ], + "genres_observed_at": "2026-11-06T16:06:10.308397809Z", + "providers_observed_at": "2026-11-06T16:06:10.308397809Z", + "checked_at": "2026-11-06T16:06:10.308397809Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 106454, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 9648, + "name": "Mystery" + }, + { + "id": 35, + "name": "Comedy" + }, + { + "id": 80, + "name": "Crime" + } + ], + "providers": [ + { + "id": 350, + "name": "Apple TV", + "logo_url": "https://image.tmdb.org/t/p/w92/9icYBfYFcwgCbky5VdGUIKJ4C5i.png" + }, + { + "id": 2243, + "name": "Apple TV Amazon Channel", + "logo_url": "https://image.tmdb.org/t/p/w92/rerVCu9auZrgxTTiZu1criqrQzg.png" + } + ], + "genres_observed_at": "2026-11-05T21:31:15.207798622Z", + "providers_observed_at": "2026-11-05T21:31:15.207798622Z", + "checked_at": "2026-11-05T21:31:15.207798622Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 71199, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 35, + "name": "Comedy" + }, + { + "id": 18, + "name": "Drama" + } + ], + "providers": [], + "genres_observed_at": "2026-11-05T21:23:04.487348035Z", + "checked_at": "2026-11-05T21:23:04.487348035Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 100883, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 35, + "name": "Comedy" + }, + { + "id": 18, + "name": "Drama" + } + ], + "providers": [ + { + "id": 8, + "name": "Netflix", + "logo_url": "https://image.tmdb.org/t/p/w92/rK1KljqmbvO9HQa1PBFLILWah72.png" + } + ], + "genres_observed_at": "2026-11-05T21:28:19.499884611Z", + "providers_observed_at": "2026-11-05T21:28:19.499884611Z", + "checked_at": "2026-11-05T21:28:19.499884611Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 42282, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 18, + "name": "Drama" + }, + { + "id": 35, + "name": "Comedy" + } + ], + "providers": [ + { + "id": 1899, + "name": "HBO Max", + "logo_url": "https://image.tmdb.org/t/p/w92/skypuy7SXuugIQeYg0IglmzoKaS.png" + } + ], + "genres_observed_at": "2026-11-05T21:25:25.936697952Z", + "providers_observed_at": "2026-11-05T21:25:25.936697952Z", + "checked_at": "2026-11-05T21:25:25.936697952Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 243861, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 18, + "name": "Drama" + }, + { + "id": 35, + "name": "Comedy" + } + ], + "providers": [ + { + "id": 8, + "name": "Netflix", + "logo_url": "https://image.tmdb.org/t/p/w92/rK1KljqmbvO9HQa1PBFLILWah72.png" + } + ], + "genres_observed_at": "2026-11-06T11:54:43.481292701Z", + "providers_observed_at": "2026-11-06T11:54:43.481292701Z", + "checked_at": "2026-11-06T11:54:43.481292701Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 94617, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 18, + "name": "Drama" + } + ], + "providers": [], + "genres_observed_at": "2026-11-06T14:16:07.710906158Z", + "checked_at": "2026-11-06T14:16:07.710906158Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 62823, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 18, + "name": "Drama" + }, + { + "id": 80, + "name": "Crime" + }, + { + "id": 9648, + "name": "Mystery" + } + ], + "providers": [], + "genres_observed_at": "2026-11-05T21:29:50.618636269Z", + "checked_at": "2026-11-05T21:29:50.618636269Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 18856, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 18, + "name": "Drama" + } + ], + "providers": [], + "genres_observed_at": "2026-11-05T20:11:40.044894331Z", + "checked_at": "2026-11-05T20:11:40.044894331Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 1803, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 18, + "name": "Drama" + }, + { + "id": 35, + "name": "Comedy" + } + ], + "providers": [], + "genres_observed_at": "2026-11-05T21:31:45.413498213Z", + "checked_at": "2026-11-05T21:31:45.413498213Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 116450, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 35, + "name": "Comedy" + }, + { + "id": 18, + "name": "Drama" + } + ], + "providers": [ + { + "id": 1899, + "name": "HBO Max", + "logo_url": "https://image.tmdb.org/t/p/w92/skypuy7SXuugIQeYg0IglmzoKaS.png" + }, + { + "id": 1825, + "name": "HBO Max Amazon Channel", + "logo_url": "https://image.tmdb.org/t/p/w92/64fLWeSyZ1KQZhIdvTdy4QHeWty.png" + } + ], + "genres_observed_at": "2026-11-05T21:21:23.639209961Z", + "providers_observed_at": "2026-11-05T21:21:23.639209961Z", + "checked_at": "2026-11-05T21:21:23.639209961Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 199001, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 18, + "name": "Drama" + } + ], + "providers": [ + { + "id": 8, + "name": "Netflix", + "logo_url": "https://image.tmdb.org/t/p/w92/rK1KljqmbvO9HQa1PBFLILWah72.png" + } + ], + "genres_observed_at": "2026-11-05T21:28:07.451469754Z", + "providers_observed_at": "2026-11-05T21:28:07.451469754Z", + "checked_at": "2026-11-05T21:28:07.451469754Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 64254, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 35, + "name": "Comedy" + }, + { + "id": 18, + "name": "Drama" + } + ], + "providers": [ + { + "id": 8, + "name": "Netflix", + "logo_url": "https://image.tmdb.org/t/p/w92/rK1KljqmbvO9HQa1PBFLILWah72.png" + } + ], + "genres_observed_at": "2026-11-05T21:27:39.175684225Z", + "providers_observed_at": "2026-11-05T21:27:39.175684225Z", + "checked_at": "2026-11-05T21:27:39.175684225Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 196454, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 18, + "name": "Drama" + } + ], + "providers": [ + { + "id": 8, + "name": "Netflix", + "logo_url": "https://image.tmdb.org/t/p/w92/rK1KljqmbvO9HQa1PBFLILWah72.png" + } + ], + "genres_observed_at": "2026-11-05T21:29:14.359242906Z", + "providers_observed_at": "2026-11-05T21:29:14.359242906Z", + "checked_at": "2026-11-05T21:29:14.359242906Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 206829, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 18, + "name": "Drama" + }, + { + "id": 35, + "name": "Comedy" + } + ], + "providers": [ + { + "id": 1899, + "name": "HBO Max", + "logo_url": "https://image.tmdb.org/t/p/w92/skypuy7SXuugIQeYg0IglmzoKaS.png" + }, + { + "id": 1825, + "name": "HBO Max Amazon Channel", + "logo_url": "https://image.tmdb.org/t/p/w92/64fLWeSyZ1KQZhIdvTdy4QHeWty.png" + } + ], + "genres_observed_at": "2026-11-05T21:32:53.972934948Z", + "providers_observed_at": "2026-11-05T21:32:53.972934948Z", + "checked_at": "2026-11-05T21:32:53.972934948Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 99248, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 18, + "name": "Drama" + } + ], + "providers": [], + "genres_observed_at": "2026-11-05T23:55:25.168767993Z", + "checked_at": "2026-11-05T23:55:25.168767993Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 124834, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 18, + "name": "Drama" + } + ], + "providers": [ + { + "id": 8, + "name": "Netflix", + "logo_url": "https://image.tmdb.org/t/p/w92/rK1KljqmbvO9HQa1PBFLILWah72.png" + } + ], + "genres_observed_at": "2026-11-05T23:55:19.136059766Z", + "providers_observed_at": "2026-11-05T23:55:19.136059766Z", + "checked_at": "2026-11-05T23:55:19.136059766Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 7406, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 35, + "name": "Comedy" + }, + { + "id": 18, + "name": "Drama" + } + ], + "providers": [ + { + "id": 472, + "name": "NLZIET", + "logo_url": "https://image.tmdb.org/t/p/w92/jY3kxKfekzgpEDtghtK9elh4G6T.png" + } + ], + "genres_observed_at": "2026-11-05T21:21:11.294138927Z", + "providers_observed_at": "2026-11-05T21:21:11.294138927Z", + "checked_at": "2026-11-05T21:21:11.294138927Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 69470, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 35, + "name": "Comedy" + }, + { + "id": 9648, + "name": "Mystery" + }, + { + "id": 80, + "name": "Crime" + } + ], + "providers": [ + { + "id": 8, + "name": "Netflix", + "logo_url": "https://image.tmdb.org/t/p/w92/rK1KljqmbvO9HQa1PBFLILWah72.png" + } + ], + "genres_observed_at": "2026-11-05T21:29:38.48700516Z", + "providers_observed_at": "2026-11-05T21:29:38.48700516Z", + "checked_at": "2026-11-05T21:29:38.48700516Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 62705, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 10764, + "name": "Reality" + } + ], + "providers": [ + { + "id": 72, + "name": "Videoland", + "logo_url": "https://image.tmdb.org/t/p/w92/3IdfSLRjSoDoo9lrpvX4oxZmnVi.png" + } + ], + "genres_observed_at": "2026-11-05T21:27:31.084696935Z", + "providers_observed_at": "2026-11-05T21:27:31.084696935Z", + "checked_at": "2026-11-05T21:27:31.084696935Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 253376, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 18, + "name": "Drama" + }, + { + "id": 9648, + "name": "Mystery" + } + ], + "providers": [ + { + "id": 119, + "name": "Amazon Prime Video", + "logo_url": "https://image.tmdb.org/t/p/w92/gMZdpavHmxFNnLpMHwVxfqeux2g.png" + } + ], + "genres_observed_at": "2026-11-06T10:13:53.419455494Z", + "providers_observed_at": "2026-11-06T10:13:53.419455494Z", + "checked_at": "2026-11-06T10:13:53.419455494Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 39468, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 18, + "name": "Drama" + } + ], + "providers": [ + { + "id": 1899, + "name": "HBO Max", + "logo_url": "https://image.tmdb.org/t/p/w92/skypuy7SXuugIQeYg0IglmzoKaS.png" + }, + { + "id": 1825, + "name": "HBO Max Amazon Channel", + "logo_url": "https://image.tmdb.org/t/p/w92/64fLWeSyZ1KQZhIdvTdy4QHeWty.png" + } + ], + "genres_observed_at": "2026-11-05T21:27:47.2630122Z", + "providers_observed_at": "2026-11-05T21:27:47.2630122Z", + "checked_at": "2026-11-05T21:27:47.2630122Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 126914, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 35, + "name": "Comedy" + }, + { + "id": 18, + "name": "Drama" + } + ], + "providers": [ + { + "id": 119, + "name": "Amazon Prime Video", + "logo_url": "https://image.tmdb.org/t/p/w92/gMZdpavHmxFNnLpMHwVxfqeux2g.png" + } + ], + "genres_observed_at": "2026-11-05T21:34:41.152157563Z", + "providers_observed_at": "2026-11-05T21:34:41.152157563Z", + "checked_at": "2026-11-05T21:34:41.152157563Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 5721, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 18, + "name": "Drama" + }, + { + "id": 35, + "name": "Comedy" + } + ], + "providers": [], + "genres_observed_at": "2026-11-05T21:33:08.217497968Z", + "checked_at": "2026-11-05T21:33:08.217497968Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 21762, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 18, + "name": "Drama" + } + ], + "providers": [ + { + "id": 8, + "name": "Netflix", + "logo_url": "https://image.tmdb.org/t/p/w92/rK1KljqmbvO9HQa1PBFLILWah72.png" + }, + { + "id": 119, + "name": "Amazon Prime Video", + "logo_url": "https://image.tmdb.org/t/p/w92/gMZdpavHmxFNnLpMHwVxfqeux2g.png" + } + ], + "genres_observed_at": "2026-11-05T21:29:02.122502055Z", + "providers_observed_at": "2026-11-05T21:29:02.122502055Z", + "checked_at": "2026-11-05T21:29:02.122502055Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 80587, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 10751, + "name": "Family" + }, + { + "id": 16, + "name": "Animation" + }, + { + "id": 35, + "name": "Comedy" + } + ], + "providers": [ + { + "id": 337, + "name": "Disney Plus", + "logo_url": "https://image.tmdb.org/t/p/w92/5eZ872CghnHFLB1j8grszbrx0dx.png" + } + ], + "genres_observed_at": "2026-11-05T21:22:05.988680828Z", + "providers_observed_at": "2026-11-05T21:22:05.988680828Z", + "checked_at": "2026-11-05T21:22:05.988680828Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 39859, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 35, + "name": "Comedy" + }, + { + "id": 18, + "name": "Drama" + } + ], + "providers": [], + "genres_observed_at": "2026-11-06T08:24:56.06076054Z", + "checked_at": "2026-11-06T08:24:56.06076054Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 210805, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 18, + "name": "Drama" + }, + { + "id": 35, + "name": "Comedy" + } + ], + "providers": [], + "genres_observed_at": "2026-11-06T16:14:25.197987217Z", + "checked_at": "2026-11-06T16:14:25.197987217Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 4177, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 9648, + "name": "Mystery" + }, + { + "id": 18, + "name": "Drama" + }, + { + "id": 80, + "name": "Crime" + } + ], + "providers": [], + "genres_observed_at": "2026-11-05T21:29:10.325858991Z", + "checked_at": "2026-11-05T21:29:10.325858991Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 46028, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 35, + "name": "Comedy" + }, + { + "id": 10751, + "name": "Family" + } + ], + "providers": [ + { + "id": 337, + "name": "Disney Plus", + "logo_url": "https://image.tmdb.org/t/p/w92/5eZ872CghnHFLB1j8grszbrx0dx.png" + } + ], + "genres_observed_at": "2026-11-05T21:25:23.923810903Z", + "providers_observed_at": "2026-11-05T21:25:23.923810903Z", + "checked_at": "2026-11-05T21:25:23.923810903Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 92621, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 35, + "name": "Comedy" + }, + { + "id": 18, + "name": "Drama" + }, + { + "id": 80, + "name": "Crime" + } + ], + "providers": [], + "genres_observed_at": "2026-11-05T21:22:26.18747643Z", + "checked_at": "2026-11-05T21:22:26.18747643Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 1395, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 18, + "name": "Drama" + } + ], + "providers": [ + { + "id": 8, + "name": "Netflix", + "logo_url": "https://image.tmdb.org/t/p/w92/rK1KljqmbvO9HQa1PBFLILWah72.png" + }, + { + "id": 119, + "name": "Amazon Prime Video", + "logo_url": "https://image.tmdb.org/t/p/w92/gMZdpavHmxFNnLpMHwVxfqeux2g.png" + }, + { + "id": 1899, + "name": "HBO Max", + "logo_url": "https://image.tmdb.org/t/p/w92/skypuy7SXuugIQeYg0IglmzoKaS.png" + }, + { + "id": 1825, + "name": "HBO Max Amazon Channel", + "logo_url": "https://image.tmdb.org/t/p/w92/64fLWeSyZ1KQZhIdvTdy4QHeWty.png" + } + ], + "genres_observed_at": "2026-11-05T21:25:36.035509536Z", + "providers_observed_at": "2026-11-05T21:25:36.035509536Z", + "checked_at": "2026-11-05T21:25:36.035509536Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 6127, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 18, + "name": "Drama" + }, + { + "id": 80, + "name": "Crime" + }, + { + "id": 9648, + "name": "Mystery" + } + ], + "providers": [], + "genres_observed_at": "2026-11-05T21:21:05.257417929Z", + "checked_at": "2026-11-05T21:21:05.257417929Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 1021, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 18, + "name": "Drama" + }, + { + "id": 10766, + "name": "Soap" + } + ], + "providers": [], + "genres_observed_at": "2026-11-05T21:22:48.413281577Z", + "checked_at": "2026-11-05T21:22:48.413281577Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 211, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 18, + "name": "Drama" + }, + { + "id": 35, + "name": "Comedy" + } + ], + "providers": [], + "genres_observed_at": "2026-11-06T12:25:13.758451016Z", + "checked_at": "2026-11-06T12:25:13.758451016Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 61118, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 35, + "name": "Comedy" + }, + { + "id": 18, + "name": "Drama" + } + ], + "providers": [], + "genres_observed_at": "2026-11-05T21:34:53.407473419Z", + "checked_at": "2026-11-05T21:34:53.407473419Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 62704, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 18, + "name": "Drama" + }, + { + "id": 35, + "name": "Comedy" + } + ], + "providers": [ + { + "id": 119, + "name": "Amazon Prime Video", + "logo_url": "https://image.tmdb.org/t/p/w92/gMZdpavHmxFNnLpMHwVxfqeux2g.png" + }, + { + "id": 1899, + "name": "HBO Max", + "logo_url": "https://image.tmdb.org/t/p/w92/skypuy7SXuugIQeYg0IglmzoKaS.png" + }, + { + "id": 1825, + "name": "HBO Max Amazon Channel", + "logo_url": "https://image.tmdb.org/t/p/w92/64fLWeSyZ1KQZhIdvTdy4QHeWty.png" + } + ], + "genres_observed_at": "2026-11-05T21:21:45.84116767Z", + "providers_observed_at": "2026-11-05T21:21:45.84116767Z", + "checked_at": "2026-11-05T21:21:45.84116767Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 39525, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 35, + "name": "Comedy" + }, + { + "id": 18, + "name": "Drama" + }, + { + "id": 9648, + "name": "Mystery" + } + ], + "providers": [], + "genres_observed_at": "2026-11-05T21:34:35.115608453Z", + "checked_at": "2026-11-05T21:34:35.115608453Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 111838, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 9648, + "name": "Mystery" + }, + { + "id": 80, + "name": "Crime" + }, + { + "id": 35, + "name": "Comedy" + } + ], + "providers": [ + { + "id": 8, + "name": "Netflix", + "logo_url": "https://image.tmdb.org/t/p/w92/rK1KljqmbvO9HQa1PBFLILWah72.png" + } + ], + "genres_observed_at": "2026-11-06T11:54:45.492394545Z", + "providers_observed_at": "2026-11-06T11:54:45.492394545Z", + "checked_at": "2026-11-06T11:54:45.492394545Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 124010, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 35, + "name": "Comedy" + }, + { + "id": 18, + "name": "Drama" + } + ], + "providers": [ + { + "id": 337, + "name": "Disney Plus", + "logo_url": "https://image.tmdb.org/t/p/w92/5eZ872CghnHFLB1j8grszbrx0dx.png" + } + ], + "genres_observed_at": "2026-11-05T21:26:22.356825229Z", + "providers_observed_at": "2026-11-05T21:26:22.356825229Z", + "checked_at": "2026-11-05T21:26:22.356825229Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 275139, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 35, + "name": "Comedy" + }, + { + "id": 18, + "name": "Drama" + }, + { + "id": 9648, + "name": "Mystery" + } + ], + "providers": [ + { + "id": 337, + "name": "Disney Plus", + "logo_url": "https://image.tmdb.org/t/p/w92/5eZ872CghnHFLB1j8grszbrx0dx.png" + } + ], + "genres_observed_at": "2026-11-06T05:51:43.36209593Z", + "providers_observed_at": "2026-11-06T05:51:43.36209593Z", + "checked_at": "2026-11-06T05:51:43.36209593Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 69050, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 80, + "name": "Crime" + }, + { + "id": 18, + "name": "Drama" + }, + { + "id": 9648, + "name": "Mystery" + } + ], + "providers": [ + { + "id": 8, + "name": "Netflix", + "logo_url": "https://image.tmdb.org/t/p/w92/rK1KljqmbvO9HQa1PBFLILWah72.png" + } + ], + "genres_observed_at": "2026-11-06T12:26:31.89821713Z", + "providers_observed_at": "2026-11-06T12:26:31.89821713Z", + "checked_at": "2026-11-06T12:26:31.89821713Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 70796, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 35, + "name": "Comedy" + }, + { + "id": 18, + "name": "Drama" + } + ], + "providers": [ + { + "id": 119, + "name": "Amazon Prime Video", + "logo_url": "https://image.tmdb.org/t/p/w92/gMZdpavHmxFNnLpMHwVxfqeux2g.png" + } + ], + "genres_observed_at": "2026-11-05T21:32:33.891659122Z", + "providers_observed_at": "2026-11-05T21:32:33.891659122Z", + "checked_at": "2026-11-05T21:32:33.891659122Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 135959, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 35, + "name": "Comedy" + } + ], + "providers": [ + { + "id": 8, + "name": "Netflix", + "logo_url": "https://image.tmdb.org/t/p/w92/rK1KljqmbvO9HQa1PBFLILWah72.png" + } + ], + "genres_observed_at": "2026-11-05T21:31:07.148373201Z", + "providers_observed_at": "2026-11-05T21:31:07.148373201Z", + "checked_at": "2026-11-05T21:31:07.148373201Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 241372, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 18, + "name": "Drama" + }, + { + "id": 80, + "name": "Crime" + }, + { + "id": 9648, + "name": "Mystery" + } + ], + "providers": [ + { + "id": 119, + "name": "Amazon Prime Video", + "logo_url": "https://image.tmdb.org/t/p/w92/gMZdpavHmxFNnLpMHwVxfqeux2g.png" + } + ], + "genres_observed_at": "2026-11-05T21:20:34.823654155Z", + "providers_observed_at": "2026-11-05T21:20:34.823654155Z", + "checked_at": "2026-11-05T21:20:34.823654155Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 250504, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 18, + "name": "Drama" + }, + { + "id": 9648, + "name": "Mystery" + } + ], + "providers": [ + { + "id": 8, + "name": "Netflix", + "logo_url": "https://image.tmdb.org/t/p/w92/rK1KljqmbvO9HQa1PBFLILWah72.png" + } + ], + "genres_observed_at": "2026-11-05T21:31:35.342107155Z", + "providers_observed_at": "2026-11-05T21:31:35.342107155Z", + "checked_at": "2026-11-05T21:31:35.342107155Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 258190, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 18, + "name": "Drama" + }, + { + "id": 80, + "name": "Crime" + } + ], + "providers": [], + "genres_observed_at": "2026-11-05T21:23:10.633405579Z", + "checked_at": "2026-11-05T21:23:10.633405579Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 120998, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 80, + "name": "Crime" + }, + { + "id": 9648, + "name": "Mystery" + } + ], + "providers": [ + { + "id": 1773, + "name": "SkyShowtime", + "logo_url": "https://image.tmdb.org/t/p/w92/ube1pVpP3FklRkeyQjlDkkC3tL2.png" + } + ], + "genres_observed_at": "2026-11-05T23:55:23.162687765Z", + "providers_observed_at": "2026-11-05T23:55:23.162687765Z", + "checked_at": "2026-11-05T23:55:23.162687765Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 4454, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 35, + "name": "Comedy" + } + ], + "providers": [], + "genres_observed_at": "2026-11-05T23:55:31.205160147Z", + "providers_observed_at": "2026-11-05T23:55:31.205160147Z", + "checked_at": "2026-11-05T23:55:31.205160147Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 2585, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 80, + "name": "Crime" + }, + { + "id": 18, + "name": "Drama" + }, + { + "id": 35, + "name": "Comedy" + } + ], + "providers": [], + "genres_observed_at": "2026-11-05T21:26:02.269758895Z", + "checked_at": "2026-11-05T21:26:02.269758895Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 269, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 18, + "name": "Drama" + } + ], + "providers": [ + { + "id": 8, + "name": "Netflix", + "logo_url": "https://image.tmdb.org/t/p/w92/rK1KljqmbvO9HQa1PBFLILWah72.png" + }, + { + "id": 119, + "name": "Amazon Prime Video", + "logo_url": "https://image.tmdb.org/t/p/w92/gMZdpavHmxFNnLpMHwVxfqeux2g.png" + } + ], + "genres_observed_at": "2026-11-06T11:49:56.238979796Z", + "providers_observed_at": "2026-11-06T11:49:56.238979796Z", + "checked_at": "2026-11-06T11:49:56.238979796Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 95004, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 18, + "name": "Drama" + }, + { + "id": 10751, + "name": "Family" + } + ], + "providers": [], + "genres_observed_at": "2026-11-05T21:34:21.087963942Z", + "checked_at": "2026-11-05T21:34:21.087963942Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 4107, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 35, + "name": "Comedy" + }, + { + "id": 18, + "name": "Drama" + } + ], + "providers": [], + "genres_observed_at": "2026-11-05T21:33:14.236852284Z", + "checked_at": "2026-11-05T21:33:14.236852284Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 70785, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 18, + "name": "Drama" + }, + { + "id": 10751, + "name": "Family" + } + ], + "providers": [ + { + "id": 8, + "name": "Netflix", + "logo_url": "https://image.tmdb.org/t/p/w92/rK1KljqmbvO9HQa1PBFLILWah72.png" + } + ], + "genres_observed_at": "2026-11-05T21:21:27.666221762Z", + "providers_observed_at": "2026-11-05T21:21:27.666221762Z", + "checked_at": "2026-11-05T21:21:27.666221762Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 4607, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 9648, + "name": "Mystery" + }, + { + "id": 10759, + "name": "Action & Adventure" + }, + { + "id": 18, + "name": "Drama" + } + ], + "providers": [ + { + "id": 337, + "name": "Disney Plus", + "logo_url": "https://image.tmdb.org/t/p/w92/5eZ872CghnHFLB1j8grszbrx0dx.png" + } + ], + "genres_observed_at": "2026-11-05T23:55:21.150838395Z", + "providers_observed_at": "2026-11-05T23:55:21.150838395Z", + "checked_at": "2026-11-05T23:55:21.150838395Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 73319, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 10764, + "name": "Reality" + } + ], + "providers": [ + { + "id": 1899, + "name": "HBO Max", + "logo_url": "https://image.tmdb.org/t/p/w92/skypuy7SXuugIQeYg0IglmzoKaS.png" + }, + { + "id": 1825, + "name": "HBO Max Amazon Channel", + "logo_url": "https://image.tmdb.org/t/p/w92/64fLWeSyZ1KQZhIdvTdy4QHeWty.png" + } + ], + "genres_observed_at": "2026-11-05T21:20:36.83693195Z", + "providers_observed_at": "2026-11-05T21:20:36.83693195Z", + "checked_at": "2026-11-05T21:20:36.83693195Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 2398, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 18, + "name": "Drama" + }, + { + "id": 35, + "name": "Comedy" + } + ], + "providers": [ + { + "id": 315, + "name": "Hoichoi", + "logo_url": "https://image.tmdb.org/t/p/w92/8dgAD4EzQWMsyudVwWCFg19998L.png" + } + ], + "genres_observed_at": "2026-11-06T12:26:07.803808577Z", + "providers_observed_at": "2026-11-06T12:26:07.803808577Z", + "checked_at": "2026-11-06T12:26:07.803808577Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 186, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 35, + "name": "Comedy" + }, + { + "id": 80, + "name": "Crime" + }, + { + "id": 18, + "name": "Drama" + } + ], + "providers": [ + { + "id": 8, + "name": "Netflix", + "logo_url": "https://image.tmdb.org/t/p/w92/rK1KljqmbvO9HQa1PBFLILWah72.png" + }, + { + "id": 2358, + "name": "Lionsgate+ Amazon Channel", + "logo_url": "https://image.tmdb.org/t/p/w92/1356slxu4QrHOMrEVDKHJXRo5sW.png" + } + ], + "genres_observed_at": "2026-11-06T11:01:27.847601167Z", + "providers_observed_at": "2026-11-06T11:01:27.847601167Z", + "checked_at": "2026-11-06T11:01:27.847601167Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 31917, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 18, + "name": "Drama" + }, + { + "id": 9648, + "name": "Mystery" + } + ], + "providers": [ + { + "id": 119, + "name": "Amazon Prime Video", + "logo_url": "https://image.tmdb.org/t/p/w92/gMZdpavHmxFNnLpMHwVxfqeux2g.png" + }, + { + "id": 1899, + "name": "HBO Max", + "logo_url": "https://image.tmdb.org/t/p/w92/skypuy7SXuugIQeYg0IglmzoKaS.png" + }, + { + "id": 1825, + "name": "HBO Max Amazon Channel", + "logo_url": "https://image.tmdb.org/t/p/w92/64fLWeSyZ1KQZhIdvTdy4QHeWty.png" + } + ], + "genres_observed_at": "2026-11-06T11:52:12.800296211Z", + "providers_observed_at": "2026-11-06T11:52:12.800296211Z", + "checked_at": "2026-11-06T11:52:12.800296211Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 88324, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 18, + "name": "Drama" + } + ], + "providers": [ + { + "id": 8, + "name": "Netflix", + "logo_url": "https://image.tmdb.org/t/p/w92/rK1KljqmbvO9HQa1PBFLILWah72.png" + } + ], + "genres_observed_at": "2026-11-05T21:34:27.102165661Z", + "providers_observed_at": "2026-11-05T21:34:27.102165661Z", + "checked_at": "2026-11-05T21:34:27.102165661Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 4582, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 80, + "name": "Crime" + }, + { + "id": 18, + "name": "Drama" + }, + { + "id": 9648, + "name": "Mystery" + } + ], + "providers": [ + { + "id": 119, + "name": "Amazon Prime Video", + "logo_url": "https://image.tmdb.org/t/p/w92/gMZdpavHmxFNnLpMHwVxfqeux2g.png" + } + ], + "genres_observed_at": "2026-11-05T21:26:34.535473425Z", + "providers_observed_at": "2026-11-05T21:26:34.535473425Z", + "checked_at": "2026-11-05T21:26:34.535473425Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 67697, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 35, + "name": "Comedy" + }, + { + "id": 18, + "name": "Drama" + }, + { + "id": 80, + "name": "Crime" + } + ], + "providers": [], + "genres_observed_at": "2026-11-06T14:23:12.166007627Z", + "checked_at": "2026-11-06T14:23:12.166007627Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 130842, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 18, + "name": "Drama" + } + ], + "providers": [ + { + "id": 76, + "name": "Viaplay", + "logo_url": "https://image.tmdb.org/t/p/w92/c1J9PGGowXwW5AxAaZaGipeFu7U.png" + }, + { + "id": 2358, + "name": "Lionsgate+ Amazon Channel", + "logo_url": "https://image.tmdb.org/t/p/w92/1356slxu4QrHOMrEVDKHJXRo5sW.png" + } + ], + "genres_observed_at": "2026-11-06T14:23:26.216965491Z", + "providers_observed_at": "2026-11-06T14:23:26.216965491Z", + "checked_at": "2026-11-06T14:23:26.216965491Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 153723, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 18, + "name": "Drama" + } + ], + "providers": [ + { + "id": 1899, + "name": "HBO Max", + "logo_url": "https://image.tmdb.org/t/p/w92/skypuy7SXuugIQeYg0IglmzoKaS.png" + }, + { + "id": 1825, + "name": "HBO Max Amazon Channel", + "logo_url": "https://image.tmdb.org/t/p/w92/64fLWeSyZ1KQZhIdvTdy4QHeWty.png" + } + ], + "genres_observed_at": "2026-11-05T21:21:09.275219679Z", + "providers_observed_at": "2026-11-05T21:21:09.275219679Z", + "checked_at": "2026-11-05T21:21:09.275219679Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 41875, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 35, + "name": "Comedy" + }, + { + "id": 18, + "name": "Drama" + } + ], + "providers": [], + "genres_observed_at": "2026-11-06T11:51:26.706620327Z", + "checked_at": "2026-11-06T11:51:26.706620327Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 60705, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 35, + "name": "Comedy" + }, + { + "id": 18, + "name": "Drama" + } + ], + "providers": [], + "genres_observed_at": "2026-11-05T23:55:17.124876009Z", + "checked_at": "2026-11-05T23:55:17.124876009Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 236235, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 35, + "name": "Comedy" + }, + { + "id": 18, + "name": "Drama" + }, + { + "id": 80, + "name": "Crime" + } + ], + "providers": [ + { + "id": 8, + "name": "Netflix", + "logo_url": "https://image.tmdb.org/t/p/w92/rK1KljqmbvO9HQa1PBFLILWah72.png" + } + ], + "genres_observed_at": "2026-11-06T18:26:46.070072679Z", + "providers_observed_at": "2026-11-06T18:26:46.070072679Z", + "checked_at": "2026-11-06T18:26:46.070072679Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 81357, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 18, + "name": "Drama" + }, + { + "id": 35, + "name": "Comedy" + }, + { + "id": 80, + "name": "Crime" + } + ], + "providers": [ + { + "id": 8, + "name": "Netflix", + "logo_url": "https://image.tmdb.org/t/p/w92/rK1KljqmbvO9HQa1PBFLILWah72.png" + } + ], + "genres_observed_at": "2026-11-05T21:23:42.814357802Z", + "providers_observed_at": "2026-11-05T21:23:42.814357802Z", + "checked_at": "2026-11-05T21:23:42.814357802Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 1088, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 35, + "name": "Comedy" + } + ], + "providers": [], + "genres_observed_at": "2026-11-05T21:24:27.206645932Z", + "checked_at": "2026-11-05T21:24:27.206645932Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 3004, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 35, + "name": "Comedy" + }, + { + "id": 18, + "name": "Drama" + } + ], + "providers": [], + "genres_observed_at": "2026-11-05T21:28:29.672713141Z", + "checked_at": "2026-11-05T21:28:29.672713141Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 3216, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 80, + "name": "Crime" + }, + { + "id": 18, + "name": "Drama" + }, + { + "id": 9648, + "name": "Mystery" + } + ], + "providers": [], + "genres_observed_at": "2026-11-06T11:47:20.045517228Z", + "checked_at": "2026-11-06T11:47:20.045517228Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 80213, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 9648, + "name": "Mystery" + }, + { + "id": 80, + "name": "Crime" + }, + { + "id": 18, + "name": "Drama" + } + ], + "providers": [ + { + "id": 1773, + "name": "SkyShowtime", + "logo_url": "https://image.tmdb.org/t/p/w92/ube1pVpP3FklRkeyQjlDkkC3tL2.png" + } + ], + "genres_observed_at": "2026-11-06T11:48:40.127262242Z", + "providers_observed_at": "2026-11-06T11:48:40.127262242Z", + "checked_at": "2026-11-06T11:48:40.127262242Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 81358, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 80, + "name": "Crime" + }, + { + "id": 18, + "name": "Drama" + } + ], + "providers": [ + { + "id": 337, + "name": "Disney Plus", + "logo_url": "https://image.tmdb.org/t/p/w92/5eZ872CghnHFLB1j8grszbrx0dx.png" + } + ], + "genres_observed_at": "2026-11-05T21:28:01.418530642Z", + "providers_observed_at": "2026-11-05T21:28:01.418530642Z", + "checked_at": "2026-11-05T21:28:01.418530642Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 153312, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 80, + "name": "Crime" + }, + { + "id": 18, + "name": "Drama" + }, + { + "id": 35, + "name": "Comedy" + } + ], + "providers": [ + { + "id": 1773, + "name": "SkyShowtime", + "logo_url": "https://image.tmdb.org/t/p/w92/ube1pVpP3FklRkeyQjlDkkC3tL2.png" + } + ], + "genres_observed_at": "2026-11-06T14:22:04.05218024Z", + "providers_observed_at": "2026-11-06T14:22:04.05218024Z", + "checked_at": "2026-11-06T14:22:04.05218024Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 242054, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 35, + "name": "Comedy" + }, + { + "id": 80, + "name": "Crime" + }, + { + "id": 9648, + "name": "Mystery" + } + ], + "providers": [ + { + "id": 8, + "name": "Netflix", + "logo_url": "https://image.tmdb.org/t/p/w92/rK1KljqmbvO9HQa1PBFLILWah72.png" + } + ], + "genres_observed_at": "2026-11-05T23:55:27.177769487Z", + "providers_observed_at": "2026-11-05T23:55:27.177769487Z", + "checked_at": "2026-11-05T23:55:27.177769487Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 749, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 35, + "name": "Comedy" + }, + { + "id": 18, + "name": "Drama" + } + ], + "providers": [ + { + "id": 472, + "name": "NLZIET", + "logo_url": "https://image.tmdb.org/t/p/w92/jY3kxKfekzgpEDtghtK9elh4G6T.png" + }, + { + "id": 1986, + "name": "NPO Plus", + "logo_url": "https://image.tmdb.org/t/p/w92/eIplw9Hv4Q5RyHtofKZwjeRufz6.png" + } + ], + "genres_observed_at": "2026-11-05T23:55:15.115823011Z", + "providers_observed_at": "2026-11-05T23:55:15.115823011Z", + "checked_at": "2026-11-05T23:55:15.115823011Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 2215, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 18, + "name": "Drama" + }, + { + "id": 35, + "name": "Comedy" + } + ], + "providers": [], + "genres_observed_at": "2026-11-05T21:27:53.294051763Z", + "checked_at": "2026-11-05T21:27:53.294051763Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 87049, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 18, + "name": "Drama" + } + ], + "providers": [ + { + "id": 8, + "name": "Netflix", + "logo_url": "https://image.tmdb.org/t/p/w92/rK1KljqmbvO9HQa1PBFLILWah72.png" + } + ], + "genres_observed_at": "2026-11-06T10:13:55.439872014Z", + "providers_observed_at": "2026-11-06T10:13:55.439872014Z", + "checked_at": "2026-11-06T10:13:55.439872014Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 95386, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 18, + "name": "Drama" + } + ], + "providers": [], + "genres_observed_at": "2026-11-05T21:28:41.730357928Z", + "checked_at": "2026-11-05T21:28:41.730357928Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 136044, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 35, + "name": "Comedy" + }, + { + "id": 80, + "name": "Crime" + }, + { + "id": 18, + "name": "Drama" + } + ], + "providers": [ + { + "id": 1899, + "name": "HBO Max", + "logo_url": "https://image.tmdb.org/t/p/w92/skypuy7SXuugIQeYg0IglmzoKaS.png" + }, + { + "id": 1825, + "name": "HBO Max Amazon Channel", + "logo_url": "https://image.tmdb.org/t/p/w92/64fLWeSyZ1KQZhIdvTdy4QHeWty.png" + } + ], + "genres_observed_at": "2026-11-06T11:54:35.332205607Z", + "providers_observed_at": "2026-11-06T11:54:35.332205607Z", + "checked_at": "2026-11-06T11:54:35.332205607Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 195670, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 35, + "name": "Comedy" + }, + { + "id": 18, + "name": "Drama" + } + ], + "providers": [ + { + "id": 8, + "name": "Netflix", + "logo_url": "https://image.tmdb.org/t/p/w92/rK1KljqmbvO9HQa1PBFLILWah72.png" + } + ], + "genres_observed_at": "2026-11-05T23:55:33.213426461Z", + "providers_observed_at": "2026-11-05T23:55:33.213426461Z", + "checked_at": "2026-11-05T23:55:33.213426461Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 102904, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 18, + "name": "Drama" + } + ], + "providers": [ + { + "id": 8, + "name": "Netflix", + "logo_url": "https://image.tmdb.org/t/p/w92/rK1KljqmbvO9HQa1PBFLILWah72.png" + } + ], + "genres_observed_at": "2026-11-05T20:24:15.046007915Z", + "providers_observed_at": "2026-11-05T20:24:15.046007915Z", + "checked_at": "2026-11-05T20:24:15.046007915Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 45194, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 80, + "name": "Crime" + }, + { + "id": 18, + "name": "Drama" + }, + { + "id": 9648, + "name": "Mystery" + } + ], + "providers": [], + "genres_observed_at": "2026-11-06T11:54:47.502903807Z", + "checked_at": "2026-11-06T11:54:47.502903807Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 69630, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 18, + "name": "Drama" + }, + { + "id": 35, + "name": "Comedy" + } + ], + "providers": [ + { + "id": 119, + "name": "Amazon Prime Video", + "logo_url": "https://image.tmdb.org/t/p/w92/gMZdpavHmxFNnLpMHwVxfqeux2g.png" + } + ], + "genres_observed_at": "2026-11-05T21:31:37.373897507Z", + "providers_observed_at": "2026-11-05T21:31:37.373897507Z", + "checked_at": "2026-11-05T21:31:37.373897507Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 91239, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 18, + "name": "Drama" + } + ], + "providers": [ + { + "id": 8, + "name": "Netflix", + "logo_url": "https://image.tmdb.org/t/p/w92/rK1KljqmbvO9HQa1PBFLILWah72.png" + } + ], + "genres_observed_at": "2026-11-06T11:51:22.696252683Z", + "providers_observed_at": "2026-11-06T11:51:22.696252683Z", + "checked_at": "2026-11-06T11:51:22.696252683Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 100412, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 10762, + "name": "Kids" + }, + { + "id": 18, + "name": "Drama" + } + ], + "providers": [ + { + "id": 8, + "name": "Netflix", + "logo_url": "https://image.tmdb.org/t/p/w92/rK1KljqmbvO9HQa1PBFLILWah72.png" + }, + { + "id": 175, + "name": "Netflix Kids", + "logo_url": "https://image.tmdb.org/t/p/w92/rjPaInWHH1nPgtRFF17BbHN2gu.png" + } + ], + "genres_observed_at": "2026-11-06T11:53:31.09024729Z", + "providers_observed_at": "2026-11-06T11:53:31.09024729Z", + "checked_at": "2026-11-06T11:53:31.09024729Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 284558, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 18, + "name": "Drama" + }, + { + "id": 80, + "name": "Crime" + } + ], + "providers": [ + { + "id": 119, + "name": "Amazon Prime Video", + "logo_url": "https://image.tmdb.org/t/p/w92/gMZdpavHmxFNnLpMHwVxfqeux2g.png" + } + ], + "genres_observed_at": "2026-11-06T12:36:00.575054236Z", + "providers_observed_at": "2026-11-06T12:36:00.575054236Z", + "checked_at": "2026-11-06T12:36:00.575054236Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 90046, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 10764, + "name": "Reality" + } + ], + "providers": [ + { + "id": 1899, + "name": "HBO Max", + "logo_url": "https://image.tmdb.org/t/p/w92/skypuy7SXuugIQeYg0IglmzoKaS.png" + }, + { + "id": 1825, + "name": "HBO Max Amazon Channel", + "logo_url": "https://image.tmdb.org/t/p/w92/64fLWeSyZ1KQZhIdvTdy4QHeWty.png" + } + ], + "genres_observed_at": "2026-11-05T21:20:38.84522959Z", + "providers_observed_at": "2026-11-05T21:20:38.84522959Z", + "checked_at": "2026-11-05T21:20:38.84522959Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 259702, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 18, + "name": "Drama" + } + ], + "providers": [ + { + "id": 119, + "name": "Amazon Prime Video", + "logo_url": "https://image.tmdb.org/t/p/w92/gMZdpavHmxFNnLpMHwVxfqeux2g.png" + } + ], + "genres_observed_at": "2026-11-06T05:48:13.225189916Z", + "providers_observed_at": "2026-11-06T05:48:13.225189916Z", + "checked_at": "2026-11-06T05:48:13.225189916Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 2382, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 18, + "name": "Drama" + }, + { + "id": 35, + "name": "Comedy" + } + ], + "providers": [], + "genres_observed_at": "2026-11-06T11:48:52.142526753Z", + "checked_at": "2026-11-06T11:48:52.142526753Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 10860, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 18, + "name": "Drama" + }, + { + "id": 10751, + "name": "Family" + } + ], + "providers": [], + "genres_observed_at": "2026-11-05T21:29:28.442352928Z", + "checked_at": "2026-11-05T21:29:28.442352928Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 211564, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 18, + "name": "Drama" + } + ], + "providers": [ + { + "id": 472, + "name": "NLZIET", + "logo_url": "https://image.tmdb.org/t/p/w92/jY3kxKfekzgpEDtghtK9elh4G6T.png" + } + ], + "genres_observed_at": "2026-11-06T16:14:23.188509289Z", + "providers_observed_at": "2026-11-06T16:14:23.188509289Z", + "checked_at": "2026-11-06T16:14:23.188509289Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 197373, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 35, + "name": "Comedy" + }, + { + "id": 18, + "name": "Drama" + } + ], + "providers": [ + { + "id": 119, + "name": "Amazon Prime Video", + "logo_url": "https://image.tmdb.org/t/p/w92/gMZdpavHmxFNnLpMHwVxfqeux2g.png" + } + ], + "genres_observed_at": "2026-11-05T21:21:49.852947013Z", + "providers_observed_at": "2026-11-05T21:21:49.852947013Z", + "checked_at": "2026-11-05T21:21:49.852947013Z" + }, + "match": "matched", + "genre_source": "ranked" + } + ], + "unresolved": [] + } +} diff --git a/app/src/test/resources/conductarr/wire.json b/app/src/test/resources/conductarr/wire.json new file mode 100644 index 00000000..2893b49a --- /dev/null +++ b/app/src/test/resources/conductarr/wire.json @@ -0,0 +1,621 @@ +{ + "operator": { + "capabilities": { + "administer": false, + "manage_library": false, + "manage_memberships": false, + "recommendation_profiles": [ + "shared" + ], + "request_media": true, + "requesters": [ + "shared", + "mindel" + ], + "watch_users": [ + "shared" + ] + }, + "name": "Shared", + "recommendation_profile": "shared", + "requester": "shared", + "role": "household", + "subject": "jellyfin:shared", + "watch_user": "shared" + }, + "profiles": { + "profiles": [ + { + "key": "shared", + "name": "Shared" + }, + { + "key": "mindel", + "name": "Mindel" + } + ], + "default_profile": "shared" + }, + "audiences": { + "audiences": [ + { + "is_anime": true, + "libraries": [ + "Shared Anime" + ], + "media_type": "tv", + "owner": "shared", + "users": [ + "shared", + "mindel" + ] + } + ] + }, + "feed": { + "profile": { + "key": "shared", + "name": "Shared" + }, + "projection": { + "coverage": { + "complete": true, + "excluded": 0, + "matched": 1, + "total": 1, + "unresolved": 0 + }, + "facts": [], + "genre_id": 0, + "media_type": "all", + "service_id": 0, + "unresolved": [] + }, + "state": "current", + "outcome": "ranked", + "materialization": "current", + "movies": [ + { + "backdrop_path": "/moon-wide.jpg", + "contributions": [ + { + "added": false, + "media_type": "movie", + "title": "Solaris", + "tmdb_id": 33, + "watched": true + } + ], + "genre_ids": [ + 35 + ], + "media_type": "movie", + "overview": "An astronaut reaches the end of his assignment.", + "poster_path": "/moon.jpg", + "score": 4.2, + "title": "Moon", + "tmdb_id": 42, + "tvdb_id": 0, + "year": 2009 + } + ], + "shows": [], + "expires_at": "2099-11-05T12:00:00Z" + }, + "options": { + "genres": [ + { + "id": 35, + "name": "Comedy" + }, + { + "id": 16, + "name": "Animation" + } + ], + "media_type": "movie", + "observations": [], + "region": "NL", + "services": [ + { + "id": 8, + "name": "Netflix" + } + ], + "state": "observed" + }, + "discover": { + "items": [ + { + "tmdb_id": 43, + "media_type": "movie", + "title": "Arrival", + "overview": "An astronaut reaches the end of his assignment.", + "poster_url": "https://images.example.invalid/moon.jpg", + "backdrop_url": "https://images.example.invalid/moon-wide.jpg", + "release_date": "2009-06-12", + "held": "unheld", + "libraries": [], + "request_state": "none", + "seerr_state": "untracked", + "genre_ids": [ + 35 + ] + } + ], + "media_type": "movie", + "observations": [], + "page": 1, + "query": "", + "source": "general", + "state": "observed", + "total_pages": 2, + "total_results": 24 + }, + "dismissals": { + "profile": "shared", + "dismissals": [ + { + "hidden_at": "2026-11-05T12:00:00Z", + "hidden_by": "shared", + "media_type": "movie", + "poster_path": "/hidden.jpg", + "title": "Hidden film", + "tmdb_id": 99, + "year": 2017 + } + ] + }, + "ratings": { + "ratings": [ + { + "source": "rt_critics", + "state": "observed", + "score": 90, + "status": "Certified Fresh" + }, + { + "source": "rt_audience", + "state": "observed", + "score": 89, + "status": "Upright" + }, + { + "source": "imdb", + "state": "observed", + "score": 7.8, + "votes": 300000 + }, + { + "source": "tmdb", + "state": "observed", + "score": 7.6, + "votes": 4000 + } + ] + }, + "detail": { + "genres": [ + "Comedy" + ], + "metadata": { + "genre_state": "observed", + "genres": [ + { + "id": 35, + "name": "Comedy" + } + ], + "media_type": "movie", + "provider_state": "observed", + "providers": [], + "region": "NL", + "tmdb_id": 42 + }, + "metadata_state": "observed", + "observations": [], + "ratings": [ + { + "source": "rt_critics", + "state": "observed", + "score": 90, + "status": "Certified Fresh" + }, + { + "source": "rt_audience", + "state": "observed", + "score": 89, + "status": "Upright" + }, + { + "source": "imdb", + "state": "observed", + "score": 7.8, + "votes": 300000 + }, + { + "source": "tmdb", + "state": "observed", + "score": 7.6, + "votes": 4000 + } + ], + "runtime": 97, + "seasons": [ + { + "number": 1, + "name": "Season 1", + "overview": "A missing child.", + "episode_count": 10, + "recorded_episodes": 0, + "coverage": "none", + "request_state": "none", + "seerr_state": "untracked" + } + ], + "state": "observed", + "title": { + "tmdb_id": 42, + "media_type": "movie", + "title": "Moon", + "overview": "An astronaut reaches the end of his assignment.", + "poster_url": "https://images.example.invalid/moon.jpg", + "backdrop_url": "https://images.example.invalid/moon-wide.jpg", + "release_date": "2009-06-12", + "held": "unheld", + "libraries": [], + "request_state": "none", + "seerr_state": "untracked", + "genre_ids": [ + 35 + ] + } + }, + "enrichment": { + "genres": [ + "Comedy" + ], + "metadata": { + "genre_state": "observed", + "genres": [ + { + "id": 35, + "name": "Comedy" + } + ], + "media_type": "movie", + "provider_state": "observed", + "providers": [], + "region": "NL", + "tmdb_id": 42 + }, + "metadata_state": "observed", + "observations": [], + "ratings": [ + { + "source": "rt_critics", + "state": "observed", + "score": 90, + "status": "Certified Fresh" + }, + { + "source": "rt_audience", + "state": "observed", + "score": 89, + "status": "Upright" + }, + { + "source": "imdb", + "state": "observed", + "score": 7.8, + "votes": 300000 + }, + { + "source": "tmdb", + "state": "observed", + "score": 7.6, + "votes": 4000 + } + ], + "runtime": 97, + "seasons": [ + { + "number": 1, + "name": "Season 1", + "overview": "A missing child.", + "episode_count": 10, + "recorded_episodes": 0, + "coverage": "none", + "request_state": "none", + "seerr_state": "untracked" + } + ], + "state": "observed", + "title": { + "tmdb_id": 42, + "media_type": "movie", + "title": "Moon", + "overview": "An astronaut reaches the end of his assignment.", + "poster_url": "https://images.example.invalid/moon.jpg", + "backdrop_url": "https://images.example.invalid/moon-wide.jpg", + "release_date": "2009-06-12", + "held": "unheld", + "libraries": [], + "request_state": "none", + "seerr_state": "untracked", + "genre_ids": [ + 35 + ] + } + }, + "display": { + "media_type": "movie", + "metadata_state": "observed", + "poster_url": "https://images.example.invalid/moon.jpg", + "title": "Moon", + "tmdb_id": 42 + }, + "cast": { + "tmdb_id": 42, + "media_type": "movie", + "metadata_state": "observed", + "source": "seerr", + "paging": "unpaged", + "credit_context": "movie-character", + "items": [ + { + "performer_id": 216, + "name": "Sam Rockwell", + "character": "Sam Bell", + "credit_id": "cast-216", + "order": 0, + "profile_url": "https://images.example.invalid/sam.jpg" + } + ] + }, + "season": { + "tmdb_id": 44, + "media_type": "tv", + "metadata_state": "observed", + "season": { + "number": 1, + "name": "Season 1", + "overview": "A missing child.", + "episode_count": 10, + "recorded_episodes": 0, + "coverage": "none", + "request_state": "none", + "seerr_state": "untracked" + }, + "episodes": [ + { + "number": 1, + "name": "Secrets", + "overview": "A child disappears.", + "recorded": "missing", + "vote_average": 8.1, + "vote_count": 28 + } + ], + "observations": [] + }, + "performer": { + "performer_id": 216, + "metadata_state": "observed", + "source": "seerr", + "name": "Sam Rockwell", + "biography": "Actor.", + "also_known_as": [] + }, + "filmography": { + "performer_id": 216, + "metadata_state": "observed", + "source": "seerr", + "source_paging": "unpaged", + "completeness": "returned-non-adult-cast", + "page": 1, + "page_size": 20, + "items": [ + { + "title": { + "tmdb_id": 42, + "media_type": "movie", + "title": "Moon", + "overview": "An astronaut reaches the end of his assignment.", + "poster_url": "https://images.example.invalid/moon.jpg", + "backdrop_url": "https://images.example.invalid/moon-wide.jpg", + "release_date": "2009-06-12", + "held": "unheld", + "libraries": [], + "request_state": "none", + "seerr_state": "untracked", + "genre_ids": [ + 35 + ] + }, + "credits": [ + { + "credit_id": "cast-216", + "character": "Sam Bell" + } + ], + "visibility_state": "unknown", + "users": [] + } + ], + "observations": [] + }, + "activity": { + "items": [ + { + "actor": { + "name": "Shared", + "requester": "shared", + "role": "household" + }, + "filed_by_you": true, + "id": "request:7", + "media_type": "movie", + "on_behalf": true, + "requester": { + "key": "mindel", + "name": "Mindel" + }, + "seasons": [], + "stages": [ + { + "name": "request", + "source": "conductarr", + "state": "done", + "summary": "Request accepted" + }, + { + "name": "acquisition", + "source": "sonarr", + "state": "in_progress", + "summary": "Downloading" + }, + { + "name": "visibility", + "source": "jellyfin", + "state": "none", + "summary": "Not yet" + }, + { + "name": "playback", + "source": "jellyfin", + "state": "none", + "summary": "Not checked" + } + ], + "title": "Moon", + "tmdb_id": 42, + "blocking_stage": "acquisition" + } + ], + "requester": "all", + "sources": [], + "truncated": false + }, + "readiness": { + "tmdb_id": 42, + "media_type": "movie", + "viewer": { + "key": "mindel", + "name": "Mindel" + }, + "stages": [ + { + "name": "request", + "source": "conductarr", + "state": "done", + "summary": "Request accepted" + }, + { + "name": "acquisition", + "source": "sonarr", + "state": "in_progress", + "summary": "Downloading" + }, + { + "name": "visibility", + "source": "jellyfin", + "state": "none", + "summary": "Not yet" + }, + { + "name": "playback", + "source": "jellyfin", + "state": "none", + "summary": "Not checked" + } + ], + "seerr": { + "state": "observed", + "requests": [] + }, + "coverage": [], + "open_link": { + "state": "missing" + }, + "sources": [] + }, + "requests": { + "requests": [ + { + "actor": { + "name": "Shared", + "requester": "shared", + "role": "household" + }, + "attempts": 1, + "coverage": [], + "created_at": "2026-11-05T12:00:00Z", + "id": 7, + "media_type": "movie", + "membership": { + "status": "added" + }, + "on_behalf": true, + "requester": "mindel", + "requester_username": "Mindel", + "seasons": null, + "seerr": { + "outcome": "requested", + "request_id": 9 + }, + "source": "recommendation", + "state": "settled", + "tmdb_id": 42, + "updated_at": "2026-11-05T12:00:00Z" + } + ] + }, + "receipt": { + "actor": { + "name": "Shared", + "requester": "shared", + "role": "household" + }, + "attempts": 1, + "coverage": [], + "created_at": "2026-11-05T12:00:00Z", + "id": 7, + "media_type": "movie", + "membership": { + "status": "added" + }, + "on_behalf": true, + "requester": "mindel", + "requester_username": "Mindel", + "seasons": null, + "seerr": { + "outcome": "requested", + "request_id": 9 + }, + "source": "recommendation", + "state": "settled", + "tmdb_id": 42, + "updated_at": "2026-11-05T12:00:00Z" + }, + "hide": { + "created": true, + "dismissal": { + "hidden_at": "2026-11-05T12:00:00Z", + "hidden_by": "shared", + "media_type": "movie", + "poster_path": "/hidden.jpg", + "title": "Moon", + "tmdb_id": 42, + "year": 2017 + }, + "materialization": "current", + "profile": "shared" + }, + "restore": { + "media_type": "movie", + "profile": "shared", + "restored": true, + "tmdb_id": 42 + } +} diff --git a/app/src/test/resources/embedded_subs.json b/app/src/test/resources/embedded_subs.json index f24aeefc..4a3c7666 100644 --- a/app/src/test/resources/embedded_subs.json +++ b/app/src/test/resources/embedded_subs.json @@ -165,7 +165,8 @@ "IsExternal": false, "IsTextSubtitleStream": true, "SupportsExternalStream": true, - "Level": 0 + "Level": 0, + "DeliveryMethod": "Embed" }, { "Codec": "ass", @@ -193,7 +194,8 @@ "IsExternal": false, "IsTextSubtitleStream": true, "SupportsExternalStream": true, - "Level": 0 + "Level": 0, + "DeliveryMethod": "Embed" }, { "Codec": "ass", @@ -221,7 +223,8 @@ "IsExternal": false, "IsTextSubtitleStream": true, "SupportsExternalStream": true, - "Level": 0 + "Level": 0, + "DeliveryMethod": "Embed" } ], "MediaAttachments": [], diff --git a/app/src/test/resources/external_subs.json b/app/src/test/resources/external_subs.json index 9f6a4b05..1ef202d4 100644 --- a/app/src/test/resources/external_subs.json +++ b/app/src/test/resources/external_subs.json @@ -50,7 +50,8 @@ "IsTextSubtitleStream": true, "SupportsExternalStream": true, "Path": "", - "Level": 0 + "Level": 0, + "DeliveryMethod": "External" }, { "Codec": "subrip", @@ -79,7 +80,8 @@ "IsTextSubtitleStream": true, "SupportsExternalStream": true, "Path": "", - "Level": 0 + "Level": 0, + "DeliveryMethod": "External" }, { "Codec": "subrip", @@ -107,7 +109,8 @@ "IsTextSubtitleStream": true, "SupportsExternalStream": true, "Path": "", - "Level": 0 + "Level": 0, + "DeliveryMethod": "External" }, { "Codec": "eac3", @@ -194,7 +197,8 @@ "IsExternal": false, "IsTextSubtitleStream": true, "SupportsExternalStream": true, - "Level": 0 + "Level": 0, + "DeliveryMethod": "Embed" } ], "MediaAttachments": [], diff --git a/app/src/test/resources/forgejo_release_latest.json b/app/src/test/resources/forgejo_release_latest.json new file mode 100644 index 00000000..f99734b9 --- /dev/null +++ b/app/src/test/resources/forgejo_release_latest.json @@ -0,0 +1,44 @@ +{ + "id": 42, + "name": "v1.0.9-2-g3094fc8c", + "tag_name": "pwolfskin-v1.0.9-2-g3094fc8c", + "body": "Wolfskin build\n<!-- app-note:Subtitle preference fix -->\n**Full Changelog**: https://git.justinvisser.org/justinzeus/wolfskin/compare/v1.0.8...v1.0.9", + "draft": false, + "prerelease": false, + "created_at": "2026-10-20T09:00:00Z", + "published_at": "2026-10-20T09:05:00Z", + "target_commitish": "3094fc8c", + "html_url": "https://git.justinvisser.org/justinzeus/wolfskin/releases/tag/pwolfskin-v1.0.9-2-g3094fc8c", + "assets": [ + { + "id": 101, + "name": "Wolfskin.apk", + "size": 12345678, + "download_count": 3, + "created_at": "2026-10-20T09:05:00Z", + "uuid": "11111111-1111-1111-1111-111111111111", + "type": "attachment", + "browser_download_url": "https://git.justinvisser.org/justinzeus/wolfskin/releases/download/pwolfskin-v1.0.9-2-g3094fc8c/Wolfskin.apk" + }, + { + "id": 102, + "name": "Wolfskin-arm64-v8a.apk", + "size": 7654321, + "download_count": 1, + "created_at": "2026-10-20T09:05:00Z", + "uuid": "22222222-2222-2222-2222-222222222222", + "type": "attachment", + "browser_download_url": "https://git.justinvisser.org/justinzeus/wolfskin/releases/download/pwolfskin-v1.0.9-2-g3094fc8c/Wolfskin-arm64-v8a.apk" + }, + { + "id": 103, + "name": "Wolfskin-armeabi-v7a.apk", + "size": 6543210, + "download_count": 0, + "created_at": "2026-10-20T09:05:00Z", + "uuid": "33333333-3333-3333-3333-333333333333", + "type": "attachment", + "browser_download_url": "https://git.justinvisser.org/justinzeus/wolfskin/releases/download/pwolfskin-v1.0.9-2-g3094fc8c/Wolfskin-armeabi-v7a.apk" + } + ] +} diff --git a/app/src/test/resources/forgejo_release_suffixed.json b/app/src/test/resources/forgejo_release_suffixed.json new file mode 100644 index 00000000..c2155081 --- /dev/null +++ b/app/src/test/resources/forgejo_release_suffixed.json @@ -0,0 +1,24 @@ +{ + "name": "v1.0.9-2-g3094fc8c", + "tag_name": "pwolfskin-v1.0.9-2-g3094fc8c", + "published_at": "2026-10-20T09:05:00Z", + "body": "Wolfskin release and debug builds", + "assets": [ + { + "name": "Wolfskin-debug.apk", + "browser_download_url": "https://git.justinvisser.org/justinzeus/wolfskin/releases/download/pwolfskin-v1.0.9-2-g3094fc8c/Wolfskin-debug.apk" + }, + { + "name": "Wolfskin-release.apk", + "browser_download_url": "https://git.justinvisser.org/justinzeus/wolfskin/releases/download/pwolfskin-v1.0.9-2-g3094fc8c/Wolfskin-release.apk" + }, + { + "name": "Wolfskin-debug-arm64-v8a.apk", + "browser_download_url": "https://git.justinvisser.org/justinzeus/wolfskin/releases/download/pwolfskin-v1.0.9-2-g3094fc8c/Wolfskin-debug-arm64-v8a.apk" + }, + { + "name": "Wolfskin-release-arm64-v8a.apk", + "browser_download_url": "https://git.justinvisser.org/justinzeus/wolfskin/releases/download/pwolfskin-v1.0.9-2-g3094fc8c/Wolfskin-release-arm64-v8a.apk" + } + ] +} diff --git a/app/src/test/resources/forgejo_releases.json b/app/src/test/resources/forgejo_releases.json new file mode 100644 index 00000000..ab84e411 --- /dev/null +++ b/app/src/test/resources/forgejo_releases.json @@ -0,0 +1,52 @@ +[ + { + "id": 44, + "name": "v1.0.10-0-gfeedbee", + "tag_name": "pwolfskin-v1.0.10-0-gfeedbee", + "body": "", + "draft": false, + "prerelease": false, + "created_at": "2026-10-22T08:00:00Z", + "published_at": "2026-10-22T08:05:00Z", + "target_commitish": "feedbee", + "html_url": "https://git.justinvisser.org/justinzeus/wolfskin/releases/tag/pwolfskin-v1.0.10-0-gfeedbee", + "assets": [ + { + "id": 104, + "name": "Wolfskin.apk", + "size": 12345679, + "download_count": 0, + "created_at": "2026-10-22T08:05:00Z", + "uuid": "44444444-4444-4444-4444-444444444444", + "type": "attachment", + "browser_download_url": "https://git.justinvisser.org/justinzeus/wolfskin/releases/download/pwolfskin-v1.0.10-0-gfeedbee/Wolfskin.apk" + } + ] + }, + { + "id": 42, + "name": "v1.0.9-2-g3094fc8c", + "tag_name": "pwolfskin-v1.0.9-2-g3094fc8c", + "body": "Wolfskin build", + "draft": false, + "prerelease": false, + "created_at": "2026-10-20T09:00:00Z", + "published_at": "2026-10-20T09:05:00Z", + "target_commitish": "3094fc8c", + "html_url": "https://git.justinvisser.org/justinzeus/wolfskin/releases/tag/pwolfskin-v1.0.9-2-g3094fc8c", + "assets": [] + }, + { + "id": 40, + "name": "nightly", + "tag_name": "nightly", + "body": "", + "draft": false, + "prerelease": true, + "created_at": "2026-10-10T09:00:00Z", + "published_at": "2026-10-10T09:05:00Z", + "target_commitish": "main", + "html_url": "https://git.justinvisser.org/justinzeus/wolfskin/releases/tag/nightly", + "assets": [] + } +] diff --git a/app/src/test/resources/no_embedded_subs.json b/app/src/test/resources/no_embedded_subs.json index be4e3051..d746913e 100644 --- a/app/src/test/resources/no_embedded_subs.json +++ b/app/src/test/resources/no_embedded_subs.json @@ -50,7 +50,8 @@ "IsTextSubtitleStream": true, "SupportsExternalStream": true, "Path": "", - "Level": 0 + "Level": 0, + "DeliveryMethod": "External" }, { "Codec": "hevc", diff --git a/app/src/test/resources/release_v108.json b/app/src/test/resources/release_v108.json new file mode 100644 index 00000000..1a64aad8 --- /dev/null +++ b/app/src/test/resources/release_v108.json @@ -0,0 +1,344 @@ +{ + "url": "https://api.github.com/repos/damontecres/Wholphin/releases/388459926", + "assets_url": "https://api.github.com/repos/damontecres/Wholphin/releases/388459926/assets", + "upload_url": "https://uploads.github.com/repos/damontecres/Wholphin/releases/388459926/assets{?name,label}", + "html_url": "https://github.com/damontecres/Wholphin/releases/tag/v1.0.8", + "id": 388459926, + "author": { + "login": "github-actions[bot]", + "id": 41898282, + "node_id": "MDM6Qm90NDE4OTgyODI=", + "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/github-actions%5Bbot%5D", + "html_url": "https://github.com/apps/github-actions", + "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", + "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", + "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", + "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", + "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", + "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", + "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", + "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", + "type": "Bot", + "user_view_type": "public", + "site_admin": false + }, + "node_id": "RE_kwDOPzwRps4XJ22W", + "tag_name": "v1.0.8", + "target_commitish": "main", + "name": "v1.0.8", + "draft": false, + "immutable": false, + "prerelease": false, + "created_at": "2026-09-14T14:04:10Z", + "updated_at": "2026-09-14T14:28:03Z", + "published_at": "2026-09-14T14:28:03Z", + "assets": [ + { + "url": "https://api.github.com/repos/damontecres/Wholphin/releases/assets/563552435", + "id": 563552435, + "node_id": "RA_kwDOPzwRps4hlyCz", + "name": "Wholphin-arm64-v8a.apk", + "label": "", + "uploader": { + "login": "github-actions[bot]", + "id": 41898282, + "node_id": "MDM6Qm90NDE4OTgyODI=", + "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/github-actions%5Bbot%5D", + "html_url": "https://github.com/apps/github-actions", + "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", + "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", + "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", + "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", + "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", + "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", + "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", + "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", + "type": "Bot", + "user_view_type": "public", + "site_admin": false + }, + "content_type": "application/vnd.android.package-archive", + "state": "uploaded", + "size": 46041302, + "digest": "sha256:77504857a15050beed9a8e589b70c29773b5e6a3c6ae76deec79c10702129755", + "download_count": 485, + "created_at": "2026-09-14T14:23:34Z", + "updated_at": "2026-09-14T14:23:39Z", + "browser_download_url": "https://github.com/damontecres/Wholphin/releases/download/v1.0.8/Wholphin-arm64-v8a.apk" + }, + { + "url": "https://api.github.com/repos/damontecres/Wholphin/releases/assets/563552436", + "id": 563552436, + "node_id": "RA_kwDOPzwRps4hlyC0", + "name": "Wholphin-armeabi-v7a.apk", + "label": "", + "uploader": { + "login": "github-actions[bot]", + "id": 41898282, + "node_id": "MDM6Qm90NDE4OTgyODI=", + "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/github-actions%5Bbot%5D", + "html_url": "https://github.com/apps/github-actions", + "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", + "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", + "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", + "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", + "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", + "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", + "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", + "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", + "type": "Bot", + "user_view_type": "public", + "site_admin": false + }, + "content_type": "application/vnd.android.package-archive", + "state": "uploaded", + "size": 41771730, + "digest": "sha256:7a7a031104f42a8314e3deed4febb670b515687ac500026f59a997dae772d950", + "download_count": 502, + "created_at": "2026-09-14T14:23:34Z", + "updated_at": "2026-09-14T14:23:39Z", + "browser_download_url": "https://github.com/damontecres/Wholphin/releases/download/v1.0.8/Wholphin-armeabi-v7a.apk" + }, + { + "url": "https://api.github.com/repos/damontecres/Wholphin/releases/assets/563552433", + "id": 563552433, + "node_id": "RA_kwDOPzwRps4hlyCx", + "name": "Wholphin-default-release-1.0.8-0-g7af7bc0a-59-arm64-v8a.apk", + "label": "", + "uploader": { + "login": "github-actions[bot]", + "id": 41898282, + "node_id": "MDM6Qm90NDE4OTgyODI=", + "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/github-actions%5Bbot%5D", + "html_url": "https://github.com/apps/github-actions", + "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", + "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", + "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", + "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", + "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", + "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", + "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", + "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", + "type": "Bot", + "user_view_type": "public", + "site_admin": false + }, + "content_type": "application/vnd.android.package-archive", + "state": "uploaded", + "size": 46041302, + "digest": "sha256:77504857a15050beed9a8e589b70c29773b5e6a3c6ae76deec79c10702129755", + "download_count": 170, + "created_at": "2026-09-14T14:23:34Z", + "updated_at": "2026-09-14T14:23:39Z", + "browser_download_url": "https://github.com/damontecres/Wholphin/releases/download/v1.0.8/Wholphin-default-release-1.0.8-0-g7af7bc0a-59-arm64-v8a.apk" + }, + { + "url": "https://api.github.com/repos/damontecres/Wholphin/releases/assets/563552428", + "id": 563552428, + "node_id": "RA_kwDOPzwRps4hlyCs", + "name": "Wholphin-default-release-1.0.8-0-g7af7bc0a-59-armeabi-v7a.apk", + "label": "", + "uploader": { + "login": "github-actions[bot]", + "id": 41898282, + "node_id": "MDM6Qm90NDE4OTgyODI=", + "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/github-actions%5Bbot%5D", + "html_url": "https://github.com/apps/github-actions", + "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", + "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", + "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", + "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", + "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", + "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", + "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", + "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", + "type": "Bot", + "user_view_type": "public", + "site_admin": false + }, + "content_type": "application/vnd.android.package-archive", + "state": "uploaded", + "size": 41771730, + "digest": "sha256:7a7a031104f42a8314e3deed4febb670b515687ac500026f59a997dae772d950", + "download_count": 143, + "created_at": "2026-09-14T14:23:34Z", + "updated_at": "2026-09-14T14:23:39Z", + "browser_download_url": "https://github.com/damontecres/Wholphin/releases/download/v1.0.8/Wholphin-default-release-1.0.8-0-g7af7bc0a-59-armeabi-v7a.apk" + }, + { + "url": "https://api.github.com/repos/damontecres/Wholphin/releases/assets/563552432", + "id": 563552432, + "node_id": "RA_kwDOPzwRps4hlyCw", + "name": "Wholphin-default-release-1.0.8-0-g7af7bc0a-59-x86_64.apk", + "label": "", + "uploader": { + "login": "github-actions[bot]", + "id": 41898282, + "node_id": "MDM6Qm90NDE4OTgyODI=", + "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/github-actions%5Bbot%5D", + "html_url": "https://github.com/apps/github-actions", + "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", + "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", + "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", + "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", + "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", + "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", + "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", + "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", + "type": "Bot", + "user_view_type": "public", + "site_admin": false + }, + "content_type": "application/vnd.android.package-archive", + "state": "uploaded", + "size": 53184555, + "digest": "sha256:74ba578c1059968c10c0884cc290d74a84793d9b78713beb0805baef7fb0a53f", + "download_count": 29, + "created_at": "2026-09-14T14:23:34Z", + "updated_at": "2026-09-14T14:23:42Z", + "browser_download_url": "https://github.com/damontecres/Wholphin/releases/download/v1.0.8/Wholphin-default-release-1.0.8-0-g7af7bc0a-59-x86_64.apk" + }, + { + "url": "https://api.github.com/repos/damontecres/Wholphin/releases/assets/563552634", + "id": 563552634, + "node_id": "RA_kwDOPzwRps4hlyF6", + "name": "Wholphin-default-release-1.0.8-0-g7af7bc0a-59.apk", + "label": "", + "uploader": { + "login": "github-actions[bot]", + "id": 41898282, + "node_id": "MDM6Qm90NDE4OTgyODI=", + "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/github-actions%5Bbot%5D", + "html_url": "https://github.com/apps/github-actions", + "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", + "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", + "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", + "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", + "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", + "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", + "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", + "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", + "type": "Bot", + "user_view_type": "public", + "site_admin": false + }, + "content_type": "application/vnd.android.package-archive", + "state": "uploaded", + "size": 124985861, + "digest": "sha256:9e569dcc4207a7817b777ffa1885cc42d22a85b5a96824a56b8ddc0f34cccd69", + "download_count": 78, + "created_at": "2026-09-14T14:23:39Z", + "updated_at": "2026-09-14T14:23:49Z", + "browser_download_url": "https://github.com/damontecres/Wholphin/releases/download/v1.0.8/Wholphin-default-release-1.0.8-0-g7af7bc0a-59.apk" + }, + { + "url": "https://api.github.com/repos/damontecres/Wholphin/releases/assets/563552644", + "id": 563552644, + "node_id": "RA_kwDOPzwRps4hlyGE", + "name": "Wholphin-x86_64.apk", + "label": "", + "uploader": { + "login": "github-actions[bot]", + "id": 41898282, + "node_id": "MDM6Qm90NDE4OTgyODI=", + "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/github-actions%5Bbot%5D", + "html_url": "https://github.com/apps/github-actions", + "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", + "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", + "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", + "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", + "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", + "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", + "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", + "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", + "type": "Bot", + "user_view_type": "public", + "site_admin": false + }, + "content_type": "application/vnd.android.package-archive", + "state": "uploaded", + "size": 53184555, + "digest": "sha256:74ba578c1059968c10c0884cc290d74a84793d9b78713beb0805baef7fb0a53f", + "download_count": 58, + "created_at": "2026-09-14T14:23:39Z", + "updated_at": "2026-09-14T14:23:44Z", + "browser_download_url": "https://github.com/damontecres/Wholphin/releases/download/v1.0.8/Wholphin-x86_64.apk" + }, + { + "url": "https://api.github.com/repos/damontecres/Wholphin/releases/assets/563552645", + "id": 563552645, + "node_id": "RA_kwDOPzwRps4hlyGF", + "name": "Wholphin.apk", + "label": "", + "uploader": { + "login": "github-actions[bot]", + "id": 41898282, + "node_id": "MDM6Qm90NDE4OTgyODI=", + "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/github-actions%5Bbot%5D", + "html_url": "https://github.com/apps/github-actions", + "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", + "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", + "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", + "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", + "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", + "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", + "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", + "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", + "type": "Bot", + "user_view_type": "public", + "site_admin": false + }, + "content_type": "application/vnd.android.package-archive", + "state": "uploaded", + "size": 124985861, + "digest": "sha256:9e569dcc4207a7817b777ffa1885cc42d22a85b5a96824a56b8ddc0f34cccd69", + "download_count": 3215, + "created_at": "2026-09-14T14:23:39Z", + "updated_at": "2026-09-14T14:23:47Z", + "browser_download_url": "https://github.com/damontecres/Wholphin/releases/download/v1.0.8/Wholphin.apk" + } + ], + "tarball_url": "https://api.github.com/repos/damontecres/Wholphin/tarball/v1.0.8", + "zipball_url": "https://api.github.com/repos/damontecres/Wholphin/zipball/v1.0.8", + "body": "<!-- Release notes generated using configuration in .github/release.yml at v1.0.8 -->\r\n\r\n## What's Changed\r\n\r\nThis release adds more media types to the favorites and search pages! There are also many bug fixes.\r\n\r\n### New Features & Enhancements\r\n* Add more searchable types & choose what to search in https://github.com/damontecres/Wholphin/pull/1857\r\n* Update favorites page with more types in https://github.com/damontecres/Wholphin/pull/1864\r\n* Add controls to edit playlists in https://github.com/damontecres/Wholphin/pull/1927\r\n### UI/UX Improvements\r\n* Add button to navigate to random item in https://github.com/damontecres/Wholphin/pull/1916\r\n* Add wrap around scrolling to settings pages in https://github.com/damontecres/Wholphin/pull/1937\r\n### Bug Fixes\r\n* Fix white flash on app start in https://github.com/damontecres/Wholphin/pull/1909\r\n* Fix image loading issues related to VPN and/or Fire OS 6 in https://github.com/damontecres/Wholphin/pull/1910\r\n* Fixes to default home view options for music & videos in https://github.com/damontecres/Wholphin/pull/1926\r\n* Fixes to Now Playing page in https://github.com/damontecres/Wholphin/pull/1915\r\n* Fixes to season/episode search with duplicates in https://github.com/damontecres/Wholphin/pull/1946\r\n* Configure playback controls for RTL in https://github.com/damontecres/Wholphin/pull/1945\r\n* Fix person credits: movie release dates, full filmography, sorted order by @SergeiAK in https://github.com/damontecres/Wholphin/pull/1950\r\n* Fixes to `v1.0.7` in https://github.com/damontecres/Wholphin/pull/1953\r\n* Fix OS screensaver starting during playback with dim enabled in https://github.com/damontecres/Wholphin/pull/1956\r\n* Fix favorites not querying for people & fix restoring focus on search page for discover items in https://github.com/damontecres/Wholphin/pull/1961\r\n### Other changes\r\n* Update API level to Android 17 in https://github.com/damontecres/Wholphin/pull/1908\r\n* Localized formatting for season and episode display by @tal-sarid in https://github.com/damontecres/Wholphin/pull/1884\r\n* Consolidate sending media reports & app logs in https://github.com/damontecres/Wholphin/pull/1936\r\n* Update dependencies in https://github.com/damontecres/Wholphin/pull/1952\r\n* Increase memory for CI builds in https://github.com/damontecres/Wholphin/pull/1954\r\n* Translations 2026-09-12 in https://github.com/damontecres/Wholphin/pull/1957\r\n\r\n## New Contributors\r\n* @SergeiAK made their first contribution in https://github.com/damontecres/Wholphin/pull/1950\r\n\r\n**Full Changelog**: https://github.com/damontecres/Wholphin/compare/v1.0.7...v1.0.8", + "reactions": { + "url": "https://api.github.com/repos/damontecres/Wholphin/releases/388459926/reactions", + "total_count": 34, + "+1": 10, + "-1": 0, + "laugh": 0, + "hooray": 6, + "confused": 0, + "heart": 10, + "rocket": 8, + "eyes": 0 + }, + "mentions_count": 2 +} diff --git a/app/src/test/resources/title-press-paths.json b/app/src/test/resources/title-press-paths.json new file mode 100644 index 00000000..14b28147 --- /dev/null +++ b/app/src/test/resources/title-press-paths.json @@ -0,0 +1,92 @@ +[ + { + "goal": "Round 3 · title page → nav", + "keys": [ + "OK", + "RIGHT", + "OK", + "UP" + ], + "presses": 4, + "note": "1 from the title page", + "start": "app launch: Who's watching with Justin focused (index.html:357)", + "replayed": "PASS", + "source": "index.html:2152-2174 PRESSES, replayed 2026-11-05", + "entryPresses": 3, + "title": "The Girl Who Leapt Through Time", + "end": "nav" + }, + { + "goal": "Round 3 · play Locke with version 2 (sample files)", + "keys": [ + "OK", + "DOWN", + "DOWN", + "DOWN", + "RIGHT", + "RIGHT", + "RIGHT", + "RIGHT", + "OK", + "RIGHT", + "OK", + "DOWN", + "OK", + "LEFT", + "OK" + ], + "presses": 15, + "note": "6 on the title page", + "start": "app launch: Who's watching with Justin focused (index.html:357)", + "replayed": "PASS", + "source": "index.html:2152-2174 PRESSES, replayed 2026-11-05", + "entryPresses": 9, + "title": "Locke", + "end": "play-version-2" + }, + { + "goal": "Round 3 · Locke → its first cast member’s page", + "keys": [ + "OK", + "DOWN", + "DOWN", + "DOWN", + "RIGHT", + "RIGHT", + "RIGHT", + "RIGHT", + "OK", + "DOWN", + "OK" + ], + "presses": 11, + "note": "2 on the title page: ↓ to Cast, OK", + "start": "app launch: Who's watching with Justin focused (index.html:357)", + "replayed": "PASS", + "source": "index.html:2152-2174 PRESSES, replayed 2026-11-05", + "entryPresses": 9, + "title": "Locke", + "end": "person-Tom Hardy" + }, + { + "goal": "Round 3 · switch person from a title page", + "keys": [ + "OK", + "RIGHT", + "OK", + "UP", + "LEFT", + "OK", + "RIGHT", + "OK" + ], + "presses": 8, + "note": "5 from the title page", + "start": "app launch: Who's watching with Justin focused (index.html:357)", + "replayed": "PASS", + "source": "index.html:2152-2174 PRESSES, replayed 2026-11-05", + "entryPresses": 3, + "title": "The Girl Who Leapt Through Time", + "end": "person-mindel" + } +] diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrCatalogue.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrCatalogue.kt new file mode 100644 index 00000000..00420a95 --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrCatalogue.kt @@ -0,0 +1,106 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.tv + +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.lastOrNull +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.justinvisser.wolfskin.conductarr.* +import org.justinvisser.wolfskin.conductarr.model.LibraryAudience +import org.justinvisser.wolfskin.conductarr.model.Options +import org.justinvisser.wolfskin.conductarr.model.Page +import org.justinvisser.wolfskin.design.* + +/** One lane can interleave movie and TV reads without exposing transport pages on screen. */ +internal data class CatalogueLaneQuery( + val id: String, + val name: String, + val media: List<MediaType>, + val sort: DiscoverSort = DiscoverSort.POPULAR, + val service: Long? = null, + val genres: Map<MediaType, Long> = emptyMap(), +) + +/** Per-person read-ahead keeps unvisited shelves as nonfocusable skeletons. */ +internal class ConductarrCatalogue(private val client: ConductarrClient, val queries: List<CatalogueLaneQuery>, private val audiences: () -> List<LibraryAudience>) { + private data class SourcePage(val number: Int, val last: Boolean, val titles: List<DiscoverTitle>, val failed: Boolean, val total: Int, val duplicateOnly: Boolean) + private val sources = mutableMapOf<String, SourcePage>() + private val visited = mutableSetOf<String>() + private val mutex = Mutex() + + suspend fun saved(): List<DiscoverLane> = mutex.withLock { + queries.forEach { load(it, ReadTrigger.CACHE_ONLY, false) } + lanes() + } + + suspend fun through(index: Int, force: Boolean = false): List<DiscoverLane> = mutex.withLock { + queries.take((index + 1).coerceIn(0, queries.size)).forEach { query -> + if (force || query.id !in visited) { + load(query, if (force) ReadTrigger.FORCE else ReadTrigger.IF_STALE, false) + visited.add(query.id) + } + } + lanes() + } + + suspend fun more(id: String): List<DiscoverLane> = mutex.withLock { + queries.firstOrNull { it.id == id }?.let { load(it, ReadTrigger.IF_STALE, true); visited.add(it.id) } + lanes() + } + + private suspend fun load(query: CatalogueLaneQuery, trigger: ReadTrigger, next: Boolean) = coroutineScope { + val updates = query.media.map { media -> async { + val key = "${query.id}:${media.name}" + val previous = sources[key] + if (next && previous?.last == true && !previous.failed) return@async key to previous + val page = if (next && previous != null && !previous.failed) previous.number + 1 else previous?.number ?: 1 + val call = TvRoutes.discover(media, page = page, genreId = query.genres[media], serviceId = query.service, sort = query.sort) + val result = client.read(call, if (previous?.failed == true && trigger != ReadTrigger.CACHE_ONLY) ReadTrigger.FORCE else trigger).lastOrNull() + val saved = when (result) { is ReadResult.Value -> result.saved.value; is ReadResult.Failure -> result.saved?.value; else -> null } + if (saved == null && trigger == ReadTrigger.CACHE_ONLY) return@async key to null + val items = saved?.items.orEmpty().map { it.discoverTitle(audiences()) } + val titles = if (page > 1) (previous?.titles.orEmpty() + items).distinctBy { it.key } else items + key to SourcePage(if (saved != null) page else previous?.number ?: 1, + saved != null && saved.page >= saved.totalPages, if (saved != null) titles else previous?.titles.orEmpty(), + result is ReadResult.Failure || saved?.state == Page.State.PARTIAL, + saved?.totalResults?.toInt() ?: previous?.total ?: 0, + next && saved != null && titles.size == previous?.titles?.size && saved.page < saved.totalPages) + } }.map { it.await() } + updates.forEach { (key, value) -> if (value != null) sources[key] = value } + } + + private fun lanes(): List<DiscoverLane> = queries.map { query -> + val pages = query.media.mapNotNull { sources["${query.id}:${it.name}"] } + val items = pages.flatMap { it.titles }.distinctBy { it.key } + val pending = pages.isEmpty() && query.id !in visited + DiscoverLane(query.id, query.name, items, total = pages.sumOf { it.total }, tail = when { + pages.any { it.failed } -> DiscoverTail.Retry + pages.any { it.duplicateOnly } -> DiscoverTail.ShowMore + pages.any { !it.last } -> DiscoverTail.SeeAll + else -> DiscoverTail.None + }, pending = pending, hasMore = pages.any { !it.last }) + } +} + +/** Option ids, not guessed service ids, determine every catalogue parameter. */ +internal fun catalogueQueries(options: List<Options>, service: DiscoverService? = null, type: String? = null): List<CatalogueLaneQuery> { + val media = if (type == null) MediaType.entries else listOf(if (type == "movie") MediaType.MOVIE else MediaType.TV) + val noun = if (type == "movie") "movies" else "series" + val genres = options.flatMap { option -> option.genres.orEmpty().map { it.name to (MediaType.valueOf(option.mediaType.name) to it.id) } } + .groupBy({ it.first }, { it.second }) + if (service != null) return listOf( + CatalogueLaneQuery("sv-pop", "Popular $noun", media, service = service.id), + CatalogueLaneQuery("sv-new", "New $noun", media, DiscoverSort.NEW, service.id), + CatalogueLaneQuery("sv-top", "Top rated $noun", media, DiscoverSort.TOP, service.id), + ) + genres.entries.filter { entry -> entry.value.any { it.first in media } }.take(3).map { (name, ids) -> + CatalogueLaneQuery("sv-g-$name", "$name $noun", media.filter { item -> ids.any { it.first == item } }, service = service.id, genres = ids.toMap()) + } + val services = options.flatMap { it.services.orEmpty() }.distinctBy { it.id } + return services.map { CatalogueLaneQuery("new-${it.id}", "New on ${it.name}", media, DiscoverSort.NEW, it.id) } + + CatalogueLaneQuery("trending", "Trending", media) + + listOf("Comedy", "Animation", "Science Fiction", "Horror", "Romance").mapNotNull { name -> + genres[name]?.let { ids -> CatalogueLaneQuery("genre-$name", name, + ids.map { it.first }.distinct(), genres = ids.toMap()) } + } + CatalogueLaneQuery("soon", "Coming soon", media, DiscoverSort.UPCOMING) +} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrDiscoverOperations.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrDiscoverOperations.kt new file mode 100644 index 00000000..c558e893 --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrDiscoverOperations.kt @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.tv + +import kotlinx.coroutines.flow.last +import kotlinx.coroutines.sync.withLock +import org.justinvisser.wolfskin.conductarr.ClientError +import org.justinvisser.wolfskin.conductarr.ConductarrClient +import org.justinvisser.wolfskin.conductarr.MediaType +import org.justinvisser.wolfskin.conductarr.MutationResult +import org.justinvisser.wolfskin.conductarr.ReadResult +import org.justinvisser.wolfskin.conductarr.TvRoutes +import org.justinvisser.wolfskin.conductarr.model.RecommendationHideInputBody +import org.justinvisser.wolfskin.conductarr.model.RequestIntentBody +import org.justinvisser.wolfskin.conductarr.model.RequestReceipt +import org.justinvisser.wolfskin.design.DiscoverOperations +import org.justinvisser.wolfskin.design.DiscoverRequest +import org.justinvisser.wolfskin.design.DiscoverRequestResult +import org.justinvisser.wolfskin.design.DiscoverSnapshot +import org.justinvisser.wolfskin.design.DiscoverTitle +import org.justinvisser.wolfskin.design.RequestPhase + +/** An injected signed-in client is the only real mutation path; the fixture host never constructs one. */ +class ConductarrDiscoverOperations( + private val client: ConductarrClient, + private val refreshSnapshot: suspend () -> DiscoverSnapshot, + private val profile: String? = null, + private val allowedRequesters: Set<String>? = null, + private val canRequest: Boolean = true, +) : DiscoverOperations { + private val submissions = mutableSetOf<Pair<String, String>>() + private val requestMutex = kotlinx.coroutines.sync.Mutex() + private val dismissalMutex = kotlinx.coroutines.sync.Mutex() + override suspend fun request(intent: DiscoverRequest): DiscoverRequestResult { + requestMutex.lock() + try { + val title = intent.title + if (!canRequest || allowedRequesters?.contains(intent.requester) == false || + (title.type == "tv" && title.seasons.isNullOrEmpty())) return DiscoverRequestResult(RequestPhase.Refused) + val key = title.key to intent.requester + val lookup = client.read(TvRoutes.requests(title.tmdb, title.mediaType()), org.justinvisser.wolfskin.conductarr.ReadTrigger.FORCE).last() + if (lookup is ReadResult.Failure) return DiscoverRequestResult(RequestPhase.NotConfirmed) + val existing = (lookup as ReadResult.Value).saved.value?.requests?.firstOrNull { + it.requester == intent.requester && (title.type != "tv" || it.seasons.orEmpty().containsAll(title.seasons.orEmpty().map(Int::toLong))) + } + if (existing != null) return existing.outcome() + if (!submissions.add(key)) return DiscoverRequestResult(RequestPhase.NotConfirmed) + val body = RequestIntentBody( + mediaType = if (title.type == "movie") RequestIntentBody.MediaType.MOVIE else RequestIntentBody.MediaType.TV, + recommendationProfile = (profile ?: intent.profile).takeIf { intent.fromRecommendation }, requester = intent.requester, + seasons = title.seasons?.map(Int::toLong), + source = if (intent.fromRecommendation) RequestIntentBody.Source.RECOMMENDATION else RequestIntentBody.Source.TITLE, + tmdbId = title.tmdb, + ) + return when (val result = client.mutate(TvRoutes.request(body))) { + is MutationResult.Success -> result.value.outcome() + is MutationResult.Failure -> DiscoverRequestResult(if (result.error == ClientError.Http(403)) RequestPhase.Refused else RequestPhase.NotConfirmed) + } + } finally { requestMutex.unlock() } + } + + override suspend fun check(intent: DiscoverRequest): DiscoverRequestResult { + return when (val result = client.read(TvRoutes.requests(intent.title.tmdb, intent.title.mediaType()), org.justinvisser.wolfskin.conductarr.ReadTrigger.FORCE).last()) { + is ReadResult.Value -> result.saved.value?.requests?.firstOrNull { + it.requester == intent.requester && (intent.title.type != "tv" || it.seasons.orEmpty().containsAll(intent.title.seasons.orEmpty().map(Int::toLong))) + }?.outcome() ?: DiscoverRequestResult(RequestPhase.NotConfirmed) + is ReadResult.Failure -> DiscoverRequestResult(RequestPhase.NotConfirmed) + } + } + + override suspend fun hide(profile: String, title: DiscoverTitle): Boolean = dismissalMutex.withLock { + val result = client.mutate(TvRoutes.hide(this.profile ?: profile, + RecommendationHideInputBody(if (title.type == "movie") RecommendationHideInputBody.MediaType.MOVIE else RecommendationHideInputBody.MediaType.TV, + title = title.title, tmdbId = title.tmdb, year = title.year?.toLong()))) + result is MutationResult.Success || observedHidden(profile, title) == true + } + + override suspend fun restore(profile: String, title: DiscoverTitle): Boolean = dismissalMutex.withLock { + val result = client.mutate(TvRoutes.restore(this.profile ?: profile, title.tmdb, title.mediaType())) + result is MutationResult.Success || observedHidden(profile, title) == false + } + + private suspend fun observedHidden(profile: String, title: DiscoverTitle): Boolean? { + return when (val result = client.read(TvRoutes.dismissals(this.profile ?: profile), org.justinvisser.wolfskin.conductarr.ReadTrigger.FORCE).last()) { + is ReadResult.Value -> result.saved.value?.dismissals?.any { + it.tmdbId == title.tmdb && it.mediaType.name == title.mediaType().name + } + is ReadResult.Failure -> null + } + } + override suspend fun refresh(): DiscoverSnapshot = refreshSnapshot() +} + +private fun DiscoverTitle.mediaType(): MediaType = if (type == "movie") MediaType.MOVIE else MediaType.TV +private fun RequestReceipt.outcome(): DiscoverRequestResult = DiscoverRequestResult( + phase = if (state == RequestReceipt.State.PENDING) RequestPhase.Pending else if (state == RequestReceipt.State.RETIRED) RequestPhase.Refused else RequestPhase.Receipt, + actor = actor.name, + requester = requester, +) diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrPreference.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrPreference.kt new file mode 100644 index 00000000..559b4365 --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrPreference.kt @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.tv + +import android.content.Context +import android.content.SharedPreferences +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.core.content.edit +import androidx.preference.PreferenceManager +import com.github.damontecres.wholphin.BuildConfig +import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import java.io.File +import javax.inject.Singleton +import kotlinx.coroutines.CoroutineScope +import org.justinvisser.wolfskin.conductarr.CacheEntry +import org.justinvisser.wolfskin.conductarr.CacheStore +import org.justinvisser.wolfskin.conductarr.FileCacheStore + +/** Advanced-only endpoint and fixture controls contain no credentials. */ +object ConductarrPreference { + private var preferences: SharedPreferences? = null + var baseUrl by mutableStateOf(BuildConfig.CONDUCTARR_BASE_URL) + private set + var fixtures by mutableStateOf(false) + private set + + fun initialize(context: Context) { + preferences = PreferenceManager.getDefaultSharedPreferences(context.applicationContext) + baseUrl = preferences?.getString("wolfskin.conductarr.url", null)?.takeIf { it.isNotBlank() } ?: BuildConfig.CONDUCTARR_BASE_URL + fixtures = BuildConfig.DEBUG && preferences?.getBoolean("wolfskin.fixtures", false) == true + } + + fun updateBaseUrl(value: String) { + val normalized = value.trim().ifBlank { BuildConfig.CONDUCTARR_BASE_URL } + baseUrl = normalized + preferences?.edit { putString("wolfskin.conductarr.url", normalized) } + } + + fun updateFixtures(value: Boolean) { + fixtures = BuildConfig.DEBUG && value + preferences?.edit { putBoolean("wolfskin.fixtures", fixtures) } + } +} + +/** Household snapshots must not enter Android backup or shared storage. */ +@Module +@InstallIn(SingletonComponent::class) +object ConductarrModule { + @Provides + @Singleton + fun session(@ApplicationContext context: Context, @IoCoroutineScope scope: CoroutineScope): ConductarrSession = + ConductarrSession(ConductarrDiskCache(context), scope) +} + +/** Android may create noBackupFilesDir, so resolve it only during an IO cache operation. */ +private class ConductarrDiskCache(context: Context) : CacheStore { + private val store by lazy { FileCacheStore(File(context.noBackupFilesDir, "conductarr")) } + override fun read(scope: String, key: String) = store.read(scope, key) + override fun write(scope: String, key: String, entry: CacheEntry, capacity: Int?) = store.write(scope, key, entry, capacity) + override fun remove(scope: String, key: String) = store.remove(scope, key) + override fun clear(scope: String) = store.clear(scope) +} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrProjection.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrProjection.kt new file mode 100644 index 00000000..3f76141f --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrProjection.kt @@ -0,0 +1,102 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.tv + +import org.justinvisser.wolfskin.conductarr.model.* +import org.justinvisser.wolfskin.design.* + +/** Relative TMDB art paths are not app or Conductarr-relative URLs. */ +internal fun catalogueImage(path: String?): String? = path?.takeIf { it.isNotBlank() }?.let { + if (it.startsWith("/")) "https://image.tmdb.org/t/p/w780$it" else it +} + +internal fun Candidate.discoverTitle(genres: Map<Long, String>): DiscoverTitle = DiscoverTitle( + tmdbId, mediaType.name.lowercase(), title, year.toInt(), genreIds.orEmpty().mapNotNull(genres::get), overview, + catalogueImage(posterPath), catalogueImage(backdropPath), score = score, +) + +internal fun Title.discoverTitle(audiences: List<LibraryAudience>): DiscoverTitle = DiscoverTitle( + tmdbId, mediaType.name.lowercase(), title, releaseDate?.take(4)?.toIntOrNull(), overview = overview, + poster = posterUrl, backdrop = backdropUrl, + heldBy = audiences.filter { audience -> audience.libraries.orEmpty().any { it in libraries.orEmpty() } }.mapTo(mutableSetOf()) { it.owner.lowercase() }, +) + +internal fun DiscoverSnapshot.homeRows(): List<HomeRow> { + if (status == DiscoverStatus.NeedsSignIn) return listOf(homeDiscoverState(HomeAvailability.SignedOut)) + if (status == DiscoverStatus.Loading) return listOf(HomeRow("catalogue-loading", HomeRowKind.Shelf, skeleton = true, catalogue = true)) + if (status == DiscoverStatus.Unavailable) return listOf(homeDiscoverState(HomeAvailability.Unavailable)) + val personal = listOf("movie" to "Movies for you", "tv" to "Series for you").mapNotNull { (type, label) -> + picks.filter { it.type == type }.takeIf { it.isNotEmpty() }?.let { items -> + HomeRow("pk-$type", HomeRowKind.Shelf, label, items.take(WolfskinTokens.Behaviour.rowLimit.toInt()).map { it.homeTitle() }, catalogue = true, total = items.size, allCards = items.map { it.homeTitle() }) + } + } + return personal + lanes.filterNot { it.id.startsWith("soon") }.mapNotNull { lane -> + if (lane.pending) HomeRow(lane.id, HomeRowKind.Shelf, lane.title, catalogue = true, skeleton = true) + else lane.titles.takeIf { it.isNotEmpty() }?.let { items -> HomeRow(lane.id, HomeRowKind.Shelf, lane.title, + items.take(WolfskinTokens.Behaviour.rowLimit.toInt()).map { it.homeTitle() }, catalogue = true, total = if (lane.hasMore) lane.total else items.size, allCards = items.map { it.homeTitle() }) } + } + HomeRow("explore", HomeRowKind.Explore) +} + +internal fun DiscoverTitle.homeTitle(): HomeTitle = HomeTitle( + id = key, title = title, type = type, year = year, runtimeSeconds = runtimeSeconds, seasons = seasonCount, + overview = overview, poster = poster, backdrop = backdrop, genres = genres, tmdbId = tmdb.toInt(), +) + +/** Activity is history; stage evidence must not turn a past dispatch into a live transfer. */ +internal fun RequestProgress.comingIn(): ComingInRequest { + val stagesByName = stages.orEmpty().associateBy { it.name } + val acquisition = stagesByName[ReadinessStage.Name.ACQUISITION] + val visibility = stagesByName[ReadinessStage.Name.VISIBILITY] + val playback = stagesByName[ReadinessStage.Name.PLAYBACK] + val stage = when { + acquisition?.summary == "The title was deleted after it was dispatched." -> ComingInStage.Removed + acquisition?.summary == "The title is not released yet; there is nothing to acquire." -> ComingInStage.NotOutYet + playback?.state == ReadinessStage.State.FAILED || playback?.state == ReadinessStage.State.CONFLICTING -> ComingInStage.FileHealth + visibility?.summary?.startsWith("The title is only in libraries this viewer does not see: ") == true -> ComingInStage.HeldElsewhere + visibility?.summary == "The title is not in a library this viewer sees." -> ComingInStage.NotInLibrary + visibility?.state == ReadinessStage.State.FAILED || visibility?.state == ReadinessStage.State.CONFLICTING -> ComingInStage.CheckNeeded + stagesByName[ReadinessStage.Name.REQUEST]?.state == ReadinessStage.State.FAILED || acquisition?.state == ReadinessStage.State.FAILED -> ComingInStage.Failed + visibility?.state == ReadinessStage.State.DONE -> ComingInStage.Ready + awaitingApproval == true || acquisition?.summary == "Waiting for Seerr's approval to reach dispatch." -> ComingInStage.Waiting + acquisition?.summary == "Dispatched; waiting for the Arr to import a file." -> ComingInStage.WaitingForFile + visibility?.state == ReadinessStage.State.IN_PROGRESS || visibility?.state == ReadinessStage.State.PARTIAL || acquisition?.state == ReadinessStage.State.DONE -> ComingInStage.AlmostReady + acquisition?.state == ReadinessStage.State.IN_PROGRESS && acquisition.summary.startsWith("Downloading", ignoreCase = true) -> ComingInStage.Downloading + acquisition?.state == ReadinessStage.State.NONE && acquisition.summary.startsWith("Searching", ignoreCase = true) -> ComingInStage.Searching + acquisition?.state == ReadinessStage.State.IN_PROGRESS || acquisition?.state == ReadinessStage.State.NONE -> ComingInStage.Waiting + else -> ComingInStage.Checking + } + val detail = when (stage) { + ComingInStage.FileHealth -> playback + ComingInStage.CheckNeeded, ComingInStage.HeldElsewhere, ComingInStage.NotInLibrary, ComingInStage.Ready -> visibility + ComingInStage.Failed -> stages.orEmpty().firstOrNull { it.state == ReadinessStage.State.FAILED } + else -> acquisition + } + return ComingInRequest(id, mediaType.name.lowercase(), tmdbId, title ?: "Title unavailable", requester.key, + requester.name, actor?.requester, actor?.name, stage, summary = detail?.summary, + readiness = ReadinessStage.Name.entries.map { stagesByName[it].readinessWord() }) +} + +/** Expanded readiness describes each observed stage, not a synthetic download failure. */ +private fun ReadinessStage?.readinessWord(): String = when { + this == null -> "Unknown" + name == ReadinessStage.Name.PLAYBACK && (state == ReadinessStage.State.FAILED || state == ReadinessStage.State.CONFLICTING) -> "Needs attention" + name == ReadinessStage.Name.VISIBILITY && (state == ReadinessStage.State.FAILED || state == ReadinessStage.State.CONFLICTING) -> "Check needed" + name == ReadinessStage.Name.ACQUISITION && summary == "The title was deleted after it was dispatched." -> "Removed" + name == ReadinessStage.Name.ACQUISITION && summary == "The title is not released yet; there is nothing to acquire." -> "Not out yet" + name == ReadinessStage.Name.ACQUISITION && summary.startsWith("Dispatched; the Arr has represented files") -> "Files present" + name == ReadinessStage.Name.ACQUISITION && summary == "Dispatched; waiting for the Arr to import a file." -> "Waiting for a file" + name == ReadinessStage.Name.VISIBILITY && summary.startsWith("The title is only in libraries this viewer does not see: ") -> "Another library" + name == ReadinessStage.Name.VISIBILITY && summary == "The title is not in a library this viewer sees." -> "Not in library" + name == ReadinessStage.Name.ACQUISITION && state == ReadinessStage.State.IN_PROGRESS && summary.startsWith("Downloading", ignoreCase = true) -> "Downloading" + name == ReadinessStage.Name.PLAYBACK && (state == ReadinessStage.State.DONE || state == ReadinessStage.State.PARTIAL) -> "Recorded play" + name == ReadinessStage.Name.PLAYBACK && state == ReadinessStage.State.UNKNOWN -> "Not checked yet" + else -> when (state) { + ReadinessStage.State.DONE -> "Done" + ReadinessStage.State.FAILED -> "Failed" + ReadinessStage.State.IN_PROGRESS -> "In progress" + ReadinessStage.State.PARTIAL -> "Partial" + ReadinessStage.State.NONE -> "Not yet" + ReadinessStage.State.UNKNOWN -> "Unknown" + ReadinessStage.State.STALE -> "Last observed" + ReadinessStage.State.CONFLICTING -> "Check needed" + } +} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrProvider.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrProvider.kt new file mode 100644 index 00000000..8196559c --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrProvider.kt @@ -0,0 +1,168 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.tv + +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.lastOrNull +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.justinvisser.wolfskin.conductarr.* +import org.justinvisser.wolfskin.conductarr.model.* +import org.justinvisser.wolfskin.design.* +import org.justinvisser.wolfskin.conductarr.MediaType + +/** Screen projections share the identity-scoped transport, never a second HTTP or credential path. */ +class ConductarrProvider(val client: ConductarrClient) : ConductarrTitleProvider by ClientTitleProvider(client) { + private var catalogue: ConductarrCatalogue? = null + private var options = emptyList<Options>() + private val services = mutableMapOf<String, ConductarrCatalogue>() + @Volatile private var libraryAudiences = emptyList<LibraryAudience>() + private var requestOperations: ConductarrDiscoverOperations? = null + private val profilesMutex = Mutex() + private var observedProfiles: RecommendationProfiles? = null + private val profileNames = MutableStateFlow<List<ReadProfile>>(emptyList()) + val requesterProfiles = profileNames.asStateFlow() + + /** One uncertain-submission ledger survives screen/navigation changes for this identity. */ + fun operations(operator: Operator): ConductarrDiscoverOperations = synchronized(this) { + requestOperations ?: ConductarrDiscoverOperations(client, { + profiles() + discover(operator.recommendationProfile, force = true).lastOrNull() + ?.copy(requestsAllowed = operator.capabilities.requestMedia) ?: DiscoverSnapshot(status = DiscoverStatus.Unavailable) + }, operator.recommendationProfile, operator.capabilities.requesters.orEmpty().toSet(), operator.capabilities.requestMedia) + .also { requestOperations = it } + } + fun discover(profile: String, force: Boolean = false): Flow<DiscoverSnapshot> = flow { + val saved = discoverSnapshot(profile, ReadTrigger.CACHE_ONLY) + if (saved.picks.isNotEmpty() || saved.lanes.any { it.titles.isNotEmpty() }) emit(saved) + emit(discoverSnapshot(profile, if (force) ReadTrigger.FORCE else ReadTrigger.OPEN)) + } + + private suspend fun discoverSnapshot(profile: String, trigger: ReadTrigger): DiscoverSnapshot = coroutineScope { + val reads = SnapshotReads(client, trigger) + val feedJob = async { reads.read(TvRoutes.feed(profile)) } + val hiddenJob = async { reads.read(TvRoutes.dismissals(profile)) } + val optionsJobs = MediaType.entries.map { media -> async { reads.read(TvRoutes.options(media), if (trigger == ReadTrigger.CACHE_ONLY) trigger else ReadTrigger.FOREGROUND) } } + val feed = feedJob.await() + val options = optionsJobs.mapNotNull { it.await() } + this@ConductarrProvider.options = options + val queries = catalogueQueries(options) + val loader = catalogue?.takeIf { it.queries == queries } ?: ConductarrCatalogue(client, queries) { libraryAudiences }.also { catalogue = it } + val lanes = if (trigger == ReadTrigger.CACHE_ONLY) loader.saved() else loader.through(1, force = trigger == ReadTrigger.FORCE) + val genres = options.flatMap { it.genres.orEmpty() }.associate { it.id to it.name } + val candidates = feed?.movies.orEmpty() + feed?.shows.orEmpty() + val picks = candidates.map { it.discoverTitle(genres) } + val seeds = candidates.flatMapIndexed { index, candidate -> + candidate.contributions.orEmpty().filter { !it.title.isNullOrBlank() }.map { it to picks[index] } + }.groupBy { (seed, _) -> "${seed.mediaType.name.lowercase()}:${seed.tmdbId}" }.map { (key, contributions) -> + DiscoverSeed(key, requireNotNull(contributions.first().first.title), contributions.map { it.second }, + watched = contributions.any { it.first.watched }, added = contributions.any { it.first.added }) + } + val hidden = hiddenJob.await()?.dismissals.orEmpty().map { + DiscoverTitle(it.tmdbId, it.mediaType.name.lowercase(), it.title, it.year.toInt(), poster = catalogueImage(it.posterPath)) + } + val status = when { + reads.needsSignIn -> DiscoverStatus.NeedsSignIn + feed == null && lanes.all { it.titles.isEmpty() } -> DiscoverStatus.Unavailable + feed?.outcome == RecommendationFeed.Outcome.NO_SEEDS -> DiscoverStatus.NoSeeds + feed == null || feed.state == RecommendationFeed.State.UNAVAILABLE -> DiscoverStatus.PicksUnavailable + else -> DiscoverStatus.Ready + } + DiscoverSnapshot(picks, seeds, options.flatMap { it.services.orEmpty() }.distinctBy { it.id }.map { DiscoverService(it.id, it.name, discoverServiceHue(it.name)) }, + lanes, hidden, status, reads.savedTime(), reachable = !reads.failed) + } + + /** Read two shelves ahead; pagination is keyed by the exact service, genre and media parameters. */ + suspend fun lanesThrough(index: Int): List<DiscoverLane> = catalogue?.through(index).orEmpty() + suspend fun more(lane: String): List<DiscoverLane> = catalogue?.more(lane).orEmpty() + suspend fun readAhead(lane: String?): List<DiscoverLane> { + val loader = catalogue ?: return emptyList() + val index = loader.queries.indexOfFirst { it.id == lane } + return loader.through(index + 2) + } + + suspend fun service(service: DiscoverService, type: String, through: Int = 1, more: String? = null): List<DiscoverLane> { + val key = "${service.id}:$type" + val loader = services.getOrPut(key) { ConductarrCatalogue(client, catalogueQueries(options, service, type)) { libraryAudiences } } + return if (more != null) loader.more(more) else loader.through(through) + } + + fun requests(): Flow<ComingInSnapshot> = flow { + for (trigger in listOf(ReadTrigger.CACHE_ONLY, ReadTrigger.FOREGROUND)) { + val reads = SnapshotReads(client, trigger) + val feed = reads.read(TvRoutes.activity()) + if (feed == null && trigger == ReadTrigger.CACHE_ONLY) continue + val artReads = SnapshotReads(client, trigger) + val items = coroutineScope { + feed?.items.orEmpty().map { item -> async { + val media = if (item.mediaType == RequestProgress.MediaType.MOVIE) MediaType.MOVIE else MediaType.TV + val art = artReads.read(TvRoutes.display(item.tmdbId, media), if (trigger == ReadTrigger.CACHE_ONLY) trigger else ReadTrigger.IF_STALE) + item.comingIn().copy(poster = art?.posterUrl) + } }.map { it.await() } + } + emit(ComingInSnapshot(items, + if (feed == null || reads.failed) ComingInStatus.Unavailable else ComingInStatus.Ready, + partial = feed?.truncated == true || reads.failed, signedIn = !reads.needsSignIn && !artReads.needsSignIn && feed != null, + savedAt = reads.savedTime())) + } + } + + suspend fun profiles(): RecommendationProfiles? = profilesMutex.withLock { + observedProfiles ?: SnapshotReads(client, ReadTrigger.LAUNCH).read(TvRoutes.profiles())?.also { + observedProfiles = it + profileNames.value = it.profiles.orEmpty() + } + } + /** Unavailable or empty metadata cannot replace Jellyfin's usable name-based classification. */ + suspend fun audiences(): List<LibraryAudience>? { + val reads = SnapshotReads(client, ReadTrigger.IF_STALE) + val observed = reads.read(TvRoutes.audiences())?.audiences?.takeIf { it.isNotEmpty() } + if (reads.failed || observed == null) return null + libraryAudiences = observed + return observed + } + + fun holders(title: Title): Set<String> = title.discoverTitle(libraryAudiences).heldBy +} + +/** Request recipients need an authorized profile, not a saved Jellyfin login on this TV. */ +internal fun ConductarrIdentity.requesters(profiles: List<ReadProfile>, saved: List<ShellPerson>): List<ShellPerson> { + val allowed = operator?.capabilities?.requesters.orEmpty() + return buildMap { + saved.filter { it.key in allowed }.forEach { put(it.key, it) } + profiles.filter { it.key in allowed }.forEach { profile -> + put(profile.key, ShellPerson(profile.key, profile.name, profile.name.take(1).uppercase(), false)) + } + }.values.toList() +} + +/** A batch tracks source failure without replacing saved bodies with empty success. */ +internal class SnapshotReads(private val client: ConductarrClient, private val trigger: ReadTrigger) { + @Volatile var failed = false + private set + @Volatile var needsSignIn = false + private set + private var oldestSaved: Long? = null + + suspend fun <T> read(call: ReadCall<T>, event: ReadTrigger = trigger): T? { + return when (val result = client.read(call, event).lastOrNull()) { + is ReadResult.Value -> result.saved.value + is ReadResult.Failure -> { + failed = true + if (result.error == ClientError.NeedsSignIn) needsSignIn = true + result.saved?.let { saved -> synchronized(this) { oldestSaved = minOf(oldestSaved ?: Long.MAX_VALUE, saved.savedAtMillis) } } + result.saved?.value + } + null -> null + } + } + + fun savedTime(): String? = oldestSaved?.let { DateTimeFormatter.ofPattern("HH:mm").format(Instant.ofEpochMilli(it).atZone(ZoneId.systemDefault())) } +} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrRatings.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrRatings.kt new file mode 100644 index 00000000..76533ebd --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrRatings.kt @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.tv + +import com.github.damontecres.wholphin.data.model.RtMark +import com.github.damontecres.wholphin.data.model.SourceRatings +import kotlinx.coroutines.flow.lastOrNull +import org.justinvisser.wolfskin.conductarr.* +import org.justinvisser.wolfskin.conductarr.model.Rating +import org.justinvisser.wolfskin.design.LibraryRating +import org.justinvisser.wolfskin.design.ScreenRatings + +/** Failed reads are not cached as absence; saved observations survive offline. */ +class ConductarrRatings(private val client: ConductarrClient) : ScreenRatings { + override suspend fun read(tmdb: Long, type: String, season: Int?, episode: Int?, cachedOnly: Boolean): List<LibraryRating>? { + val media = if (type == "movie") MediaType.MOVIE else MediaType.TV + val trigger = if (cachedOnly) ReadTrigger.CACHE_ONLY else ReadTrigger.IF_STALE + val ratings = when (val result = client.read(TvRoutes.ratings(tmdb, media), trigger).lastOrNull()) { + is ReadResult.Value -> result.saved.value?.ratings.orEmpty().screenRatings(type) + is ReadResult.Failure -> if (result.error == ClientError.NeedsSignIn) return emptyList() else result.saved?.value?.ratings?.screenRatings(type) ?: return null + null -> return null + } + if (type != "episode" || season == null || episode == null) return ratings + val result = client.read(TvRoutes.season(tmdb, season), trigger).lastOrNull() + if (result is ReadResult.Failure && result.error == ClientError.NeedsSignIn) return emptyList() + val detail = when (result) { is ReadResult.Value -> result.saved.value; is ReadResult.Failure -> result.saved?.value; else -> null } + val vote = detail?.episodes?.firstOrNull { it.number == episode.toLong() } + return ratings.withEpisode(vote?.voteAverage, vote?.voteCount) + } +} + +internal fun List<LibraryRating>.withEpisode(score: Double?, votes: Long?): List<LibraryRating> { + val observed = SourceRatings.episodeScore(score, votes) ?: return this + return filterNot { it.source == "tmdb" } + LibraryRating("tmdb", observed, episode = true) +} + +/** Use the app's existing source precedence and label rules, not a second interpretation. */ +internal fun List<Rating>.screenRatings(type: String): List<LibraryRating> { + val observed = filter { it.state == Rating.State.OBSERVED } + fun source(source: Rating.Source) = observed.firstOrNull { it.source == source } + val critics = source(Rating.Source.RT_CRITICS) + val audience = source(Rating.Source.RT_AUDIENCE) + val imdb = source(Rating.Source.IMDB).takeIf { type == "movie" } + val tmdb = source(Rating.Source.TMDB).takeIf { (it?.votes ?: 0) > 0 } + val ratings = SourceRatings.of(critics?.score, critics?.status, audience?.score, audience?.status, + imdb?.score, imdb?.votes, tmdb?.score, tmdb?.votes) + fun RtMark.label(): String? = when (this) { + RtMark.FRESH -> "Fresh"; RtMark.ROTTEN -> "Rotten"; RtMark.UPRIGHT -> "Upright"; RtMark.SPILLED -> "Spilled"; RtMark.UNKNOWN -> null + } + return buildList { + ratings.rtCritics?.let { add(LibraryRating("rtCritics", it.percent.toDouble(), it.mark.label())) } + ratings.rtAudience?.let { add(LibraryRating("rtAudience", it.percent.toDouble(), it.mark.label())) } + ratings.imdb?.let { add(LibraryRating("imdb", it)) } + ratings.tmdb?.let { add(LibraryRating("tmdb", it)) } + } +} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrSession.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrSession.kt new file mode 100644 index 00000000..5ba52caa --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrSession.kt @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.tv + +import java.io.Closeable +import java.security.MessageDigest +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import org.justinvisser.wolfskin.conductarr.* +import org.justinvisser.wolfskin.conductarr.model.Operator +import timber.log.Timber + +/** A selected Jellyfin login owns every provider and all in-memory observations. */ +data class ConductarrIdentity( + val person: String, + val client: ConductarrClient, + val provider: ConductarrProvider, + val operator: Operator? = null, + val error: ClientError? = null, + val signedIn: Boolean = false, +) + +/** Tokens are borrowed from upstream, never persisted or exposed as observable state. */ +class ConductarrSession( + private val cache: CacheStore, + private val disposalScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO), +) : Closeable { + private val mutableIdentity = MutableStateFlow<ConductarrIdentity?>(null) + val identity: StateFlow<ConductarrIdentity?> = mutableIdentity.asStateFlow() + private var selection: ByteArray? = null + @Volatile private var generation = 0L + + fun select(person: String, token: String?, baseUrl: String, retry: Boolean = false) { + val fingerprint = MessageDigest.getInstance("SHA-256") + .digest("$person\n$baseUrl\n${token.orEmpty()}".toByteArray()) + val previous: ConductarrClient? + val currentGeneration: Long + synchronized(this) { + if (!retry && selection?.contentEquals(fingerprint) == true) return + generation++ + currentGeneration = generation + previous = mutableIdentity.value?.client + mutableIdentity.value = null + selection = fingerprint + } + try { + val client = if (token.isNullOrBlank()) null else try { + ConductarrClient(baseUrl, token, PersonCacheStore(cache, person), timeouts = ClientTimeouts(3, 5, 8), onEvent = { event -> + if (event == ClientEvent.NEEDS_SIGN_IN) { + mutableIdentity.update { + if (generation == currentGeneration) it?.copy(operator = null, signedIn = false, error = ClientError.NeedsSignIn) else it + } + } + }) + } catch (_: IllegalArgumentException) { null } + synchronized(this) { + if (generation == currentGeneration) { + mutableIdentity.value = client?.let { ConductarrIdentity(person, it, ConductarrProvider(it)) } + } else dispose(client) + } + } finally { + dispose(previous) + } + } + + /** Disk identity paints first; a 401 always removes it, even after an earlier saved emission. */ + suspend fun authenticate() { + val selected = mutableIdentity.value ?: return + selected.client.read(TvRoutes.operator(), ReadTrigger.LAUNCH).collect { result -> + when (result) { + is ReadResult.Value -> mutableIdentity.update { + if (it?.client === selected.client) it.copy(operator = result.saved.value, signedIn = result.saved.value != null, error = null) else it + } + is ReadResult.Failure -> mutableIdentity.update { + if (it?.client !== selected.client) it + else if (result.error == ClientError.NeedsSignIn) it.copy(operator = null, signedIn = false, error = result.error) + else it.copy(error = result.error) + } + } + } + } + + /** Remove read authority synchronously; socket disposal finishes independently on IO. */ + @Synchronized + override fun close() { + generation++ + val previous = mutableIdentity.value?.client + mutableIdentity.value = null + selection = null + dispose(previous) + } + + private fun dispose(client: ConductarrClient?) { + if (client == null) return + // The application scope outlives the screen replaced by a person switch. + disposalScope.launch(Dispatchers.IO) { + try { + client.close() + } catch (_: Exception) { + Timber.w("Conductarr client disposal failed") + } + } + } +} + +/** Person ids partition disk entries even if a caller accidentally supplies the same credential twice. */ +private class PersonCacheStore(private val store: CacheStore, private val person: String) : CacheStore { + override fun read(scope: String, key: String) = store.read("$person:$scope", key) + override fun write(scope: String, key: String, entry: CacheEntry, capacity: Int?) = store.write("$person:$scope", key, entry, capacity) + override fun remove(scope: String, key: String) = store.remove("$person:$scope", key) + override fun clear(scope: String) = store.clear("$person:$scope") +} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrTitlePages.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrTitlePages.kt new file mode 100644 index 00000000..9030249d --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrTitlePages.kt @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.tv + +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import org.justinvisser.wolfskin.conductarr.* +import org.justinvisser.wolfskin.conductarr.model.* +import org.justinvisser.wolfskin.design.* + +/** Conductarr never grants playback: only a resolved Jellyfin page can supply held/play identities. */ +internal class ConductarrTitlePages(private val identity: ConductarrIdentity) { + fun title(tmdb: Long, media: MediaType, held: TitlePageData? = null): Flow<TitlePageData> = flow { + for (trigger in listOf(ReadTrigger.CACHE_ONLY, ReadTrigger.OPEN)) { + val page = titleSnapshot(tmdb, media, held, trigger) + if (trigger != ReadTrigger.CACHE_ONLY || page.status == TitleReadStatus.Ready) emit(page) + } + } + + private suspend fun titleSnapshot(tmdb: Long, media: MediaType, held: TitlePageData?, trigger: ReadTrigger): TitlePageData = coroutineScope { + val client = identity.client + val reads = SnapshotReads(client, trigger) + val profile = identity.operator!!.recommendationProfile + val detailJob = async { reads.read(TvRoutes.title(tmdb, media)) } + val castJob = async { reads.read(TvRoutes.cast(tmdb, media)) } + val relatedJob = async { reads.read(TvRoutes.related(tmdb, media, 1)) } + val enrichmentJob = async { reads.read(TvRoutes.enrichment(tmdb, media)) } + val displayJob = async { reads.read(TvRoutes.display(tmdb, media)) } + val ratingsJob = async { reads.read(TvRoutes.ratings(tmdb, media), if (trigger == ReadTrigger.CACHE_ONLY) trigger else ReadTrigger.IF_STALE) } + val readinessJob = async { reads.read(TvRoutes.readiness(tmdb, media, identity.operator.watchUser)) } + val receiptJob = async { reads.read(TvRoutes.requests(tmdb, media)) } + val feedJob = async { reads.read(TvRoutes.feed(profile), if (trigger == ReadTrigger.CACHE_ONLY) trigger else ReadTrigger.IF_STALE) } + val hiddenJob = async { reads.read(TvRoutes.dismissals(profile), if (trigger == ReadTrigger.CACHE_ONLY) trigger else ReadTrigger.IF_STALE) } + val seasonJobs = if (media == MediaType.TV && held != null) { + (listOfNotNull(held.selectedSeason) + listOfNotNull(held.playTarget, held.watchTarget).filter { it.kind == "episode" }.map { it.season }) + .distinct().associateWith { number -> async { reads.read(TvRoutes.season(tmdb, number)) } } + } else emptyMap() + val detail = detailJob.await() + val enrichment = enrichmentJob.await() + val display = displayJob.await() + val raw = detail?.title ?: enrichment?.title + val mediaName = media.name.lowercase() + val sourceItem = raw?.titleItem(identity.provider) ?: TitleItem("$mediaName:$tmdb", display?.title.orEmpty(), mediaName, poster = display?.posterUrl, tmdbId = tmdb) + val genres = detail?.genres ?: enrichment?.genres + val seasons = detail?.seasons ?: enrichment?.seasons + val cast = castJob.await() + val related = relatedJob.await()?.items.orEmpty().map { it.titleItem(identity.provider) } + val readiness = readinessJob.await() + val receipt = receiptJob.await()?.requests.orEmpty().filter { it.requester == identity.operator.requester || it.actor.requester == identity.operator.requester }.maxByOrNull { it.id } + val stages = readiness?.stages.orEmpty().takeIf { receipt?.requester == identity.operator.requester }.orEmpty() + val stage = when { + receipt == null -> null + stages.any { it.state == ReadinessStage.State.FAILED } -> "Failed" + stages.any { it.name == ReadinessStage.Name.VISIBILITY && it.state == ReadinessStage.State.DONE } -> "Ready" + stages.any { it.name == ReadinessStage.Name.VISIBILITY && it.state in listOf(ReadinessStage.State.PARTIAL, ReadinessStage.State.IN_PROGRESS) } -> "Almost ready" + stages.any { it.name == ReadinessStage.Name.ACQUISITION && it.state == ReadinessStage.State.IN_PROGRESS } -> "Downloading" + receipt?.state == RequestReceipt.State.SETTLED -> "Requested" + receipt != null -> "Checking" + else -> null + } + val feed = feedJob.await() + val hidden = hiddenJob.await()?.dismissals.orEmpty().any { it.tmdbId == tmdb && it.mediaType.name.lowercase() == mediaName } + val votes = seasonJobs.mapValues { (_, job) -> job.await()?.episodes.orEmpty().associateBy { it.number } } + val observedRatings = ratingsJob.await()?.ratings.orEmpty() + val ratings = if (reads.needsSignIn) emptyList() else observedRatings.screenRatings(mediaName) + val item = (held?.item ?: sourceItem).copy( + runtimeSeconds = held?.item?.runtimeSeconds?.takeIf { it > 0 } ?: ((detail?.runtime ?: enrichment?.runtime ?: 0) * 60), + genres = genres ?: held?.item?.genres.orEmpty(), seasons = held?.item?.seasons ?: seasons.orEmpty().size, + ratings = ratings, tmdbId = tmdb, + ) + fun rated(target: TitleItem): TitleItem { + val episode = votes[target.season]?.get(target.episode.toLong()) + return target.copy(tmdbId = tmdb, ratings = if (reads.needsSignIn) emptyList() else if (target.kind == "episode") ratings.withEpisode(episode?.voteAverage, episode?.voteCount) else ratings) + } + val base = held ?: TitlePageData(item) + base.copy(item = item, watchTarget = rated(base.watchTarget), playTarget = base.playTarget?.let(::rated), + cast = cast?.items?.sortedBy { it.order }?.map { TitlePerson(it.performerId.toString(), it.name, it.character, catalogueImage(it.profileUrl)) } ?: base.cast, + castStatus = if (cast == null && base.cast.isEmpty()) TitleReadStatus.Unavailable else TitleReadStatus.Ready, + related = related, episodes = base.episodes.map(::rated), + status = if (raw != null || held != null || display?.title != null) TitleReadStatus.Ready else TitleReadStatus.Unavailable, + connection = when { reads.needsSignIn -> TitleConnection.SignedOut; reads.failed || identity.error != null -> TitleConnection.Offline; else -> TitleConnection.SignedIn }, + requestedStage = stage, requestActor = receipt?.actor?.name, + requestFor = receipt?.takeIf { it.requester != identity.operator.requester }?.requesterUsername, + hidden = hidden, pick = (feed?.movies.orEmpty() + feed?.shows.orEmpty()).any { it.tmdbId == tmdb && it.mediaType.name.lowercase() == mediaName }, + savedAt = reads.savedTime() ?: held?.savedAt, + seasonNumbers = held?.seasonNumbers ?: seasons.orEmpty().map { it.number.toInt() }.filter { it > 0 }, + requestsAllowed = identity.operator.capabilities.requestMedia && !reads.failed && !reads.needsSignIn, + ) + } + + fun person(id: Long, page: Int = 1): Flow<ConductarrPersonPage> = flow { + for (trigger in listOf(ReadTrigger.CACHE_ONLY, ReadTrigger.OPEN)) { + val reads = SnapshotReads(identity.client, trigger) + val result = coroutineScope { + val detail = async { reads.read(TvRoutes.performer(id)) } + val credits = async { reads.read(TvRoutes.filmography(id, page)) } + detail.await() to credits.await() + } + val detail = result.first + val credits = result.second + if (trigger == ReadTrigger.CACHE_ONLY && detail?.name == null) continue + emit(ConductarrPersonPage(detail?.name?.let { TitlePerson(id.toString(), it, portrait = catalogueImage(detail.profileUrl), biography = detail.biography.orEmpty()) }, + credits?.items.orEmpty().map { it.title.titleItem(identity.provider).copy(character = it.credits.orEmpty().map { credit -> credit.character }.filter { it.isNotBlank() }.distinct().joinToString(" / ")) }, + credits != null && credits.page < (credits.totalPages ?: 1), reads.savedTime(), reads.needsSignIn, reads.failed)) + } + } +} + +internal data class ConductarrPersonPage(val person: TitlePerson?, val credits: List<TitleItem>, val hasMore: Boolean, val savedAt: String?, val needsSignIn: Boolean, val failed: Boolean) + +private fun Title.titleItem(provider: ConductarrProvider): TitleItem = TitleItem( + "${mediaType.name.lowercase()}:$tmdbId", title, mediaType.name.lowercase(), releaseDate?.take(4)?.toIntOrNull(), + overview = overview, poster = catalogueImage(posterUrl), backdrop = catalogueImage(backdropUrl), + otherOwners = provider.holders(this).toList(), tmdbId = tmdbId, +) + +internal fun TitlePageData.requestTitle(viewer: ShellPerson): DiscoverTitle = DiscoverTitle( + item.tmdbId ?: item.id.substringAfter(':').toLongOrNull() ?: error("Request needs a catalogue identity"), if (item.kind == "movie") "movie" else "tv", item.name, item.year, + genres = item.genres, overview = item.overview, poster = item.poster, backdrop = item.backdrop, + runtimeSeconds = item.runtimeSeconds.toInt(), seasonCount = item.seasons.takeIf { it > 0 }, + seasons = seasonNumbers.filter { it > 0 }.takeIf { it.isNotEmpty() }, ratings = item.ratings.map { DiscoverRating(it.source, it.score, it.label) }, + heldBy = item.otherOwners.toSet() + if (item.held) setOf(viewer.key) else emptySet(), +) diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrTitleProvider.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrTitleProvider.kt new file mode 100644 index 00000000..439bca3d --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrTitleProvider.kt @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.tv + +import kotlinx.coroutines.flow.Flow +import org.justinvisser.wolfskin.conductarr.* +import org.justinvisser.wolfskin.conductarr.model.* + +/** Titles can adopt these reads without importing transport or owning another login. */ +interface ConductarrTitleProvider { + fun detail(tmdb: Long, media: MediaType): Flow<ReadResult<Detail>> + fun cast(tmdb: Long, media: MediaType): Flow<ReadResult<TitleCast>> + fun related(tmdb: Long, media: MediaType, page: Int = 1): Flow<ReadResult<Page>> + fun season(tmdb: Long, number: Int): Flow<ReadResult<SeasonDetail>> + fun performer(id: Long): Flow<ReadResult<PerformerDetail>> + fun filmography(id: Long, page: Int = 1): Flow<ReadResult<FilmographyPage>> + fun activity(tmdb: Long, media: MediaType, viewer: String): Flow<ReadResult<ViewerReadiness>> + fun requests(tmdb: Long, media: MediaType): Flow<ReadResult<TitleRequestsBody>> + fun enrichment(tmdb: Long, media: MediaType): Flow<ReadResult<Enrichment>> + fun display(tmdb: Long, media: MediaType): Flow<ReadResult<Display>> +} + +/** All title lifetimes, conditional requests and 401 handling remain in the shared client. */ +internal class ClientTitleProvider(private val client: ConductarrClient) : ConductarrTitleProvider { + override fun detail(tmdb: Long, media: MediaType) = client.read(TvRoutes.title(tmdb, media), ReadTrigger.OPEN) + override fun cast(tmdb: Long, media: MediaType) = client.read(TvRoutes.cast(tmdb, media), ReadTrigger.OPEN) + override fun related(tmdb: Long, media: MediaType, page: Int) = client.read(TvRoutes.related(tmdb, media, page), ReadTrigger.OPEN) + override fun season(tmdb: Long, number: Int) = client.read(TvRoutes.season(tmdb, number), ReadTrigger.OPEN) + override fun performer(id: Long) = client.read(TvRoutes.performer(id), ReadTrigger.OPEN) + override fun filmography(id: Long, page: Int) = client.read(TvRoutes.filmography(id, page), ReadTrigger.OPEN) + override fun activity(tmdb: Long, media: MediaType, viewer: String) = client.read(TvRoutes.readiness(tmdb, media, viewer), ReadTrigger.OPEN) + override fun requests(tmdb: Long, media: MediaType) = client.read(TvRoutes.requests(tmdb, media), ReadTrigger.FORCE) + override fun enrichment(tmdb: Long, media: MediaType) = client.read(TvRoutes.enrichment(tmdb, media), ReadTrigger.OPEN) + override fun display(tmdb: Long, media: MediaType) = client.read(TvRoutes.display(tmdb, media), ReadTrigger.OPEN) +} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/DiscoverHost.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/DiscoverHost.kt new file mode 100644 index 00000000..ee29266b --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/DiscoverHost.kt @@ -0,0 +1,126 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.tv + +import android.content.Context +import androidx.compose.runtime.* +import androidx.compose.ui.platform.LocalContext +import com.github.damontecres.wholphin.BuildConfig +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.lastOrNull +import kotlinx.coroutines.withContext +import org.justinvisser.wolfskin.conductarr.* +import org.justinvisser.wolfskin.design.* + +internal val LocalDiscoverState = compositionLocalOf<DiscoverState> { error("Discover requires the Wolfskin shell") } + +/** Production uses the selected login; explicit fixtures remain deterministic and network-free. */ +@Composable +internal fun rememberDiscoverState( + shell: ShellState, + push: (String) -> Unit, + pop: () -> Unit, + loadFixture: suspend (Context) -> DiscoverFixture = { context -> withContext(Dispatchers.IO) { readDiscoverFixture(context) } }, + identity: ConductarrIdentity? = null, + fixtures: Boolean = BuildConfig.DEBUG, + retrySignIn: suspend () -> Unit = {}, +): DiscoverState { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val servicePages = remember(identity?.client, shell.person.key) { mutableStateMapOf<String, List<DiscoverLane>>() } + val profileNames = identity?.provider?.requesterProfiles?.collectAsState()?.value.orEmpty() + val requesters = remember(profileNames, shell.people, identity?.operator, fixtures) { + if (fixtures) shell.people else identity?.requesters(profileNames, shell.people).orEmpty() + } + LaunchedEffect(identity?.client, identity?.signedIn, fixtures) { + if (!fixtures && identity?.signedIn == true) identity.provider.profiles() + } + val refresh: suspend () -> DiscoverSnapshot = { + if (identity?.signedIn == true) identity.provider.discover(identity.operator!!.recommendationProfile, force = true).lastOrNull()?.copy(requestsAllowed = identity.operator.capabilities.requestMedia) ?: DiscoverSnapshot(status = DiscoverStatus.Unavailable) + else { retrySignIn(); DiscoverSnapshot() } + } + val state = remember(shell, shell.person.key, identity?.client, identity?.signedIn, identity?.error, fixtures) { + val operations = identity?.takeIf { it.signedIn }?.let { it.provider.operations(it.operator!!) } + ?: SignedOutDiscoverOperations(refresh) + lateinit var loaded: DiscoverState + loaded = DiscoverState(shell, DiscoverSnapshot(status = if (fixtures || identity?.signedIn == true) DiscoverStatus.Loading else if (identity?.error != null && identity.error != ClientError.NeedsSignIn) DiscoverStatus.Unavailable else DiscoverStatus.NeedsSignIn), operations, scope, push, pop, + { service, type -> servicePages["${service.id}:$type"].orEmpty() }, + requesters = requesters, + onMore = if (fixtures || identity?.signedIn != true) null else { lane -> + val service = loaded.service + if (service == null) identity.provider.more(lane) + else { + servicePages["${service.id}:${loaded.filter}"] = identity.provider.service(service, loaded.filter, more = lane) + loaded.snapshot.lanes + } + }) + loaded + } + SideEffect { if (!fixtures) state.updateRequesters(requesters) } + var fixtureState by remember(shell, shell.person.key, fixtures) { mutableStateOf<DiscoverState?>(null) } + LaunchedEffect(state, fixtures) { + if (fixtures) { + val fixture = loadFixture(context) + val snapshot = fixture.snapshot(shell.person.key) + val loaded = DiscoverState(shell, snapshot, DebugDiscoverOperations(snapshot, shell.person.name), scope, push, pop, fixture::service) + fixtureState = loaded + if (shell.route == "discover" && (!shell.navFocused || shell.navIndex == shell.currentTab)) loaded.focus(if (loaded.hasHero || !loaded.canRequest) 0 else 1) + } else if (identity?.signedIn == true) { + identity.provider.discover(identity.operator!!.recommendationProfile).collect { state.updateSnapshot(it.copy(requestsAllowed = identity.operator.capabilities.requestMedia)) } + } + } + LaunchedEffect(state.service?.id, state.filter, state) { + val service = state.service ?: return@LaunchedEffect + if (!fixtures && identity?.signedIn == true) { + servicePages["${service.id}:${state.filter}"] = identity.provider.service(service, state.filter) + state.invalidateLanes() + } + } + LaunchedEffect(state, state.snapshot.status, state.service?.id, state.filter) { + if (!fixtures && identity?.signedIn == true && state.hasCatalogue) snapshotFlow { state.focusedLane?.id to state.column }.collect { (lane, column) -> + val service = state.service + if (service == null) { + state.updateLanes(identity.provider.readAhead(lane)) + val focused = state.focusedLane + if (focused != null && focused.hasMore && focused.tail == DiscoverTail.SeeAll && column >= focused.fullTitles.size - WolfskinTokens.Behaviour.nextPageCards) { + state.updateLanes(identity.provider.more(focused.id)) + } + } else { + val index = state.lanes.indexOfFirst { it.id == lane } + servicePages["${service.id}:${state.filter}"] = identity.provider.service(service, state.filter, through = index + 2) + val focused = state.focusedLane + if (focused != null && focused.hasMore && focused.tail == DiscoverTail.SeeAll && column >= focused.fullTitles.size - WolfskinTokens.Behaviour.nextPageCards) { + servicePages["${service.id}:${state.filter}"] = identity.provider.service(service, state.filter, more = focused.id) + } + state.invalidateLanes() + } + } + } + val result = if (fixtures) fixtureState ?: state else state + LaunchedEffect(shell.route, result) { + result.enterRoute(shell.route) + if (shell.route == "discover" && !fixtures && identity?.signedIn == true && result.snapshot.status != DiscoverStatus.Loading) result.refresh() + } + LaunchedEffect(result.snapshot.savedAt, shell.route) { if (shell.route.startsWith("discover")) shell.view.savedAt = result.snapshot.savedAt } + return result +} + +private fun readDiscoverFixture(context: Context): DiscoverFixture { + val json = context.assets.open("wolfskin-discover/fixture.json").bufferedReader().use { it.readText() } + return DiscoverFixture(json) { "file:///android_asset/wolfskin-discover/$it" } +} + +private class DebugDiscoverOperations(private val snapshot: DiscoverSnapshot, private val actor: String) : DiscoverOperations { + override suspend fun request(intent: DiscoverRequest) = DiscoverRequestResult(RequestPhase.Receipt, actor) + override suspend fun check(intent: DiscoverRequest) = DiscoverRequestResult(RequestPhase.NotConfirmed) + override suspend fun hide(profile: String, title: DiscoverTitle) = true + override suspend fun restore(profile: String, title: DiscoverTitle) = true + override suspend fun refresh() = snapshot +} + +private class SignedOutDiscoverOperations(private val retry: suspend () -> DiscoverSnapshot) : DiscoverOperations { + override suspend fun request(intent: DiscoverRequest) = DiscoverRequestResult(RequestPhase.Refused) + override suspend fun check(intent: DiscoverRequest) = DiscoverRequestResult(RequestPhase.NotConfirmed) + override suspend fun hide(profile: String, title: DiscoverTitle) = false + override suspend fun restore(profile: String, title: DiscoverTitle) = false + override suspend fun refresh() = retry() +} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/HomeDestination.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/HomeDestination.kt new file mode 100644 index 00000000..35f10b1e --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/HomeDestination.kt @@ -0,0 +1,247 @@ +package org.justinvisser.wolfskin.tv + +import android.content.Context +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.data.model.JellyfinServer +import com.github.damontecres.wholphin.data.model.JellyfinUser +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.ui.nav.Destination +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import javax.inject.Inject +import kotlinx.coroutines.CancellationException +import com.github.damontecres.wholphin.util.WholphinDispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.coroutines.coroutineScope +import org.justinvisser.wolfskin.tv.read.playbackPositionMs +import org.justinvisser.wolfskin.design.* +import org.justinvisser.wolfskin.tv.read.HomeLibraryCache +import org.justinvisser.wolfskin.tv.read.HomeReadFacade + +/** The Home entry owns its source reads and upstream navigation, never the design module. */ +@HiltViewModel +class WolfskinHomeViewModel @Inject constructor( + @param:ApplicationContext private val context: Context, + private val home: HomeReadFacade, + private val cache: HomeLibraryCache, + private val navigation: NavigationManager, + private val seeAll: WolfskinSeeAllLists, + private val conductarr: ConductarrSession, +) : ViewModel() { + val conductarrState get() = conductarr.identity + val audiences get() = home.audiences + var snapshot by mutableStateOf(HomeSnapshot(buildHomeRows(HomeLibrary(), emptyList(), loading = true), HomeAvailability.Loading)) + private set + var toast by mutableStateOf<String?>(null) + private set + private var catalogueJob: kotlinx.coroutines.Job? = null + private var sourceScope: String? = null + private var loadRevision = 0L + private var catalogueSnapshot = DiscoverSnapshot() + private var publishCatalogue: ((DiscoverSnapshot) -> Unit)? = null + + suspend fun load(server: JellyfinServer, user: JellyfinUser, person: ShellPerson, retry: Boolean = false) { + catalogueJob?.cancel() + withContext(WholphinDispatchers.IO) { debugHomeSnapshot(context, person.key) }?.let { snapshot = it; return } + if (retry && conductarr.identity.value?.signedIn != true) { + withContext(WholphinDispatchers.IO) { + conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl, retry = true) + } + } + loadLibrary(server, user, person, retry) + } + + /** The authenticated read path is shared by release and offline fixture-driven tests. */ + internal suspend fun loadLibrary(server: JellyfinServer, user: JellyfinUser, person: ShellPerson, retry: Boolean = false) { + val revision = ++loadRevision + val matchesUser = person.jellyfinUserId?.let { it == user.id.toString() } ?: + (person.key == user.name.orEmpty().lowercase() || (conductarr.identity.value?.person == "${server.id}:${user.id}" && person.key == conductarr.identity.value?.operator?.requester)) + if (!matchesUser) { + snapshot = HomeSnapshot(buildHomeRows(HomeLibrary(), listOf(homeDiscoverState(HomeAvailability.SignedOut)), libraryAvailable = false), jellyfinAvailable = false) + return + } + val scope = "${server.id}:${user.id}" + if (sourceScope != scope) { + sourceScope = scope + toast = null + snapshot = HomeSnapshot(buildHomeRows(HomeLibrary(), emptyList(), loading = true), HomeAvailability.Loading) + } + val saved = withContext(WholphinDispatchers.IO) { try { cache.read(scope) } catch (_: java.io.IOException) { null } } + if (revision != loadRevision) return + var libraryCopy = saved?.first ?: HomeLibrary() + var libraryAvailable = true + var librarySavedAt: String? = null + var watchReady = false + val identity = conductarr.identity.value + var catalogueCopy = DiscoverSnapshot(status = if (identity?.signedIn == true || (retry && identity != null)) DiscoverStatus.Loading else DiscoverStatus.NeedsSignIn) + fun publish() { + if (revision != loadRevision || sourceScope != scope) return + val status = when (catalogueCopy.status) { + DiscoverStatus.NeedsSignIn -> HomeAvailability.SignedOut + DiscoverStatus.Loading -> HomeAvailability.Loading + DiscoverStatus.Unavailable -> HomeAvailability.Unavailable + else -> if (catalogueCopy.savedAt != null) HomeAvailability.Saved else HomeAvailability.Ready + } + snapshot = HomeSnapshot(buildHomeRows(libraryCopy, catalogueCopy.homeRows(), libraryAvailable = libraryAvailable).map { + if (it.kind == HomeRowKind.Hero) it.copy(watchReady = watchReady) else it + }, status, libraryAvailable, catalogueCopy.savedAt ?: librarySavedAt) + } + catalogueSnapshot = catalogueCopy + publishCatalogue = { value -> if (revision == loadRevision && sourceScope == scope) { catalogueCopy = value; catalogueSnapshot = value; publish() } } + if (saved != null) publish() + if (identity != null && (identity.signedIn || retry)) catalogueJob = viewModelScope.launch { + if (retry && !identity.signedIn) conductarr.authenticate() + val source = conductarr.identity.value + if (source == null || source.client !== identity.client) return@launch + if (!source.signedIn) { + publishCatalogue?.invoke(DiscoverSnapshot(status = if (source.error == org.justinvisser.wolfskin.conductarr.ClientError.NeedsSignIn) DiscoverStatus.NeedsSignIn else DiscoverStatus.Unavailable)) + return@launch + } + source.provider.discover(source.operator!!.recommendationProfile, retry).collect { catalogue -> + if (revision == loadRevision && conductarr.identity.value?.client === identity.client) publishCatalogue?.invoke(catalogue) + } + } + suspend fun save() { + val library = libraryCopy + withContext(WholphinDispatchers.IO) { + try { cache.write(scope, library, System.currentTimeMillis()) } catch (_: java.io.IOException) { /* Disk failure cannot revoke fresh watch data. */ } + } + } + coroutineScope { + launch { + try { + val watch = withContext(WholphinDispatchers.IO) { home.readWatch(user.id) } + if (revision != loadRevision) return@launch + libraryCopy = libraryCopy.copy(resume = watch.resume, nextUp = watch.nextUp) + watchReady = true + libraryAvailable = true + publish() + } catch (exception: Exception) { + if (exception is CancellationException) throw exception + if (revision != loadRevision) return@launch + libraryAvailable = false + librarySavedAt = saved?.second?.let { SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(it)) } + publish() + if (retry) showToast(RecoveryMessage.StillUnreachable) + } + } + launch { + try { + val shelves = withContext(WholphinDispatchers.IO) { home.readLibrary(user.id) } + if (revision != loadRevision) return@launch + libraryCopy = libraryCopy.copy(newest = shelves.newest, destinations = shelves.destinations, destinationTotals = shelves.destinationTotals) + publish() + } catch (exception: Exception) { + if (exception is CancellationException) throw exception + if (revision == loadRevision && retry) showToast(RecoveryMessage.StillUnreachable) + } + } + } + if (revision == loadRevision) save() + } + + suspend fun readAhead(lane: String?) { + val identity = conductarr.identity.value ?: return + if (!identity.signedIn || ConductarrPreference.fixtures) return + val revision = loadRevision + val lanes = identity.provider.readAhead(lane) + if (revision == loadRevision && conductarr.identity.value?.client === identity.client) publishCatalogue?.invoke(catalogueSnapshot.copy(lanes = lanes)) + } + + fun act(action: HomeAction, shell: ShellState, server: JellyfinServer, user: JellyfinUser) { + when (action) { + HomeAction.Discover -> openRoot(shell, "discover") + HomeAction.Retry -> viewModelScope.launch { load(server, user, shell.person, retry = true) } + is HomeAction.SeeAll -> if (action.row.target in shell.tabs) openRoot(shell, action.row.target!!) else openCatalogueGrid(action.row, shell) + is HomeAction.Details -> navigation.navigateTo(Destination.Wolfskin(titleRoute(action.title.id))) + is HomeAction.Play -> viewModelScope.launch { + val personKey = shell.person.key + val selectedId = shell.person.jellyfinUserId + val matchesUser = if (selectedId != null) selectedId == user.id.toString() else + shell.person.key == user.name.orEmpty().lowercase() || (conductarr.identity.value?.person == "${server.id}:${user.id}" && shell.person.key == conductarr.identity.value?.operator?.requester) + if (!snapshot.jellyfinAvailable || !matchesUser) { + showToast(RecoveryMessage.PlaybackUnavailable) + return@launch + } + try { + val item = withContext(WholphinDispatchers.IO) { home.preparePlayback(user.id, action.title) } + if (item == null) showToast(RecoveryMessage.PlaybackUnavailable) + else if (shell.person.key == personKey && (sourceScope == null || sourceScope == "${server.id}:${user.id}")) navigation.navigateTo(Destination.Playback(item.id, item.playbackPositionMs())) + } catch (exception: Exception) { + if (exception is CancellationException) throw exception + showToast(RecoveryMessage.PlaybackUnavailable) + } + } + } + } + + private fun openCatalogueGrid(row: HomeRow, shell: ShellState) { + val identity = conductarr.identity.value + val lane = catalogueSnapshot.lanes.firstOrNull { it.id == row.id } + val loadMore: (suspend (Int) -> LibraryPage)? = if (identity?.signedIn == true && lane?.hasMore == true) { _ -> + if (conductarr.identity.value?.client !== identity.client) throw java.io.IOException("Selected person changed") + val next = identity.provider.more(lane.id).first { it.id == lane.id } + if (next.tail == DiscoverTail.Retry) throw java.io.IOException("Catalogue unavailable") + LibraryPage(next.fullTitles.map { it.homeTitle().libraryTitle() }, next.hasMore) + } else null + seeAll.open(shell.person.key, row.id, row.title, row.allCards.map { it.libraryTitle() }, navigation, loadMore) + } + + private fun openRoot(shell: ShellState, route: String) { + shell.enterDestination(route) + shell.focusNav(shell.tabs.indexOf(route)) + shell.key(ShellKey.Ok) + } + + private fun showToast(message: String) { + toast = message + viewModelScope.launch { delay(WolfskinTokens.Motion.toast.toLong()); if (toast == message) toast = null } + } +} + +/** Complete catalogue projections feed the shared See all grid without another source read. */ +internal fun HomeTitle.libraryTitle(): LibraryTitle = LibraryTitle( + id = id, title = title, type = type, year = year, runtimeSeconds = runtimeSeconds?.toLong(), seasons = seasons, + poster = poster, played = played, progress = runtimeSeconds?.let { if (it > 0) (resumeSeconds / it).toFloat() else 0f } ?: 0f, + addedAt = addedAt, tmdbId = tmdbId?.toString(), + availability = if (held) "In your library" else otherOwner?.let { "In $it’s library" }.orEmpty(), + badge = if (held) "held" else null, + ratings = ratings.map { rating -> + when (rating.mark) { + "fresh" -> LibraryRating("rtCritics", rating.score, "Fresh") + "rotten" -> LibraryRating("rtCritics", rating.score, "Rotten") + "tomato" -> LibraryRating("rtCritics", rating.score) + "upright" -> LibraryRating("rtAudience", rating.score, "Upright") + "spilled" -> LibraryRating("rtAudience", rating.score, "Spilled") + else -> LibraryRating(rating.mark, rating.score) + } + }, +) + +/** The shell keeps its focus memory while playback or later-slice screens are pushed above Home. */ +@Composable +fun HomeDestination(shell: ShellState, server: JellyfinServer, user: JellyfinUser, modifier: Modifier = Modifier, model: WolfskinHomeViewModel = hiltViewModel()) { + val identity by model.conductarrState.collectAsState() + val watchRefresh = rememberWatchRefresh("${server.id}:${user.id}") + LaunchedEffect(shell.person, server.id, user.id, identity?.client, identity?.signedIn, model.audiences, watchRefresh) { model.load(server, user, shell.person) } + val view = shell.rememberedView("home") + LaunchedEffect(view.row, model.snapshot.rows) { + val lane = model.snapshot.rows.drop(view.row).take(3).lastOrNull { it.catalogue }?.id + model.readAhead(lane) + } + Box(modifier) { + val shown = if (!ConductarrPreference.fixtures && identity?.signedIn != true) model.snapshot.copy(conductarr = HomeAvailability.SignedOut) else model.snapshot + HomeScreen(shell, shown, Modifier.fillMaxSize()) { model.act(it, shell, server, user) } + model.toast?.let { RecoveryToast(it) } + } +} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/RequestsRefresh.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/RequestsRefresh.kt new file mode 100644 index 00000000..a678ca21 --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/RequestsRefresh.kt @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.tv + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.repeatOnLifecycle +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import org.justinvisser.wolfskin.design.ShellState +import org.justinvisser.wolfskin.design.WolfskinTokens + +/** Foreground refreshes once; only an open sheet or an unsettled request keeps polling. */ +@Composable +internal fun RequestsRefresh(state: ShellState, refresh: () -> Unit) { + val owner = LocalLifecycleOwner.current + LaunchedEffect(state, state.person.key, owner) { + owner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { + refreshRequestsWhileStarted(state, refresh) + } + } +} + +/** Cancellation on leaving STARTED stops polling; reentry performs a fresh foreground read. */ +internal suspend fun refreshRequestsWhileStarted(state: ShellState, refresh: () -> Unit) { + refresh() + while (currentCoroutineContext().isActive) { + delay(WolfskinTokens.Behaviour.requestsPollSeconds.toLong() * 1000L) + if (state.requestsVisible || state.requests.snapshot.hasUnsettledRequests) refresh() + } +} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ShellPeople.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ShellPeople.kt new file mode 100644 index 00000000..5984cb5a --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ShellPeople.kt @@ -0,0 +1,23 @@ +package org.justinvisser.wolfskin.tv + +import android.content.Context +import com.github.damontecres.wholphin.BuildConfig +import com.github.damontecres.wholphin.data.model.JellyfinUser +import org.json.JSONObject +import org.justinvisser.wolfskin.design.ShellPerson + +/** Prototype identities are packaged exclusively in debug assets, never release resources. */ +fun readShellPeople(context: Context, saved: List<JellyfinUser>): List<ShellPerson> { + if (ConductarrPreference.fixtures) { + val json = context.assets.open("wolfskin-shell.json").bufferedReader().use { JSONObject(it.readText()) } + val profiles = json.getJSONArray("profiles") + return List(profiles.length()) { index -> + val person = profiles.getJSONObject(index) + ShellPerson(person.getString("key"), person.getString("name"), person.getString("initial"), person.getJSONObject("destinations").has("Anime")) + } + } + return saved.map { user -> + val name = user.name.orEmpty() + ShellPerson(name.lowercase(), name, name.take(1).uppercase(), false, user.id.toString()) + } +} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ShellRequests.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ShellRequests.kt new file mode 100644 index 00000000..a5b32953 --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ShellRequests.kt @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.tv + +import android.content.Context +import com.github.damontecres.wholphin.BuildConfig +import org.justinvisser.wolfskin.design.ComingInSnapshot +import org.justinvisser.wolfskin.design.ComingInStatus +import org.justinvisser.wolfskin.design.readComingInFixture + +/** Release stays unavailable until Conductarr sign-in supplies an authenticated generated client. */ +fun readShellRequests(context: Context): ComingInSnapshot { + if (!BuildConfig.DEBUG) return ComingInSnapshot() + val json = context.assets.open("wolfskin-requests.json").bufferedReader().use { it.readText() } + val items = readComingInFixture(json) { name -> + context.assets.open("wolfskin-requests/$name").use { it.readBytes() } + } + return ComingInSnapshot(items.take(6), ComingInStatus.Ready, signedIn = true) +} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ShellRouting.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ShellRouting.kt new file mode 100644 index 00000000..dffa611b --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ShellRouting.kt @@ -0,0 +1,16 @@ +package org.justinvisser.wolfskin.tv + +import com.github.damontecres.wholphin.ui.nav.Destination +import org.jellyfin.sdk.model.api.BaseItemKind + +/** The rollout switch changes only intercepted screens; unrelated destinations always pass through. */ +fun resolveUpstreamDestination(destination: Destination, wolfskinEnabled: Boolean, libraries: Map<String, Destination>): Destination? = + when (destination) { + is Destination.Home -> if (wolfskinEnabled) null else destination + is Destination.MediaItem -> if (wolfskinEnabled && destination.type in TITLE_KINDS) null else destination + is Destination.Wolfskin -> if (!wolfskinEnabled && destination.route in LIBRARY_ROUTES) libraries[destination.route] else null + else -> destination + } + +private val LIBRARY_ROUTES = setOf("movies", "shows", "anime") +private val TITLE_KINDS = setOf(BaseItemKind.MOVIE, BaseItemKind.SERIES, BaseItemKind.EPISODE, BaseItemKind.PERSON) diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleDestination.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleDestination.kt new file mode 100644 index 00000000..4db5512d --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleDestination.kt @@ -0,0 +1,357 @@ +package org.justinvisser.wolfskin.tv + +import android.content.Context +import android.net.Uri +import androidx.activity.compose.BackHandler +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.foundation.layout.Box +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.type +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.justinvisser.wolfskin.design.DiscoverOverlay +import org.justinvisser.wolfskin.design.ShellKey +import org.justinvisser.wolfskin.design.readShellKey +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.BuildConfig +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.ui.nav.Destination +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import com.github.damontecres.wholphin.data.ServerRepository +import org.justinvisser.wolfskin.conductarr.MediaType +import org.justinvisser.wolfskin.design.PersonState +import org.justinvisser.wolfskin.design.PersonSurface +import org.justinvisser.wolfskin.design.RecoveryMessage +import org.justinvisser.wolfskin.design.ShellPerson +import org.justinvisser.wolfskin.design.ShellState +import org.justinvisser.wolfskin.design.TitleAction +import org.justinvisser.wolfskin.design.TitleConnection +import org.justinvisser.wolfskin.design.TitleItem +import org.justinvisser.wolfskin.design.TitlePageData +import org.justinvisser.wolfskin.design.TitleReadStatus +import org.justinvisser.wolfskin.design.TitleState +import org.justinvisser.wolfskin.design.TitleSurface +import org.justinvisser.wolfskin.tv.read.TitleReadFacade +import org.justinvisser.wolfskin.tv.read.playbackPositionMs +import java.util.UUID +import javax.inject.Inject + +/** Title and performer routes remain within the single existing Wolfskin destination subtype. */ +fun titleRoute(id: String): String = "title/${Uri.encode(id)}" +fun personRoute(id: String): String = "person/${Uri.encode(id)}" + +/** Each navigation entry owns its title focus and selections across nested person/title pages. */ +@HiltViewModel +class WolfskinTitleViewModel @Inject constructor( + @param:ApplicationContext private val context: Context, + private val facade: TitleReadFacade, + private val fixtureProvider: TitleFixtureProvider, + private val navigation: NavigationManager, + val conductarr: ConductarrSession, + private val repository: ServerRepository, +) : ViewModel() { + var title by mutableStateOf<TitleState?>(null) + private set + var person by mutableStateOf<PersonState?>(null) + private set + private var route = "" + private lateinit var viewer: ShellPerson + private lateinit var preferences: UserPreferences + private var fixture: TitleFixture? = null + private var personReadPending = false + private var personReadFinished = false + private var loadJob: Job? = null + private val saveMutex = Mutex() + private var performerPage = 1 + var conductarrAction: ((TitleAction) -> Unit)? = null + val watchScope: String get() = "${repository.currentServer?.id}:${repository.currentUser?.id}" + + fun openRoute(route: String) = navigation.navigateTo(Destination.Wolfskin(route)) + fun popRoute() = navigation.goBack() + private val fixtureSelections by lazy { context.getSharedPreferences("wolfskin-title-fixture", Context.MODE_PRIVATE) } + + /** Only an explicit preview/test choice admits assets; real catalogue ids use Conductarr. */ + fun load(destination: String, selected: ShellPerson, userPreferences: UserPreferences, fixtures: Boolean = BuildConfig.DEBUG, force: Boolean = false) { + if (!force && route == destination && ::viewer.isInitialized && viewer.key == selected.key) return + if (force && route == destination && ::viewer.isInitialized && viewer.key == selected.key && + title?.data?.status == TitleReadStatus.Ready && title?.data?.item?.held == true && + (title?.data?.connection != TitleConnection.SignedIn || conductarr.identity.value?.signedIn == true) && + !destination.startsWith("person/") && fixture == null) { + loadJob?.cancel() + preferences = userPreferences + title?.let { it.update(it.data.copy(watchReady = false)) } + loadJob = viewModelScope.launch { readTitle(Uri.decode(destination.substringAfter('/'))) } + return + } + route = destination + loadJob?.cancel() + person = null + fixture = null + personReadPending = false + personReadFinished = false + performerPage = 1 + viewer = selected + preferences = userPreferences + val id = Uri.decode(destination.substringAfter('/')) + title = TitleState(TitlePageData(TitleItem(id, "", if (destination.startsWith("person/")) "person" else "movie"), status = TitleReadStatus.Loading), viewer, ::handle) + loadJob = viewModelScope.launch { + if (fixtures && BuildConfig.DEBUG && id.toUuidOrNull() == null) { + val source = fixtureProvider.read() + fixture = source + if (destination.startsWith("person/")) { + val (actor, credits) = withContext(Dispatchers.IO) { source.person(id, selected.key) } + person = PersonState(actor, credits, ::handle) + } else { + val data = withContext(Dispatchers.IO) { source.title(id, selected.key) } + val key = "${viewer.key}:${data.item.id}" + val watchKey = "${viewer.key}:${data.watchTarget.id}" + title = TitleState(data.copy( + selectedVersion = fixtureSelections.getString("$key:version", null), + selectedAudio = if (fixtureSelections.contains("$key:audio")) fixtureSelections.getInt("$key:audio", -1) else null, + selectedSubtitle = fixtureSelections.getInt("$key:subtitle", -1), + watchTarget = data.watchTarget.copy(watched = fixtureSelections.getBoolean("$watchKey:watched", data.watchTarget.watched)), + hidden = fixtureSelections.getBoolean("$key:hidden", data.hidden), + ), viewer, ::handle) + } + } else if (destination.startsWith("person/")) readPerson(id) else readTitle(id) + } + } + + private suspend fun readTitle(id: String, retry: Boolean = false) { + val current = title ?: return + val uuid = id.toUuidOrNull() + var held: TitlePageData? = null + val catalogue = if (uuid == null) id.split(':').takeIf { it.size == 2 && it[0] in listOf("movie", "tv") && it[1].toLongOrNull() != null } else null + if (uuid == null && catalogue == null) { current.update(current.data.copy(status = TitleReadStatus.Unavailable)); return } + try { + val heldId = uuid ?: catalogue?.let { facade.findHeld(it[1].toLong(), it[0]) } + if (title !== current) return + if (heldId != null) { + if (current.data.status != TitleReadStatus.Ready) facade.readSaved(heldId, preferences)?.let { current.update(it) } + held = facade.read(heldId, current.requestedSeason, preferences) + current.update(held) + } + } catch (exception: Exception) { + if (exception is CancellationException) throw exception + if (current.data.status == TitleReadStatus.Ready) { + if (current.data.episodesStatus == TitleReadStatus.Loading) current.restoreSeason() + held = current.data + } else if (uuid != null) current.update(current.data.copy(status = TitleReadStatus.Unavailable)) + if (uuid != null && (current.data.status == TitleReadStatus.Ready || retry)) current.toast = RecoveryMessage.StillUnreachable + } + val identity = conductarr.identity.value?.takeIf { it.signedIn } + val tmdb = catalogue?.get(1)?.toLong() ?: held?.item?.tmdbId + if (identity != null && tmdb != null) { + val media = if ((catalogue?.get(0) ?: held?.item?.kind) == "movie") MediaType.MOVIE else MediaType.TV + ConductarrTitlePages(identity).title(tmdb, media, held).collect { data -> + if (title === current && conductarr.identity.value?.client === identity.client && conductarr.identity.value?.signedIn == true) current.update(data) + } + } else if (uuid == null) current.update(current.data.copy(status = TitleReadStatus.Unavailable, connection = TitleConnection.SignedOut, requestsAllowed = false)) + } + + private suspend fun readPerson(id: String, retry: Boolean = false) { + if (personReadPending || personReadFinished) return + if (id.toLongOrNull() != null) { readPerformer(id.toLong(), retry); return } + val uuid = id.toUuidOrNull() ?: return + val owner = viewer.key + personReadPending = true + try { + if (person == null) facade.readPersonSaved(uuid)?.let { saved -> + if (owner == viewer.key) person = newPerson(saved.person, saved.credits).apply { savedAt = saved.savedAt } + } + val start = if (person?.savedAt != null) 0 else person?.credits?.size ?: 0 + val result = facade.readPerson(uuid, start) + if (owner != viewer.key) return + val current = person + val credits = if (start == 0) result.credits else (current?.credits.orEmpty() + result.credits).distinctBy { it.id } + if (current == null) person = newPerson(result.person, credits) else current.update(result.person, credits) + personReadFinished = result.credits.size < 200 + } catch (exception: Exception) { + if (exception is CancellationException) throw exception + if (owner == viewer.key) { + person?.toast = RecoveryMessage.StillUnreachable + if (person == null) { + title?.update(title!!.data.copy(status = TitleReadStatus.Unavailable)) + if (retry) title?.toast = RecoveryMessage.StillUnreachable + } + } + } finally { if (owner == viewer.key) personReadPending = false } + } + + private suspend fun readPerformer(id: Long, retry: Boolean) { + val identity = conductarr.identity.value?.takeIf { it.signedIn } ?: run { + title?.let { it.update(it.data.copy(status = TitleReadStatus.Unavailable, connection = TitleConnection.SignedOut)) } + return + } + val currentTitle = title + personReadPending = true + var failed = false + try { + ConductarrTitlePages(identity).person(id, performerPage).collect { page -> + if (title !== currentTitle || conductarr.identity.value?.client !== identity.client || conductarr.identity.value?.signedIn != true) return@collect + if (page.person != null) { + val current = person + val credits = if (performerPage == 1) page.credits else (current?.credits.orEmpty() + page.credits).distinctBy { it.id } + if (current == null) person = PersonState(page.person, credits, ::handle) { viewModelScope.launch { readPerson(id.toString()) } } + person?.update(page.person, credits, page.savedAt) + if (page.failed && retry) person?.toast = RecoveryMessage.StillUnreachable + } else title?.update(requireNotNull(title).data.copy(status = TitleReadStatus.Unavailable)) + personReadFinished = !page.hasMore && !page.failed + failed = page.failed + } + if (!personReadFinished && !failed) performerPage++ + } finally { personReadPending = false } + } + + internal suspend fun retrySignIn() { + val user = repository.currentUser ?: return + val server = repository.currentServer ?: return + withContext(Dispatchers.IO) { + conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl, retry = true) + } + conductarr.authenticate() + } + + private fun newPerson(actor: org.justinvisser.wolfskin.design.TitlePerson, credits: List<TitleItem>) = + PersonState(actor, credits, ::handle) { viewModelScope.launch { readPerson(actor.id) } } + + private fun handle(action: TitleAction) { + when (action) { + TitleAction.Pop -> navigation.goBack() + is TitleAction.Person -> navigation.navigateTo(action.person.id.toUuidOrNull()?.let { Destination.MediaItem(it, org.jellyfin.sdk.model.api.BaseItemKind.PERSON) } ?: Destination.Wolfskin(personRoute(action.person.id))) + is TitleAction.OpenTitle -> { + val destination = action.item.id.toUuidOrNull()?.let { Destination.MediaItem(it, when (action.item.kind) { + "movie" -> org.jellyfin.sdk.model.api.BaseItemKind.MOVIE + "episode" -> org.jellyfin.sdk.model.api.BaseItemKind.EPISODE + else -> org.jellyfin.sdk.model.api.BaseItemKind.SERIES + }) } ?: Destination.Wolfskin(titleRoute(action.item.id)) + if (action.episodeSelection && title?.data?.watchTarget?.kind == "episode") navigation.replaceTop(destination) + else navigation.navigateTo(destination) + } + is TitleAction.Play -> { + val opened = title?.data?.watchTarget + play((if (action.restart || opened?.kind != "tv") action.item.id else opened.id).toUuidOrNull(), + restart = action.restart) + } + is TitleAction.Trailer -> if (action.url.startsWith("jellyfin:")) play(action.url.removePrefix("jellyfin:").toUuidOrNull(), restart = true) + else if (!TitleTrailer(context).open(action.url)) title?.toast = "No YouTube app is available to play this trailer." + is TitleAction.Version, is TitleAction.Track, is TitleAction.Watched -> savePlayback(action) + is TitleAction.Season -> { + loadJob?.cancel() + loadJob = viewModelScope.launch { + val current = title ?: return@launch + current.showSeasonLoading() + val source = fixture + if (source == null) readTitle(Uri.decode(route.substringAfter('/'))) + else { + val data = withContext(Dispatchers.IO) { source.title(Uri.decode(route.substringAfter('/')), viewer.key, action.number) } + current.update(data.copy(selectedVersion = current.versionId, selectedAudio = current.audioIndex, + selectedSubtitle = current.subtitleIndex, watchTarget = current.data.watchTarget.copy(watched = current.watched), hidden = current.data.hidden)) + } + } + } + TitleAction.Retry -> viewModelScope.launch { + if (conductarr.identity.value?.signedIn != true) retrySignIn() + personReadFinished = false + performerPage = 1 + if (fixture == null) { + if (route.startsWith("person/")) readPerson(Uri.decode(route.substringAfter('/')), retry = true) else readTitle(Uri.decode(route.substringAfter('/')), retry = true) + } + else title?.toast = RecoveryMessage.StillUnreachable + } + is TitleAction.Request, is TitleAction.Requests -> requireNotNull(conductarrAction).invoke(action) + is TitleAction.Hide -> if (fixture != null) fixtureSelections.edit().putBoolean("${viewer.key}:${action.item.id}:hidden", action.hidden).apply() + else requireNotNull(conductarrAction).invoke(action) + } + } + + private fun play(id: UUID?, restart: Boolean) { + val current = title + val playbackScope = watchScope + viewModelScope.launch { + try { + if (id == null) throw IllegalArgumentException("No Jellyfin playback identity") + val item = facade.preparePlayback(id) ?: throw IllegalArgumentException("No playable item") + if (title !== current || watchScope != playbackScope) return@launch + navigation.navigateTo(Destination.Playback(item.id, if (restart) 0L else item.playbackPositionMs())) + } catch (exception: Exception) { + if (exception is CancellationException) throw exception + title?.toast = RecoveryMessage.PlaybackUnavailable + } + } + } + + private fun savePlayback(action: TitleAction) { + val current = title ?: return + if (fixture != null) { + val edits = fixtureSelections.edit() + when (action) { + is TitleAction.Version -> edits.putString("${viewer.key}:${action.item.id}:version", action.version.id) + is TitleAction.Track -> { + action.audio?.let { edits.putInt("${viewer.key}:${action.item.id}:audio", it) } + edits.putInt("${viewer.key}:${action.item.id}:subtitle", action.subtitle) + } + is TitleAction.Watched -> edits.putBoolean("${viewer.key}:${action.item.id}:watched", action.watched) + else -> Unit + } + edits.apply() + current.confirmPlayback(action) + return + } + viewModelScope.launch { + saveMutex.withLock { + try { facade.apply(action); current.confirmPlayback(action) } + catch (exception: Exception) { + if (exception is CancellationException) throw exception + current.rejectPlayback(action) + current.toast = "Couldn’t save that change. Try again." + } + } + } + } +} + +/** The screen's own Back handler closes its sheet before delegating a navigation pop. */ +@Composable +fun TitleDestination(route: String, shell: ShellState, preferences: UserPreferences, modifier: Modifier = Modifier, viewModel: WolfskinTitleViewModel = hiltViewModel(), fixtures: Boolean = ConductarrPreference.fixtures) { + val identity by viewModel.conductarr.identity.collectAsStateWithLifecycle() + val watchRefresh = rememberWatchRefresh(viewModel.watchScope) + LaunchedEffect(route, shell.person.key, identity?.client, identity?.signedIn, fixtures, watchRefresh) { viewModel.load(route, shell.person, preferences, fixtures, force = true) } + val title = viewModel.title?.takeIf { it.person.key == shell.person.key } + val requests = if (route.startsWith("title/")) rememberTitleRequests(viewModel, shell, route, identity, fixtures, title) else null + LaunchedEffect(title?.data?.status) { + if (title?.data?.status == TitleReadStatus.Ready) shell.navFocused = false + } + val person = viewModel.person.takeIf { title != null } + BackHandler(enabled = title != null) { + if (requests?.overlay != null) requests.key(ShellKey.Back) else title?.back(shell) + } + Box(modifier.onPreviewKeyEvent { event -> + if (requests?.overlay == null || shell.pickerVisible) false else readShellKey(event)?.let { + if (event.type == KeyEventType.KeyDown && event.nativeKeyEvent.repeatCount == 0) requests.key(it) + true + } ?: false + }) { + if (person != null && route.startsWith("person/")) PersonSurface(person, shell) + else if (title != null) TitleSurface(title, shell) + if (requests != null) DiscoverOverlay(requests) + } +} + +private fun String.toUuidOrNull(): UUID? = try { UUID.fromString(this) } catch (_: IllegalArgumentException) { null } diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleFixture.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleFixture.kt new file mode 100644 index 00000000..3e9709aa --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleFixture.kt @@ -0,0 +1,144 @@ +package org.justinvisser.wolfskin.tv + +import android.content.Context +import com.github.damontecres.wholphin.BuildConfig +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import javax.inject.Inject +import javax.inject.Singleton +import org.json.JSONArray +import org.json.JSONObject +import org.justinvisser.wolfskin.design.ShellPerson +import org.justinvisser.wolfskin.design.TitleConnection +import org.justinvisser.wolfskin.design.TitleItem +import org.justinvisser.wolfskin.design.TitlePageData +import org.justinvisser.wolfskin.design.TitlePerson +import org.justinvisser.wolfskin.design.LibraryRating +import org.justinvisser.wolfskin.design.TitleTrack +import org.justinvisser.wolfskin.design.TitleVersion +import java.util.Locale + +/** The locked prototype is projected only by debug callers and offline JVM captures. */ +class TitleFixture(private val fixture: JSONObject) { + private val profiles = fixture.getJSONArray("profiles").objects() + private val cast = fixture.getJSONObject("cast") + private val availableArt = fixture.getJSONArray("availableArt").let { array -> (0 until array.length()).map { array.getString(it) }.toSet() } + val people: List<ShellPerson> = profiles.map { ShellPerson(it.getString("key"), it.getString("name"), it.getString("initial"), it.getJSONObject("destinations").has("Anime")) } + private val all = profiles.flatMap { profile -> listOf("movies", "shows", "anime", "discover").flatMap { profile.getJSONArray(it).objects() } }.distinctBy { key(it) } + private val pool = profiles.flatMap { it.getJSONArray("discover").objects() }.distinctBy { key(it) } + + /** Each viewer gets their own ownership, resume target and episode watch identity. */ + fun title(id: String, person: String, season: Int? = null): TitlePageData { + val profile = profiles.first { it.getString("key") == person } + val library = listOf("movies", "shows", "anime").flatMap { profile.getJSONArray(it).objects() } + val episodes = listOf("resume", "nextUp").flatMap { profile.getJSONArray(it).objects() } + val source = (all + episodes).first { it.getString("id") == id || key(it) == id || it.getString("title") == id } + val selectedEpisode = source.takeIf { it.getString("type") == "episode" } + val container = if (selectedEpisode != null) library.first { it.getString("title") == selectedEpisode.getString("seriesTitle") } else source + val own = library.firstOrNull { key(it) == key(container) } + val item = item(own ?: container, person) + val play = selectedEpisode ?: episodes.firstOrNull { it.optString("seriesTitle") == container.getString("title") } + ?: own?.takeIf { it.optDouble("progress") > 0 } + val target = play?.let { item(it, person) } ?: item.takeIf { it.held && it.kind == "movie" } + val selectedSeason = season ?: play?.optInt("seasonNumber")?.takeIf { it > 0 } ?: 1 + val related = (pool + library).filter { key(it) != key(container) && overlap(it, container) > 0 } + .sortedByDescending { overlap(it, container) }.distinctBy { key(it) }.take(16).map { item(it, person) } + val castMembers = cast.optJSONArray(key(container))?.objects().orEmpty().map { member -> + TitlePerson(member.getString("name"), member.getString("name"), member.optString("character"), art(member.optString("img").takeIf { it.isNotEmpty() }?.let { "assets/cast/$it" })) + } + return TitlePageData(item, play?.let { item(it, person) }, selectedEpisode?.let { item(it, person) } ?: item, + versions = target?.let { versions(it) }.orEmpty(), + trailerUrl = if (item.held && key(container) in TRAILERS) "fixture:trailer" else null, + cast = castMembers, related = related, episodes = episodes.filter { it.optString("seriesTitle") == container.getString("title") && it.optInt("seasonNumber") == selectedSeason }.map { item(it, person) }, + connection = TitleConnection.SignedIn, pick = container.optString("source") == "materialized_personal_recommendation", selectedSeason = selectedSeason) + } + + /** Only real household credits are retained; prototype filler is never promoted to filmography. */ + fun person(name: String, viewer: String): Pair<TitlePerson, List<TitleItem>> { + val credits = all.mapNotNull { title -> + val actor = cast.optJSONArray(key(title))?.objects()?.firstOrNull { it.getString("name") == name } ?: return@mapNotNull null + item(title, viewer).copy(character = actor.optString("character")) + }.sortedWith(compareByDescending<TitleItem> { it.year ?: 0 }.thenBy { it.name.replace(Regex("^(The|A|An) "), "") }) + val actor = cast.keys().asSequence().flatMap { cast.getJSONArray(it).objects() }.first { it.getString("name") == name } + return TitlePerson(name, name, portrait = art(actor.optString("img").takeIf { it.isNotEmpty() }?.let { "assets/cast/$it" })) to credits + } + + private fun item(source: JSONObject, person: String): TitleItem { + val owners = profiles.filter { profile -> listOf("movies", "shows", "anime").any { bucket -> profile.getJSONArray(bucket).objects().any { key(it) == key(source) } } } + val held = source.optString("type") == "episode" || owners.any { it.getString("key") == person } + return TitleItem( + source.getString("id"), source.getString("title"), source.getString("type"), source.optInt("year").takeIf { it > 0 }, + source.optLong("runtimeSeconds"), source.optInt("seasonCount"), source.optString("overview"), + source.optJSONArray("genres")?.let { values -> (0 until values.length()).map { values.getString(it) } }.orEmpty(), + art(source.optString("poster")), art(source.optString("backdrop")), held, source.optBoolean("played"), source.optDouble("resumeSeconds").toLong(), + seriesName = source.optString("seriesTitle").takeIf { it.isNotEmpty() }, season = source.optInt("seasonNumber"), episode = source.optInt("episodeNumber"), + ratings = ratings(source), otherOwners = owners.filter { it.getString("key") != person }.map { it.getString("name") }, + isPosterBackdrop = source.optJSONObject("artwork")?.optString("backdropKind") == "poster_crop", + ) + } + + private fun art(path: String?): String? = path?.takeIf { it in availableArt }?.let { "file:///android_asset/wolfskin-titles/$it" } + + private fun ratings(item: JSONObject): List<LibraryRating> { + val episode = item.optString("type") == "episode" + if (episode) { + val series = all.firstOrNull { it.optString("title") == item.optString("seriesTitle") } + val base = series?.let { ratings(it) }.orEmpty() + val score = item.optDouble("communityRating", Double.NaN) + return if (score in 0.0..10.0) base.filter { it.source != "tmdb" } + LibraryRating("tmdb", score, episode = true) else base + } + val ratings = item.optJSONObject("ratings") ?: JSONObject().apply { + if (!item.isNull("criticRating")) put("rtCritics", item.getDouble("criticRating")) + if (!item.isNull("communityRating")) put("tmdb", item.getDouble("communityRating")) + } + return buildList { + if (!ratings.isNull("rtCritics")) add(LibraryRating("rtCritics", ratings.getDouble("rtCritics"), ratings.optString("rtCriticsLabel").takeIf { it.isNotEmpty() })) + if (!ratings.isNull("rtAudience")) add(LibraryRating("rtAudience", ratings.getDouble("rtAudience"), ratings.optString("rtAudienceLabel").takeIf { it.isNotEmpty() })) + if (item.optString("type") == "movie" && (!ratings.has("imdbVotes") || ratings.optInt("imdbVotes") > 0) && !ratings.isNull("imdb")) add(LibraryRating("imdb", ratings.getDouble("imdb"))) + if (isEmpty() && (!ratings.has("tmdbVotes") || ratings.optInt("tmdbVotes") > 0) && !ratings.isNull("tmdb")) add(LibraryRating("tmdb", ratings.getDouble("tmdb"))) + }.filter { it.score in 0.0..if (it.source.startsWith("rt")) 100.0 else 10.0 } + } + + private fun versions(item: TitleItem): List<TitleVersion> { + val subtitles = listOf(TitleTrack(2, "English"), TitleTrack(3, "English SDH"), TitleTrack(4, "Dutch")) + fun version(id: String, name: String, video: String, bitrate: Double, container: String, audio: List<TitleTrack>) = + TitleVersion(id, name, video, "$bitrate Mbps", String.format(Locale.US, "%.1f GB", bitrate * item.runtimeSeconds / 8000), container, audio, subtitles) + val standardAudio = listOf(TitleTrack(0, "English · AC3 5.1", true), TitleTrack(1, "English · AAC 2.0")) + return if (item.name == "Locke") listOf( + version("v1", "2160p · HDR10 · Remux", "2160p HEVC Main 10 · HDR10", 58.4, "MKV", listOf(TitleTrack(0, "English · TrueHD 7.1", true), TitleTrack(1, "English · AC3 5.1"))), + version("v2", "1080p · SDR", "1080p H.264 High · SDR", 9.8, "MP4", standardAudio), + ) else listOf(version("v1", "1080p · SDR", "1080p H.264 High · SDR", 8.2, "MKV", standardAudio)) + } + + private fun key(item: JSONObject): String = "${item.getString("type")}:${item.optLong("tmdbId")}" + private fun overlap(first: JSONObject, second: JSONObject): Int { + fun genres(item: JSONObject): Set<String> = item.optJSONArray("genres")?.let { array -> (0 until array.length()).flatMap { + when (val genre = array.getString(it)) { + "Sci-Fi & Fantasy" -> listOf("Science Fiction", "Fantasy") + "Action & Adventure" -> listOf("Action", "Adventure") + "Sci-Fi" -> listOf("Science Fiction") + else -> listOf(genre) + } + }.toSet() }.orEmpty() + return genres(first).intersect(genres(second)).size + } + + companion object { + private val TRAILERS = setOf("movie:14069", "movie:210479", "movie:318917", "movie:42994", "movie:123431", "tv:83867", "tv:76479", "tv:1215", "tv:1437", "tv:67070", "movie:1930", "movie:21469", "movie:76203", "tv:60948") + } +} + +/** Every title entry shares one parsed, read-only debug fixture; release callers cannot load it. */ +@Singleton +class TitleFixtureProvider @Inject constructor(@param:ApplicationContext private val context: Context) { + private val fixture by lazy { + TitleFixture(context.assets.open("wolfskin-titles.json").bufferedReader().use { JSONObject(it.readText()) }) + } + suspend fun read(): TitleFixture = withContext(Dispatchers.IO) { + check(BuildConfig.DEBUG) { "Title fixtures are debug-only" } + fixture + } +} + +private fun JSONArray.objects(): List<JSONObject> = List(length()) { getJSONObject(it) } diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleRequests.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleRequests.kt new file mode 100644 index 00000000..f4188e3f --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleRequests.kt @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.tv + +import androidx.compose.runtime.* +import org.justinvisser.wolfskin.design.* + +/** Title reads do not depend on a successful recommendation feed to authorize a request. */ +@Composable +internal fun rememberTitleRequests(viewModel: WolfskinTitleViewModel, shell: ShellState, route: String, + identity: ConductarrIdentity?, fixtures: Boolean, title: TitleState?): DiscoverState? { + val scope = rememberCoroutineScope() + val profileNames = identity?.provider?.requesterProfiles?.collectAsState()?.value.orEmpty() + val requesters = remember(profileNames, shell.people, identity?.operator) { identity?.requesters(profileNames, shell.people).orEmpty() } + LaunchedEffect(identity?.client, identity?.signedIn, fixtures) { + if (!fixtures && identity?.signedIn == true) identity.provider.profiles() + } + val requests = if (fixtures) rememberDiscoverState(shell, viewModel::openRoute, viewModel::popRoute, + identity = null, fixtures = true, retrySignIn = viewModel::retrySignIn) + else remember(shell, shell.person.key, route, identity?.client, identity?.signedIn) { + identity?.takeIf { it.signedIn }?.let { + DiscoverState(shell, DiscoverSnapshot(status = DiscoverStatus.Loading), it.provider.operations(it.operator!!), + scope, viewModel::openRoute, viewModel::popRoute, + requesters = requesters) + } + } + SideEffect { if (!fixtures) requests?.updateRequesters(requesters) } + LaunchedEffect(requests, title?.data) { + val data = title?.data ?: return@LaunchedEffect + if (!fixtures && requests != null && data.item.tmdbId != null) { + val item = data.requestTitle(shell.person) + requests.updateSnapshot(DiscoverSnapshot(picks = if (data.pick) listOf(item) else emptyList(), + hidden = if (data.hidden) listOf(item) else emptyList(), + status = if (data.connection == TitleConnection.SignedOut) DiscoverStatus.NeedsSignIn else DiscoverStatus.Ready, + savedAt = data.savedAt, reachable = data.connection == TitleConnection.SignedIn, requestsAllowed = data.requestsAllowed)) + } + } + val hidden = title?.data?.takeIf { it.item.tmdbId != null }?.let { data -> requests?.isHidden(data.requestTitle(shell.person)) } + LaunchedEffect(requests, hidden, requests?.toast) { + val data = title?.data ?: return@LaunchedEffect + if (!fixtures && hidden != null && data.hidden != hidden) title.update(data.copy(hidden = hidden)) + } + val receipt = (requests?.overlay as? DiscoverOverlayState.Request)?.state + LaunchedEffect(receipt?.phase, receipt?.result) { + val data = title?.data ?: return@LaunchedEffect + if (receipt?.phase == RequestPhase.Receipt) title.update(data.copy(requestedStage = receipt.result?.stage ?: "Requested", + requestActor = receipt.result?.actor, requestFor = receipt.selectedPerson.name.takeIf { receipt.selectedPerson.key != shell.person.key })) + } + SideEffect { + viewModel.conductarrAction = { action -> + val current = viewModel.title + when (action) { + is TitleAction.Request -> if (current != null && requests != null) requests.openRequest(action.forSomeoneElse, current.data.requestTitle(shell.person)) + is TitleAction.Requests -> shell.openRequests() + is TitleAction.Hide -> if (current != null && requests != null) { + val item = current.data.requestTitle(shell.person) + if (action.hidden) requests.hide(item) else requests.restore(item, showToast = false) + requests.overlay = null // TitleState already owns the native hide receipt and Undo. + } + else -> error("Not a Conductarr title action") + } + } + } + return requests +} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleTrailer.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleTrailer.kt new file mode 100644 index 00000000..0dce1f9f --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleTrailer.kt @@ -0,0 +1,33 @@ +package org.justinvisser.wolfskin.tv + +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri + +/** Only an installed app that handles the YouTube scheme may receive a remote trailer. */ +class TitleTrailer(private val context: Context) { + /** A missing handler leaves the title in place rather than falling back to a browser. */ + fun open(url: String): Boolean { + val source = Uri.parse(url) + val video = when (source.host?.lowercase()) { + "youtu.be" -> source.pathSegments.firstOrNull() + "youtube.com", "www.youtube.com", "m.youtube.com" -> source.getQueryParameter("v") + ?: source.pathSegments.takeIf { it.firstOrNull() in setOf("shorts", "embed") }?.getOrNull(1) + else -> null + } ?: return false + val intent = Intent(Intent.ACTION_VIEW, Uri.parse("vnd.youtube:$video")).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + val handler = context.packageManager.queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY) + .firstOrNull { it.activityInfo.exported } ?: return false + intent.setPackage(handler.activityInfo.packageName) + return try { + context.startActivity(intent) + true + } catch (_: ActivityNotFoundException) { + false + } catch (_: SecurityException) { + false + } + } +} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WatchRefresh.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WatchRefresh.kt new file mode 100644 index 00000000..fb44b681 --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WatchRefresh.kt @@ -0,0 +1,32 @@ +package org.justinvisser.wolfskin.tv + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import com.github.damontecres.wholphin.util.PlaybackWatchState + +/** Re-read on entry, foreground and successful stop, even when a screen remains composed. */ +@Composable +internal fun rememberWatchRefresh(scope: String?): Pair<Long, Int> { + val revisions by PlaybackWatchState.revisions.collectAsState() + var foreground by remember(scope) { mutableIntStateOf(0) } + val owner = LocalLifecycleOwner.current + DisposableEffect(owner, scope) { + var initialResume = owner.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED) + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + if (initialResume) initialResume = false else foreground++ + } + } + owner.lifecycle.addObserver(observer) + onDispose { owner.lifecycle.removeObserver(observer) } + } + return (revisions[scope] ?: 0L) to foreground +} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt new file mode 100644 index 00000000..aed8ec7b --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt @@ -0,0 +1,361 @@ +package org.justinvisser.wolfskin.tv + +import android.content.Context +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator +import androidx.navigation3.ui.NavDisplay +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.JellyfinServer +import com.github.damontecres.wholphin.data.model.JellyfinUser +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.services.BackdropResult +import com.github.damontecres.wholphin.services.BackdropService +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.SetupDestination +import com.github.damontecres.wholphin.services.SetupNavigationManager +import com.github.damontecres.wholphin.ui.nav.Backdrop +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.nav.DestinationContent +import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import org.jellyfin.sdk.model.serializer.toUUIDOrNull +import kotlinx.coroutines.withContext +import org.justinvisser.wolfskin.design.PeopleStatus +import org.justinvisser.wolfskin.design.SettingsList +import org.justinvisser.wolfskin.design.ShellAction +import org.justinvisser.wolfskin.design.ShellPerson +import org.justinvisser.wolfskin.design.ShellPlaceholder +import org.justinvisser.wolfskin.design.ShellState +import org.justinvisser.wolfskin.design.ShellSurface +import org.justinvisser.wolfskin.design.WolfskinTheme +import org.justinvisser.wolfskin.tv.read.LibraryReadFacade +import org.justinvisser.wolfskin.design.DiscoverOverlay +import org.justinvisser.wolfskin.design.DiscoverScreen + +/** View memory must outlive the upstream setup entry replaced by a user switch. */ +@Singleton +class WolfskinSession @Inject constructor() { + var state by mutableStateOf<ShellState?>(null) + var serverId: String? = null + internal var fixtureMode: Boolean? = null + var onAction: (ShellAction) -> Unit = {} +} + +/** The shell routes only through the existing NavigationManager and real settings pages. */ +@HiltViewModel +class WolfskinShellViewModel @Inject constructor( + @param:ApplicationContext private val context: Context, + val session: WolfskinSession, + private val repository: ServerRepository, + private val navigation: NavigationManager, + private val setupNavigation: SetupNavigationManager, + private val backdrop: BackdropService, + val libraries: LibraryReadFacade, + val seeAll: WolfskinSeeAllLists, + val conductarr: ConductarrSession, + artwork: WolfskinArtwork, +) : ViewModel() { + init { artwork.configure(context) } + val backdropFlow: StateFlow<BackdropResult> = backdrop.backdropFlow + private val requestsSnapshot by lazy { readShellRequests(context) } + var libraryDestinations by mutableStateOf<Map<String, Destination>>(emptyMap()) + private set + var signedInPerson by mutableStateOf("") + private set + private var identityJob: kotlinx.coroutines.Job? = null + private var personSwitchJob: kotlinx.coroutines.Job? = null + private val personSwitchMutex = Mutex() + + suspend fun load(server: JellyfinServer, user: JellyfinUser) { + withContext(Dispatchers.IO) { + WolfskinScreenPreference.initialize(context) + ConductarrPreference.initialize(context) + } + identityJob?.cancel() + libraries.audiences = null + if (ConductarrPreference.fixtures) conductarr.close() + else withContext(Dispatchers.IO) { conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl) } + signedInPerson = session.state?.people?.firstOrNull { it.jellyfinUserId == user.id.toString() }?.key ?: user.name.orEmpty().lowercase() + session.onAction = { action -> handleAction(action, server, user) } + if (session.state == null || session.serverId != server.id.toString() || session.fixtureMode != ConductarrPreference.fixtures) { + session.serverId = server.id.toString() + session.fixtureMode = ConductarrPreference.fixtures + val people = withContext(Dispatchers.IO) { readShellPeople(context, listOf(user)) } + session.state = ShellState(people, signedInPerson) { session.onAction(it) } + val saved = withContext(Dispatchers.IO) { repository.serverDao.getServer(server.id)?.users.orEmpty() } + val savedPeople = withContext(Dispatchers.IO) { readShellPeople(context, saved.ifEmpty { listOf(user) }) } + session.state?.updatePeople(savedPeople) + } + identityJob = viewModelScope.launch { + if (!ConductarrPreference.fixtures) loadHousehold(user) + } + try { + libraryDestinations = libraries.readDestinations(user.id) + } catch (exception: Exception) { + if (exception is kotlinx.coroutines.CancellationException) throw exception + libraryDestinations = emptyMap() + } + } + + private suspend fun loadHousehold(user: JellyfinUser) { + conductarr.authenticate() + val identity = conductarr.identity.value ?: return + if (!identity.signedIn || !identity.person.endsWith(":${user.id}")) return + val profiles = identity.provider.profiles()?.profiles.orEmpty() + val audiences = identity.provider.audiences() + if (conductarr.identity.value?.client !== identity.client || conductarr.identity.value?.signedIn != true) return + audiences?.filter { it.owner == identity.operator?.requester || identity.operator?.watchUser in it.users.orEmpty() } + ?.takeIf { it.isNotEmpty() }?.let { libraries.audiences = it } + val hasAnime = try { + libraries.hasAnimeTitles(user.id) + } catch (exception: Exception) { + if (exception is kotlinx.coroutines.CancellationException) throw exception + session.state?.people?.firstOrNull { it.jellyfinUserId == user.id.toString() }?.hasAnime ?: false + } + if (conductarr.identity.value?.client !== identity.client || conductarr.identity.value?.signedIn != true) return + val updated = session.state?.people.orEmpty().map { person -> + val profile = profiles.firstOrNull { it.key == person.key || it.name.equals(person.name, ignoreCase = true) } + val current = person.jellyfinUserId == user.id.toString() + person.copy(key = if (current) identity.operator!!.requester else profile?.key ?: person.key, + name = if (current) identity.operator!!.name else profile?.name ?: person.name, + hasAnime = if (current) hasAnime else person.hasAnime) + } + session.state?.updatePeople(updated) + signedInPerson = updated.firstOrNull { it.jellyfinUserId == user.id.toString() }?.key ?: signedInPerson + } + + /** Foreground recovery retains the current identity and any usable audience mapping. */ + fun onForeground(server: JellyfinServer, user: JellyfinUser): kotlinx.coroutines.Job? { + if (ConductarrPreference.fixtures || conductarr.identity.value?.person != "${server.id}:${user.id}") return null + identityJob?.cancel() + return viewModelScope.launch { loadHousehold(user) }.also { identityJob = it } + } + + suspend fun retryConductarr(server: JellyfinServer, user: JellyfinUser) { + withContext(Dispatchers.IO) { + conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl, + retry = conductarr.identity.value?.signedIn != true) + } + loadHousehold(user) + } + + fun clearBackdrop() { viewModelScope.launch { backdrop.clearBackdrop() } } + + /** Capture the person's state before I/O so a late refresh cannot paint another person's sheet. */ + fun refreshRequests() = refreshRequests(retry = false) + + private fun refreshRequests(retry: Boolean) { + val shell = session.state ?: return + val target = shell.requests + val identity = conductarr.identity.value + viewModelScope.launch { + if (ConductarrPreference.fixtures) { + target.update(withContext(Dispatchers.IO) { requestsSnapshot }) + } else if (identity?.signedIn != true) { + target.update(org.justinvisser.wolfskin.design.ComingInSnapshot()) + } else identity.provider.requests().collect { snapshot -> + if (conductarr.identity.value?.client === identity.client) { + target.update(if (conductarr.identity.value?.signedIn == true) snapshot else org.justinvisser.wolfskin.design.ComingInSnapshot()) + } + } + if (retry && target.snapshot.status == org.justinvisser.wolfskin.design.ComingInStatus.Unavailable && shell.requests === target) { + shell.toast = org.justinvisser.wolfskin.design.RecoveryMessage.StillUnreachable + } + } + } + + private fun handleAction(action: ShellAction, server: JellyfinServer, user: JellyfinUser) { + when (action) { + is ShellAction.Destination -> { + val target = if (action.route == "home") Destination.Home() else Destination.Wolfskin(action.route) + when { + action.route == "settings" -> navigation.navigateTo(target) + action.preservePushed && navigation.backStack.size > 1 -> navigation.backStack[0] = target + else -> navigation.replace(target) + } + } + ShellAction.Pop -> navigation.goBack() + is ShellAction.HandOff -> navigation.navigateTo(handOffDestination(action.group)) + is ShellAction.Person -> selectPerson(action.person, server) + ShellAction.RetryPeople -> viewModelScope.launch { retryConductarr(server, user) } + ShellAction.RetryRequests -> viewModelScope.launch { + if (conductarr.identity.value?.signedIn != true) retryConductarr(server, user) + refreshRequests(retry = true) + } + is ShellAction.RequestTitle -> navigation.navigateTo(Destination.Wolfskin(titleRoute("${action.request.mediaType}:${action.request.tmdbId}"))) + } + } + + private fun selectPerson(person: ShellPerson, server: JellyfinServer) { + navigation.replace(Destination.Home()) + if (person.key == signedInPerson) return + identityJob?.cancel() + personSwitchJob?.cancel() + signedInPerson = "" + conductarr.close() + libraries.audiences = null + personSwitchJob = viewModelScope.launch { + personSwitchMutex.withLock { + val saved = withContext(Dispatchers.IO) { + repository.serverDao.getServer(server.id)?.users?.firstOrNull { it.id.toString() == person.jellyfinUserId || it.name.equals(person.name, ignoreCase = true) } + } + if (saved == null || saved.isProtected) return@launch + try { + val current = repository.changeUser(server, saved) + coroutineContext.ensureActive() + setupNavigation.navigateTo(SetupDestination.AppContent(current)) + } catch (exception: Exception) { + if (exception is kotlinx.coroutines.CancellationException) throw exception + coroutineContext.ensureActive() + session.state?.apply { peopleStatus = PeopleStatus.Unavailable; pickerVisible = true } + } + } + } + } +} + +/** Settings groups open the fork's own pages; the prototype hand-off panel is never shipped. */ +fun handOffDestination(group: String): Destination = when (group) { + "advanced" -> Destination.Settings(PreferenceScreenOption.ADVANCED) + "profile" -> Destination.HomeSettings + else -> Destination.Settings(PreferenceScreenOption.BASIC) +} + +private val LocalShellState = compositionLocalOf<ShellState> { error("Wolfskin destination requires the shell") } + +/** The one upstream hook preserves destination decorators and full-screen hand-offs. */ +@Composable +fun WolfskinApp( + server: JellyfinServer, + user: JellyfinUser, + navigationManager: NavigationManager, + preferences: UserPreferences, + modifier: Modifier = Modifier, + viewModel: WolfskinShellViewModel = hiltViewModel(), + homeContent: @Composable (ShellState, JellyfinServer, JellyfinUser, Modifier) -> Unit = + { shell, homeServer, homeUser, contentModifier -> HomeDestination(shell, homeServer, homeUser, contentModifier) }, +) { + val identity by viewModel.conductarr.identity.collectAsStateWithLifecycle() + LaunchedEffect(server.id, user.id, user.accessToken, ConductarrPreference.baseUrl, ConductarrPreference.fixtures) { viewModel.load(server, user) } + androidx.lifecycle.compose.LifecycleEventEffect(androidx.lifecycle.Lifecycle.Event.ON_RESUME) { viewModel.onForeground(server, user) } + LaunchedEffect(identity?.signedIn, identity?.person) { + if (!ConductarrPreference.fixtures) { + if (identity?.signedIn != true) viewModel.session.state?.requests?.update(org.justinvisser.wolfskin.design.ComingInSnapshot()) + else viewModel.refreshRequests() + } + } + val loading = remember(user.id) { + ShellState(listOf(ShellPerson(user.name.orEmpty().lowercase(), user.name.orEmpty(), user.name.orEmpty().take(1), false)), user.name.orEmpty().lowercase()).apply { peopleStatus = PeopleStatus.Loading } + } + val state = viewModel.session.state ?: loading + val discover = rememberDiscoverState( + state, + { route -> navigationManager.navigateTo(Destination.Wolfskin(route)) }, + navigationManager::goBack, + identity = identity, + fixtures = ConductarrPreference.fixtures, + retrySignIn = { + viewModel.retryConductarr(server, user) + }, + ) + RequestsRefresh(state, viewModel::refreshRequests) + WolfskinTheme { + CompositionLocalProvider(LocalShellState provides state, LocalDiscoverState provides discover, + org.justinvisser.wolfskin.design.LocalScreenRatings provides if (identity?.signedIn == true && !ConductarrPreference.fixtures) remember(identity?.client) { ConductarrRatings(identity!!.client) } else null) { + Box(modifier) { + val current = navigationManager.backStack.last() + if (!state.pickerVisible && resolveUpstreamDestination(current, WolfskinScreenPreference.enabled, viewModel.libraryDestinations) != null) { + val backdrop by viewModel.backdropFlow.collectAsStateWithLifecycle() + Backdrop( + backdrop = backdrop, + drawerIsOpen = false, + backdropStyle = preferences.appPreferences.interfacePreferences.backdropStyle, + ) + } + NavDisplay( + backStack = navigationManager.backStack, + onBack = { if (navigationManager.backStack.last().fullScreen) navigationManager.goBack() else state.key(org.justinvisser.wolfskin.design.ShellKey.Back) }, + entryDecorators = listOf(rememberSaveableStateHolderNavEntryDecorator(), rememberViewModelStoreNavEntryDecorator()), + entryProvider = { destination -> + NavEntry(destination, contentKey = "${destination}_${server.id}_${user.id}") { + // Cached entries must not retain the launch shell after sign-in completes. + val state = LocalShellState.current + val discover = LocalDiscoverState.current + val route = when (destination) { + is Destination.Home -> "home" + is Destination.Wolfskin -> destination.route + is Destination.MediaItem -> if (destination.type == org.jellyfin.sdk.model.api.BaseItemKind.PERSON) personRoute(destination.itemId.toString()) else titleRoute(destination.itemId.toString()) + else -> "pushed" + } + val isCurrent = navigationManager.backStack.lastOrNull() == destination + LaunchedEffect(destination, state, isCurrent) { + if (isCurrent && !destination.fullScreen) state.enterDestination(route) + } + if (destination.fullScreen) { + DestinationContent(destination, preferences, viewModel::clearBackdrop, Modifier.fillMaxSize()) + } else { + BackHandler(enabled = isCurrent) { state.key(org.justinvisser.wolfskin.design.ShellKey.Back) } + val upstream = resolveUpstreamDestination(destination, WolfskinScreenPreference.enabled, viewModel.libraryDestinations) + if (upstream == null && (route.startsWith("title/") || route.startsWith("person/"))) { + val jellyfinIdentity = destination is Destination.MediaItem || android.net.Uri.decode(route.substringAfter('/')).toUUIDOrNull() != null + if (jellyfinIdentity && state.person.key != viewModel.signedInPerson) { + ShellSurface(state, handlesContentKeys = false) { ShellPlaceholder("Sign in as ${state.person.name} to open their Jellyfin library", it) } + } else TitleDestination(route, state, preferences) + } else { + ShellSurface(state, handlesContentKeys = upstream == null, + overlay = { if (route == "discover" || route.startsWith("discover-")) DiscoverOverlay(discover) }) { contentModifier -> + when { + (upstream != null || destination is Destination.Home || route in LIBRARY_DESTINATIONS) && state.person.key != viewModel.signedInPerson -> ShellPlaceholder("Sign in as ${state.person.name} to open their Jellyfin library", contentModifier) + upstream != null -> DestinationContent(upstream, preferences, viewModel::clearBackdrop, contentModifier) + destination is Destination.Home -> homeContent(state, server, user, contentModifier) + route in LIBRARY_DESTINATIONS || route.startsWith("seeall/") -> + WolfskinLibraryContent(androidx.compose.ui.platform.LocalContext.current, route, state, user, "${server.id}:${user.id}", viewModel.libraries, navigationManager, viewModel.seeAll, contentModifier) + destination is Destination.Wolfskin -> DestinationContent(destination, preferences, viewModel::clearBackdrop, contentModifier) + else -> ShellPlaceholder(route, contentModifier) + } + } + } + } + } + }, + ) + } + } + } +} + +/** The single registered destination branch delegates Wolfskin-only screens back to the shell. */ +@Composable +fun WolfskinDestinationContent(route: String, modifier: Modifier = Modifier) { + when { + route == "settings" -> SettingsList(LocalShellState.current, modifier) + route == "discover" || route.startsWith("discover-") -> DiscoverScreen(LocalDiscoverState.current, modifier) + else -> ShellPlaceholder(route, modifier) + } +} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinArtwork.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinArtwork.kt new file mode 100644 index 00000000..e546a09b --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinArtwork.kt @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.tv + +import android.content.Context +import coil3.ComponentRegistry +import coil3.network.ConnectivityChecker +import coil3.network.okhttp.OkHttpNetworkFetcherFactory +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient +import javax.inject.Inject +import javax.inject.Singleton +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.OkHttpClient +import org.justinvisser.wolfskin.design.WolfskinImages + +/** Strip credentials after upstream auth injection, including each redirect's network request. */ +@OptIn(coil3.annotation.ExperimentalCoilApi::class) +@Singleton +class WolfskinArtwork @Inject constructor( + @param:AuthOkHttpClient client: OkHttpClient, + repository: ServerRepository, +) { + private val artworkClient = client.newBuilder().addNetworkInterceptor { chain -> + val request = chain.request() + val jellyfin = repository.currentServer?.url?.toHttpUrlOrNull() + val isJellyfin = jellyfin != null && request.url.scheme == jellyfin.scheme && + request.url.host == jellyfin.host && request.url.port == jellyfin.port + chain.proceed(if (isJellyfin) request else request.newBuilder().removeHeader("Authorization").build()) + }.build() + private val configure: ComponentRegistry.Builder.() -> Unit = { + add(OkHttpNetworkFetcherFactory(callFactory = { artworkClient }, connectivityChecker = { ConnectivityChecker { true } })) + } + + /** Install host-scoped networking before any Wolfskin artwork is rendered. */ + fun configure(context: Context) = WolfskinImages.configureNetwork(context, configure) +} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinLibrary.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinLibrary.kt new file mode 100644 index 00000000..c73a68ee --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinLibrary.kt @@ -0,0 +1,107 @@ +package org.justinvisser.wolfskin.tv + +import android.content.Context +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import com.github.damontecres.wholphin.BuildConfig +import com.github.damontecres.wholphin.data.model.JellyfinUser +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.ui.nav.Destination +import java.io.File +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.justinvisser.wolfskin.design.* +import org.justinvisser.wolfskin.tv.read.LibraryReadFacade +import org.justinvisser.wolfskin.tv.read.LibrarySnapshotStore +import org.justinvisser.wolfskin.conductarr.FileCacheStore +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.serializer.toUUIDOrNull + +/** Other shelves register the complete per-type list, not the visible twenty-card window. */ +@Singleton +class WolfskinSeeAllLists @Inject constructor() { + private val lists = mutableMapOf<String, LibraryGridState>() + + fun open(person: String, key: String, title: String, items: List<LibraryTitle>, navigation: NavigationManager, loadNextPage: (suspend (Int) -> LibraryPage)? = null) { + val identity = "$person/$key" + val grid = LibraryGridState(title, items, catalogue = true, + onTitle = { navigation.navigateTo(libraryTitleDestination(it)) }) + if (loadNextPage != null) grid.pagination = LibraryPagination(grid, loadNextPage) + lists[identity] = grid + navigation.navigateTo(Destination.Wolfskin("seeall/$identity")) + } + + fun get(key: String): LibraryGridState? = lists[key] +} + +/** Debug fixture reads never become a fallback for an unavailable authenticated library. */ +@Composable +fun WolfskinLibraryContent( + context: Context, + route: String, + shell: ShellState, + user: JellyfinUser, + cacheScope: String, + facade: LibraryReadFacade, + navigation: NavigationManager, + seeAll: WolfskinSeeAllLists, + modifier: Modifier = Modifier, +) { + val view = shell.rememberedView(route) + val fixture by produceState<LibraryFixture?>(null, context, ConductarrPreference.fixtures) { + value = null + if (ConductarrPreference.fixtures) value = withContext(Dispatchers.IO) { + context.assets.open("wolfskin-library.json").bufferedReader().use { LibraryFixture(it.readText(), "file:///android_asset/library/") } + } + } + val snapshots = remember(context) { lazy { LibrarySnapshotStore(FileCacheStore(File(context.filesDir, "wolfskin-library-cache"))) } } + val grid = remember(shell.person.key, route, view) { + view.library ?: if (route.startsWith("seeall/")) seeAll.get(route.substringAfter("seeall/").takeIf { it.startsWith("${shell.person.key}/") } ?: "") else LibraryGridState( + title = route.replaceFirstChar { it.uppercase() }, items = emptyList(), + onTitle = { navigation.navigateTo(libraryTitleDestination(it)) }, + onDiscover = { shell.enterDestination("discover"); navigation.replace(Destination.Wolfskin("discover")) }, + onRecover = { view.library?.let { it.refreshRevision++ } }, + ).also { it.status = LibraryStatus.Loading; view.library = it } + } + if (grid == null) { + ShellPlaceholder("See all list unavailable", modifier) + return + } + val watchRefresh = rememberWatchRefresh(cacheScope) + LaunchedEffect(grid, grid.refreshRevision, fixture, if (route in LIBRARY_DESTINATIONS) facade.audiences else null, watchRefresh) { + if (route in LIBRARY_DESTINATIONS && (!ConductarrPreference.fixtures || fixture != null)) { + val wasLoading = grid.status == LibraryStatus.Loading + if (wasLoading && !shell.navFocused) shell.focusNav(shell.currentTab) + val entryRevision = shell.inputRevision + if (fixture != null) { + grid.source = withContext(Dispatchers.IO) { fixture!!.library(shell.person.key, route) } + grid.status = LibraryStatus.Ready + } else { + val store = withContext(Dispatchers.IO) { snapshots.value } + store.read(cacheScope, route, force = grid.refreshRevision > 0, invalidatedAtMillis = watchRefresh.first) { facade.readLibrary(user.id, route) }.collect { snapshot -> + val focusedId = grid.focusedTitle?.id + grid.source = snapshot.items + focusedId?.let { id -> grid.items.indexOfFirst { it.id == id }.takeIf { it >= 0 }?.let { grid.index = it } } + grid.status = if (snapshot.unreachable && snapshot.items.isEmpty() && snapshot.savedAtMillis == null) LibraryStatus.Unavailable else LibraryStatus.Ready + grid.savedAt = if (snapshot.unreachable) snapshot.savedAtMillis?.let { DateTimeFormatter.ofPattern("HH:mm").format(Instant.ofEpochMilli(it).atZone(ZoneId.systemDefault())) } else null + grid.tail = if (snapshot.unreachable && snapshot.items.isNotEmpty()) GridTail.Retry else GridTail.None + if (snapshot.unreachable && grid.refreshRevision > 0) shell.toast = RecoveryMessage.StillUnreachable + if (wasLoading && shell.inputRevision == entryRevision && shell.navFocused && shell.navIndex == shell.currentTab) shell.navFocused = false + } + } + if (wasLoading && shell.inputRevision == entryRevision && shell.navFocused && shell.navIndex == shell.currentTab) shell.navFocused = false + } + } + LibraryScreen(shell, grid, modifier, ratingsSignedIn = fixture != null || LocalScreenRatings.current != null, view = view) +} + +val LIBRARY_DESTINATIONS = setOf("movies", "shows", "anime") + +private fun libraryTitleDestination(title: LibraryTitle): Destination = + title.id.toUUIDOrNull()?.let { Destination.MediaItem(it, if (title.type == "movie") BaseItemKind.MOVIE else BaseItemKind.SERIES) } + ?: Destination.Wolfskin(titleRoute(title.id)) diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinScreenPreference.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinScreenPreference.kt new file mode 100644 index 00000000..b85b702f --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinScreenPreference.kt @@ -0,0 +1,27 @@ +package org.justinvisser.wolfskin.tv + +import android.content.Context +import android.content.SharedPreferences +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.core.content.edit +import androidx.preference.PreferenceManager + +/** The single rollout preference is exposed only in upstream Advanced settings. */ +object WolfskinScreenPreference { + private const val KEY = "wolfskin.screens.enabled" + private lateinit var preferences: SharedPreferences + private var current by mutableStateOf(true) + + fun initialize(context: Context) { + preferences = PreferenceManager.getDefaultSharedPreferences(context.applicationContext) + current = preferences.getBoolean(KEY, true) + } + var enabled: Boolean + get() = current + set(value) { + current = value + preferences.edit { putBoolean(KEY, value) } + } +} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/HomeLibraryCache.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/HomeLibraryCache.kt new file mode 100644 index 00000000..10422c09 --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/HomeLibraryCache.kt @@ -0,0 +1,66 @@ +package org.justinvisser.wolfskin.tv.read + +import android.content.Context +import dagger.hilt.android.qualifiers.ApplicationContext +import java.io.File +import javax.inject.Inject +import javax.inject.Singleton +import org.json.JSONArray +import org.json.JSONException +import org.json.JSONObject +import org.justinvisser.wolfskin.conductarr.CacheEntry +import org.justinvisser.wolfskin.conductarr.FileCacheStore +import org.justinvisser.wolfskin.design.HomeLibrary +import org.justinvisser.wolfskin.design.HomeTitle + +/** Saved Jellyfin projections reuse the app-private atomic cache and are partitioned by server/user. */ +@Singleton +class HomeLibraryCache @Inject constructor(@param:ApplicationContext context: Context) { + private val store by lazy { FileCacheStore(File(context.filesDir, "wolfskin-home")) } + + fun read(scope: String): Pair<HomeLibrary, Long>? { + val entry = store.read(scope, KEY) ?: return null + return try { + val json = JSONObject(entry.body) + val destinations = json.getJSONObject("destinations") + val totals = json.getJSONObject("totals") + HomeLibrary( + decode(json.getJSONArray("resume")), decode(json.getJSONArray("nextUp")), decode(json.getJSONArray("newest")), + destinations.keys().asSequence().associateWith { decode(destinations.getJSONArray(it)) }, + totals.keys().asSequence().associateWith { totals.getInt(it) }, + ) to entry.savedAtMillis + } catch (_: JSONException) { store.remove(scope, KEY); null } + } + + fun write(scope: String, library: HomeLibrary, savedAtMillis: Long) { + val json = JSONObject().put("resume", encode(library.resume)).put("nextUp", encode(library.nextUp)).put("newest", encode(library.newest)) + .put("destinations", JSONObject().apply { library.destinations.forEach { (key, titles) -> put(key, encode(titles)) } }) + .put("totals", JSONObject(library.destinationTotals)) + store.write(scope, KEY, CacheEntry(json.toString(), null, savedAtMillis, savedAtMillis, group = "home")) + } + + private fun encode(titles: List<HomeTitle>): JSONArray = JSONArray().apply { + titles.forEach { title -> put(JSONObject().apply { + put("id", title.id); put("title", title.title); put("type", title.type); put("year", title.year) + put("runtime", title.runtimeSeconds); put("seasons", title.seasons); put("overview", title.overview) + put("poster", title.poster); put("backdrop", title.backdrop); put("posterOnly", title.posterOnly) + put("seriesTitle", title.seriesTitle); put("season", title.season); put("episode", title.episode) + put("resume", title.resumeSeconds); put("played", title.played); put("addedAt", title.addedAt) + put("genres", JSONArray(title.genres)); put("tmdb", title.tmdbId) + }) } + } + + private fun decode(items: JSONArray): List<HomeTitle> = List(items.length()) { index -> + val item = items.getJSONObject(index) + fun string(key: String): String? = if (item.isNull(key)) null else item.getString(key) + fun number(key: String): Int? = if (item.isNull(key)) null else item.getInt(key) + val genres = item.getJSONArray("genres") + HomeTitle( + item.getString("id"), item.getString("title"), item.getString("type"), number("year"), number("runtime"), number("seasons"), item.getString("overview"), + string("poster"), string("backdrop"), item.getBoolean("posterOnly"), string("seriesTitle"), number("season"), number("episode"), item.getDouble("resume"), item.getBoolean("played"), item.getString("addedAt"), + List(genres.length()) { genres.getString(it) }, held = true, tmdbId = number("tmdb"), + ) + } + + companion object { private const val KEY = "home:jellyfin" } +} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/HomeReadFacade.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/HomeReadFacade.kt new file mode 100644 index 00000000..bb86e68a --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/HomeReadFacade.kt @@ -0,0 +1,87 @@ +package org.justinvisser.wolfskin.tv.read + +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.services.ImageUrlService +import java.util.UUID +import javax.inject.Inject +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.itemsApi +import org.jellyfin.sdk.api.client.extensions.tvShowsApi +import org.jellyfin.sdk.api.client.extensions.userViewsApi +import org.jellyfin.sdk.api.client.extensions.userLibraryApi +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.api.ImageType +import org.jellyfin.sdk.model.api.ItemFields +import org.jellyfin.sdk.model.api.ItemSortBy +import org.jellyfin.sdk.model.api.MediaType +import org.jellyfin.sdk.model.api.SortOrder +import org.justinvisser.wolfskin.design.HomeLibrary +import org.justinvisser.wolfskin.design.HomeTitle + +/** All Home SDK calls and image URLs stay behind this facade, with their own required field list. */ +class HomeReadFacade @Inject constructor(private val api: ApiClient, private val images: ImageUrlService, private val libraries: LibraryReadFacade) { + val audiences get() = libraries.audiences + /** Watch rows are publishable without waiting for any library shelf. */ + suspend fun readWatch(userId: UUID): HomeLibrary { + val resumeItems = api.itemsApi.getResumeItems(userId = userId, fields = FIELDS, mediaTypes = listOf(MediaType.VIDEO), limit = 20).content.items + val nextItems = api.tvShowsApi.getNextUp(userId = userId, fields = FIELDS, limit = 20, enableUserData = true).content.items + val series = (resumeItems + nextItems).mapNotNull { it.seriesId }.distinct().associateWith { id -> + try { api.userLibraryApi.getItem(userId = userId, itemId = id).content.providerIds?.get("Tmdb")?.toIntOrNull() } + catch (exception: Exception) { + if (exception is kotlinx.coroutines.CancellationException) throw exception + null + } + } + val resume = resumeItems.map { title(it, series) } + val next = nextItems.map { title(it, series) } + return HomeLibrary(resume = resume, nextUp = next) + } + + /** Library failures do not discard an already successful watch read. */ + suspend fun readLibrary(userId: UUID): HomeLibrary { + val libraries = api.userViewsApi.getUserViews(userId = userId).content.items + val destinations = linkedMapOf<String, MutableList<HomeTitle>>() + val newest = mutableListOf<HomeTitle>() + val totals = linkedMapOf<String, Int>() + for (library in libraries) { + val key = this.libraries.destination(library.name.orEmpty(), library.collectionType) ?: continue + val response = api.itemsApi.getItems(userId = userId, parentId = library.id, recursive = true, includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES), fields = FIELDS, sortBy = listOf(ItemSortBy.IS_PLAYED, ItemSortBy.DATE_CREATED), sortOrder = listOf(SortOrder.ASCENDING, SortOrder.DESCENDING), limit = 20).content + destinations.getOrPut(key) { mutableListOf() }.addAll(response.items.map { title(it) }) + totals[key] = (totals[key] ?: 0) + response.totalRecordCount + newest.addAll(api.itemsApi.getItems(userId = userId, parentId = library.id, recursive = true, includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES), fields = FIELDS, sortBy = listOf(ItemSortBy.DATE_CREATED), sortOrder = listOf(SortOrder.DESCENDING), limit = 12).content.items.map { title(it) }) + } + return HomeLibrary(newest = newest.sortedByDescending { it.addedAt }.take(12), destinations = destinations, destinationTotals = totals) + } + + /** Resolve the current checkpoint even when the hero was painted from disk. */ + suspend fun preparePlayback(userId: UUID, title: HomeTitle): BaseItemDto? { + val id = runCatching { UUID.fromString(title.id) }.getOrNull() ?: run { + val kinds = if (title.isEpisode) listOf(BaseItemKind.EPISODE) else listOf(BaseItemKind.MOVIE) + api.itemsApi.getItems(userId = userId, recursive = true, searchTerm = title.title, includeItemTypes = kinds, fields = FIELDS, limit = 20).content.items.firstOrNull { + it.name == title.title && (!title.isEpisode || (it.seriesName == title.seriesTitle && it.parentIndexNumber == title.season && it.indexNumber == title.episode)) + }?.id + } ?: return null + return readPlayback(api, userId, id) + } + + private fun title(item: BaseItemDto, series: Map<UUID, Int?> = emptyMap()): HomeTitle { + val wrapped = BaseItem(item) + return HomeTitle( + id = item.id.toString(), title = item.name.orEmpty(), type = when (item.type) { BaseItemKind.EPISODE -> "episode"; BaseItemKind.SERIES -> "tv"; else -> "movie" }, + year = item.productionYear, runtimeSeconds = item.runTimeTicks?.div(TICKS_PER_SECOND)?.toInt(), seasons = item.childCount, + overview = item.overview.orEmpty(), poster = images.getItemImageUrl(wrapped, ImageType.PRIMARY, fillWidth = 256, fillHeight = 384), + backdrop = images.getItemImageUrl(wrapped, ImageType.BACKDROP, fillWidth = 1280, fillHeight = 720), + seriesTitle = item.seriesName, season = item.parentIndexNumber, episode = item.indexNumber, + resumeSeconds = (item.userData?.playbackPositionTicks ?: 0) / TICKS_PER_SECOND.toDouble(), played = item.userData?.played == true, + addedAt = item.dateCreated?.toString().orEmpty(), genres = item.genres.orEmpty(), held = true, + tmdbId = if (item.type == BaseItemKind.EPISODE) series[item.seriesId] else item.providerIds?.get("Tmdb")?.toIntOrNull(), + ) + } + + companion object { + private const val TICKS_PER_SECOND = 10_000_000L + private val FIELDS = listOf(ItemFields.PROVIDER_IDS, ItemFields.OVERVIEW, ItemFields.DATE_CREATED, ItemFields.GENRES, ItemFields.CHILD_COUNT) + } +} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/LibraryReadFacade.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/LibraryReadFacade.kt new file mode 100644 index 00000000..775235f2 --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/LibraryReadFacade.kt @@ -0,0 +1,98 @@ +package org.justinvisser.wolfskin.tv.read + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow + +import com.github.damontecres.wholphin.ui.nav.Destination +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.userViewsApi +import org.jellyfin.sdk.api.client.extensions.itemsApi +import org.jellyfin.sdk.api.client.extensions.imageApi +import org.jellyfin.sdk.model.api.ItemFields +import org.jellyfin.sdk.model.api.ItemSortBy +import org.jellyfin.sdk.model.api.ImageType +import org.justinvisser.wolfskin.design.LibraryTitle +import org.justinvisser.wolfskin.design.WolfskinTokens +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.CollectionType +import java.util.UUID +import javax.inject.Inject + +/** SDK reads are confined here so shell and screen state never depend on SDK responses. */ +@javax.inject.Singleton +class LibraryReadFacade @Inject constructor(private val api: ApiClient) { + var audiences by mutableStateOf<List<org.justinvisser.wolfskin.conductarr.model.LibraryAudience>?>(null) + + fun destination(name: String, collection: CollectionType?): String? { + audiences?.let { known -> + val audience = known.firstOrNull { name in it.libraries.orEmpty() } ?: return null + return if (audience.isAnime) "anime" else if (audience.mediaType.name == "MOVIE") "movies" else "shows" + } + return when { + name.contains("anime", ignoreCase = true) -> "anime" + collection == CollectionType.MOVIES -> "movies" + collection == CollectionType.TVSHOWS -> "shows" + else -> null + } + } + suspend fun readDestinations(userId: UUID): Map<String, Destination> { + val libraries = api.userViewsApi.getUserViews(userId = userId).content.items.orEmpty() + return buildMap { + libraries.forEach { library -> + val key = destination(library.name.orEmpty(), library.collectionType) + if (key != null) put(key, Destination.MediaItem(library.id, BaseItemKind.COLLECTION_FOLDER, library.collectionType)) + } + } + } + + /** Audience metadata names a grouping; only this user's visible titles make Anime available. */ + suspend fun hasAnimeTitles(userId: UUID): Boolean { + val libraries = api.userViewsApi.getUserViews(userId = userId).content.items.orEmpty() + for (library in libraries) { + if (destination(library.name.orEmpty(), library.collectionType) != "anime") continue + val titles = api.itemsApi.getItems(userId = userId, parentId = library.id, recursive = true, + includeItemTypes = listOf(BaseItemKind.SERIES), limit = 1).content.items.orEmpty() + if (titles.isNotEmpty()) return true + } + return false + } + + /** A destination spans every matching accessible library; containers never become titles. */ + fun readLibrary(userId: UUID, destination: String): Flow<List<LibraryTitle>> = flow { + val libraries = api.userViewsApi.getUserViews(userId = userId).content.items.orEmpty().filter { library -> + destination(library.name.orEmpty(), library.collectionType) == destination + } + val titles = linkedMapOf<UUID, LibraryTitle>() + for (library in libraries) { + var offset = 0 + do { + val response = api.itemsApi.getItems( + userId = userId, parentId = library.id, recursive = true, + includeItemTypes = listOf(if (destination == "movies") BaseItemKind.MOVIE else BaseItemKind.SERIES), + fields = listOf(ItemFields.PROVIDER_IDS, ItemFields.SORT_NAME, ItemFields.DATE_CREATED), + sortBy = listOf(ItemSortBy.SORT_NAME), startIndex = offset, + limit = WolfskinTokens.Behaviour.libraryChunk.toInt(), + ).content + val chunk = response.items.orEmpty() + for (item in chunk) { + val runtime = item.runTimeTicks?.div(10_000_000) + val position = item.userData?.playbackPositionTicks ?: 0 + titles[item.id] = LibraryTitle( + id = item.id.toString(), title = item.name.orEmpty(), type = if (item.type == BaseItemKind.MOVIE) "movie" else "tv", + year = item.productionYear, runtimeSeconds = runtime, seasons = item.childCount, + poster = item.imageTags?.get(ImageType.PRIMARY)?.let { api.imageApi.getItemImageUrl(item.id, ImageType.PRIMARY, tag = it) }, + played = item.userData?.played == true, + progress = if ((item.runTimeTicks ?: 0) > 0) (position.toDouble() / item.runTimeTicks!!).toFloat() else 0f, + addedAt = item.dateCreated?.toString().orEmpty(), tmdbId = item.providerIds?.get("Tmdb"), + ) + } + offset += chunk.size + if (chunk.isNotEmpty()) emit(titles.values.toList()) + } while (chunk.isNotEmpty() && offset < response.totalRecordCount) + } + if (titles.isEmpty()) emit(emptyList()) + } +} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/LibrarySnapshotStore.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/LibrarySnapshotStore.kt new file mode 100644 index 00000000..fffe1f2c --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/LibrarySnapshotStore.kt @@ -0,0 +1,63 @@ +package org.justinvisser.wolfskin.tv.read + +import java.io.IOException +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import org.json.JSONArray +import org.json.JSONException +import org.json.JSONObject +import org.justinvisser.wolfskin.conductarr.CacheEntry +import org.justinvisser.wolfskin.conductarr.CacheStore +import org.justinvisser.wolfskin.design.LibraryTitle +import org.justinvisser.wolfskin.design.WolfskinTokens + +/** Saved data is published before refresh; an unreachable server never replaces it with an error. */ +data class LibrarySnapshot(val items: List<LibraryTitle>, val savedAtMillis: Long?, val unreachable: Boolean) + +/** Library snapshots share the existing atomic disk store, partitioned by server and person. */ +class LibrarySnapshotStore(private val cache: CacheStore, private val now: () -> Long = System::currentTimeMillis) { + fun read(scope: String, destination: String, force: Boolean = false, invalidatedAtMillis: Long = 0L, load: () -> Flow<List<LibraryTitle>>): Flow<LibrarySnapshot> = flow { + val key = "library:$destination" + val saved = try { cache.read(scope, key) } catch (_: IOException) { null } + val titles = saved?.let { try { decode(it.body) } catch (_: JSONException) { null } } + if (saved != null && titles != null) emit(LibrarySnapshot(titles, saved.savedAtMillis, false)) + val lifetime = (WolfskinTokens.Behaviour.libraryRefreshMinutes * 60_000).toLong() + if (!force && saved != null && titles != null && saved.validatedAtMillis > invalidatedAtMillis && now() - saved.validatedAtMillis < lifetime) return@flow + var current: List<LibraryTitle> = emptyList() + try { + load().collect { chunk -> + current = chunk + if (titles == null) emit(LibrarySnapshot(chunk, null, false)) + } + val time = now() + if (titles != null) emit(LibrarySnapshot(current, time, false)) + try { cache.write(scope, key, CacheEntry(encode(current), null, time, time, group = "library")) } catch (_: IOException) { /* A readable server remains usable without writable local storage. */ } + } catch (exception: Exception) { + if (exception is CancellationException) throw exception + emit(LibrarySnapshot(titles ?: current, saved?.savedAtMillis.takeIf { titles != null }, true)) + } + }.flowOn(Dispatchers.IO) + + private fun encode(items: List<LibraryTitle>): String = JSONArray().apply { + items.forEach { title -> put(JSONObject().apply { + put("id", title.id); put("title", title.title); put("type", title.type) + put("year", title.year); put("runtime", title.runtimeSeconds); put("seasons", title.seasons) + put("poster", title.poster); put("played", title.played); put("progress", title.progress) + put("added", title.addedAt); put("tmdb", title.tmdbId) + }) } + }.toString() + + private fun decode(body: String): List<LibraryTitle> { + val array = JSONArray(body) + return List(array.length()) { index -> array.getJSONObject(index).let { item -> + LibraryTitle(id = item.getString("id"), title = item.getString("title"), type = item.getString("type"), + year = item.optInt("year").takeIf { it > 0 }, runtimeSeconds = item.optLong("runtime").takeIf { it > 0 }, + seasons = item.optInt("seasons").takeIf { it > 0 }, poster = item.optString("poster").takeIf { it.isNotEmpty() }, + played = item.optBoolean("played"), progress = item.optDouble("progress", 0.0).toFloat(), addedAt = item.optString("added"), + tmdbId = item.optString("tmdb").takeIf { it.isNotEmpty() }) + } } + } +} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/PlaybackRead.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/PlaybackRead.kt new file mode 100644 index 00000000..ad17d57b --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/PlaybackRead.kt @@ -0,0 +1,23 @@ +package org.justinvisser.wolfskin.tv.read + +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.tvShowsApi +import org.jellyfin.sdk.api.client.extensions.userLibraryApi +import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.BaseItemKind +import java.util.UUID + +/** A click resolves its target and checkpoint from Jellyfin, never from a painted projection. */ +internal suspend fun readPlayback(api: ApiClient, userId: UUID?, id: UUID): BaseItemDto? { + val opened = api.userLibraryApi.getItem(itemId = id, userId = userId).content + val target = if (opened.type == BaseItemKind.SERIES) { + api.tvShowsApi.getNextUp(userId = userId, seriesId = id, limit = 1, enableResumable = true, enableUserData = true) + .content.items.firstOrNull()?.id + ?: api.tvShowsApi.getEpisodes(seriesId = id, userId = userId, limit = 1, enableUserData = true).content.items.firstOrNull()?.id + } else opened.id + val item = if (target == opened.id) opened else target?.let { api.userLibraryApi.getItem(itemId = it, userId = userId).content } + return item?.takeIf { it.type == BaseItemKind.EPISODE || it.type == BaseItemKind.MOVIE } +} + +/** Preserve subsecond server checkpoints, including resumable rewatches marked Played. */ +internal fun BaseItemDto.playbackPositionMs(): Long = (userData?.playbackPositionTicks ?: 0L) / 10_000L diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/TitleReadFacade.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/TitleReadFacade.kt new file mode 100644 index 00000000..ff727717 --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/TitleReadFacade.kt @@ -0,0 +1,235 @@ +package org.justinvisser.wolfskin.tv.read + +import android.content.Context +import com.github.damontecres.wholphin.data.ItemPlaybackRepository +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.TrackIndex +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.services.FavoriteWatchManager +import com.github.damontecres.wholphin.services.ImageUrlService +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.json.Json +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.itemsApi +import org.jellyfin.sdk.api.client.extensions.tvShowsApi +import org.jellyfin.sdk.api.client.extensions.userLibraryApi +import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.ImageType +import org.jellyfin.sdk.model.api.ItemFields +import org.jellyfin.sdk.model.api.ItemSortBy +import org.jellyfin.sdk.model.api.MediaStreamType +import org.jellyfin.sdk.model.api.request.GetEpisodesRequest +import org.jellyfin.sdk.model.api.request.GetItemsRequest +import org.jellyfin.sdk.model.api.request.GetNextUpRequest +import org.jellyfin.sdk.model.serializer.toUUIDOrNull +import org.json.JSONObject +import org.justinvisser.wolfskin.design.TitleAction +import org.justinvisser.wolfskin.design.TitleConnection +import org.justinvisser.wolfskin.design.TitleItem +import org.justinvisser.wolfskin.design.TitlePageData +import org.justinvisser.wolfskin.design.TitlePerson +import org.justinvisser.wolfskin.design.TitleTrack +import org.justinvisser.wolfskin.design.TitleVersion +import org.justinvisser.wolfskin.design.WolfskinTokens +import java.io.File +import java.util.Locale +import java.util.UUID +import javax.inject.Inject + +data class PersonRead(val person: TitlePerson, val credits: List<TitleItem>, val savedAt: String? = null) + +/** Jellyfin SDK changes are confined to this boundary; title UI sees immutable screen data only. */ +class TitleReadFacade @Inject constructor( + private val api: ApiClient, + private val images: ImageUrlService, + private val playback: ItemPlaybackRepository, + private val repository: ServerRepository, + private val watched: FavoriteWatchManager, + @param:ApplicationContext private val context: Context, +) { + private val json = Json { ignoreUnknownKeys = true } + private val fields = listOf(ItemFields.PROVIDER_IDS, ItemFields.MEDIA_SOURCES, ItemFields.MEDIA_STREAMS, ItemFields.OVERVIEW, ItemFields.PEOPLE, ItemFields.PARENT_ID) + + /** Resolve through this user's Jellyfin permissions before granting a catalogue card playback. */ + suspend fun findHeld(tmdb: Long, media: String): UUID? { + val response = api.request(org.jellyfin.sdk.api.client.HttpMethod.GET, "/Items", + mapOf("UserId" to repository.currentUser?.id, "Recursive" to true, + "IncludeItemTypes" to if (media == "movie") "Movie" else "Series", + "AnyProviderIdEquals" to "Tmdb.$tmdb", "Fields" to "ProviderIds", "Limit" to 1), emptyMap(), null) + .createContent<org.jellyfin.sdk.model.api.BaseItemDtoQueryResult>() + return response.items?.firstOrNull { row -> + row.type == (if (media == "movie") BaseItemKind.MOVIE else BaseItemKind.SERIES) && + row.providerIds?.entries?.any { it.key.equals("Tmdb", true) && it.value?.toLongOrNull() == tmdb } == true + }?.id + } + + /** A disk copy is decoded before any network read; ownership is isolated by server and user. */ + suspend fun readSaved(id: UUID, preferences: UserPreferences): TitlePageData? = withContext(Dispatchers.IO) { + val file = cacheFile(id) + if (!file.exists()) return@withContext null + try { + val saved = JSONObject(file.readText()) + val rows = json.decodeFromString(ListSerializer(BaseItemDto.serializer()), saved.getString("items")) + val numbers = saved.getJSONArray("seasonNumbers").let { array -> List(array.length()) { array.getInt(it) } } + build(rows, numbers, saved.optInt("selectedSeason"), saved.getString("watch"), saved.optString("target").takeIf { it.isNotEmpty() }, preferences) + .copy(savedAt = savedTime(file), trailerUrl = saved.optString("trailer").takeIf { it.isNotEmpty() }, watchReady = false) + } catch (_: java.io.IOException) { null } catch (_: IllegalArgumentException) { null } catch (_: org.json.JSONException) { null } + } + + /** An episode opens its series with that exact episode as both play and watched target. */ + suspend fun read(id: UUID, season: Int?, preferences: UserPreferences): TitlePageData = withContext(Dispatchers.IO) { + val file = cacheFile(id) + val opened = readItem(id) + val series = if (opened.type == BaseItemKind.EPISODE && opened.seriesId != null) readItem(opened.seriesId!!) else opened + val seasons = if (series.type == BaseItemKind.SERIES) api.itemsApi.getItems(GetItemsRequest(parentId = series.id, includeItemTypes = listOf(BaseItemKind.SEASON), recursive = false, sortBy = listOf(ItemSortBy.INDEX_NUMBER))).content.items.orEmpty() else emptyList() + val next = if (opened.type == BaseItemKind.EPISODE || opened.type == BaseItemKind.MOVIE) opened else { + api.tvShowsApi.getNextUp(GetNextUpRequest(seriesId = series.id, userId = repository.currentUser?.id, limit = 1, fields = fields, enableResumable = true)).content.items.firstOrNull()?.let { api.userLibraryApi.getItem(it.id).content } + } + val selectedNumber = season ?: next?.parentIndexNumber ?: seasons.firstOrNull()?.indexNumber + val selected = seasons.firstOrNull { it.indexNumber == selectedNumber } ?: seasons.firstOrNull() + val episodes = selected?.let { api.tvShowsApi.getEpisodes(GetEpisodesRequest(seriesId = series.id, seasonId = it.id, fields = fields)).content.items.orEmpty() }.orEmpty() + val target = next ?: episodes.firstOrNull { it.type == BaseItemKind.EPISODE } + val rows = (listOf(series, opened) + listOfNotNull(target) + episodes).distinctBy { it.id } + val numbers = seasons.mapNotNull { it.indexNumber } + val result = build(rows, numbers, selected?.indexNumber, opened.id.toString(), target?.id?.toString(), preferences) + val localTrailer = if (result.trailerUrl == null && (series.localTrailerCount ?: 0) > 0) api.userLibraryApi.getLocalTrailers(series.id).content.firstOrNull()?.id?.toString() else null + val trailer = result.trailerUrl ?: localTrailer?.let { "jellyfin:$it" } + val saved = JSONObject().put("items", json.encodeToString(ListSerializer(BaseItemDto.serializer()), rows)) + .put("seasonNumbers", org.json.JSONArray(numbers)).put("selectedSeason", selected?.indexNumber).put("watch", opened.id.toString()) + .put("target", target?.id?.toString().orEmpty()).put("trailer", trailer.orEmpty()) + if (cacheFile(id) != file) throw kotlinx.coroutines.CancellationException("Title identity changed") + file.parentFile?.mkdirs() + try { file.writeText(saved.toString()) } catch (_: java.io.IOException) { /* Playback remains available when the disk cannot save a copy. */ } + result.copy(trailerUrl = trailer) + } + + /** User-library detail may omit metadata fields; request catalogue identity and cast explicitly. */ + private suspend fun readItem(id: UUID): BaseItemDto { + val item = api.userLibraryApi.getItem(id).content + if (item.providerIds.orEmpty().keys.any { it.equals("Tmdb", true) } && item.people != null) return item + val metadata = try { + api.itemsApi.getItems(GetItemsRequest(ids = listOf(id), userId = repository.currentUser?.id, + fields = listOf(ItemFields.PROVIDER_IDS, ItemFields.PEOPLE))).content.items.orEmpty().firstOrNull { it.id == id } + } catch (exception: Exception) { + if (exception is kotlinx.coroutines.CancellationException) throw exception + null + } ?: return item + return item.copy(providerIds = metadata.providerIds ?: item.providerIds, people = item.people ?: metadata.people) + } + + /** Series actions re-resolve NextUp; explicit episodes and Restart retain their exact identity. */ + suspend fun preparePlayback(id: UUID): BaseItemDto? = + readPlayback(api, repository.currentUser?.id, id) + + /** Track selection uses the same repository consumed by the upstream player. */ + suspend fun apply(action: TitleAction) = withContext(Dispatchers.IO) { + when (action) { + is TitleAction.Version -> playback.savePlayVersion( + UUID.fromString(action.item.id), + requireNotNull(action.version.id.toUUIDOrNull()) { "Invalid Jellyfin media source identifier" }, + ) + is TitleAction.Track -> { + val id = UUID.fromString(action.item.id) + val item = BaseItem(api.userLibraryApi.getItem(id).content) + var choice = repository.currentUser?.let { playback.itemPlaybackDao.getItem(it, id) } + action.audio?.let { choice = playback.saveTrackSelection(item, choice, it, MediaStreamType.AUDIO) } + playback.saveTrackSelection(item, choice, if (action.subtitle == -1) TrackIndex.DISABLED else action.subtitle, MediaStreamType.SUBTITLE) + } + is TitleAction.Watched -> watched.setWatched(UUID.fromString(action.item.id), action.watched) + else -> Unit + } + Unit + } + + /** A person keeps the same server/user-scoped last good copy as a title. */ + suspend fun readPersonSaved(id: UUID): PersonRead? = withContext(Dispatchers.IO) { + val file = personCacheFile(id) + if (!file.exists()) return@withContext null + try { + val rows = json.decodeFromString(ListSerializer(BaseItemDto.serializer()), file.readText()) + personData(id, rows.first(), rows.drop(1)).copy(savedAt = savedTime(file)) + } catch (_: java.io.IOException) { null } catch (_: IllegalArgumentException) { null } + } + + /** Held performer credits are read continuously; a signed-out TV never falls back to Seerr. */ + suspend fun readPerson(id: UUID, start: Int): PersonRead = withContext(Dispatchers.IO) { + val file = personCacheFile(id) + val previous = if (start > 0 && file.exists()) { + try { json.decodeFromString(ListSerializer(BaseItemDto.serializer()), file.readText()) } + catch (_: java.io.IOException) { emptyList() } catch (_: IllegalArgumentException) { emptyList() } + } else emptyList() + val person = previous.firstOrNull() ?: api.userLibraryApi.getItem(id).content + val credits = api.itemsApi.getItems(GetItemsRequest(personIds = listOf(id), recursive = true, + includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES), fields = fields, startIndex = start, limit = 200, + sortBy = listOf(ItemSortBy.PRODUCTION_YEAR, ItemSortBy.SORT_NAME))).content.items.orEmpty() + val rows = listOf(person) + (previous.drop(1) + credits).distinctBy { it.id } + if (personCacheFile(id) != file) throw kotlinx.coroutines.CancellationException("Performer identity changed") + file.parentFile?.mkdirs() + try { file.writeText(json.encodeToString(ListSerializer(BaseItemDto.serializer()), rows)) } + catch (_: java.io.IOException) { /* The readable grid does not disappear when saving fails. */ } + personData(id, person, credits) + } + + private fun personData(id: UUID, person: BaseItemDto, credits: List<BaseItemDto>): PersonRead = + PersonRead(TitlePerson(id.toString(), person.name.orEmpty(), portrait = images.getItemImageUrl(id, ImageType.PRIMARY, maxWidth = 240, maxHeight = 240), biography = person.overview.orEmpty()), + credits.map { credit -> mapItem(credit).copy(character = credit.people.orEmpty().firstOrNull { it.id == id }?.role) }) + + private suspend fun build(rows: List<BaseItemDto>, seasonNumbers: List<Int>, selectedSeason: Int?, watchId: String, targetId: String?, preferences: UserPreferences): TitlePageData { + val item = rows.first() + val target = rows.firstOrNull { it.id.toString() == targetId } + val chosen = target?.let { playback.getSelectedTracks(it.id, BaseItem(it), preferences) } + val versions = target?.mediaSources.orEmpty().map { source -> + val streams = source.mediaStreams.orEmpty() + TitleVersion(source.id.orEmpty(), source.name ?: source.path ?: source.id.orEmpty(), streams.firstOrNull { it.type == MediaStreamType.VIDEO }?.displayTitle.orEmpty(), + source.bitrate?.let { String.format(Locale.getDefault(), "%.1f Mbps", it / 1_000_000.0) }.orEmpty(), + source.size?.let { String.format(Locale.getDefault(), "%.1f GB", it / 1_000_000_000.0) }.orEmpty(), source.container.orEmpty().uppercase(), + streams.filter { it.type == MediaStreamType.AUDIO }.map { TitleTrack(it.index, it.displayTitle ?: it.language ?: it.codec.orEmpty(), it.isDefault) }, + streams.filter { it.type == MediaStreamType.SUBTITLE }.map { TitleTrack(it.index, it.displayTitle ?: it.language ?: it.codec.orEmpty(), it.isDefault) }) + } + val pageItem = mapItem(item).copy(seasons = seasonNumbers.size) + return TitlePageData(pageItem, target?.takeIf { it.type == BaseItemKind.EPISODE || (it.userData?.playbackPositionTicks ?: 0) > 0 }?.let { mapItem(it, pageItem.backdrop) }, + rows.first { it.id.toString() == watchId }.let { mapItem(it, pageItem.backdrop) }, versions, chosen?.source?.id, chosen?.audioStream?.stream?.index, + if (chosen?.subtitlesDisabled == true) -1 else chosen?.subtitleStream?.stream?.index ?: -1, + item.remoteTrailers.orEmpty().firstNotNullOfOrNull { it.url }, + cast = item.people.orEmpty().filter { it.type.toString().equals("Actor", true) }.map { person -> + TitlePerson(person.id.toString(), person.name.orEmpty(), person.role.orEmpty(), person.primaryImageTag?.let { images.getItemImageUrl(person.id, ImageType.PRIMARY, maxWidth = 200, maxHeight = 200, tag = it) }) + }, + episodes = rows.filter { it.type == BaseItemKind.EPISODE && it.parentIndexNumber == selectedSeason }.map { mapItem(it, pageItem.backdrop) }, + connection = TitleConnection.SignedOut, seasonNumbers = seasonNumbers, selectedSeason = selectedSeason) + } + + private fun mapItem(item: BaseItemDto, seriesBackdrop: String? = null): TitleItem = TitleItem( + item.id.toString(), item.name.orEmpty(), when (item.type) { BaseItemKind.MOVIE -> "movie"; BaseItemKind.EPISODE -> "episode"; else -> "tv" }, + item.productionYear, (item.runTimeTicks ?: 0) / 10_000_000, overview = item.overview.orEmpty(), genres = item.genres.orEmpty(), + poster = item.imageTags?.get(ImageType.PRIMARY)?.let { images.getItemImageUrl(item.id, ImageType.PRIMARY, maxWidth = 256, maxHeight = 384, tag = it) }, + backdrop = if (item.type == BaseItemKind.EPISODE) episodeArtwork(item, seriesBackdrop) else item.backdropImageTags?.firstOrNull()?.let { images.getItemImageUrl(item.id, ImageType.BACKDROP, maxWidth = WolfskinTokens.Image.heroRequestWidth, maxHeight = WolfskinTokens.Image.heroRequestHeight, tag = it) }, + held = true, watched = item.userData?.played == true, resumeSeconds = (item.userData?.playbackPositionTicks ?: 0) / 10_000_000, + seriesId = item.seriesId?.toString(), seriesName = item.seriesName, season = item.parentIndexNumber ?: 0, episode = item.indexNumber ?: 0, + tmdbId = item.providerIds?.entries?.firstOrNull { it.key.equals("Tmdb", true) }?.value?.toLongOrNull(), + ) + + private fun episodeArtwork(item: BaseItemDto, seriesBackdrop: String?): String? { + val type = when { + item.imageTags?.get(ImageType.PRIMARY) != null -> ImageType.PRIMARY + item.imageTags?.get(ImageType.THUMB) != null -> ImageType.THUMB + else -> null + } + if (type != null) return images.getItemImageUrl(item.id, type, + maxWidth = WolfskinTokens.Image.episodeRequestWidth, maxHeight = WolfskinTokens.Image.episodeRequestHeight, tag = item.imageTags?.get(type)) + return seriesBackdrop ?: item.parentBackdropItemId?.let { parent -> + item.parentBackdropImageTags?.firstOrNull()?.let { tag -> + images.getItemImageUrl(parent, ImageType.BACKDROP, + maxWidth = WolfskinTokens.Image.episodeRequestWidth, maxHeight = WolfskinTokens.Image.episodeRequestHeight, tag = tag) + } + } + } + + private fun cacheFile(id: UUID): File = File(context.filesDir, "wolfskin-title/${repository.currentServer?.id}/${repository.currentUser?.id}/$id.json") + private fun personCacheFile(id: UUID): File = File(cacheFile(id).parentFile, "person-$id.json") + private fun savedTime(file: File): String = java.text.SimpleDateFormat("HH:mm", Locale.getDefault()).format(java.util.Date(file.lastModified())) +} diff --git a/build.gradle.kts b/build.gradle.kts index 7e765b97..4ff28386 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -6,4 +6,5 @@ plugins { alias(libs.plugins.ksp) apply false alias(libs.plugins.kotlin.compose) apply false alias(libs.plugins.hilt) apply false + alias(libs.plugins.android.library) apply false } diff --git a/conductarr-client/.gitignore b/conductarr-client/.gitignore new file mode 100644 index 00000000..84c048a7 --- /dev/null +++ b/conductarr-client/.gitignore @@ -0,0 +1 @@ +/build/ diff --git a/conductarr-client/README.md b/conductarr-client/README.md new file mode 100644 index 00000000..a589b36d --- /dev/null +++ b/conductarr-client/README.md @@ -0,0 +1,64 @@ +# Conductarr TV client + +A UI-free JVM module for the TV operations in the [approved TV spec](https://pages.justinvisser.org/wolfskin/tv-spec), sections 3 and 8. The Android app supplies the currently selected Jellyfin login through its person-scoped `ConductarrSession`. + +## Pinned contract and generator decision + +`openapi/openapi.json` is the unmodified OpenAPI 3.1.0 document from Conductarr commit `7e8f3f0a1c9a88d67b2a9518c1161db84ade531a`. `openapi/commit.sha` records the source. It was read with `git show <commit>:openapi.json` from the Conductarr clone, not downloaded from a running service. + +The approved fallback is used: checked-in, maintained kotlinx.serialization data classes and a thin typed TV API, rather than build-time generation. + +The OpenAPI Generator **7.26.0** probe used the `kotlin` generator with `serializationLibrary=kotlinx_serialization`, `useCoroutines=true`, `modelMutable=false` and `enumPropertyNaming=UPPERCASE`. Generation completed with its OpenAPI 3.1 beta warning. Nullable arrays were retained. However, the unconstrained JSON schema at `ErrorDetail.value` became `@Contextual Any?`. The generated serializer module has no `Any` adapter. Compiling the generated `ErrorDetail` and decoding `{"value":{"nested":[1,null,true]}}` with kotlinx.serialization produced: + +```text +Serializer for class 'Any' is not found. +``` + +Thus the generator does **not** handle every construct in this pinned document. The fallback uses `JsonElement?` for arbitrary error values. Its regression test covers objects, arrays, numbers, booleans and null. + +`ContractTest` checks: + +- All 22 TV method/path pairs against the pinned paths, including response and mutation body schemas. +- All 70 maintained schemas through their actual serialization descriptors: fields, references, primitive widths, collections, maps, enum wire values and requiredness. Read-only `$schema` metadata is intentionally ignored. Optional scalar properties use nullable Kotlin fields to represent absence; encoding omits them. +- A separately reviewed schema snapshot, `src/test/resources/model-contracts.json`, so changes to formats, limits, defaults or other schema constraints also require explicit review. Models preserve those wire shapes; Conductarr remains responsible for business validation and authority. + +When updating the pin, update the models and reviewed schema snapshot together. Do not regenerate the Seerr client into this module or substitute a running server's schema. + +## API and identity + +Create a `ConductarrClient(baseUrl, jellyfinToken, cache)` for the selected person's Jellyfin session. The caller supplies the token; the client sends exactly `Authorization: MediaBrowser Token="<token>"`. The token must match the pinned contract's 32-hex Jellyfin access-token shape. HTTPS is required except for loopback fake servers. Base URL credentials, query strings and fragments are rejected. A path prefix is preserved. + +`TvRoutes` provides typed calls for operator, profiles, feed, dismissals, catalogue options/discovery/title/cast/related/enrichment/ratings/display/seasons, performers/filmography, activity/readiness, requests and library audiences. Request actors cannot be supplied: only the authenticated operator establishes the actor. The request body's requester selects the beneficiary. + +`client.read(call, trigger)` is a cold `Flow<ReadResult<T>>`: collect it once per screen-level read. It emits saved data before starting network work, then a revalidation result when due. `ReadResult.Failure.saved` retains LKG on transport, HTTP and decoding failure. A 401 returns `NeedsSignIn` **without** fallback, invalidates that client's identity-scoped saved data, and prevents further calls on that instance. Create a new instance after sign-in/person switch. No Seerr fallback exists. + +`client.mutate(call)` returns a typed success or error. It never uses cached receipts, retries or follows redirects. Unknown request outcomes must be checked using `TvRoutes.requests(tmdb, media)`; do not blindly resend. + +The transport owns its OkHttp client rather than accepting arbitrary credential-logging interceptors. Diagnostics expose only `ClientEvent` enum values. Returned errors exclude bodies and exception text. Default timeouts: connection 10 s, read 20 s, whole call 30 s. At most two catalogue reads run concurrently. Close the client when its identity/session is discarded. + +## Saved data and refresh events + +Pass an **app-private, backup-excluded directory** to `FileCacheStore`; one store instance owns a directory in a process. Writes use a synced temporary file and atomic same-filesystem replacement. Corrupt records become misses. `MemoryCacheStore` is the deterministic fake. Data is not encrypted: cached household content belongs in private app storage, never shared/external storage. + +Cache keys partition by normalized server URL, credential digest, endpoint, path parameters and sorted query. Raw credentials are neither stored nor logged. A stale lifetime means refresh eligibility, **not deletion** of LKG. `Saved.savedAtMillis` supplies the Saved HH:MM timestamp; 304 preserves that timestamp while advancing validation time. + +| Operation family | Refresh rule | +|---|---| +| Operator and recommendation profiles | Retain indefinitely; `LAUNCH` refreshes | +| Library audiences | Daily | +| Feed | Server `expires_at`; conditional ETag revalidation | +| Catalogue options | `FOREGROUND` refreshes | +| Discovery pages | 30 minutes per parameterized lane/page | +| Ratings-only read | 6 hours; 300 entries per movie/TV group; 404 means saved absence, failures never overwrite LKG | +| Title, cast, related, enrichment, display, performer, filmography, dismissals, readiness | `OPEN` refreshes | +| Seasons | 6 hours for episode scores; `OPEN` refreshes on title-season focus; retained per season, 300 entries | +| Activity | 30 seconds for caller-driven `POLL`, or `FOREGROUND` | +| Title requests and request mutations | No cache | + +`IF_STALE` observes lifetimes; `FORCE` explicitly revalidates; `CACHE_ONLY` paints without network. The caller schedules launch, foreground, screen-open and activity polling events; the module does not run a poller or own a UI spinner. Title-related calls should be hoisted, not issued by individual cards. ETags are sent when present on any endpoint, regardless of `Cache-Control: no-store`; this is the TV-owned application cache, not an HTTP response cache. + +## Verification + +The root `make test` selects `testDefaultDebugUnitTest` in this JVM module as well as the app. That module alias runs the JUnit contract/MockWebServer suite and the `ClientSmokeKt` executable. The smoke exercises a real loopback socket, disk reopen, ETag 304, and 503 recovery. All tests are local; none contact Conductarr or Jellyfin. + +The build gate is `make test` followed by `make apk-debug`, using the repository's serialized Docker Gradle wrapper. No signed release, installation, ADB, version change or production activation is part of this module. diff --git a/conductarr-client/build.gradle.kts b/conductarr-client/build.gradle.kts new file mode 100644 index 00000000..d4e32aa0 --- /dev/null +++ b/conductarr-client/build.gradle.kts @@ -0,0 +1,36 @@ +plugins { + `java-library` + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.kotlin.plugin.serialization) +} + +kotlin { + compilerOptions.jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11) +} +java { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 +} + +dependencies { + api(libs.kotlinx.serialization.json) + api(platform(libs.okhttp.bom)) + implementation(libs.okhttp) + api("org.jetbrains.kotlinx:kotlinx-coroutines-core:${libs.versions.kotlinxCoroutinesTest.get()}") + testImplementation(libs.junit) + testImplementation("com.squareup.okhttp3:mockwebserver3") +} + +sourceSets.test { resources.srcDir("openapi") } + +val smoke by tasks.registering(JavaExec::class) { + dependsOn(tasks.testClasses) + classpath = sourceSets.test.get().runtimeClasspath + mainClass.set("org.justinvisser.wolfskin.conductarr.ClientSmokeKt") + mustRunAfter(tasks.test) +} + +// The root gate selects this name across all projects, including this JVM module. +tasks.register("testDefaultDebugUnitTest") { + dependsOn(tasks.test, smoke) +} diff --git a/conductarr-client/openapi/commit.sha b/conductarr-client/openapi/commit.sha new file mode 100644 index 00000000..9944c1d5 --- /dev/null +++ b/conductarr-client/openapi/commit.sha @@ -0,0 +1 @@ +7e8f3f0a1c9a88d67b2a9518c1161db84ade531a diff --git a/conductarr-client/openapi/openapi.json b/conductarr-client/openapi/openapi.json new file mode 100644 index 00000000..5486db7c --- /dev/null +++ b/conductarr-client/openapi/openapi.json @@ -0,0 +1,16347 @@ +{ + "components": { + "schemas": { + "AbandonInputBody": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/AbandonInputBody.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "reason": { + "description": "required non-empty audited reason for abandoning the candidate", + "type": "string" + } + }, + "required": [ + "reason" + ], + "type": "object" + }, + "ActingCredit": { + "additionalProperties": false, + "properties": { + "character": { + "type": "string" + }, + "credit_id": { + "type": "string" + }, + "episode_count": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "credit_id", + "character" + ], + "type": "object" + }, + "ActionResult": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/ActionResult.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "cleared": { + "description": "queue records cleared by a reviewed closeout", + "format": "int64", + "type": "integer" + }, + "detail": { + "type": "string" + }, + "health_state": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "status": { + "description": "ok | blocked | busy | not_eligible | missing | invalid | arr_error", + "type": "string" + } + }, + "required": [ + "status" + ], + "type": "object" + }, + "ActivityFeed": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/ActivityFeed.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "items": { + "items": { + "$ref": "#/components/schemas/RequestProgress" + }, + "type": [ + "array", + "null" + ] + }, + "requester": { + "description": "Household key the feed is attributed to, or all for the household", + "type": "string" + }, + "sources": { + "items": { + "$ref": "#/components/schemas/EvidenceSource" + }, + "type": [ + "array", + "null" + ] + }, + "truncated": { + "description": "More recent requests exist than the window returned", + "type": "boolean" + } + }, + "required": [ + "requester", + "items", + "truncated", + "sources" + ], + "type": "object" + }, + "Actor": { + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + }, + "requester": { + "description": "The acting operator's own household requester key", + "type": "string" + }, + "role": { + "enum": [ + "admin", + "household" + ], + "type": "string" + } + }, + "required": [ + "name", + "role", + "requester" + ], + "type": "object" + }, + "ArrGateDrop": { + "additionalProperties": false, + "properties": { + "indexer": { + "type": "string" + }, + "rejections": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "release": { + "type": "string" + } + }, + "required": [ + "indexer", + "release", + "rejections" + ], + "type": "object" + }, + "Candidate": { + "additionalProperties": false, + "properties": { + "backdrop_path": { + "description": "TMDB-relative landscape artwork path; absent when none is observed", + "type": "string" + }, + "contributions": { + "items": { + "$ref": "#/components/schemas/SeedContribution" + }, + "type": [ + "array", + "null" + ] + }, + "genre_ids": { + "description": "Observed candidate genre IDs; absence does not prove no genres", + "items": { + "format": "int64", + "type": "integer" + }, + "type": [ + "array", + "null" + ] + }, + "media_type": { + "enum": [ + "movie", + "tv" + ], + "type": "string" + }, + "overview": { + "type": "string" + }, + "poster_path": { + "type": "string" + }, + "score": { + "format": "double", + "type": "number" + }, + "title": { + "type": "string" + }, + "tmdb_id": { + "format": "int64", + "type": "integer" + }, + "tvdb_id": { + "format": "int64", + "type": "integer" + }, + "year": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "tvdb_id", + "title", + "overview", + "poster_path", + "year", + "score", + "contributions", + "media_type", + "tmdb_id" + ], + "type": "object" + }, + "CandidateReviewMapping": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/CandidateReviewMapping.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "available": { + "type": "boolean" + }, + "episode": { + "format": "int64", + "type": "integer" + }, + "episode_id": { + "format": "int64", + "type": "integer" + }, + "key": { + "description": "SxxEyy", + "type": "string" + }, + "season": { + "format": "int64", + "type": "integer" + }, + "title": { + "type": "string" + } + }, + "required": [ + "available" + ], + "type": "object" + }, + "CastMember": { + "additionalProperties": false, + "properties": { + "character": { + "type": "string" + }, + "credit_id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "order": { + "format": "int64", + "type": "integer" + }, + "performer_id": { + "format": "int64", + "type": "integer" + }, + "profile_url": { + "type": "string" + } + }, + "required": [ + "performer_id", + "name", + "character", + "credit_id", + "order" + ], + "type": "object" + }, + "CreateReplacementHoldInputBody": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/CreateReplacementHoldInputBody.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "arr": { + "description": "primary Arr instance name", + "minLength": 1, + "type": "string" + }, + "episode_ids": { + "description": "one or more Sonarr episode ids; mutually exclusive with movie_id", + "items": { + "format": "int64", + "type": "integer" + }, + "type": [ + "array", + "null" + ] + }, + "movie_id": { + "description": "one Radarr movie id; mutually exclusive with episode_ids", + "format": "int64", + "type": "integer" + }, + "reason": { + "description": "why this replacement is being held", + "minLength": 1, + "type": "string" + }, + "ttl_hours": { + "description": "hold duration in hours; defaults to 168", + "format": "int64", + "maximum": 720, + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "arr", + "reason" + ], + "type": "object" + }, + "DeleteBody": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/DeleteBody.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "result": { + "$ref": "#/components/schemas/DeleteResult", + "description": "the delete outcome (ok | partial | conflict | inconclusive | missing_in_arr | arr_error | not_wired | error) with pruned + mirror counts" + } + }, + "required": [ + "result" + ], + "type": "object" + }, + "DeleteRequest": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/DeleteRequest.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "scope_id": { + "description": "The scope_id of the deletion impact the operator confirmed; a different or unobtainable current scope deletes nothing", + "pattern": "^[0-9a-f]{64}$", + "type": "string" + } + }, + "required": [ + "scope_id" + ], + "type": "object" + }, + "DeleteResult": { + "additionalProperties": false, + "properties": { + "detail": { + "type": "string" + }, + "media_type": { + "type": "string" + }, + "mirrors": { + "format": "int64", + "type": "integer" + }, + "orphans": { + "format": "int64", + "type": "integer" + }, + "pruned": { + "format": "int64", + "type": "integer" + }, + "status": { + "type": "string" + }, + "tmdb_id": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "tmdb_id", + "media_type", + "status" + ], + "type": "object" + }, + "DeletionAudience": { + "additionalProperties": false, + "properties": { + "bucket": { + "description": "Present only for a layer source", + "type": "string" + }, + "libraries": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "owner": { + "description": "Membership owner label; absent for direct import sources", + "type": "string" + }, + "source": { + "description": "Configured layer:bucket or import:root-folder-basename reference", + "type": "string" + }, + "users": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + } + }, + "required": [ + "source", + "libraries", + "users" + ], + "type": "object" + }, + "DeletionCopy": { + "additionalProperties": false, + "properties": { + "arr_kind": { + "type": "string" + }, + "arr_name": { + "type": "string" + }, + "id": { + "format": "int64", + "type": "integer" + }, + "path": { + "type": "string" + }, + "present": { + "type": "boolean" + }, + "title": { + "type": "string" + }, + "year": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "arr_kind", + "arr_name", + "present", + "id", + "title", + "year", + "path" + ], + "type": "object" + }, + "DeletionImpact": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/DeletionImpact.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "detail": { + "type": "string" + }, + "media_type": { + "type": "string" + }, + "scope": { + "$ref": "#/components/schemas/DeletionScope" + }, + "scope_id": { + "description": "SHA-256 comparison fingerprint, not authorization; fresh observation required before execution", + "type": "string" + }, + "status": { + "enum": [ + "ready", + "missing", + "inconclusive" + ], + "type": "string" + }, + "tmdb_id": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "tmdb_id", + "media_type", + "status" + ], + "type": "object" + }, + "DeletionScope": { + "additionalProperties": false, + "properties": { + "audiences": { + "description": "Configured libraries/viewers affected through memberships, layer links or present primary/mirror import paths", + "items": { + "$ref": "#/components/schemas/DeletionAudience" + }, + "type": [ + "array", + "null" + ] + }, + "links": { + "description": "Managed layer symlinks to unlink, including separately labelled orphan collateral; emptied directories are pruned", + "items": { + "$ref": "#/components/schemas/RemovalLink" + }, + "type": [ + "array", + "null" + ] + }, + "memberships": { + "description": "Exact membership buckets to remove; no watched-state erasure", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "mirrors": { + "description": "Every configured rule-derived mirror, including verified absences", + "items": { + "$ref": "#/components/schemas/DeletionCopy" + }, + "type": [ + "array", + "null" + ] + }, + "primary": { + "$ref": "#/components/schemas/DeletionCopy" + }, + "retained_links": { + "description": "Existing layer links retained by the executor but losing their present primary/mirror source content; not unlink effects", + "items": { + "$ref": "#/components/schemas/RemovalLink" + }, + "type": [ + "array", + "null" + ] + } + }, + "required": [ + "primary", + "mirrors", + "memberships", + "links", + "retained_links", + "audiences" + ], + "type": "object" + }, + "Detail": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/Detail.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "genres": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "held_title": { + "$ref": "#/components/schemas/HeldTitle" + }, + "metadata": { + "$ref": "#/components/schemas/TitleMetadata" + }, + "metadata_state": { + "enum": [ + "observed", + "missing", + "unavailable" + ], + "type": "string" + }, + "observations": { + "items": { + "$ref": "#/components/schemas/LibraryObservation" + }, + "type": [ + "array", + "null" + ] + }, + "ratings": { + "items": { + "$ref": "#/components/schemas/Rating" + }, + "type": [ + "array", + "null" + ] + }, + "runtime": { + "format": "int64", + "type": "integer" + }, + "seasons": { + "items": { + "$ref": "#/components/schemas/Season" + }, + "type": [ + "array", + "null" + ] + }, + "state": { + "enum": [ + "observed", + "partial", + "unavailable" + ], + "type": "string" + }, + "title": { + "$ref": "#/components/schemas/Title" + } + }, + "required": [ + "state", + "title", + "metadata_state", + "genres", + "seasons", + "metadata", + "observations" + ], + "type": "object" + }, + "Discovery": { + "additionalProperties": false, + "properties": { + "genre_id": { + "format": "int64", + "type": "integer" + }, + "next": { + "description": "Same selected query's next public page, bounded at page 500; absence alone does not prove catalogue exhaustion", + "type": "string" + }, + "region": { + "description": "Comma-separated selected service regions; NL for unfiltered discovery", + "type": "string" + }, + "service_id": { + "format": "int64", + "type": "integer" + }, + "sort": { + "enum": [ + "trending", + "popular", + "new", + "top", + "upcoming" + ], + "type": "string" + }, + "state": { + "description": "Empty is an answered zero-match lane; exhausted is an empty page beyond upstream total_pages; partial cannot prove absence", + "enum": [ + "observed", + "empty", + "partial", + "exhausted" + ], + "type": "string" + } + }, + "required": [ + "genre_id", + "service_id", + "sort", + "region", + "state" + ], + "type": "object" + }, + "DispatchLogEntry": { + "additionalProperties": false, + "properties": { + "arr_kind": { + "type": "string" + }, + "arr_name": { + "type": "string" + }, + "bucket": { + "type": "string" + }, + "detail": { + "type": "string" + }, + "id": { + "format": "int64", + "type": "integer" + }, + "media_type": { + "type": "string" + }, + "mirrored_to": { + "type": "string" + }, + "outcome": { + "type": "string" + }, + "requested_by": { + "type": "string" + }, + "rule": { + "type": "string" + }, + "source": { + "type": "string" + }, + "title": { + "type": "string" + }, + "tmdb_id": { + "format": "int64", + "type": "integer" + }, + "ts": { + "type": "string" + } + }, + "required": [ + "id", + "ts", + "tmdb_id", + "media_type", + "outcome" + ], + "type": "object" + }, + "Display": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/Display.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "media_type": { + "enum": [ + "movie", + "tv" + ], + "type": "string" + }, + "metadata_state": { + "enum": [ + "observed", + "missing", + "unavailable" + ], + "type": "string" + }, + "poster_url": { + "type": "string" + }, + "title": { + "type": "string" + }, + "tmdb_id": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "tmdb_id", + "media_type", + "metadata_state" + ], + "type": "object" + }, + "DownloadsBody": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/DownloadsBody.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "provenance": { + "description": "current primary-Arr library items classified by the acquisition source of their current files: torrent, spot, manual, or usenet-streamed. torrent means at least one record still holds the file a torrent handoff imported; once managed usenet imports have replaced every such file, the item reads as that usenet lane and its torrent history stays in the torrent lane's handoff log", + "items": { + "$ref": "#/components/schemas/ProvenanceItem" + }, + "type": [ + "array", + "null" + ] + }, + "rows": { + "description": "one row per physical download, grouped by Arr target and download id", + "items": { + "$ref": "#/components/schemas/Row" + }, + "type": [ + "array", + "null" + ] + }, + "usage": { + "description": "library item counts and cached actual local-dataset bytes by provenance source", + "items": { + "$ref": "#/components/schemas/SourceUsage" + }, + "type": [ + "array", + "null" + ] + } + }, + "required": [ + "rows", + "provenance", + "usage" + ], + "type": "object" + }, + "DryRunPlan": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/DryRunPlan.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "is_anime": { + "description": "bucket-routing anime-ness (from the matched rule)", + "type": "boolean" + }, + "matched": { + "description": "false when no arr had a lookup candidate or no rule matched", + "type": "boolean" + }, + "mirrors": { + "description": "rule-derived mirror instances the add fans out to", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "rule": { + "type": "string" + }, + "target": { + "description": "routed primary arr instance name", + "type": "string" + }, + "target_kind": { + "type": "string" + }, + "title": { + "type": "string" + } + }, + "required": [ + "matched", + "mirrors", + "is_anime" + ], + "type": "object" + }, + "Enrichment": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/Enrichment.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "genres": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "metadata": { + "$ref": "#/components/schemas/TitleMetadata" + }, + "metadata_state": { + "enum": [ + "observed", + "missing", + "unavailable" + ], + "type": "string" + }, + "observations": { + "items": { + "$ref": "#/components/schemas/LibraryObservation" + }, + "type": [ + "array", + "null" + ] + }, + "ratings": { + "items": { + "$ref": "#/components/schemas/Rating" + }, + "type": [ + "array", + "null" + ] + }, + "runtime": { + "format": "int64", + "type": "integer" + }, + "seasons": { + "items": { + "$ref": "#/components/schemas/Season" + }, + "type": [ + "array", + "null" + ] + }, + "state": { + "enum": [ + "observed", + "unavailable" + ], + "type": "string" + }, + "title": { + "$ref": "#/components/schemas/Title" + } + }, + "required": [ + "state", + "title", + "metadata_state", + "genres", + "seasons", + "metadata", + "observations", + "ratings" + ], + "type": "object" + }, + "Episode": { + "additionalProperties": false, + "properties": { + "air_date": { + "type": "string" + }, + "name": { + "type": "string" + }, + "number": { + "format": "int64", + "type": "integer" + }, + "overview": { + "type": "string" + }, + "recorded": { + "enum": [ + "recorded", + "missing", + "unknown" + ], + "type": "string" + }, + "vote_average": { + "description": "Provider episode score on a 0–10 scale; absence is not zero.", + "format": "double", + "type": "number" + }, + "vote_count": { + "description": "Provider episode vote count; explicit zero is retained.", + "format": "int64", + "type": "integer" + } + }, + "required": [ + "number", + "name", + "overview", + "recorded" + ], + "type": "object" + }, + "EpisodeFileChip": { + "additionalProperties": false, + "properties": { + "quality": { + "type": "string" + }, + "release_name": { + "type": "string" + }, + "resolution": { + "type": "string" + }, + "size_bytes": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "size_bytes" + ], + "type": "object" + }, + "EpisodeOption": { + "additionalProperties": false, + "properties": { + "absolute_episode": { + "format": "int64", + "type": "integer" + }, + "air_date": { + "type": "string" + }, + "episode": { + "format": "int64", + "type": "integer" + }, + "episode_id": { + "format": "int64", + "type": "integer" + }, + "has_file": { + "type": "boolean" + }, + "in_payload": { + "type": "boolean" + }, + "key": { + "description": "SxxEyy", + "type": "string" + }, + "monitored": { + "type": "boolean" + }, + "reason": { + "type": "string" + }, + "season": { + "format": "int64", + "type": "integer" + }, + "selectable": { + "type": "boolean" + }, + "title": { + "type": "string" + } + }, + "required": [ + "episode_id", + "season", + "episode", + "key", + "monitored", + "has_file", + "in_payload", + "selectable" + ], + "type": "object" + }, + "EpisodeRow": { + "additionalProperties": false, + "properties": { + "air_date": { + "type": "string" + }, + "episode": { + "format": "int64", + "type": "integer" + }, + "file": { + "$ref": "#/components/schemas/EpisodeFileChip" + }, + "has_file": { + "type": "boolean" + }, + "key": { + "description": "SxxEyy", + "type": "string" + }, + "monitored": { + "type": "boolean" + }, + "queue_state": { + "description": "Arr queue status for this exact episode; omitted when no matching queue row was observed", + "type": "string" + }, + "runtime_minutes": { + "description": "Sonarr episode runtime in minutes; omitted when Sonarr reports none", + "format": "int64", + "type": "integer" + }, + "season": { + "format": "int64", + "type": "integer" + }, + "title": { + "type": "string" + }, + "watched": { + "additionalProperties": { + "type": "boolean" + }, + "type": "object" + } + }, + "required": [ + "season", + "episode", + "key", + "monitored", + "has_file", + "watched" + ], + "type": "object" + }, + "EpisodeView": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/EpisodeView.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "owners": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "seasons": { + "items": { + "$ref": "#/components/schemas/SeasonGroup" + }, + "type": [ + "array", + "null" + ] + }, + "title": { + "type": "string" + }, + "tmdb_id": { + "format": "int64", + "type": "integer" + }, + "users": { + "description": "jellyfin usernames whose sees: matrix covers this item (watch-chip columns)", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + } + }, + "required": [ + "tmdb_id", + "title", + "owners", + "users", + "seasons" + ], + "type": "object" + }, + "ErrorDetail": { + "additionalProperties": false, + "properties": { + "location": { + "description": "Where the error occurred, e.g. 'body.items[3].tags' or 'path.thing-id'", + "type": "string" + }, + "message": { + "description": "Error message text", + "type": "string" + }, + "value": { + "description": "The value at the given location" + } + }, + "type": "object" + }, + "ErrorModel": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/ErrorModel.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "detail": { + "description": "A human-readable explanation specific to this occurrence of the problem.", + "examples": [ + "Property foo is required but is missing." + ], + "type": "string" + }, + "errors": { + "description": "Optional list of individual error details", + "items": { + "$ref": "#/components/schemas/ErrorDetail" + }, + "type": [ + "array", + "null" + ] + }, + "instance": { + "description": "A URI reference that identifies the specific occurrence of the problem.", + "examples": [ + "https://example.com/error-log/abc123" + ], + "format": "uri", + "type": "string" + }, + "status": { + "description": "HTTP status code", + "examples": [ + 400 + ], + "format": "int64", + "type": "integer" + }, + "title": { + "description": "A short, human-readable summary of the problem type. This value should not change between occurrences of the error.", + "examples": [ + "Bad Request" + ], + "type": "string" + }, + "type": { + "default": "about:blank", + "description": "A URI reference to human-readable documentation for the error.", + "examples": [ + "https://example.com/errors/example" + ], + "format": "uri", + "type": "string" + } + }, + "type": "object" + }, + "EvidenceSource": { + "additionalProperties": false, + "properties": { + "detail": { + "type": "string" + }, + "name": { + "type": "string" + }, + "state": { + "enum": [ + "observed", + "unavailable" + ], + "type": "string" + } + }, + "required": [ + "name", + "state" + ], + "type": "object" + }, + "FileDetail": { + "additionalProperties": false, + "properties": { + "audio": { + "type": "string" + }, + "container": { + "type": "string" + }, + "quality": { + "type": "string" + }, + "release_name": { + "type": "string" + }, + "resolution": { + "type": "string" + }, + "size_bytes": { + "format": "int64", + "type": "integer" + }, + "video": { + "type": "string" + } + }, + "required": [ + "size_bytes" + ], + "type": "object" + }, + "FillGapsResult": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/FillGapsResult.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "detail": { + "type": "string" + }, + "searched_count": { + "format": "int64", + "type": "integer" + }, + "status": { + "description": "ok | busy | missing_in_arr | arr_error", + "type": "string" + } + }, + "required": [ + "status", + "searched_count" + ], + "type": "object" + }, + "FilmographyPage": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/FilmographyPage.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "completeness": { + "description": "Seerr filters adult credits and excludes crew here; not an exhaustive career", + "enum": [ + "returned-non-adult-cast" + ], + "type": "string" + }, + "items": { + "items": { + "$ref": "#/components/schemas/FilmographyTitle" + }, + "type": [ + "array", + "null" + ] + }, + "metadata_state": { + "enum": [ + "observed", + "missing", + "unavailable" + ], + "type": "string" + }, + "observations": { + "items": { + "$ref": "#/components/schemas/LibraryObservation" + }, + "type": [ + "array", + "null" + ] + }, + "page": { + "format": "int64", + "type": "integer" + }, + "page_size": { + "format": "int64", + "type": "integer" + }, + "performer_id": { + "format": "int64", + "type": "integer" + }, + "source": { + "enum": [ + "seerr" + ], + "type": "string" + }, + "source_paging": { + "enum": [ + "unpaged" + ], + "type": "string" + }, + "total_pages": { + "description": "Native pages of grouped typed titles; absent when source is unanswered", + "format": "int64", + "type": "integer" + }, + "total_results": { + "description": "Unique typed titles in this returned source set; absent when source is unanswered", + "format": "int64", + "type": "integer" + } + }, + "required": [ + "performer_id", + "metadata_state", + "source", + "source_paging", + "completeness", + "page", + "page_size", + "items", + "observations" + ], + "type": "object" + }, + "FilmographyTitle": { + "additionalProperties": false, + "properties": { + "credits": { + "items": { + "$ref": "#/components/schemas/ActingCredit" + }, + "type": [ + "array", + "null" + ] + }, + "title": { + "$ref": "#/components/schemas/Title" + }, + "users": { + "description": "Configured viewers when visibility_state is configured; otherwise unknown", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "visibility_state": { + "description": "Configured library audience only, never observed Jellyfin visibility or playback", + "enum": [ + "configured", + "unknown" + ], + "type": "string" + } + }, + "required": [ + "title", + "credits", + "visibility_state", + "users" + ], + "type": "object" + }, + "Finding": { + "additionalProperties": false, + "properties": { + "id": { + "format": "int64", + "type": "integer" + }, + "message": { + "type": "string" + }, + "severity": { + "description": "Raw Arr HealthCheckResult; informational update warnings remain warning", + "enum": [ + "ok", + "notice", + "warning", + "error" + ], + "type": "string" + }, + "source": { + "type": "string" + }, + "state": { + "description": "Health impact, independent of the raw severity", + "enum": [ + "healthy", + "degraded", + "unhealthy" + ], + "type": "string" + }, + "wiki_url": { + "type": "string" + } + }, + "required": [ + "source", + "severity", + "message", + "state" + ], + "type": "object" + }, + "Genre": { + "additionalProperties": false, + "properties": { + "id": { + "format": "int64", + "type": "integer" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ], + "type": "object" + }, + "HealthDetail": { + "additionalProperties": false, + "properties": { + "failed": { + "type": "boolean" + }, + "has_file": { + "type": "boolean" + }, + "in_queue": { + "type": "boolean" + }, + "missing": { + "type": "boolean" + } + }, + "required": [ + "has_file", + "in_queue", + "failed", + "missing" + ], + "type": "object" + }, + "HealthzBody": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/HealthzBody.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "config": { + "$ref": "#/components/schemas/HealthzConfig", + "description": "Provenance of the config bytes this process parsed" + }, + "features": { + "$ref": "#/components/schemas/HealthzFeatures", + "description": "Config gates the cockpit shows or hides surfaces on" + }, + "modules": { + "additionalProperties": { + "$ref": "#/components/schemas/HealthzModule" + }, + "description": "Per-module status from the registry", + "type": "object" + }, + "state": { + "description": "Aggregate state; a disabled module is excluded from it", + "enum": [ + "starting", + "healthy", + "degraded", + "unhealthy", + "stopped" + ], + "type": "string" + } + }, + "required": [ + "state", + "modules", + "features" + ], + "type": "object" + }, + "HealthzConfig": { + "additionalProperties": false, + "properties": { + "path": { + "description": "Config file the process loaded", + "type": "string" + }, + "sha256": { + "description": "Hex sha256 of the raw config bytes; compare with a host-side sha256sum", + "type": "string" + }, + "size": { + "description": "Byte length of those config bytes", + "format": "int64", + "type": "integer" + } + }, + "type": "object" + }, + "HealthzFeatures": { + "additionalProperties": false, + "properties": { + "torrent_fallback": { + "description": "features.torrent_fallback: the torrent lane and its Torrent tab", + "type": "boolean" + } + }, + "required": [ + "torrent_fallback" + ], + "type": "object" + }, + "HealthzModule": { + "additionalProperties": false, + "properties": { + "checked_at": { + "description": "Cached check time; for Arr health this is the last valid sample, omitted before the first valid sample", + "format": "date-time", + "type": "string" + }, + "detail": { + "description": "Human-readable context for non-healthy states", + "type": "string" + }, + "findings": { + "description": "Cached application diagnostics, preserving raw severity separately from health impact", + "items": { + "$ref": "#/components/schemas/Finding" + }, + "type": [ + "array", + "null" + ] + }, + "kind": { + "description": "process | glue; empty when the restart surface is not wired (spec emission)", + "type": "string" + }, + "observation": { + "$ref": "#/components/schemas/Observation", + "description": "Application observation freshness and latest attempt failure; omitted for modules without this contract" + }, + "state": { + "description": "Module state; disabled means a config gate turned this subsystem off", + "enum": [ + "starting", + "healthy", + "degraded", + "unhealthy", + "stopped", + "disabled" + ], + "type": "string" + } + }, + "required": [ + "state" + ], + "type": "object" + }, + "HeldTitle": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/HeldTitle.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "detail": { + "$ref": "#/components/schemas/ItemDetail" + }, + "episodes": { + "$ref": "#/components/schemas/EpisodeView" + }, + "libraries": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "observations": { + "items": { + "$ref": "#/components/schemas/LibraryObservation" + }, + "type": [ + "array", + "null" + ] + }, + "state": { + "enum": [ + "observed", + "partial" + ], + "type": "string" + }, + "versions": { + "items": { + "$ref": "#/components/schemas/TitleVersion" + }, + "type": [ + "array", + "null" + ] + }, + "watched": { + "additionalProperties": { + "type": "boolean" + }, + "type": "object" + } + }, + "required": [ + "state", + "detail", + "libraries", + "versions", + "observations", + "watched" + ], + "type": "object" + }, + "HistoryReport": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/HistoryReport.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "from": { + "format": "date-time", + "type": "string" + }, + "modules": { + "items": { + "$ref": "#/components/schemas/ModuleHistory" + }, + "type": [ + "array", + "null" + ] + }, + "samples": { + "format": "int64", + "type": "integer" + }, + "to": { + "format": "date-time", + "type": "string" + }, + "window": { + "type": "string" + } + }, + "required": [ + "window", + "from", + "to", + "samples", + "modules" + ], + "type": "object" + }, + "HoldingStats": { + "additionalProperties": false, + "properties": { + "missing": { + "format": "int64", + "type": "integer" + }, + "size_bytes": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "size_bytes", + "missing" + ], + "type": "object" + }, + "Item": { + "additionalProperties": false, + "properties": { + "arr_kind": { + "type": "string" + }, + "arr_name": { + "type": "string" + }, + "is_anime": { + "type": "boolean" + }, + "media_type": { + "type": "string" + }, + "owners": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "poster_ref": { + "type": "string" + }, + "title": { + "type": "string" + }, + "tmdb_id": { + "format": "int64", + "type": "integer" + }, + "year": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "tmdb_id", + "media_type", + "title", + "owners", + "is_anime", + "arr_kind", + "arr_name" + ], + "type": "object" + }, + "ItemDetail": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/ItemDetail.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "backdrop_ref": { + "description": "Public provider landscape artwork from the holding Arr", + "type": "string" + }, + "file": { + "$ref": "#/components/schemas/FileDetail" + }, + "health": { + "$ref": "#/components/schemas/HealthDetail" + }, + "is_anime": { + "type": "boolean" + }, + "media_type": { + "type": "string" + }, + "owners": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "poster_ref": { + "description": "Public provider poster artwork from the holding Arr", + "type": "string" + }, + "runtime": { + "description": "minutes", + "format": "int64", + "type": "integer" + }, + "source": { + "$ref": "#/components/schemas/SourceDetail" + }, + "title": { + "type": "string" + }, + "tmdb_id": { + "format": "int64", + "type": "integer" + }, + "users": { + "description": "jellyfin usernames whose sees: matrix covers this item (movie watch-chip columns)", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "year": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "tmdb_id", + "media_type", + "title", + "owners", + "is_anime", + "source", + "health", + "users" + ], + "type": "object" + }, + "ItemsBody": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/ItemsBody.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "counts": { + "description": "owner-rail counts including Unassigned", + "items": { + "$ref": "#/components/schemas/OwnerCount" + }, + "type": [ + "array", + "null" + ] + }, + "items": { + "description": "poster-grid entries: title/year/poster ref/owners/anime badge", + "items": { + "$ref": "#/components/schemas/Item" + }, + "type": [ + "array", + "null" + ] + }, + "library": { + "$ref": "#/components/schemas/RailStats", + "description": "owner-rail Library stats: size/last import/queue/missing/failed" + }, + "media_type": { + "type": "string" + } + }, + "required": [ + "media_type", + "items", + "counts", + "library" + ], + "type": "object" + }, + "LayerVisibilityActionOutputBody": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/LayerVisibilityActionOutputBody.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "action": { + "$ref": "#/components/schemas/LayerVisibilityActionView" + }, + "events": { + "items": { + "$ref": "#/components/schemas/LayerVisibilityEventView" + }, + "type": [ + "array", + "null" + ] + }, + "observation": { + "$ref": "#/components/schemas/LayerVisibilityObservationView" + } + }, + "required": [ + "action", + "events" + ], + "type": "object" + }, + "LayerVisibilityActionView": { + "additionalProperties": false, + "properties": { + "action_id": { + "description": "durable layer action identity; the approval names this", + "type": "string" + }, + "bucket": { + "type": "string" + }, + "created_at": { + "format": "date-time", + "type": "string" + }, + "detail": { + "type": "string" + }, + "media_type": { + "enum": [ + "movie", + "tv" + ], + "type": "string" + }, + "operation": { + "enum": [ + "MaterializeAndRefresh" + ], + "type": "string" + }, + "stage": { + "type": "string" + }, + "tmdb_id": { + "format": "int64", + "type": "integer" + }, + "updated_at": { + "format": "date-time", + "type": "string" + }, + "version": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "action_id", + "tmdb_id", + "media_type", + "bucket", + "operation", + "stage", + "detail", + "version", + "created_at", + "updated_at" + ], + "type": "object" + }, + "LayerVisibilityActionsOutputBody": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/LayerVisibilityActionsOutputBody.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "items": { + "items": { + "$ref": "#/components/schemas/LayerVisibilityActionView" + }, + "type": [ + "array", + "null" + ] + } + }, + "required": [ + "items" + ], + "type": "object" + }, + "LayerVisibilityApprovalView": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/LayerVisibilityApprovalView.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "action_id": { + "type": "string" + }, + "bucket": { + "type": "string" + }, + "detail": { + "type": "string" + }, + "stage": { + "type": "string" + }, + "updated_at": { + "format": "date-time", + "type": "string" + }, + "version": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "action_id", + "bucket", + "stage", + "detail", + "version", + "updated_at" + ], + "type": "object" + }, + "LayerVisibilityEventView": { + "additionalProperties": false, + "properties": { + "created_at": { + "format": "date-time", + "type": "string" + }, + "from_stage": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "to_stage": { + "type": "string" + } + }, + "required": [ + "from_stage", + "to_stage", + "reason", + "created_at" + ], + "type": "object" + }, + "LayerVisibilityExpectedItemView": { + "additionalProperties": false, + "properties": { + "key": { + "description": "movie sentinel or SxxEyy logical identity", + "type": "string" + }, + "path": { + "description": "expected owned-layer media-source path", + "type": "string" + }, + "provider": { + "description": "provider episode identity when available", + "type": "string" + } + }, + "required": [ + "key", + "path" + ], + "type": "object" + }, + "LayerVisibilityObservationView": { + "additionalProperties": false, + "properties": { + "bucket": { + "type": "string" + }, + "detail": { + "type": "string" + }, + "expected": { + "items": { + "$ref": "#/components/schemas/LayerVisibilityExpectedItemView" + }, + "type": [ + "array", + "null" + ] + }, + "first_drift_at": { + "format": "date-time", + "type": "string" + }, + "media_type": { + "enum": [ + "movie", + "tv" + ], + "type": "string" + }, + "observed_at": { + "format": "date-time", + "type": "string" + }, + "status": { + "enum": [ + "converged", + "drift", + "scanner_pending", + "unknown", + "no_files", + "unreleased", + "needs_operator" + ], + "type": "string" + }, + "tmdb_id": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "tmdb_id", + "media_type", + "bucket", + "status", + "detail", + "observed_at", + "expected" + ], + "type": "object" + }, + "LayerVisibilityObservationsOutputBody": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/LayerVisibilityObservationsOutputBody.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "items": { + "items": { + "$ref": "#/components/schemas/LayerVisibilityObservationView" + }, + "type": [ + "array", + "null" + ] + } + }, + "required": [ + "items" + ], + "type": "object" + }, + "LegacyMovieEntry": { + "additionalProperties": false, + "properties": { + "bucket": { + "type": "string" + }, + "entry_path": { + "type": "string" + }, + "tmdb_id": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "tmdb_id", + "bucket", + "entry_path" + ], + "type": "object" + }, + "LibraryActivity": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/LibraryActivity.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "failed": { + "description": "Titles with a recent failed grab across sources whose history answered", + "format": "int64", + "type": "integer" + }, + "history_state": { + "description": "Whether every source answered its import history; partial means failed and last_import are lower bounds", + "enum": [ + "observed", + "partial", + "unavailable" + ], + "type": "string" + }, + "last_import": { + "description": "Newest import timestamp across sources whose history answered", + "type": "string" + }, + "media_type": { + "type": "string" + }, + "queue": { + "description": "Active downloads across sources whose queue answered; never a stand-in for an unanswered queue", + "format": "int64", + "type": "integer" + }, + "queue_state": { + "description": "Whether every source answered its download queue; partial means queue is a lower bound", + "enum": [ + "observed", + "partial", + "unavailable" + ], + "type": "string" + }, + "sources": { + "description": "Per-source answers; detail names the unanswered read", + "items": { + "$ref": "#/components/schemas/LibraryObservation" + }, + "type": [ + "array", + "null" + ] + }, + "state": { + "description": "Weakest of queue_state and history_state", + "enum": [ + "observed", + "partial", + "unavailable" + ], + "type": "string" + } + }, + "required": [ + "state", + "queue_state", + "history_state", + "media_type", + "queue", + "failed", + "sources" + ], + "type": "object" + }, + "LibraryAudience": { + "additionalProperties": false, + "properties": { + "is_anime": { + "type": "boolean" + }, + "libraries": { + "description": "Enabled configured household libraries covering this membership scope", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "media_type": { + "enum": [ + "movie", + "tv" + ], + "type": "string" + }, + "owner": { + "description": "Named household membership used by the existing add, move and remove interface", + "type": "string" + }, + "users": { + "description": "Enabled Jellyfin usernames whose configured sees covers this scope, not watch-write authority", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + } + }, + "required": [ + "owner", + "media_type", + "is_anime", + "libraries", + "users" + ], + "type": "object" + }, + "LibraryAudiencesBody": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/LibraryAudiencesBody.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "audiences": { + "items": { + "$ref": "#/components/schemas/LibraryAudience" + }, + "type": [ + "array", + "null" + ] + } + }, + "required": [ + "audiences" + ], + "type": "object" + }, + "LibraryCoverage": { + "additionalProperties": false, + "properties": { + "complete": { + "description": "All active predicates classified and collection sources observed; not metadata freshness or playback proof", + "type": "boolean" + }, + "matched": { + "format": "int64", + "type": "integer" + }, + "metadata_observed": { + "description": "Titles with both latest genre and provider outcomes observed, not a freshness guarantee", + "format": "int64", + "type": "integer" + }, + "total": { + "format": "int64", + "type": "integer" + }, + "unresolved": { + "format": "int64", + "type": "integer" + }, + "watched": { + "description": "Watched source observation; absent/conflicting per-title flags remain unknown even after a successful collection read", + "enum": [ + "not_requested", + "observed", + "unavailable" + ], + "type": "string" + } + }, + "required": [ + "total", + "matched", + "unresolved", + "metadata_observed", + "complete", + "watched" + ], + "type": "object" + }, + "LibraryFact": { + "additionalProperties": false, + "properties": { + "metadata": { + "$ref": "#/components/schemas/TitleMetadata" + }, + "watched": { + "enum": [ + "watched", + "unwatched", + "unknown" + ], + "type": "string" + } + }, + "required": [ + "metadata", + "watched" + ], + "type": "object" + }, + "LibraryItem": { + "additionalProperties": false, + "properties": { + "added_at": { + "description": "When the title joined its holding Arr; not an import or playback timestamp", + "type": "string" + }, + "arr_kind": { + "type": "string" + }, + "arr_name": { + "type": "string" + }, + "backdrop_ref": { + "description": "Remote landscape artwork from the holding Arr", + "type": "string" + }, + "is_anime": { + "type": "boolean" + }, + "libraries": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "media_type": { + "type": "string" + }, + "missing": { + "type": "boolean" + }, + "owners": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "poster_ref": { + "type": "string" + }, + "title": { + "type": "string" + }, + "tmdb_id": { + "format": "int64", + "type": "integer" + }, + "users": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "year": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "libraries", + "users", + "missing", + "tmdb_id", + "media_type", + "title", + "owners", + "is_anime", + "arr_kind", + "arr_name" + ], + "type": "object" + }, + "LibraryObservation": { + "additionalProperties": false, + "properties": { + "detail": { + "type": "string" + }, + "observed_at": { + "format": "date-time", + "type": "string" + }, + "source": { + "type": "string" + }, + "state": { + "enum": [ + "observed", + "partial", + "unavailable" + ], + "type": "string" + } + }, + "required": [ + "source", + "state" + ], + "type": "object" + }, + "LibraryResult": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/LibraryResult.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "counts": { + "items": { + "$ref": "#/components/schemas/OwnerCount" + }, + "type": [ + "array", + "null" + ] + }, + "coverage": { + "$ref": "#/components/schemas/LibraryCoverage" + }, + "facts": { + "items": { + "$ref": "#/components/schemas/LibraryFact" + }, + "type": [ + "array", + "null" + ] + }, + "items": { + "items": { + "$ref": "#/components/schemas/LibraryItem" + }, + "type": [ + "array", + "null" + ] + }, + "library": { + "type": "string" + }, + "media_type": { + "type": "string" + }, + "observation_age_ms": { + "description": "Age of observed_at when this response was built", + "format": "int64", + "type": "integer" + }, + "observed_at": { + "description": "Oldest answering source read behind this collection; may predate the request by the shared-acquisition window", + "format": "date-time", + "type": "string" + }, + "query": { + "type": "string" + }, + "snapshot": { + "type": "string" + }, + "sources": { + "items": { + "$ref": "#/components/schemas/LibraryObservation" + }, + "type": [ + "array", + "null" + ] + }, + "state": { + "enum": [ + "observed", + "partial", + "unavailable" + ], + "type": "string" + }, + "stats": { + "$ref": "#/components/schemas/HoldingStats" + }, + "unresolved": { + "items": { + "$ref": "#/components/schemas/LibraryItem" + }, + "type": [ + "array", + "null" + ] + }, + "watch_checked_at": { + "description": "Successful watched title-collection observation time; omitted for unrequested or unavailable reads", + "format": "date-time", + "type": "string" + } + }, + "required": [ + "snapshot", + "observed_at", + "observation_age_ms", + "facts", + "unresolved", + "coverage", + "state", + "library", + "media_type", + "query", + "items", + "counts", + "sources", + "stats" + ], + "type": "object" + }, + "MediaHealthBreakerHoldInputBody": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/MediaHealthBreakerHoldInputBody.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "reason": { + "description": "REQUIRED non-empty reason for suspending detection", + "type": "string" + } + }, + "required": [ + "reason" + ], + "type": "object" + }, + "MediaHealthBreakerResult": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/MediaHealthBreakerResult.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "detail": { + "type": "string" + }, + "healthy_epochs": { + "description": "clean control epochs credited so far; two close the breaker", + "format": "int64", + "type": "integer" + }, + "held": { + "type": "boolean" + }, + "held_at": { + "type": "string" + }, + "held_by": { + "type": "string" + }, + "hold_reason": { + "type": "string" + }, + "open": { + "type": "boolean" + }, + "reason": { + "description": "the detector fault that opened the breaker, if any", + "type": "string" + } + }, + "required": [ + "open", + "held", + "healthy_epochs", + "detail" + ], + "type": "object" + }, + "MediaHealthConsumerView": { + "additionalProperties": false, + "properties": { + "arr": { + "description": "kind/name of the holding arr", + "type": "string" + }, + "episode_key": { + "description": "SxxEyy; \"-\" for a movie", + "type": "string" + }, + "import_path": { + "type": "string" + }, + "imported_at": { + "type": "string" + }, + "record_type": { + "description": "episode | movie", + "type": "string" + }, + "title": { + "type": "string" + }, + "tmdb_id": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "arr", + "record_type", + "tmdb_id" + ], + "type": "object" + }, + "MediaHealthDecisionInputBody": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/MediaHealthDecisionInputBody.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "reason": { + "description": "audited operator note; REQUIRED to reject", + "type": "string" + } + }, + "type": "object" + }, + "MediaHealthDecisionResult": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/MediaHealthDecisionResult.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "detail": { + "type": "string" + }, + "state": { + "description": "the proposal status after the decision", + "type": "string" + }, + "status": { + "description": "ok | not_eligible | missing | invalid", + "type": "string" + } + }, + "required": [ + "status" + ], + "type": "object" + }, + "MediaHealthDetail": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/MediaHealthDetail.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "history": { + "items": { + "$ref": "#/components/schemas/MediaHealthRepairEvent" + }, + "type": [ + "array", + "null" + ] + }, + "proposal": { + "$ref": "#/components/schemas/MediaHealthProposalView" + } + }, + "required": [ + "proposal", + "history" + ], + "type": "object" + }, + "MediaHealthProbeInputBody": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/MediaHealthProbeInputBody.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "blob_id": { + "description": "blob id to probe", + "type": "string" + }, + "media_type": { + "description": "Required with tmdb_id for non-admin probes", + "enum": [ + "movie", + "tv" + ], + "type": "string" + }, + "reason": { + "description": "why this blob needs a fresh probe", + "minLength": 1, + "type": "string" + }, + "tmdb_id": { + "description": "Required for non-admin probes: exact title with retained evidence for this blob", + "format": "int64", + "type": "integer" + }, + "webdav_path": { + "description": "WebDAV path to resolve to a blob", + "type": "string" + } + }, + "required": [ + "reason" + ], + "type": "object" + }, + "MediaHealthProbeResult": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/MediaHealthProbeResult.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "blob_id": { + "type": "string" + }, + "requested_at": { + "type": "string" + } + }, + "required": [ + "blob_id", + "requested_at" + ], + "type": "object" + }, + "MediaHealthProposalView": { + "additionalProperties": false, + "properties": { + "blob_id": { + "type": "string" + }, + "confirmed_at": { + "type": "string" + }, + "consumers": { + "items": { + "$ref": "#/components/schemas/MediaHealthConsumerView" + }, + "type": [ + "array", + "null" + ] + }, + "fault_kind": { + "description": "dead_blob", + "type": "string" + }, + "group": { + "description": "review | repairing | closed", + "type": "string" + }, + "media_type": { + "description": "tv | movie", + "type": "string" + }, + "proposal_id": { + "format": "int64", + "type": "integer" + }, + "repair_note": { + "type": "string" + }, + "repair_stage": { + "type": "string" + }, + "size_bytes": { + "format": "int64", + "type": "integer" + }, + "status": { + "description": "the proposal status grammar", + "type": "string" + }, + "summary": { + "description": "what approving this repair will do", + "type": "string" + }, + "title": { + "type": "string" + }, + "tmdb_id": { + "format": "int64", + "type": "integer" + }, + "webdav_path": { + "type": "string" + } + }, + "required": [ + "proposal_id", + "group", + "status", + "fault_kind", + "blob_id", + "confirmed_at", + "media_type", + "summary", + "consumers" + ], + "type": "object" + }, + "MediaHealthRepairEvent": { + "additionalProperties": false, + "properties": { + "at": { + "type": "string" + }, + "from_stage": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "to_stage": { + "type": "string" + } + }, + "required": [ + "to_stage", + "at" + ], + "type": "object" + }, + "MediaHealthReviewResult": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/MediaHealthReviewResult.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "breaker_open": { + "type": "boolean" + }, + "breaker_reason": { + "type": "string" + }, + "group": { + "type": "string" + }, + "proposals": { + "items": { + "$ref": "#/components/schemas/MediaHealthProposalView" + }, + "type": [ + "array", + "null" + ] + } + }, + "required": [ + "breaker_open", + "proposals" + ], + "type": "object" + }, + "MembershipChange": { + "additionalProperties": false, + "properties": { + "action": { + "enum": [ + "move", + "add", + "remove" + ], + "type": "string" + }, + "actor": { + "description": "Server-authenticated subject that submitted this change", + "type": "string" + }, + "items": { + "items": { + "$ref": "#/components/schemas/MembershipItem" + }, + "type": [ + "array", + "null" + ] + }, + "source": { + "type": "string" + }, + "target": { + "type": "string" + } + }, + "required": [ + "action", + "items" + ], + "type": "object" + }, + "MembershipInputBody": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/MembershipInputBody.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "action": { + "enum": [ + "move", + "add", + "remove" + ], + "type": "string" + }, + "items": { + "items": { + "$ref": "#/components/schemas/MembershipItem" + }, + "type": [ + "array", + "null" + ] + }, + "source": { + "type": "string" + }, + "target": { + "type": "string" + } + }, + "required": [ + "action", + "items" + ], + "type": "object" + }, + "MembershipItem": { + "additionalProperties": false, + "properties": { + "is_anime": { + "type": "boolean" + }, + "media_type": { + "type": "string" + }, + "tmdb_id": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "tmdb_id", + "media_type", + "is_anime" + ], + "type": "object" + }, + "MembershipJob": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/MembershipJob.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "change": { + "$ref": "#/components/schemas/MembershipChange" + }, + "error": { + "type": "string" + }, + "id": { + "type": "string" + }, + "results": { + "items": { + "$ref": "#/components/schemas/MembershipResult" + }, + "type": [ + "array", + "null" + ] + }, + "state": { + "enum": [ + "pending", + "running", + "completed", + "failed" + ], + "type": "string" + } + }, + "required": [ + "id", + "state", + "change", + "results" + ], + "type": "object" + }, + "MembershipJobsBody": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/MembershipJobsBody.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "jobs": { + "items": { + "$ref": "#/components/schemas/MembershipJob" + }, + "type": [ + "array", + "null" + ] + } + }, + "required": [ + "jobs" + ], + "type": "object" + }, + "MembershipResult": { + "additionalProperties": false, + "properties": { + "action": { + "type": "string" + }, + "bucket": { + "type": "string" + }, + "media_type": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "source": { + "type": "string" + }, + "status": { + "type": "string" + }, + "target": { + "type": "string" + }, + "tmdb_id": { + "format": "int64", + "type": "integer" + }, + "watch_items": { + "description": "durable rows captured; may also be restored for destination-visible users", + "format": "int64", + "type": "integer" + } + }, + "required": [ + "action", + "source", + "target", + "tmdb_id", + "media_type", + "status" + ], + "type": "object" + }, + "MissingItem": { + "additionalProperties": false, + "properties": { + "check_error": { + "description": "an arr lookup errored — membership uncertain, not pruned", + "type": "boolean" + }, + "media_type": { + "type": "string" + }, + "missing_in": { + "description": "arr instance names the item is absent from", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "rule": { + "type": "string" + }, + "title": { + "type": "string" + }, + "tmdb_id": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "tmdb_id", + "media_type", + "missing_in" + ], + "type": "object" + }, + "MissingOutputBody": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/MissingOutputBody.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "items": { + "items": { + "$ref": "#/components/schemas/MissingItem" + }, + "type": [ + "array", + "null" + ] + } + }, + "required": [ + "items" + ], + "type": "object" + }, + "ModuleHistory": { + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + }, + "series": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "transitions": { + "format": "int64", + "type": "integer" + }, + "uptime": { + "format": "double", + "type": "number" + } + }, + "required": [ + "name", + "series", + "uptime", + "transitions" + ], + "type": "object" + }, + "MovieCutoverBody": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/MovieCutoverBody.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "result": { + "$ref": "#/components/schemas/MovieCutoverResult", + "description": "the cutover outcome, recovery paths, snapshot count and primary path plan" + } + }, + "required": [ + "result" + ], + "type": "object" + }, + "MovieCutoverCandidate": { + "additionalProperties": false, + "properties": { + "buckets": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "entries": { + "items": { + "$ref": "#/components/schemas/LegacyMovieEntry" + }, + "type": [ + "array", + "null" + ] + }, + "recovery_cutover_id": { + "format": "int64", + "type": "integer" + }, + "tmdb_id": { + "format": "int64", + "type": "integer" + }, + "unresolved_paths": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + } + }, + "required": [ + "tmdb_id", + "buckets", + "entries" + ], + "type": "object" + }, + "MovieCutoverEvidence": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/MovieCutoverEvidence.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "entries": { + "items": { + "$ref": "#/components/schemas/LegacyMovieEntry" + }, + "type": [ + "array", + "null" + ] + }, + "latest": { + "$ref": "#/components/schemas/MovieCutoverResult" + }, + "tmdb_id": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "tmdb_id", + "entries" + ], + "type": "object" + }, + "MovieCutoverPendingBody": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/MovieCutoverPendingBody.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "candidates": { + "description": "legacy entries and unfinished recovery cutovers grouped by movie", + "items": { + "$ref": "#/components/schemas/MovieCutoverCandidate" + }, + "type": [ + "array", + "null" + ] + } + }, + "required": [ + "candidates" + ], + "type": "object" + }, + "MovieCutoverResult": { + "additionalProperties": false, + "properties": { + "cutover_id": { + "format": "int64", + "type": "integer" + }, + "detail": { + "type": "string" + }, + "entries": { + "items": { + "$ref": "#/components/schemas/LegacyMovieEntry" + }, + "type": [ + "array", + "null" + ] + }, + "primary_moves": { + "items": { + "$ref": "#/components/schemas/PrimaryMove" + }, + "type": [ + "array", + "null" + ] + }, + "status": { + "type": "string" + }, + "step": { + "type": "string" + }, + "tmdb_id": { + "format": "int64", + "type": "integer" + }, + "unresolved_paths": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "watch_items": { + "format": "int64", + "type": "integer" + }, + "watch_snapshot": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "cutover_id", + "tmdb_id", + "status", + "step", + "watch_items", + "watch_snapshot" + ], + "type": "object" + }, + "NotifyLogEntry": { + "additionalProperties": false, + "properties": { + "event": { + "description": "media_arrived | media_stalled | media_regrab | media_import_issue", + "type": "string" + }, + "hint": { + "description": "context recorded at send time (stall hint, or issue state + sanitized reason folded with arr/episodes/age)", + "type": "string" + }, + "media_type": { + "type": "string" + }, + "owner": { + "description": "the bucket owner (requester) at send time", + "type": "string" + }, + "sent_at": { + "type": "string" + }, + "stage": { + "description": "0 arrived; 1 first-stall; 2 incomplete-followup; regrab attempt / issue step otherwise", + "format": "int64", + "type": "integer" + }, + "title": { + "type": "string" + }, + "tmdb_id": { + "format": "int64", + "type": "integer" + }, + "year": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "tmdb_id", + "media_type", + "event", + "stage", + "sent_at" + ], + "type": "object" + }, + "NotifyRecentOutputBody": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/NotifyRecentOutputBody.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "entries": { + "items": { + "$ref": "#/components/schemas/NotifyLogEntry" + }, + "type": [ + "array", + "null" + ] + } + }, + "required": [ + "entries" + ], + "type": "object" + }, + "Observation": { + "additionalProperties": false, + "properties": { + "attempted_at": { + "description": "Completion time of the latest observation attempt, successful or failed", + "format": "date-time", + "type": "string" + }, + "error": { + "description": "Latest observation failure; retained findings are not a new successful observation", + "type": "string" + }, + "freshness": { + "description": "Unknown before a valid sample; stale only after the freshness threshold", + "enum": [ + "unknown", + "fresh", + "stale" + ], + "type": "string" + } + }, + "required": [ + "freshness" + ], + "type": "object" + }, + "OpenLink": { + "additionalProperties": false, + "properties": { + "detail": { + "type": "string" + }, + "state": { + "enum": [ + "observed", + "missing", + "unavailable", + "unconfigured", + "not_permitted" + ], + "type": "string" + }, + "url": { + "description": "The viewer's own Jellyfin item page; the person signs in to Jellyfin as themself", + "type": "string" + } + }, + "required": [ + "state" + ], + "type": "object" + }, + "Operator": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/Operator.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "capabilities": { + "$ref": "#/components/schemas/OperatorCapabilities" + }, + "name": { + "type": "string" + }, + "recommendation_profile": { + "type": "string" + }, + "requester": { + "type": "string" + }, + "role": { + "enum": [ + "admin", + "household" + ], + "type": "string" + }, + "subject": { + "type": "string" + }, + "watch_user": { + "type": "string" + } + }, + "required": [ + "subject", + "name", + "role", + "requester", + "watch_user", + "recommendation_profile", + "capabilities" + ], + "type": "object" + }, + "OperatorCapabilities": { + "additionalProperties": false, + "properties": { + "administer": { + "type": "boolean" + }, + "manage_library": { + "type": "boolean" + }, + "manage_memberships": { + "type": "boolean" + }, + "recommendation_profiles": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "request_media": { + "type": "boolean" + }, + "requesters": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "watch_users": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + } + }, + "required": [ + "administer", + "manage_library", + "manage_memberships", + "request_media", + "watch_users", + "recommendation_profiles", + "requesters" + ], + "type": "object" + }, + "Option": { + "additionalProperties": false, + "properties": { + "id": { + "format": "int64", + "type": "integer" + }, + "logo_url": { + "description": "Validated TMDB CDN logo for a watch provider; absent for genres and when upstream has no safe logo", + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ], + "type": "object" + }, + "Options": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/Options.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "genres": { + "items": { + "$ref": "#/components/schemas/Option" + }, + "type": [ + "array", + "null" + ] + }, + "media_type": { + "enum": [ + "movie", + "tv" + ], + "type": "string" + }, + "observations": { + "items": { + "$ref": "#/components/schemas/LibraryObservation" + }, + "type": [ + "array", + "null" + ] + }, + "region": { + "description": "Comma-separated configured catalogue regions", + "type": "string" + }, + "services": { + "description": "Curated streaming providers observed in their configured regions, not title availability or playback evidence", + "items": { + "$ref": "#/components/schemas/Option" + }, + "type": [ + "array", + "null" + ] + }, + "state": { + "enum": [ + "observed", + "partial", + "unavailable" + ], + "type": "string" + } + }, + "required": [ + "state", + "media_type", + "region", + "genres", + "services", + "observations" + ], + "type": "object" + }, + "OwnerCount": { + "additionalProperties": false, + "properties": { + "count": { + "format": "int64", + "type": "integer" + }, + "owner": { + "type": "string" + } + }, + "required": [ + "owner", + "count" + ], + "type": "object" + }, + "Page": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/Page.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "discovery": { + "$ref": "#/components/schemas/Discovery", + "description": "General discovery context and upstream page observation, independent of local holding freshness" + }, + "items": { + "items": { + "$ref": "#/components/schemas/Title" + }, + "type": [ + "array", + "null" + ] + }, + "media_type": { + "enum": [ + "all", + "movie", + "tv" + ], + "type": "string" + }, + "observations": { + "items": { + "$ref": "#/components/schemas/LibraryObservation" + }, + "type": [ + "array", + "null" + ] + }, + "page": { + "format": "int64", + "type": "integer" + }, + "query": { + "type": "string" + }, + "source": { + "enum": [ + "search", + "general", + "related" + ], + "type": "string" + }, + "state": { + "enum": [ + "observed", + "partial" + ], + "type": "string" + }, + "total_pages": { + "description": "Known pages; merged regional service lanes expose only the materialized prefix until exhaustion", + "format": "int64", + "type": "integer" + }, + "total_results": { + "description": "Upstream matches before type filtering, or the known deduplicated prefix for merged regional service lanes", + "format": "int64", + "type": "integer" + } + }, + "required": [ + "state", + "source", + "query", + "media_type", + "page", + "total_pages", + "total_results", + "items", + "observations" + ], + "type": "object" + }, + "PayloadSanitizeBlockView": { + "additionalProperties": false, + "properties": { + "dav_dir": { + "type": "string" + }, + "detail": { + "type": "string" + }, + "from_name": { + "type": "string" + }, + "recorded_at": { + "type": "string" + }, + "to_name": { + "type": "string" + } + }, + "required": [ + "dav_dir", + "from_name", + "to_name", + "detail", + "recorded_at" + ], + "type": "object" + }, + "PerformerDetail": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/PerformerDetail.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "also_known_as": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "biography": { + "type": "string" + }, + "birthday": { + "type": "string" + }, + "deathday": { + "type": "string" + }, + "known_for_department": { + "type": "string" + }, + "metadata_state": { + "enum": [ + "observed", + "missing", + "unavailable" + ], + "type": "string" + }, + "name": { + "type": "string" + }, + "performer_id": { + "format": "int64", + "type": "integer" + }, + "place_of_birth": { + "type": "string" + }, + "profile_url": { + "type": "string" + }, + "source": { + "enum": [ + "seerr" + ], + "type": "string" + } + }, + "required": [ + "performer_id", + "metadata_state", + "source" + ], + "type": "object" + }, + "Person": { + "additionalProperties": false, + "properties": { + "key": { + "type": "string" + }, + "name": { + "description": "Jellyfin username the household member is known by", + "type": "string" + } + }, + "required": [ + "key", + "name" + ], + "type": "object" + }, + "PrimaryMove": { + "additionalProperties": false, + "properties": { + "bucket": { + "type": "string" + }, + "new_path": { + "type": "string" + }, + "old_path": { + "type": "string" + } + }, + "required": [ + "bucket", + "old_path", + "new_path" + ], + "type": "object" + }, + "ProvenanceItem": { + "additionalProperties": false, + "properties": { + "arr": { + "type": "string" + }, + "media_type": { + "type": "string" + }, + "owner": { + "type": "string" + }, + "source": { + "type": "string" + }, + "title": { + "type": "string" + }, + "tmdb_id": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "tmdb_id", + "media_type", + "title", + "owner", + "arr", + "source" + ], + "type": "object" + }, + "Provider": { + "additionalProperties": false, + "properties": { + "id": { + "format": "int64", + "type": "integer" + }, + "logo_url": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ], + "type": "object" + }, + "RailStats": { + "additionalProperties": false, + "properties": { + "failed": { + "format": "int64", + "type": "integer" + }, + "last_import": { + "type": "string" + }, + "missing": { + "format": "int64", + "type": "integer" + }, + "queue": { + "format": "int64", + "type": "integer" + }, + "size_bytes": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "size_bytes", + "queue", + "missing", + "failed" + ], + "type": "object" + }, + "Rating": { + "additionalProperties": false, + "properties": { + "score": { + "description": "RT is 0–100; IMDb and TMDB are 0–10. Absent is never zero.", + "format": "double", + "type": "number" + }, + "source": { + "enum": [ + "rt_critics", + "rt_audience", + "imdb", + "tmdb" + ], + "type": "string" + }, + "state": { + "enum": [ + "observed", + "unrated", + "missing", + "unavailable" + ], + "type": "string" + }, + "status": { + "description": "Observed RT critics or audience label, never inferred from a percentage.", + "type": "string" + }, + "url": { + "type": "string" + }, + "votes": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "source", + "state" + ], + "type": "object" + }, + "ReadProfile": { + "additionalProperties": false, + "properties": { + "key": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "key", + "name" + ], + "type": "object" + }, + "ReadinessStage": { + "additionalProperties": false, + "properties": { + "name": { + "enum": [ + "request", + "acquisition", + "visibility", + "playback" + ], + "type": "string" + }, + "observed_at": { + "description": "When the evidence was last observed (RFC 3339 UTC), when the owner records it", + "type": "string" + }, + "source": { + "description": "The evidence owner this stage reads; it never stands in for another stage", + "type": "string" + }, + "state": { + "enum": [ + "done", + "partial", + "in_progress", + "none", + "unknown", + "stale", + "conflicting", + "failed" + ], + "type": "string" + }, + "summary": { + "description": "What this stage's own evidence says, including why it is unknown, stale, conflicting or failed", + "type": "string" + } + }, + "required": [ + "name", + "state", + "summary", + "source" + ], + "type": "object" + }, + "RecentOutputBody": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/RecentOutputBody.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "entries": { + "items": { + "$ref": "#/components/schemas/DispatchLogEntry" + }, + "type": [ + "array", + "null" + ] + } + }, + "required": [ + "entries" + ], + "type": "object" + }, + "RecommendationCoverage": { + "additionalProperties": false, + "properties": { + "complete": { + "description": "Successful ranked snapshot and all active predicates observed for its scoped candidates; not freshness, catalogue completeness or playback proof", + "type": "boolean" + }, + "excluded": { + "format": "int64", + "type": "integer" + }, + "matched": { + "format": "int64", + "type": "integer" + }, + "total": { + "format": "int64", + "type": "integer" + }, + "unresolved": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "total", + "matched", + "excluded", + "unresolved", + "complete" + ], + "type": "object" + }, + "RecommendationDismissal": { + "additionalProperties": false, + "properties": { + "hidden_at": { + "format": "date-time", + "type": "string" + }, + "hidden_by": { + "description": "Household requester key of the operator who hid it", + "type": "string" + }, + "media_type": { + "enum": [ + "movie", + "tv" + ], + "type": "string" + }, + "poster_path": { + "type": "string" + }, + "title": { + "description": "Display name captured when hidden; empty when it was unknown", + "type": "string" + }, + "tmdb_id": { + "format": "int64", + "type": "integer" + }, + "year": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "media_type", + "tmdb_id", + "title", + "year", + "poster_path", + "hidden_by", + "hidden_at" + ], + "type": "object" + }, + "RecommendationDismissals": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/RecommendationDismissals.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "dismissals": { + "items": { + "$ref": "#/components/schemas/RecommendationDismissal" + }, + "type": [ + "array", + "null" + ] + }, + "profile": { + "type": "string" + } + }, + "required": [ + "profile", + "dismissals" + ], + "type": "object" + }, + "RecommendationFact": { + "additionalProperties": false, + "properties": { + "genre_source": { + "description": "Ranked genre context takes precedence; metadata is a fallback only when ranked genres are unknown", + "enum": [ + "ranked", + "metadata", + "unknown" + ], + "type": "string" + }, + "match": { + "enum": [ + "matched", + "excluded", + "unresolved" + ], + "type": "string" + }, + "metadata": { + "$ref": "#/components/schemas/TitleMetadata" + } + }, + "required": [ + "metadata", + "match", + "genre_source" + ], + "type": "object" + }, + "RecommendationFeed": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/RecommendationFeed.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "attempted_at": { + "format": "date-time", + "type": "string" + }, + "expires_at": { + "description": "observed_at plus the configured feed interval; expiry only, not a scheduled refresh or retry", + "format": "date-time", + "type": "string" + }, + "failure": { + "enum": [ + "profile_resolution", + "memberships", + "history", + "candidates", + "cycle" + ], + "type": "string" + }, + "materialization": { + "enum": [ + "unknown", + "pending", + "current", + "failed" + ], + "type": "string" + }, + "movies": { + "items": { + "$ref": "#/components/schemas/Candidate" + }, + "type": [ + "array", + "null" + ] + }, + "observed_at": { + "format": "date-time", + "type": "string" + }, + "outcome": { + "enum": [ + "unknown", + "ranked", + "no_seeds", + "empty" + ], + "type": "string" + }, + "profile": { + "$ref": "#/components/schemas/ReadProfile" + }, + "projection": { + "$ref": "#/components/schemas/RecommendationProjection" + }, + "shows": { + "items": { + "$ref": "#/components/schemas/Candidate" + }, + "type": [ + "array", + "null" + ] + }, + "state": { + "enum": [ + "current", + "stale", + "unavailable" + ], + "type": "string" + } + }, + "required": [ + "projection", + "profile", + "state", + "outcome", + "materialization", + "movies", + "shows" + ], + "type": "object" + }, + "RecommendationHideInputBody": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/RecommendationHideInputBody.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "media_type": { + "enum": [ + "movie", + "tv" + ], + "type": "string" + }, + "poster_path": { + "maxLength": 200, + "type": "string" + }, + "title": { + "description": "Display hint, used only when the retained feed no longer carries the title", + "maxLength": 300, + "type": "string" + }, + "tmdb_id": { + "format": "int64", + "minimum": 1, + "type": "integer" + }, + "year": { + "format": "int64", + "maximum": 9999, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "media_type", + "tmdb_id" + ], + "type": "object" + }, + "RecommendationHideResult": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/RecommendationHideResult.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "created": { + "description": "False when the title was already hidden", + "type": "boolean" + }, + "dismissal": { + "$ref": "#/components/schemas/RecommendationDismissal" + }, + "materialization": { + "description": "Jellyfin recommendation folders after the hide; failed leaves the dismissal in force until the next cycle converges them", + "enum": [ + "current", + "failed", + "unchanged" + ], + "type": "string" + }, + "profile": { + "type": "string" + } + }, + "required": [ + "profile", + "dismissal", + "created", + "materialization" + ], + "type": "object" + }, + "RecommendationProfiles": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/RecommendationProfiles.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "default_profile": { + "type": "string" + }, + "profiles": { + "items": { + "$ref": "#/components/schemas/ReadProfile" + }, + "type": [ + "array", + "null" + ] + } + }, + "required": [ + "profiles", + "default_profile" + ], + "type": "object" + }, + "RecommendationProjection": { + "additionalProperties": false, + "properties": { + "coverage": { + "$ref": "#/components/schemas/RecommendationCoverage" + }, + "facts": { + "description": "Scoped candidates in original movie rank followed by original series rank; unknown observations never prove exclusion", + "items": { + "$ref": "#/components/schemas/RecommendationFact" + }, + "type": [ + "array", + "null" + ] + }, + "genre_id": { + "format": "int64", + "type": "integer" + }, + "media_type": { + "enum": [ + "all", + "movie", + "tv" + ], + "type": "string" + }, + "service_id": { + "format": "int64", + "type": "integer" + }, + "unresolved": { + "description": "Possible matches with unknown active predicates, in original per-media relative order", + "items": { + "$ref": "#/components/schemas/Candidate" + }, + "type": [ + "array", + "null" + ] + } + }, + "required": [ + "media_type", + "genre_id", + "service_id", + "coverage", + "facts", + "unresolved" + ], + "type": "object" + }, + "RecommendationRestoreResult": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/RecommendationRestoreResult.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "media_type": { + "enum": [ + "movie", + "tv" + ], + "type": "string" + }, + "profile": { + "type": "string" + }, + "restored": { + "description": "False when the title was not hidden", + "type": "boolean" + }, + "tmdb_id": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "profile", + "media_type", + "tmdb_id", + "restored" + ], + "type": "object" + }, + "RecommendationSyncResult": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/RecommendationSyncResult.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "detail": { + "type": "string" + }, + "state": { + "enum": [ + "healthy", + "degraded", + "unhealthy" + ], + "type": "string" + }, + "status": { + "description": "synced", + "type": "string" + } + }, + "required": [ + "status", + "state" + ], + "type": "object" + }, + "RedispatchInputBody": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/RedispatchInputBody.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "items": { + "description": "items to re-dispatch", + "items": { + "$ref": "#/components/schemas/RedispatchItem" + }, + "type": [ + "array", + "null" + ] + } + }, + "required": [ + "items" + ], + "type": "object" + }, + "RedispatchItem": { + "additionalProperties": false, + "properties": { + "media_type": { + "enum": [ + "tv", + "movie" + ], + "type": "string" + }, + "tmdb_id": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "tmdb_id", + "media_type" + ], + "type": "object" + }, + "RedispatchOutputBody": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/RedispatchOutputBody.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "results": { + "items": { + "$ref": "#/components/schemas/RedispatchResult" + }, + "type": [ + "array", + "null" + ] + } + }, + "required": [ + "results" + ], + "type": "object" + }, + "RedispatchResult": { + "additionalProperties": false, + "properties": { + "media_type": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "status": { + "description": "ok | partial | failed | skipped", + "type": "string" + }, + "tmdb_id": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "tmdb_id", + "media_type", + "status" + ], + "type": "object" + }, + "RegrabRequest": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/RegrabRequest.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "episode_ids": { + "description": "arr episode ids to regrab (tv)", + "items": { + "format": "int64", + "type": "integer" + }, + "type": [ + "array", + "null" + ] + }, + "episode_keys": { + "description": "SxxEyy keys to regrab (tv)", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "season": { + "description": "regrab a whole season (tv)", + "format": "int64", + "type": "integer" + } + }, + "type": "object" + }, + "RegrabResult": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/RegrabResult.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "blocklisted": { + "format": "int64", + "type": "integer" + }, + "cleared": { + "format": "int64", + "type": "integer" + }, + "detail": { + "type": "string" + }, + "searched": { + "format": "int64", + "type": "integer" + }, + "status": { + "description": "ok | busy | superseded | missing_in_arr | invalid | arr_error", + "type": "string" + } + }, + "required": [ + "status", + "blocklisted", + "cleared", + "searched" + ], + "type": "object" + }, + "RemovalLink": { + "additionalProperties": false, + "properties": { + "bucket": { + "type": "string" + }, + "orphan": { + "type": "boolean" + }, + "path": { + "type": "string" + }, + "target": { + "type": "string" + } + }, + "required": [ + "bucket", + "path", + "target", + "orphan" + ], + "type": "object" + }, + "RemoveFromQueueInputBody": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/RemoveFromQueueInputBody.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "reason": { + "description": "required non-empty audited queue-removal reason", + "type": "string" + }, + "then": { + "description": "reopen for a new grab, or resolve an already-imported/duplicate issue", + "enum": [ + "reopen", + "resolve" + ], + "type": "string" + } + }, + "required": [ + "reason", + "then" + ], + "type": "object" + }, + "RenameHistoryOutputBody": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/RenameHistoryOutputBody.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "passes": { + "items": { + "$ref": "#/components/schemas/RenamePass" + }, + "type": [ + "array", + "null" + ] + } + }, + "required": [ + "passes" + ], + "type": "object" + }, + "RenamePass": { + "additionalProperties": false, + "properties": { + "arr": { + "type": "string" + }, + "detail": { + "type": "string" + }, + "ended_at": { + "type": "string" + }, + "files": { + "format": "int64", + "type": "integer" + }, + "pass_id": { + "format": "int64", + "type": "integer" + }, + "plan_json": { + "type": "string" + }, + "repointed": { + "format": "int64", + "type": "integer" + }, + "request_id": { + "description": "Opaque originating request identity, retained in the durable plan; absent for legacy or uncorrelated callers. Correlation only, not idempotency.", + "type": "string" + }, + "series_id": { + "format": "int64", + "type": "integer" + }, + "started_at": { + "type": "string" + }, + "status": { + "type": "string" + }, + "title": { + "type": "string" + }, + "tmdb_id": { + "format": "int64", + "type": "integer" + }, + "watch_items": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "pass_id", + "tmdb_id", + "arr", + "series_id", + "title", + "status", + "plan_json", + "files", + "watch_items", + "repointed", + "detail", + "started_at", + "ended_at" + ], + "type": "object" + }, + "RenamePlan": { + "additionalProperties": false, + "properties": { + "arr": { + "type": "string" + }, + "files": { + "items": { + "$ref": "#/components/schemas/RenamePlanFile" + }, + "type": [ + "array", + "null" + ] + }, + "series_id": { + "format": "int64", + "type": "integer" + }, + "title": { + "type": "string" + }, + "tmdb_id": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "tmdb_id", + "arr", + "series_id", + "title", + "files" + ], + "type": "object" + }, + "RenamePlanBody": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/RenamePlanBody.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "plan": { + "$ref": "#/components/schemas/RenamePlan", + "description": "the holding Sonarr, the series, and each pending file's existing and new path" + } + }, + "required": [ + "plan" + ], + "type": "object" + }, + "RenamePlanFile": { + "additionalProperties": false, + "properties": { + "episode_file_id": { + "format": "int64", + "type": "integer" + }, + "episode_numbers": { + "items": { + "format": "int64", + "type": "integer" + }, + "type": [ + "array", + "null" + ] + }, + "existing_path": { + "type": "string" + }, + "new_path": { + "type": "string" + }, + "season_number": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "episode_file_id", + "season_number", + "episode_numbers", + "existing_path", + "new_path" + ], + "type": "object" + }, + "RenameResult": { + "additionalProperties": false, + "properties": { + "converged": { + "type": "boolean" + }, + "detail": { + "type": "string" + }, + "pass_id": { + "format": "int64", + "type": "integer" + }, + "plan": { + "$ref": "#/components/schemas/RenamePlan" + }, + "renamed": { + "format": "int64", + "type": "integer" + }, + "repointed": { + "items": { + "$ref": "#/components/schemas/RepointedLink" + }, + "type": [ + "array", + "null" + ] + }, + "skipped": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "status": { + "type": "string" + }, + "watch_items": { + "format": "int64", + "type": "integer" + }, + "watch_snapshot": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "pass_id", + "plan", + "status", + "renamed", + "watch_items", + "watch_snapshot", + "converged" + ], + "type": "object" + }, + "RenameResultBody": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/RenameResultBody.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "result": { + "$ref": "#/components/schemas/RenameResult", + "description": "the pass outcome with the plan it ran, the watch-state counts and any repointed layer links" + } + }, + "required": [ + "result" + ], + "type": "object" + }, + "ReplacementHoldReleaseResult": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/ReplacementHoldReleaseResult.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "released": { + "type": "boolean" + } + }, + "required": [ + "released" + ], + "type": "object" + }, + "ReplacementHoldView": { + "additionalProperties": false, + "properties": { + "arr": { + "type": "string" + }, + "created_at": { + "format": "date-time", + "type": "string" + }, + "expires_at": { + "format": "date-time", + "type": "string" + }, + "reason": { + "type": "string" + }, + "record_id": { + "format": "int64", + "type": "integer" + }, + "record_type": { + "type": "string" + }, + "requested_by": { + "type": "string" + } + }, + "required": [ + "arr", + "record_type", + "record_id", + "requested_by", + "reason", + "created_at", + "expires_at" + ], + "type": "object" + }, + "ReplacementHoldsResult": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/ReplacementHoldsResult.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "holds": { + "items": { + "$ref": "#/components/schemas/ReplacementHoldView" + }, + "type": [ + "array", + "null" + ] + } + }, + "required": [ + "holds" + ], + "type": "object" + }, + "RepointedLink": { + "additionalProperties": false, + "properties": { + "bucket": { + "type": "string" + }, + "dir": { + "type": "string" + }, + "new_name": { + "type": "string" + }, + "new_target": { + "type": "string" + }, + "old_name": { + "type": "string" + }, + "old_target": { + "type": "string" + } + }, + "required": [ + "bucket", + "dir", + "old_name", + "new_name", + "old_target", + "new_target" + ], + "type": "object" + }, + "RequestIntentBody": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/RequestIntentBody.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "media_type": { + "enum": [ + "movie", + "tv" + ], + "type": "string" + }, + "recommendation_profile": { + "type": "string" + }, + "requester": { + "description": "Household requester key to attribute the request to; defaults to the signed-in operator's own requester and must be one of the operator's capability requesters", + "type": "string" + }, + "seasons": { + "description": "Television only: the explicit seasons wanted (0 is specials). An empty selection is refused, never read as all seasons. Omit for a movie", + "items": { + "format": "int64", + "type": "integer" + }, + "type": [ + "array", + "null" + ] + }, + "source": { + "default": "title", + "description": "Recommendation requires recommendation_profile and settles that profile's own acceptance only when it is also the requester", + "enum": [ + "title", + "recommendation" + ], + "type": "string" + }, + "tmdb_id": { + "format": "int64", + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "media_type", + "tmdb_id" + ], + "type": "object" + }, + "RequestProgress": { + "additionalProperties": false, + "properties": { + "actor": { + "$ref": "#/components/schemas/Actor", + "description": "Who filed it, as recorded; absent when the request was filed outside the cockpit" + }, + "awaiting_approval": { + "description": "The request is filed in Seerr and nothing has been dispatched, imported or observed yet, so no acquisition has started. Absent otherwise", + "type": "boolean" + }, + "blocking_stage": { + "description": "First stage without done evidence. Absent only when every stage has it. Not a readiness claim", + "enum": [ + "request", + "acquisition", + "visibility", + "playback" + ], + "type": "string" + }, + "filed_by_you": { + "description": "The signed-in operator filed this command", + "type": "boolean" + }, + "id": { + "description": "request-N for a cockpit command, dispatch-N for a request Seerr approved outside the cockpit", + "type": "string" + }, + "media_type": { + "enum": [ + "movie", + "tv" + ], + "type": "string" + }, + "on_behalf": { + "description": "The actor's own requester differs from the attributed requester", + "type": "boolean" + }, + "requested_at": { + "type": "string" + }, + "requester": { + "$ref": "#/components/schemas/Person", + "description": "The household member the request is attributed to; this decides which feed the item belongs to" + }, + "seasons": { + "description": "Television seasons explicitly requested; empty for a movie or when not recorded", + "items": { + "format": "int64", + "type": "integer" + }, + "type": [ + "array", + "null" + ] + }, + "stages": { + "items": { + "$ref": "#/components/schemas/ReadinessStage" + }, + "type": [ + "array", + "null" + ] + }, + "title": { + "description": "Known from the dispatch log or library membership; empty when no local record names the title yet", + "type": "string" + }, + "tmdb_id": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "id", + "tmdb_id", + "media_type", + "requester", + "on_behalf", + "filed_by_you", + "seasons", + "stages" + ], + "type": "object" + }, + "RequestReceipt": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/RequestReceipt.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "actor": { + "$ref": "#/components/schemas/RequestReceiptActor", + "description": "Authenticated operator who issued the command; separate from the requester" + }, + "attempts": { + "format": "int64", + "type": "integer" + }, + "coverage": { + "description": "One outcome per selected season, never inferred from other seasons", + "items": { + "$ref": "#/components/schemas/RequestReceiptCoverage" + }, + "type": [ + "array", + "null" + ] + }, + "created_at": { + "type": "string" + }, + "detail": { + "description": "Why the command is pending or what was refused", + "type": "string" + }, + "id": { + "description": "Native request record; retrying the same command resumes this record", + "format": "int64", + "type": "integer" + }, + "media_type": { + "enum": [ + "movie", + "tv" + ], + "type": "string" + }, + "membership": { + "$ref": "#/components/schemas/RequestReceiptMembership" + }, + "on_behalf": { + "description": "The requester differs from the acting operator's own household requester", + "type": "boolean" + }, + "recommendation_profile": { + "type": "string" + }, + "requester": { + "description": "Household requester the request is attributed to", + "type": "string" + }, + "requester_username": { + "type": "string" + }, + "seasons": { + "description": "Explicit television seasons the operator asked for; empty for a movie", + "items": { + "format": "int64", + "type": "integer" + }, + "type": [ + "array", + "null" + ] + }, + "seerr": { + "$ref": "#/components/schemas/RequestReceiptSeerr" + }, + "source": { + "enum": [ + "title", + "recommendation" + ], + "type": "string" + }, + "state": { + "description": "Settled: every part has a verified outcome. Pending: a fact is unresolved. Retired: an administrator ended a stale-identity pending command without cancelling any Seerr request or reversing media effects", + "enum": [ + "settled", + "pending", + "retired" + ], + "type": "string" + }, + "tmdb_id": { + "format": "int64", + "type": "integer" + }, + "updated_at": { + "type": "string" + } + }, + "required": [ + "id", + "media_type", + "tmdb_id", + "state", + "requester", + "requester_username", + "actor", + "on_behalf", + "source", + "seasons", + "membership", + "seerr", + "coverage", + "attempts", + "created_at", + "updated_at" + ], + "type": "object" + }, + "RequestReceiptActor": { + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + }, + "requester": { + "description": "The acting operator's own household requester key", + "type": "string" + }, + "role": { + "enum": [ + "admin", + "household" + ], + "type": "string" + } + }, + "required": [ + "name", + "role", + "requester" + ], + "type": "object" + }, + "RequestReceiptCoverage": { + "additionalProperties": false, + "properties": { + "outcome": { + "enum": [ + "requested", + "already_requested", + "already_held", + "unresolved" + ], + "type": "string" + }, + "request_id": { + "format": "int64", + "type": "integer" + }, + "season": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "season", + "outcome" + ], + "type": "object" + }, + "RequestReceiptMembership": { + "additionalProperties": false, + "properties": { + "bucket": { + "description": "Library route the membership landed in", + "type": "string" + }, + "dispatch_log_id": { + "format": "int64", + "type": "integer" + }, + "status": { + "enum": [ + "added", + "already_owned", + "pending_dispatch", + "conflict", + "failed" + ], + "type": "string" + } + }, + "required": [ + "status" + ], + "type": "object" + }, + "RequestReceiptSeerr": { + "additionalProperties": false, + "properties": { + "outcome": { + "enum": [ + "requested", + "already_requested", + "not_needed", + "not_attempted", + "unresolved" + ], + "type": "string" + }, + "request_id": { + "description": "Seerr request attributed to the requester that covers this command", + "format": "int64", + "type": "integer" + } + }, + "required": [ + "outcome" + ], + "type": "object" + }, + "RestartResult": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/RestartResult.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "detail": { + "type": "string" + }, + "kind": { + "description": "process | sibling", + "type": "string" + }, + "status": { + "description": "restarting | degraded", + "type": "string" + }, + "target": { + "type": "string" + } + }, + "required": [ + "target", + "kind", + "status" + ], + "type": "object" + }, + "ResumeBody": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/ResumeBody.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "items": { + "items": { + "$ref": "#/components/schemas/ResumeTitle" + }, + "type": [ + "array", + "null" + ] + } + }, + "required": [ + "items" + ], + "type": "object" + }, + "ResumeTitle": { + "additionalProperties": false, + "properties": { + "episode": { + "type": "string" + }, + "media_type": { + "enum": [ + "movie", + "tv" + ], + "type": "string" + }, + "progress": { + "format": "double", + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "title": { + "type": "string" + }, + "tmdb_id": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "tmdb_id", + "media_type", + "title", + "progress" + ], + "type": "object" + }, + "Row": { + "additionalProperties": false, + "properties": { + "arr": { + "type": "string" + }, + "candidate_health": { + "type": "string" + }, + "candidate_key": { + "type": "string" + }, + "candidate_reason": { + "type": "string" + }, + "download_id": { + "type": "string" + }, + "episode": { + "type": "string" + }, + "episode_count": { + "format": "int64", + "type": "integer" + }, + "eta": { + "type": "string" + }, + "progress": { + "format": "double", + "type": "number" + }, + "protocol": { + "type": "string" + }, + "source": { + "type": "string" + }, + "state": { + "type": "string" + }, + "state_detail": { + "type": "string" + }, + "title": { + "type": "string" + }, + "tmdb_id": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "title", + "protocol", + "progress", + "state", + "source" + ], + "type": "object" + }, + "Season": { + "additionalProperties": false, + "properties": { + "air_date": { + "type": "string" + }, + "coverage": { + "enum": [ + "none", + "partial", + "complete", + "unknown" + ], + "type": "string" + }, + "episode_count": { + "format": "int64", + "type": "integer" + }, + "name": { + "type": "string" + }, + "number": { + "format": "int64", + "type": "integer" + }, + "overview": { + "type": "string" + }, + "recorded_episodes": { + "format": "int64", + "type": "integer" + }, + "request_state": { + "enum": [ + "none", + "requested", + "unknown" + ], + "type": "string" + }, + "seerr_state": { + "enum": [ + "untracked", + "unknown", + "pending", + "processing", + "partial", + "available", + "blocklisted", + "deleted" + ], + "type": "string" + } + }, + "required": [ + "number", + "name", + "overview", + "episode_count", + "recorded_episodes", + "coverage", + "request_state", + "seerr_state" + ], + "type": "object" + }, + "SeasonCoverage": { + "additionalProperties": false, + "properties": { + "coverage": { + "enum": [ + "complete", + "partial", + "none", + "unknown" + ], + "type": "string" + }, + "season": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "season", + "coverage" + ], + "type": "object" + }, + "SeasonDetail": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/SeasonDetail.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "episodes": { + "items": { + "$ref": "#/components/schemas/Episode" + }, + "type": [ + "array", + "null" + ] + }, + "media_type": { + "enum": [ + "tv" + ], + "type": "string" + }, + "metadata_state": { + "enum": [ + "observed", + "missing", + "unavailable" + ], + "type": "string" + }, + "observations": { + "items": { + "$ref": "#/components/schemas/LibraryObservation" + }, + "type": [ + "array", + "null" + ] + }, + "season": { + "$ref": "#/components/schemas/Season" + }, + "tmdb_id": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "tmdb_id", + "media_type", + "metadata_state", + "season", + "episodes", + "observations" + ], + "type": "object" + }, + "SeasonGroup": { + "additionalProperties": false, + "properties": { + "episodes": { + "items": { + "$ref": "#/components/schemas/EpisodeRow" + }, + "type": [ + "array", + "null" + ] + }, + "season": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "season", + "episodes" + ], + "type": "object" + }, + "SeedContribution": { + "additionalProperties": false, + "properties": { + "added": { + "description": "A successful attributed addition contributed nonzero seed weight", + "type": "boolean" + }, + "media_type": { + "enum": [ + "movie", + "tv" + ], + "type": "string" + }, + "title": { + "description": "Observed display name; absent when unknown", + "type": "string" + }, + "tmdb_id": { + "format": "int64", + "type": "integer" + }, + "watched": { + "description": "A played history event contributed nonzero seed weight", + "type": "boolean" + } + }, + "required": [ + "watched", + "added", + "media_type", + "tmdb_id" + ], + "type": "object" + }, + "SeerrObservation": { + "additionalProperties": false, + "properties": { + "detail": { + "type": "string" + }, + "requests": { + "items": { + "$ref": "#/components/schemas/SeerrRequest" + }, + "type": [ + "array", + "null" + ] + }, + "state": { + "enum": [ + "observed", + "unavailable" + ], + "type": "string" + } + }, + "required": [ + "state", + "requests" + ], + "type": "object" + }, + "SeerrRequest": { + "additionalProperties": false, + "properties": { + "id": { + "format": "int64", + "type": "integer" + }, + "status": { + "enum": [ + "pending", + "approved", + "declined", + "failed", + "completed", + "unknown" + ], + "type": "string" + } + }, + "required": [ + "id", + "status" + ], + "type": "object" + }, + "SelectEpisodesInputBody": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/SelectEpisodesInputBody.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "episode_ids": { + "description": "explicit arr episode ids to import (re-verified aired+monitored+belonging server-side)", + "items": { + "format": "int64", + "type": "integer" + }, + "type": [ + "array", + "null" + ] + } + }, + "required": [ + "episode_ids" + ], + "type": "object" + }, + "SourceDetail": { + "additionalProperties": false, + "properties": { + "arr": { + "type": "string" + }, + "imported": { + "description": "import timestamp (RFC3339)", + "type": "string" + }, + "indexer": { + "type": "string" + } + }, + "required": [ + "arr" + ], + "type": "object" + }, + "SourceUsage": { + "additionalProperties": false, + "properties": { + "item_count": { + "format": "int64", + "type": "integer" + }, + "source": { + "type": "string" + } + }, + "required": [ + "source", + "item_count" + ], + "type": "object" + }, + "SpottarrFreshnessView": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/SpottarrFreshnessView.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "age": { + "type": "string" + }, + "age_seconds": { + "format": "int64", + "type": "integer" + }, + "checked_at": { + "format": "date-time", + "type": "string" + }, + "count": { + "format": "int64", + "type": "integer" + }, + "detail": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "known": { + "type": "boolean" + }, + "newest": { + "format": "date-time", + "type": "string" + }, + "state": { + "enum": [ + "disabled", + "starting", + "healthy", + "degraded", + "unhealthy", + "stopped" + ], + "type": "string" + } + }, + "required": [ + "enabled", + "known", + "state" + ], + "type": "object" + }, + "Title": { + "additionalProperties": false, + "properties": { + "backdrop_url": { + "description": "Landscape artwork on the TMDB image CDN; absent when none is observed or the upstream path is not a CDN-relative path", + "type": "string" + }, + "genre_ids": { + "description": "TMDB genre IDs in this title's media-type namespace; absent is unobserved, empty is observed without genres", + "items": { + "format": "int64", + "type": "integer" + }, + "type": "array" + }, + "held": { + "enum": [ + "held", + "unheld", + "unknown" + ], + "type": "string" + }, + "libraries": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "media_type": { + "enum": [ + "movie", + "tv" + ], + "type": "string" + }, + "overview": { + "type": "string" + }, + "poster_url": { + "type": "string" + }, + "release_date": { + "type": "string" + }, + "request_state": { + "enum": [ + "none", + "requested", + "unknown" + ], + "type": "string" + }, + "seerr_state": { + "enum": [ + "untracked", + "unknown", + "pending", + "processing", + "partial", + "available", + "blocklisted", + "deleted" + ], + "type": "string" + }, + "title": { + "type": "string" + }, + "tmdb_id": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "tmdb_id", + "media_type", + "title", + "overview", + "held", + "request_state", + "seerr_state", + "libraries" + ], + "type": "object" + }, + "TitleCast": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/TitleCast.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "credit_context": { + "description": "Seerr exposes only each TV performer's most frequent aggregate role, not every role or episode count", + "enum": [ + "movie-character", + "tv-leading-character" + ], + "type": "string" + }, + "items": { + "items": { + "$ref": "#/components/schemas/CastMember" + }, + "type": [ + "array", + "null" + ] + }, + "media_type": { + "enum": [ + "movie", + "tv" + ], + "type": "string" + }, + "metadata_state": { + "enum": [ + "observed", + "missing", + "unavailable" + ], + "type": "string" + }, + "paging": { + "description": "One complete returned Seerr cast list, not invented upstream pages", + "enum": [ + "unpaged" + ], + "type": "string" + }, + "source": { + "enum": [ + "seerr" + ], + "type": "string" + }, + "tmdb_id": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "tmdb_id", + "media_type", + "metadata_state", + "source", + "paging", + "credit_context", + "items" + ], + "type": "object" + }, + "TitleDiagnosis": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/TitleDiagnosis.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "candidates": { + "items": { + "$ref": "#/components/schemas/TriageDetail" + }, + "type": [ + "array", + "null" + ] + }, + "dispatch": { + "items": { + "$ref": "#/components/schemas/DispatchLogEntry" + }, + "type": [ + "array", + "null" + ] + }, + "dispatch_observation": { + "$ref": "#/components/schemas/TitleDiagnosisObservation" + }, + "imports_observation": { + "$ref": "#/components/schemas/TitleDiagnosisObservation" + }, + "media_type": { + "enum": [ + "movie", + "tv" + ], + "type": "string" + }, + "missing": { + "items": { + "$ref": "#/components/schemas/MissingItem" + }, + "type": [ + "array", + "null" + ] + }, + "presence_observation": { + "$ref": "#/components/schemas/TitleDiagnosisObservation" + }, + "tmdb_id": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "tmdb_id", + "media_type", + "candidates", + "dispatch", + "missing", + "imports_observation", + "dispatch_observation", + "presence_observation" + ], + "type": "object" + }, + "TitleDiagnosisObservation": { + "additionalProperties": false, + "properties": { + "detail": { + "type": "string" + }, + "state": { + "enum": [ + "observed", + "unavailable" + ], + "type": "string" + } + }, + "required": [ + "state" + ], + "type": "object" + }, + "TitleMediaHealth": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/TitleMediaHealth.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "breaker": { + "$ref": "#/components/schemas/MediaHealthBreakerResult" + }, + "enabled": { + "type": "boolean" + }, + "media_type": { + "enum": [ + "movie", + "tv" + ], + "type": "string" + }, + "proposals": { + "items": { + "$ref": "#/components/schemas/TitleMediaHealthProposal" + }, + "type": [ + "array", + "null" + ] + }, + "tmdb_id": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "tmdb_id", + "media_type", + "enabled", + "breaker", + "proposals" + ], + "type": "object" + }, + "TitleMediaHealthFile": { + "additionalProperties": false, + "properties": { + "arr": { + "type": "string" + }, + "file_id": { + "format": "int64", + "type": "integer" + }, + "path": { + "type": "string" + }, + "record_id": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "arr", + "record_id", + "file_id", + "path" + ], + "type": "object" + }, + "TitleMediaHealthProposal": { + "additionalProperties": false, + "properties": { + "files": { + "items": { + "$ref": "#/components/schemas/TitleMediaHealthFile" + }, + "type": [ + "array", + "null" + ] + }, + "first_attempt_at": { + "format": "date-time", + "type": "string" + }, + "history": { + "items": { + "$ref": "#/components/schemas/MediaHealthRepairEvent" + }, + "type": [ + "array", + "null" + ] + }, + "proposal": { + "$ref": "#/components/schemas/MediaHealthProposalView" + }, + "usenet_attempts": { + "format": "int64", + "type": "integer" + }, + "verify_attempts": { + "description": "Recorded inconclusive verification retries; a successful first read does not increment this", + "format": "int64", + "type": "integer" + } + }, + "required": [ + "files", + "usenet_attempts", + "verify_attempts", + "proposal", + "history" + ], + "type": "object" + }, + "TitleMetadata": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/TitleMetadata.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "checked_at": { + "format": "date-time", + "type": "string" + }, + "genre_state": { + "enum": [ + "unobserved", + "observed", + "missing", + "unavailable" + ], + "type": "string" + }, + "genres": { + "items": { + "$ref": "#/components/schemas/Genre" + }, + "type": [ + "array", + "null" + ] + }, + "genres_observed_at": { + "format": "date-time", + "type": "string" + }, + "media_type": { + "enum": [ + "movie", + "tv" + ], + "type": "string" + }, + "provider_state": { + "enum": [ + "unobserved", + "observed", + "missing", + "unavailable" + ], + "type": "string" + }, + "providers": { + "items": { + "$ref": "#/components/schemas/Provider" + }, + "type": [ + "array", + "null" + ] + }, + "providers_observed_at": { + "format": "date-time", + "type": "string" + }, + "region": { + "enum": [ + "NL" + ], + "type": "string" + }, + "tmdb_id": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "tmdb_id", + "media_type", + "region", + "genre_state", + "provider_state", + "genres", + "providers" + ], + "type": "object" + }, + "TitleRatings": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/TitleRatings.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "ratings": { + "items": { + "$ref": "#/components/schemas/Rating" + }, + "type": [ + "array", + "null" + ] + } + }, + "required": [ + "ratings" + ], + "type": "object" + }, + "TitleRequestsBody": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/TitleRequestsBody.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "requests": { + "items": { + "$ref": "#/components/schemas/RequestReceipt" + }, + "type": [ + "array", + "null" + ] + } + }, + "required": [ + "requests" + ], + "type": "object" + }, + "TitleVersion": { + "additionalProperties": false, + "properties": { + "file": { + "$ref": "#/components/schemas/FileDetail" + }, + "role": { + "enum": [ + "primary", + "mirror" + ], + "type": "string" + }, + "seasons": { + "items": { + "$ref": "#/components/schemas/SeasonGroup" + }, + "type": [ + "array", + "null" + ] + }, + "source": { + "type": "string" + } + }, + "required": [ + "source", + "role" + ], + "type": "object" + }, + "TorrentArmInputBody": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/TorrentArmInputBody.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "acknowledge_trip": { + "description": "The owner acknowledged the trip reason; required to re-arm a tripped lane", + "type": "boolean" + }, + "revision": { + "description": "Plan revision the arm confirm listed", + "format": "int64", + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "revision" + ], + "type": "object" + }, + "TorrentArmingFloor": { + "additionalProperties": false, + "properties": { + "dead_file": { + "description": "At least one acceptable would-push for a dead-file proposal", + "type": "boolean" + }, + "missing": { + "description": "At least one acceptable would-push for a missing record", + "type": "boolean" + } + }, + "required": [ + "dead_file", + "missing" + ], + "type": "object" + }, + "TorrentArrRejectionCount": { + "additionalProperties": false, + "properties": { + "count": { + "description": "Number of releases with this rejection", + "format": "int64", + "type": "integer" + }, + "rejection": { + "description": "Unsupported rejection returned by the Arr", + "type": "string" + } + }, + "required": [ + "rejection", + "count" + ], + "type": "object" + }, + "TorrentCandidateView": { + "additionalProperties": false, + "properties": { + "arr_kind": { + "description": "Arr kind holding the record", + "enum": [ + "sonarr", + "radarr" + ], + "type": "string" + }, + "arr_name": { + "description": "Primary Arr instance holding the record", + "type": "string" + }, + "attempts": { + "description": "Failed torrent attempts spent in the record's current qualification window", + "format": "int64", + "type": "integer" + }, + "attempts_per_window": { + "description": "Failed attempts one qualification window allows", + "format": "int64", + "type": "integer" + }, + "available_at": { + "description": "When the record became available (air date, or Radarr availability)", + "format": "date-time", + "type": "string" + }, + "detail": { + "description": "Human-readable state of the clock, or the outcome that set the record's budget stage", + "type": "string" + }, + "episode_key": { + "description": "SxxEyy for an episode, empty for a movie", + "type": "string" + }, + "kind": { + "description": "dead_file: a repair proposal's record; missing: an Arr missing monitored record", + "enum": [ + "dead_file", + "missing" + ], + "type": "string" + }, + "last_search_at": { + "description": "The lane's latest own usenet search", + "format": "date-time", + "type": "string" + }, + "last_search_outcome": { + "description": "Outcome of the lane's latest own usenet search", + "enum": [ + "silent", + "found", + "transport", + "error" + ], + "type": "string" + }, + "ledger": { + "description": "Ledger tracking the record's attempts: the repair proposal row, or import_candidates (source torrent)", + "enum": [ + "repair", + "import" + ], + "type": "string" + }, + "new_silent_days": { + "description": "Answered-silent days credited since the clock (or the seed) started", + "format": "int64", + "type": "integer" + }, + "next_at": { + "description": "Earliest a waiting, retry-due or resting record can start again, assuming one answered-silent day per day", + "format": "date-time", + "type": "string" + }, + "paused": { + "description": "A usenet grab or repair is in flight; the silence count is paused", + "type": "boolean" + }, + "proposal_id": { + "description": "Repair proposal for a dead_file record", + "format": "int64", + "type": "integer" + }, + "qualified": { + "description": "Enough silence days, and a seeded record has at least one new one", + "type": "boolean" + }, + "record_id": { + "description": "Arr episode or movie id; the clock and budget belong to this record", + "format": "int64", + "type": "integer" + }, + "record_type": { + "description": "Arr record type", + "enum": [ + "episode", + "movie" + ], + "type": "string" + }, + "searching_since": { + "description": "The conductarr-owned usenet-searching-since stamp; the clock start", + "format": "date-time", + "type": "string" + }, + "seeded": { + "description": "The count was seeded from history at the lane's first pass", + "type": "boolean" + }, + "silence_days": { + "description": "Answered-silent days, seeded proxy days included", + "format": "int64", + "type": "integer" + }, + "silence_needed": { + "description": "Silence days that qualify a record", + "format": "int64", + "type": "integer" + }, + "stage": { + "description": "Lane stage: silent while the record accrues answered-silent days; waiting after a start that spent no attempt (no acceptable release, or a magnet with no peers); retry due after a failed attempt with attempts left; resting once the window's attempts are spent", + "enum": [ + "silent", + "waiting", + "retry due", + "resting" + ], + "type": "string" + }, + "title": { + "description": "Series or movie title", + "type": "string" + }, + "tmdb_id": { + "description": "TMDB id of the series or movie", + "format": "int64", + "type": "integer" + } + }, + "required": [ + "arr_kind", + "arr_name", + "record_type", + "record_id", + "tmdb_id", + "title", + "kind", + "ledger", + "stage", + "paused", + "silence_days", + "silence_needed", + "seeded", + "new_silent_days", + "qualified", + "available_at", + "searching_since", + "attempts", + "attempts_per_window", + "detail" + ], + "type": "object" + }, + "TorrentCandidatesResult": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/TorrentCandidatesResult.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "candidates": { + "description": "Dead files first, then missing records, longest silence first", + "items": { + "$ref": "#/components/schemas/TorrentCandidateView" + }, + "type": [ + "array", + "null" + ] + }, + "qualified": { + "description": "How many rows qualify", + "format": "int64", + "type": "integer" + }, + "silence_needed": { + "description": "Silence days that qualify a record", + "format": "int64", + "type": "integer" + } + }, + "required": [ + "silence_needed", + "qualified", + "candidates" + ], + "type": "object" + }, + "TorrentCoverage": { + "additionalProperties": false, + "properties": { + "anime": { + "$ref": "#/components/schemas/TorrentCoverageSlice", + "description": "Records on an anime Arr" + }, + "dead_file": { + "$ref": "#/components/schemas/TorrentCoverageSlice", + "description": "Dead-file proposal records" + }, + "missing": { + "$ref": "#/components/schemas/TorrentCoverageSlice", + "description": "Missing monitored records" + }, + "non_anime": { + "$ref": "#/components/schemas/TorrentCoverageSlice", + "description": "Records on a non-anime Arr" + } + }, + "required": [ + "dead_file", + "missing", + "anime", + "non_anime" + ], + "type": "object" + }, + "TorrentCoverageSlice": { + "additionalProperties": false, + "properties": { + "acceptable": { + "description": "Of those, rows holding an acceptable release (a would-push)", + "format": "int64", + "type": "integer" + }, + "dry_runs": { + "description": "Plan rows whose latest torrent search ran", + "format": "int64", + "type": "integer" + } + }, + "required": [ + "dry_runs", + "acceptable" + ], + "type": "object" + }, + "TorrentDiskProjection": { + "additionalProperties": false, + "properties": { + "bytes": { + "description": "Summed size of those rows' would-push releases", + "format": "int64", + "type": "integer" + }, + "floor_bytes": { + "description": "Configured free-space floor below which no start runs", + "format": "int64", + "type": "integer" + }, + "free_bytes": { + "description": "Free space on the /data/import dataset when the plan was built", + "format": "int64", + "type": "integer" + }, + "starts": { + "description": "Plan rows counted: the first ones, up to the rolling start cap", + "format": "int64", + "type": "integer" + } + }, + "required": [ + "starts", + "bytes", + "free_bytes", + "floor_bytes" + ], + "type": "object" + }, + "TorrentHandoff": { + "additionalProperties": false, + "properties": { + "arr_gate_drops": { + "items": { + "$ref": "#/components/schemas/ArrGateDrop" + }, + "type": [ + "array", + "null" + ] + }, + "candidate": { + "$ref": "#/components/schemas/TorrentCandidateView" + }, + "finished_at": { + "format": "date-time", + "type": "string" + }, + "note": { + "type": "string" + }, + "outcome": { + "type": "string" + }, + "started_at": { + "format": "date-time", + "type": "string" + } + }, + "required": [ + "started_at", + "finished_at", + "candidate", + "outcome", + "note" + ], + "type": "object" + }, + "TorrentHistoryResult": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/TorrentHistoryResult.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "events": { + "items": { + "$ref": "#/components/schemas/TorrentLaneEvent" + }, + "type": [ + "array", + "null" + ] + }, + "handoffs": { + "items": { + "$ref": "#/components/schemas/TorrentHandoff" + }, + "type": [ + "array", + "null" + ] + } + }, + "required": [ + "handoffs", + "events" + ], + "type": "object" + }, + "TorrentIndexerDrops": { + "additionalProperties": false, + "properties": { + "arr_rejections": { + "description": "Distinct unsupported Arr rejection strings and their release counts", + "items": { + "$ref": "#/components/schemas/TorrentArrRejectionCount" + }, + "type": [ + "array", + "null" + ] + }, + "coverage": { + "description": "Maps an episode that is not a monitored, qualified record without a usenet file", + "format": "int64", + "type": "integer" + }, + "gate": { + "description": "Refused by the Arr for any reason but ProtocolDisabled (or, for a dead file, the existing file)", + "format": "int64", + "type": "integer" + }, + "indexer": { + "description": "Indexer display name as the Arr reports it", + "type": "string" + }, + "language": { + "description": "Language: Not Original on a non-anime Arr, or dub-only on an anime Arr", + "format": "int64", + "type": "integer" + }, + "seeders": { + "description": "Fewer seeders than the minimum, or an unknown count", + "format": "int64", + "type": "integer" + }, + "tried": { + "description": "GUID or infohash already tried for a covered record, unprovable, or in the release-corpse ledger", + "format": "int64", + "type": "integer" + } + }, + "required": [ + "indexer", + "gate", + "language", + "seeders", + "coverage", + "tried" + ], + "type": "object" + }, + "TorrentLaneEvent": { + "additionalProperties": false, + "properties": { + "at": { + "format": "date-time", + "type": "string" + }, + "detail": { + "type": "string" + }, + "kind": { + "description": "arm and disarm by the owner, a breaker trip, or unrecorded torrents removed at a qBittorrent child start", + "enum": [ + "arm", + "disarm", + "trip", + "unrecorded_removed" + ], + "type": "string" + } + }, + "required": [ + "at", + "kind", + "detail" + ], + "type": "object" + }, + "TorrentPick": { + "additionalProperties": false, + "properties": { + "guid": { + "type": "string" + }, + "indexer_id": { + "format": "int64", + "type": "integer" + }, + "release": { + "type": "string" + }, + "seeders": { + "format": "int64", + "type": "integer" + }, + "size": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "release", + "indexer_id", + "seeders", + "size", + "guid" + ], + "type": "object" + }, + "TorrentPlanResult": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/TorrentPlanResult.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "dry_run_at": { + "description": "When the shadow plan was produced", + "format": "date-time", + "type": "string" + }, + "next_rotation": { + "description": "Next time the lane may start a handoff", + "format": "date-time", + "type": "string" + }, + "revision": { + "description": "Monotonic plan revision", + "format": "int64", + "type": "integer" + }, + "rows": { + "description": "Current handoff decisions; no row causes a grab", + "items": { + "$ref": "#/components/schemas/TorrentPlanRow" + }, + "type": [ + "array", + "null" + ] + } + }, + "required": [ + "revision", + "dry_run_at", + "rows" + ], + "type": "object" + }, + "TorrentPlanRow": { + "additionalProperties": false, + "properties": { + "arr_gate_drops": { + "description": "Rejections from each release refused by the Arr gate", + "items": { + "$ref": "#/components/schemas/ArrGateDrop" + }, + "type": [ + "array", + "null" + ] + }, + "candidate": { + "$ref": "#/components/schemas/TorrentCandidateView" + }, + "note": { + "type": "string" + }, + "outcome": { + "type": "string" + }, + "pick": { + "$ref": "#/components/schemas/TorrentPick" + }, + "usenet_check": { + "type": "string" + } + }, + "required": [ + "candidate", + "usenet_check", + "outcome", + "note" + ], + "type": "object" + }, + "TorrentRotation": { + "additionalProperties": false, + "properties": { + "day_one": { + "description": "Plan records the lane's first pass saw", + "format": "int64", + "type": "integer" + }, + "dry_run": { + "description": "Day-one plan records that have had a dry run", + "format": "int64", + "type": "integer" + } + }, + "required": [ + "dry_run", + "day_one" + ], + "type": "object" + }, + "TorrentStatusResult": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/TorrentStatusResult.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "active": { + "format": "int64", + "type": "integer" + }, + "armed_revision": { + "description": "Plan revision the owner armed on; absent in shadow and tripped, kept while paused", + "format": "int64", + "type": "integer" + }, + "child_detail": { + "description": "The qBittorrent child's supervision detail", + "type": "string" + }, + "child_state": { + "description": "The qBittorrent child's supervision state; anything but healthy pauses an armed lane", + "type": "string" + }, + "failure_limit": { + "description": "This many failed attempts in a row trip an armed lane", + "format": "int64", + "type": "integer" + }, + "failure_streak": { + "description": "Failed attempts in a row since the latest import; a handoff that spent no attempt neither counts nor resets it", + "format": "int64", + "type": "integer" + }, + "free_bytes": { + "format": "int64", + "type": "integer" + }, + "mode": { + "description": "shadow: starts are dry runs; armed: a start pushes its pick; tripped: a breaker returned the lane to shadow until an acknowledged re-arm; paused: armed, but the qBittorrent child is not healthy, so nothing starts", + "enum": [ + "shadow", + "armed", + "tripped", + "paused" + ], + "type": "string" + }, + "newly_qualified_24h": { + "description": "Candidates newly qualified in the rolling 24h, the day-one baseline and anything before the latest acknowledged trip left out", + "format": "int64", + "type": "integer" + }, + "next_start": { + "format": "date-time", + "type": "string" + }, + "spike_limit": { + "description": "More newly qualified candidates than this in 24h trip an armed lane", + "format": "int64", + "type": "integer" + }, + "starts_24h": { + "format": "int64", + "type": "integer" + }, + "trip_acknowledged": { + "description": "An arm acknowledged the latest trip", + "type": "boolean" + }, + "trip_reason": { + "description": "Why the latest breaker trip fired; absent before any trip", + "type": "string" + }, + "tripped_at": { + "description": "When the latest breaker trip fired", + "format": "date-time", + "type": "string" + } + }, + "required": [ + "mode", + "starts_24h", + "active", + "free_bytes", + "trip_acknowledged", + "newly_qualified_24h", + "spike_limit", + "failure_streak", + "failure_limit", + "child_state", + "child_detail" + ], + "type": "object" + }, + "TorrentSummaryResult": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/TorrentSummaryResult.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "arming_floor": { + "$ref": "#/components/schemas/TorrentArmingFloor" + }, + "coverage": { + "$ref": "#/components/schemas/TorrentCoverage" + }, + "indexers": { + "description": "Drops per indexer and rule, by indexer name", + "items": { + "$ref": "#/components/schemas/TorrentIndexerDrops" + }, + "type": [ + "array", + "null" + ] + }, + "projection": { + "$ref": "#/components/schemas/TorrentDiskProjection" + }, + "revision": { + "description": "Plan revision the summary was summed over", + "format": "int64", + "type": "integer" + }, + "rotation": { + "$ref": "#/components/schemas/TorrentRotation" + }, + "usenet_won": { + "description": "Plan rows whose fresh usenet check found an acceptable release", + "format": "int64", + "type": "integer" + } + }, + "required": [ + "revision", + "indexers", + "coverage", + "rotation", + "usenet_won", + "projection", + "arming_floor" + ], + "type": "object" + }, + "TriageCandidate": { + "additionalProperties": false, + "properties": { + "arr": { + "description": "kind/name of the holding MAIN arr", + "type": "string" + }, + "candidate_key": { + "type": "string" + }, + "episode_ids": { + "description": "Arr episode ids this candidate was expected to satisfy", + "items": { + "format": "int64", + "type": "integer" + }, + "type": [ + "array", + "null" + ] + }, + "group": { + "description": "active | issues | exhausted | resolved", + "type": "string" + }, + "health_state": { + "description": "the closed item-health grammar", + "type": "string" + }, + "media_type": { + "type": "string" + }, + "observed_at": { + "type": "string" + }, + "owner": { + "type": "string" + }, + "poster_ref": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "resolved_at": { + "type": "string" + }, + "retry_count": { + "format": "int64", + "type": "integer" + }, + "source": { + "type": "string" + }, + "title": { + "type": "string" + }, + "tmdb_id": { + "format": "int64", + "type": "integer" + }, + "year": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "candidate_key", + "group", + "health_state", + "source", + "tmdb_id", + "media_type", + "arr", + "retry_count", + "observed_at" + ], + "type": "object" + }, + "TriageDetail": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/TriageDetail.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "candidate": { + "$ref": "#/components/schemas/TriageCandidate" + }, + "history": { + "items": { + "$ref": "#/components/schemas/TriageHistoryEvent" + }, + "type": [ + "array", + "null" + ] + }, + "payload_sanitize_block": { + "$ref": "#/components/schemas/PayloadSanitizeBlockView" + } + }, + "required": [ + "candidate", + "history" + ], + "type": "object" + }, + "TriageHistoryEvent": { + "additionalProperties": false, + "properties": { + "at": { + "type": "string" + }, + "from_state": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "to_state": { + "type": "string" + } + }, + "required": [ + "to_state", + "at" + ], + "type": "object" + }, + "TriageResult": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/TriageResult.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "candidates": { + "items": { + "$ref": "#/components/schemas/TriageCandidate" + }, + "type": [ + "array", + "null" + ] + }, + "group": { + "description": "the requested group filter (empty = all)", + "type": "string" + } + }, + "required": [ + "candidates" + ], + "type": "object" + }, + "ViewerReadiness": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/ViewerReadiness.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "blocking_stage": { + "enum": [ + "request", + "acquisition", + "visibility", + "playback" + ], + "type": "string" + }, + "coverage": { + "description": "Recorded-file coverage of the requested seasons (or every listed season when none were recorded); empty for a movie", + "items": { + "$ref": "#/components/schemas/SeasonCoverage" + }, + "type": [ + "array", + "null" + ] + }, + "media_type": { + "enum": [ + "movie", + "tv" + ], + "type": "string" + }, + "open_link": { + "$ref": "#/components/schemas/OpenLink" + }, + "seerr": { + "$ref": "#/components/schemas/SeerrObservation" + }, + "sources": { + "items": { + "$ref": "#/components/schemas/EvidenceSource" + }, + "type": [ + "array", + "null" + ] + }, + "stages": { + "items": { + "$ref": "#/components/schemas/ReadinessStage" + }, + "type": [ + "array", + "null" + ] + }, + "title": { + "type": "string" + }, + "tmdb_id": { + "format": "int64", + "type": "integer" + }, + "viewer": { + "$ref": "#/components/schemas/Person" + } + }, + "required": [ + "tmdb_id", + "media_type", + "viewer", + "stages", + "seerr", + "coverage", + "open_link", + "sources" + ], + "type": "object" + }, + "WatchConfigBody": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/WatchConfigBody.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "status": { + "type": "string" + } + }, + "required": [ + "status" + ], + "type": "object" + }, + "WatchStateRequest": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/WatchStateRequest.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "episode_key": { + "description": "SxxEyy for a tv episode; empty for a movie", + "type": "string" + }, + "played": { + "type": "boolean" + }, + "user": { + "description": "jellyfin username (must be covered by the item's sees: matrix)", + "type": "string" + } + }, + "required": [ + "user", + "played" + ], + "type": "object" + }, + "WatchStateResult": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/WatchStateResult.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "detail": { + "type": "string" + }, + "played": { + "description": "the read-back value", + "type": "boolean" + }, + "status": { + "description": "ok | forbidden | no_controller | missing | error", + "type": "string" + } + }, + "required": [ + "status", + "played" + ], + "type": "object" + }, + "WebhookAck": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/WebhookAck.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "detail": { + "description": "Human-readable context", + "type": "string" + }, + "status": { + "description": "accepted | ignored | test | error", + "type": "string" + } + }, + "required": [ + "status" + ], + "type": "object" + } + }, + "securitySchemes": { + "authentikSession": { + "description": "Existing Authentik forward-single proxy session cookie, verified against the configured outpost. Caller identity/role headers are never trusted.", + "in": "header", + "name": "Cookie", + "type": "apiKey" + }, + "jellyfinToken": { + "description": "Jellyfin access token: MediaBrowser Token=\"\u003c32 hex\u003e\". Verified per request against the configured Jellyfin GET /Users/Me; the immutable user id maps to a configured household person. Household authority only, TV-allowlisted operations only. Inert unless server.cockpit.jellyfin_sign_in.enabled.", + "in": "header", + "name": "Authorization", + "type": "apiKey" + } + } + }, + "info": { + "description": "The tailored media bundle: supervisor, dispatch, layer, watch-state, recommendations, curation, backfill.", + "title": "conductarr", + "version": "0.1.0-dev" + }, + "openapi": "3.1.0", + "paths": { + "/api/v1/activity": { + "get": { + "description": "Defaults to requests attributed to the signed-in operator; requester=all shows household progress. Items carry the recorded requester and actor exactly as filed, so an on-behalf request belongs to its requester. Stages are separate evidence from separate owners and are never collapsed into a ready flag: a request receipt, an Arr file, a library membership or Seerr availability each prove only their own stage, and unknown, stale, conflicting or failed evidence stays in its stage. The feed reads local ledgers and cached authenticated server playback admission only; no provider is called per row. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers. Conditional reads authenticate and authorize first. ETag hashes verified operator authority, request identity and exact serialized representation; matching If-None-Match returns bodyless 304. Cache-Control: no-store remains unchanged: HTTP caches must not store the response or validator. An application-owned, person-partitioned last-known-good display model may be revalidated explicitly; validators never permit cross-person reuse. Vary includes Cookie and Authorization. Reads, projection, encoding and hashing still run on 304; only repeat transfer is avoided.", + "operationId": "get-activity", + "parameters": [ + { + "description": "Household requester key to follow, or all for household progress. Defaults to the signed-in operator's own requests. This selects whose requests are shown; it never changes who is acting", + "explode": false, + "in": "query", + "name": "requester", + "schema": { + "description": "Household requester key to follow, or all for household progress. Defaults to the signed-in operator's own requests. This selects whose requests are shown; it never changes who is acting", + "type": "string" + } + }, + { + "description": "Validator from this operator's prior application-owned display representation; weak tags, lists and * use GET weak comparison", + "in": "header", + "name": "If-None-Match", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActivityFeed" + } + } + }, + "description": "OK", + "headers": { + "ETag": { + "description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes", + "schema": { + "type": "string" + } + } + } + }, + "304": { + "description": "Authenticated representation unchanged; no body. Reuse only the same person's application-owned display state.", + "headers": { + "ETag": { + "description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes", + "schema": { + "type": "string" + } + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Authentik sign-in required" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Operator or target is not permitted" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Identity verification or operation unavailable" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "authentikSession": [] + } + ], + "summary": "Recent requests with their request, acquisition, visibility and playback stages" + } + }, + "/api/v1/activity/titles/{tmdb}/{type}": { + "get": { + "description": "Reads Seerr request status, the holding Arr and a viewer-scoped Jellyfin view for this one title. Each live source fails independently and degrades only its own stage; a Seerr, Arr or Jellyfin outage leaves its stage stale or unknown and never proves readiness. Playback joins cached Jellyfin PlaybackInfo queried with the server key on the viewer's behalf, using a separate request pool sized by the layer visibility item_budget, with watch records and media-health faults as fallback. Items without a recorded Video stream in the first source and .strm paths are not sampled because Jellyfin can refresh metadata and inspect media for them. Admission proves neither playback policy, phone compatibility nor media bytes. open_link is the viewer's own Jellyfin item page, offered only after the viewer-scoped read found that item and only for a viewer the operator may open Jellyfin as; it carries no credential. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers. Conditional reads authenticate and authorize first. ETag hashes verified operator authority, request identity and exact serialized representation; matching If-None-Match returns bodyless 304. Cache-Control: no-store remains unchanged: HTTP caches must not store the response or validator. An application-owned, person-partitioned last-known-good display model may be revalidated explicitly; validators never permit cross-person reuse. Vary includes Cookie and Authorization. Reads, projection, encoding and hashing still run on 304; only repeat transfer is avoided.", + "operationId": "get-activity-title", + "parameters": [ + { + "in": "path", + "name": "tmdb", + "required": true, + "schema": { + "format": "int64", + "minimum": 1, + "type": "integer" + } + }, + { + "in": "path", + "name": "type", + "required": true, + "schema": { + "enum": [ + "tv", + "movie" + ], + "type": "string" + } + }, + { + "description": "Household member to read readiness for. Defaults to the signed-in operator", + "explode": false, + "in": "query", + "name": "viewer", + "schema": { + "description": "Household member to read readiness for. Defaults to the signed-in operator", + "type": "string" + } + }, + { + "description": "Validator from this operator's prior application-owned display representation; weak tags, lists and * use GET weak comparison", + "in": "header", + "name": "If-None-Match", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ViewerReadiness" + } + } + }, + "description": "OK", + "headers": { + "ETag": { + "description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes", + "schema": { + "type": "string" + } + } + } + }, + "304": { + "description": "Authenticated representation unchanged; no body. Reuse only the same person's application-owned display state.", + "headers": { + "ETag": { + "description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes", + "schema": { + "type": "string" + } + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Authentik sign-in required" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Operator or target is not permitted" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Identity verification or operation unavailable" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "authentikSession": [] + } + ], + "summary": "One title's viewer readiness with live Seerr, Arr and Jellyfin evidence and the Jellyfin hand-off" + } + }, + "/api/v1/home/resume": { + "get": { + "description": "Read-only Jellyfin metadata: at most twelve resume items and one parent-series batch, bounded to five seconds. No caller-selected viewer, media-source lookup or playback write. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers.", + "operationId": "get-home-resume", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResumeBody" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Authentik sign-in required" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Operator or target is not permitted" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Identity verification or operation unavailable" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "authentikSession": [] + } + ], + "summary": "Unfinished titles for the authenticated viewer" + } + }, + "/api/v1/operator": { + "get": { + "description": "Identity is verified from the existing Authentik proxy session or, when enabled, the caller's Jellyfin token. Roles and targets come only from server configuration, never identity headers, requester bodies or selected profiles. Jellyfin sign-in grants household-only authority without library-management powers. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers.", + "operationId": "get-cockpit-operator", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Operator" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Authentik or Jellyfin sign-in required" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Operator or target is not permitted" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Identity verification or operation unavailable" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "authentikSession": [] + }, + { + "jellyfinToken": [] + } + ], + "summary": "Authenticated cockpit operator and household authority" + } + }, + "/api/v1/recommendations/profiles": { + "get": { + "description": " Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers.", + "operationId": "get-recommendation-profiles", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RecommendationProfiles" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Authentik sign-in required" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Operator or target is not permitted" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Identity verification or operation unavailable" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "authentikSession": [] + } + ], + "summary": "Configured household recommendation read profiles" + } + }, + "/api/v1/recommendations/profiles/{profile}/dismissals": { + "get": { + "description": "Read-only durable list, newest first. Like the feed read it is not limited to the operator's own profile. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers.", + "operationId": "get-recommendation-dismissals", + "parameters": [ + { + "in": "path", + "name": "profile", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RecommendationDismissals" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Authentik sign-in required" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Operator or target is not permitted" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Identity verification or operation unavailable" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "authentikSession": [] + } + ], + "summary": "Titles hidden from one profile's personalised feed" + }, + "post": { + "description": "Durable until an explicit restore. Requires the profile in the operator's recommendation_profiles capability. The title leaves the retained feed, its watched-seed and service projections and the profile's Jellyfin recommendation folders; it is never requested, and real media, general search and watch-state are untouched. Repeating the command is a no-op. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers.", + "operationId": "post-recommendation-dismissal", + "parameters": [ + { + "in": "path", + "name": "profile", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RecommendationHideInputBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RecommendationHideResult" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Authentik sign-in required" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Operator or target is not permitted" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Identity verification or operation unavailable" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "authentikSession": [] + } + ], + "summary": "Hide a title from one profile's personalised feed" + } + }, + "/api/v1/recommendations/profiles/{profile}/dismissals/{type}/{tmdb}": { + "delete": { + "description": "Requires the profile in the operator's recommendation_profiles capability. The title is eligible again at the next ranking cycle under the same scorer and filters; it is not inserted into any feed and not requested. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers.", + "operationId": "delete-recommendation-dismissal", + "parameters": [ + { + "in": "path", + "name": "profile", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "type", + "required": true, + "schema": { + "enum": [ + "movie", + "tv" + ], + "type": "string" + } + }, + { + "in": "path", + "name": "tmdb", + "required": true, + "schema": { + "format": "int64", + "minimum": 1, + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RecommendationRestoreResult" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Authentik sign-in required" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Operator or target is not permitted" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Identity verification or operation unavailable" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "authentikSession": [] + } + ], + "summary": "Restore a hidden title's eligibility for one profile" + } + }, + "/api/v1/recommendations/profiles/{profile}/feed": { + "get": { + "description": "Read-only in-memory observation; current expires at the configured feed interval. Ranking and Jellyfin materialization are independent. Unavailable means no successful ranking observation; stale preserves the last known result after expiry or failed refresh. Private conditional reads authenticate first and hash the complete retained projection plus operator authority and filters; matching If-None-Match returns a bodyless 304. This saves repeat transfer, not projection or hashing work. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers.", + "operationId": "get-recommendation-feed", + "parameters": [ + { + "description": "Private display validator from a prior authenticated feed read", + "in": "header", + "name": "If-None-Match", + "schema": { + "description": "Private display validator from a prior authenticated feed read", + "type": "string" + } + }, + { + "in": "path", + "name": "profile", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Typed contributing watched seed; requires seed_tmdb_id", + "explode": false, + "in": "query", + "name": "seed_media_type", + "schema": { + "description": "Typed contributing watched seed; requires seed_tmdb_id", + "enum": [ + "movie", + "tv" + ], + "type": "string" + } + }, + { + "description": "Contributing watched seed identity; requires seed_media_type", + "explode": false, + "in": "query", + "name": "seed_tmdb_id", + "schema": { + "description": "Contributing watched seed identity; requires seed_media_type", + "format": "int64", + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Observed candidate genre; filters retained rank without rescoring", + "explode": false, + "in": "query", + "name": "genre_id", + "schema": { + "description": "Observed candidate genre; filters retained rank without rescoring", + "format": "int64", + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Retained candidate type scope; never a new discovery query", + "explode": false, + "in": "query", + "name": "media_type", + "schema": { + "description": "Retained candidate type scope; never a new discovery query", + "enum": [ + "all", + "movie", + "tv" + ], + "type": "string" + } + }, + { + "description": "Observed NL flatrate provider ID; unknown candidate providers remain unresolved", + "explode": false, + "in": "query", + "name": "service_id", + "schema": { + "description": "Observed NL flatrate provider ID; unknown candidate providers remain unresolved", + "format": "int64", + "minimum": 1, + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RecommendationFeed" + } + } + }, + "description": "OK", + "headers": { + "Cache-Control": { + "schema": { + "type": "string" + } + }, + "ETag": { + "schema": { + "type": "string" + } + } + } + }, + "304": { + "description": "Authenticated display representation unchanged; reuse the private cached feed" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Authentik sign-in required" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Operator or target is not permitted" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Identity verification or operation unavailable" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error, including 404 for an unknown profile and 422 for invalid input" + } + }, + "security": [ + { + "authentikSession": [] + } + ], + "summary": "Latest known native ranked recommendation feed" + } + }, + "/api/v1/recommendations/sync": { + "post": { + "description": "Waits for any active scheduled cycle, then resolves, scores, materializes, and read-back verifies every configured profile feed. Administrative cockpit authority required.", + "operationId": "post-recommendations-sync", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RecommendationSyncResult" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Authentik sign-in required" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Operator or target is not permitted" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Identity verification or operation unavailable" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "authentikSession": [] + } + ], + "summary": "Run a complete recommendation feed cycle" + } + }, + "/api/v1/requests": { + "post": { + "description": "Files the request for the chosen household requester (default: the signed-in operator) through the non-admin Seerr on-behalf path, or, for media a household library already holds, adds the requester's membership without a second grab. The acting operator is recorded separately from the requester. Missing selected seasons stay requestable; a season is excluded only when its own files are complete or an active Seerr request for the same requester already covers it. Duplicate, lost or inconclusive Seerr responses are reconciled against Seerr's own request list: unproven coverage returns a pending, unresolved receipt, and resubmitting the same command resumes it before filing anything. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers.", + "operationId": "post-request-intent", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestIntentBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestReceipt" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Authentik sign-in required" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Operator or target is not permitted" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Identity verification or operation unavailable" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "authentikSession": [] + } + ], + "summary": "Request a movie or explicit television seasons for a household requester" + } + }, + "/api/v1/requests/titles/{tmdb}/{type}": { + "get": { + "description": "Read-only receipts: acting operator, requester, outcomes per season, and the Seerr and dispatch receipts each command produced. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers.", + "operationId": "get-title-requests", + "parameters": [ + { + "in": "path", + "name": "tmdb", + "required": true, + "schema": { + "format": "int64", + "minimum": 1, + "type": "integer" + } + }, + { + "in": "path", + "name": "type", + "required": true, + "schema": { + "enum": [ + "tv", + "movie" + ], + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TitleRequestsBody" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Authentik sign-in required" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Operator or target is not permitted" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Identity verification or operation unavailable" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "authentikSession": [] + } + ], + "summary": "Native request records for one typed title" + } + }, + "/api/v1/requests/{id}/retire": { + "post": { + "description": "Administrator-only local closeout after a fresh Seerr identity read proves the pending record no longer matches its household target. Preserves attribution and effect evidence. Does not cancel any filed Seerr request, undo membership, settle recommendations or submit another request. Unchanged identities, settled/retired records and concurrent title operations conflict. Administrative cockpit authority required.", + "operationId": "post-retire-request", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "minimum": 1, + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestReceipt" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Authentik sign-in required" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Operator or target is not permitted" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Identity verification or operation unavailable" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "authentikSession": [] + } + ], + "summary": "Retire an exact stale-identity pending native request record" + } + }, + "/arr/webhook": { + "post": { + "description": "Download and non-upgrade EpisodeFileDelete (Sonarr) or MovieFileDelete (Radarr) events are inserted into webhook_dedup and acknowledged immediately. File-delete events only re-materialize the title because a deleted Arr file otherwise leaves a dangling per-file Jellyfin layer link. Test events and other event types are ignored. Restricted to direct loopback bundled-service callers without browser/session/proxy headers; no cockpit role is granted.", + "operationId": "post-arr-webhook", + "requestBody": { + "content": { + "application/octet-stream": { + "schema": { + "contentMediaType": "application/octet-stream", + "format": "binary", + "type": "string" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebhookAck" + } + } + }, + "description": "OK" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Bundled-service caller required" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "summary": "Sonarr/Radarr primary intake webhook (insert + 200; worker materializes)" + } + }, + "/arr/webhook/mirror": { + "post": { + "description": "Download and non-upgrade EpisodeFileDelete (Sonarr) or MovieFileDelete (Radarr) events are inserted into webhook_dedup and acknowledged immediately. The worker only re-materializes existing memberships; file deletes prune dangling per-file Jellyfin layer links. Restricted to direct loopback bundled-service callers without browser/session/proxy headers; no cockpit role is granted.", + "operationId": "post-arr-mirror-webhook", + "requestBody": { + "content": { + "application/octet-stream": { + "schema": { + "contentMediaType": "application/octet-stream", + "format": "binary", + "type": "string" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebhookAck" + } + } + }, + "description": "OK" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Bundled-service caller required" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "summary": "Sonarr/Radarr mirror refresh webhook (insert + 200; worker refreshes)" + } + }, + "/auth/login": { + "get": { + "description": "Returns to the configured cockpit origin only; caller-supplied redirect destinations are not accepted. The existing reverse proxy routes /outpost.goauthentik.io to Authentik.", + "operationId": "get-cockpit-login", + "responses": { + "302": { + "description": "Found", + "headers": { + "Location": { + "schema": { + "type": "string" + } + } + } + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "summary": "Enter the existing Authentik proxy sign-in flow" + } + }, + "/catalogue/discover": { + "get": { + "description": "General-interest provider suggestions, not personalised Conductarr recommendations. Browsing cannot request or acquire media. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers. Conditional reads authenticate and authorize first. ETag hashes verified operator authority, request identity and exact serialized representation; matching If-None-Match returns bodyless 304. Cache-Control: no-store remains unchanged: HTTP caches must not store the response or validator. An application-owned, person-partitioned last-known-good display model may be revalidated explicitly; validators never permit cross-person reuse. Vary includes Cookie and Authorization. Reads, projection, encoding and hashing still run on 304; only repeat transfer is avoided.", + "operationId": "get-catalogue-discover", + "parameters": [ + { + "explode": false, + "in": "query", + "name": "media_type", + "schema": { + "default": "all", + "enum": [ + "all", + "movie", + "tv" + ], + "type": "string" + } + }, + { + "explode": false, + "in": "query", + "name": "page", + "schema": { + "default": 1, + "format": "int64", + "maximum": 500, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "TMDB genre ID in the selected movie or TV namespace; zero means no genre constraint", + "explode": false, + "in": "query", + "name": "genre_id", + "schema": { + "description": "TMDB genre ID in the selected movie or TV namespace; zero means no genre constraint", + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, + { + "description": "Configured TMDB streaming-provider ID; zero means no service constraint", + "explode": false, + "in": "query", + "name": "service_id", + "schema": { + "description": "Configured TMDB streaming-provider ID; zero means no service constraint", + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, + { + "description": "Requires movie or TV. Popular sorts by popularity; New by primary release date (movies) or first air date (TV), capped inclusively at today's UTC date, not service arrival; Top by vote average with at least 200 TMDB votes; Upcoming uses Seerr's premiere discovery from today's UTC date, ordered by popularity", + "explode": false, + "in": "query", + "name": "sort", + "schema": { + "description": "Requires movie or TV. Popular sorts by popularity; New by primary release date (movies) or first air date (TV), capped inclusively at today's UTC date, not service arrival; Top by vote average with at least 200 TMDB votes; Upcoming uses Seerr's premiere discovery from today's UTC date, ordered by popularity", + "enum": [ + "popular", + "new", + "top", + "upcoming" + ], + "type": "string" + } + }, + { + "description": "Validator from this operator's prior application-owned display representation; weak tags, lists and * use GET weak comparison", + "in": "header", + "name": "If-None-Match", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Page" + } + } + }, + "description": "OK", + "headers": { + "ETag": { + "description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes", + "schema": { + "type": "string" + } + } + } + }, + "304": { + "description": "Authenticated representation unchanged; no body. Reuse only the same person's application-owned display state.", + "headers": { + "ETag": { + "description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes", + "schema": { + "type": "string" + } + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Authentik sign-in required" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Operator or target is not permitted" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Identity verification or operation unavailable" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "authentikSession": [] + } + ], + "summary": "General Seerr discovery" + } + }, + "/catalogue/options": { + "get": { + "description": "Read-only cached genre and configured regional streaming-provider options. Independent source observations distinguish answered empty lists from unavailable metadata. No title/lane preload, personalised ranking or availability claim. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers. Conditional reads authenticate and authorize first. ETag hashes verified operator authority, request identity and exact serialized representation; matching If-None-Match returns bodyless 304. Cache-Control: no-store remains unchanged: HTTP caches must not store the response or validator. An application-owned, person-partitioned last-known-good display model may be revalidated explicitly; validators never permit cross-person reuse. Vary includes Cookie and Authorization. Reads, projection, encoding and hashing still run on 304; only repeat transfer is avoided.", + "operationId": "get-catalogue-options", + "parameters": [ + { + "explode": false, + "in": "query", + "name": "media_type", + "required": true, + "schema": { + "enum": [ + "movie", + "tv" + ], + "type": "string" + } + }, + { + "description": "Validator from this operator's prior application-owned display representation; weak tags, lists and * use GET weak comparison", + "in": "header", + "name": "If-None-Match", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Options" + } + } + }, + "description": "OK", + "headers": { + "ETag": { + "description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes", + "schema": { + "type": "string" + } + } + } + }, + "304": { + "description": "Authenticated representation unchanged; no body. Reuse only the same person's application-owned display state.", + "headers": { + "ETag": { + "description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes", + "schema": { + "type": "string" + } + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Authentik sign-in required" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Operator or target is not permitted" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Identity verification or operation unavailable" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "authentikSession": [] + } + ], + "summary": "Media-type genres and curated streaming services" + } + }, + "/catalogue/performers/{performer}": { + "get": { + "description": "Read-only Seerr person facts with strict performer identity. Observed identity with empty optional facts differs from unavailable or explicitly missing metadata. Seerr lookup failures, including unknown people, return unavailable, not verified absence. No title holding, request, visibility or playback claim follows from person metadata. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers. Conditional reads authenticate and authorize first. ETag hashes verified operator authority, request identity and exact serialized representation; matching If-None-Match returns bodyless 304. Cache-Control: no-store remains unchanged: HTTP caches must not store the response or validator. An application-owned, person-partitioned last-known-good display model may be revalidated explicitly; validators never permit cross-person reuse. Vary includes Cookie and Authorization. Reads, projection, encoding and hashing still run on 304; only repeat transfer is avoided.", + "operationId": "get-catalogue-performer", + "parameters": [ + { + "in": "path", + "name": "performer", + "required": true, + "schema": { + "format": "int64", + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Validator from this operator's prior application-owned display representation; weak tags, lists and * use GET weak comparison", + "in": "header", + "name": "If-None-Match", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PerformerDetail" + } + } + }, + "description": "OK", + "headers": { + "ETag": { + "description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes", + "schema": { + "type": "string" + } + } + } + }, + "304": { + "description": "Authenticated representation unchanged; no body. Reuse only the same person's application-owned display state.", + "headers": { + "ETag": { + "description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes", + "schema": { + "type": "string" + } + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Authentik sign-in required" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Operator or target is not permitted" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Identity verification or operation unavailable" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "authentikSession": [] + } + ], + "summary": "Selected cast performer identity and biography" + } + }, + "/catalogue/performers/{performer}/filmography": { + "get": { + "description": "Read-only native pages of Seerr's unpaged non-adult cast credits, not an exhaustive career. Groups movie/series identities while preserving every role in returned order. Counts describe this source snapshot and are absent on failure. Household holding, configured audience and incomplete request relations remain independent; none proves playback. No per-credit detail fetch or mutation. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers. Conditional reads authenticate and authorize first. ETag hashes verified operator authority, request identity and exact serialized representation; matching If-None-Match returns bodyless 304. Cache-Control: no-store remains unchanged: HTTP caches must not store the response or validator. An application-owned, person-partitioned last-known-good display model may be revalidated explicitly; validators never permit cross-person reuse. Vary includes Cookie and Authorization. Reads, projection, encoding and hashing still run on 304; only repeat transfer is avoided.", + "operationId": "get-catalogue-filmography", + "parameters": [ + { + "in": "path", + "name": "performer", + "required": true, + "schema": { + "format": "int64", + "minimum": 1, + "type": "integer" + } + }, + { + "explode": false, + "in": "query", + "name": "page", + "schema": { + "default": 1, + "format": "int64", + "maximum": 1000000, + "minimum": 1, + "type": "integer" + } + }, + { + "explode": false, + "in": "query", + "name": "page_size", + "schema": { + "default": 20, + "format": "int64", + "maximum": 100, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Validator from this operator's prior application-owned display representation; weak tags, lists and * use GET weak comparison", + "in": "header", + "name": "If-None-Match", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FilmographyPage" + } + } + }, + "description": "OK", + "headers": { + "ETag": { + "description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes", + "schema": { + "type": "string" + } + } + } + }, + "304": { + "description": "Authenticated representation unchanged; no body. Reuse only the same person's application-owned display state.", + "headers": { + "ETag": { + "description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes", + "schema": { + "type": "string" + } + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Authentik sign-in required" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Operator or target is not permitted" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Identity verification or operation unavailable" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "authentikSession": [] + } + ], + "summary": "Selected performer's typed acting filmography" + } + }, + "/catalogue/search": { + "get": { + "description": "Authenticated read-only catalogue search. Request, local holding and provider availability are separate observations; failed provider reads are unavailable, never fabricated empty results. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers.", + "operationId": "get-catalogue-search", + "parameters": [ + { + "explode": false, + "in": "query", + "name": "q", + "required": true, + "schema": { + "maxLength": 300, + "minLength": 1, + "type": "string" + } + }, + { + "explode": false, + "in": "query", + "name": "media_type", + "schema": { + "default": "all", + "enum": [ + "all", + "movie", + "tv" + ], + "type": "string" + } + }, + { + "explode": false, + "in": "query", + "name": "page", + "schema": { + "default": 1, + "format": "int64", + "maximum": 500, + "minimum": 1, + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Page" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Authentik sign-in required" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Operator or target is not permitted" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Identity verification or operation unavailable" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "authentikSession": [] + } + ], + "summary": "Native Seerr movie and series search" + } + }, + "/catalogue/titles/{tmdb}/{type}": { + "get": { + "description": "Reads provider metadata and the existing held-title owner independently. Unavailable metadata never erases known held files or implies a title is unheld. No request, membership, watched-state or acquisition writer is called. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers. Conditional reads authenticate and authorize first. ETag hashes verified operator authority, request identity and exact serialized representation; matching If-None-Match returns bodyless 304. Cache-Control: no-store remains unchanged: HTTP caches must not store the response or validator. An application-owned, person-partitioned last-known-good display model may be revalidated explicitly; validators never permit cross-person reuse. Vary includes Cookie and Authorization. Reads, projection, encoding and hashing still run on 304; only repeat transfer is avoided.", + "operationId": "get-catalogue-title", + "parameters": [ + { + "in": "path", + "name": "tmdb", + "required": true, + "schema": { + "format": "int64", + "minimum": 1, + "type": "integer" + } + }, + { + "in": "path", + "name": "type", + "required": true, + "schema": { + "enum": [ + "tv", + "movie" + ], + "type": "string" + } + }, + { + "description": "Validator from this operator's prior application-owned display representation; weak tags, lists and * use GET weak comparison", + "in": "header", + "name": "If-None-Match", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Detail" + } + } + }, + "description": "OK", + "headers": { + "ETag": { + "description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes", + "schema": { + "type": "string" + } + } + } + }, + "304": { + "description": "Authenticated representation unchanged; no body. Reuse only the same person's application-owned display state.", + "headers": { + "ETag": { + "description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes", + "schema": { + "type": "string" + } + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Authentik sign-in required" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Operator or target is not permitted" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Identity verification or operation unavailable" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "authentikSession": [] + } + ], + "summary": "Native movie or series title workspace" + } + }, + "/catalogue/titles/{tmdb}/{type}/cast": { + "get": { + "description": "Read-only unpaged Seerr cast with performer IDs distinct from typed title IDs. Series expose only the leading aggregate character per performer. Missing, unavailable and observed empty cast differ. Cast metadata proves neither holding, requests, visibility nor playback. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers. Conditional reads authenticate and authorize first. ETag hashes verified operator authority, request identity and exact serialized representation; matching If-None-Match returns bodyless 304. Cache-Control: no-store remains unchanged: HTTP caches must not store the response or validator. An application-owned, person-partitioned last-known-good display model may be revalidated explicitly; validators never permit cross-person reuse. Vary includes Cookie and Authorization. Reads, projection, encoding and hashing still run on 304; only repeat transfer is avoided.", + "operationId": "get-catalogue-cast", + "parameters": [ + { + "in": "path", + "name": "tmdb", + "required": true, + "schema": { + "format": "int64", + "minimum": 1, + "type": "integer" + } + }, + { + "in": "path", + "name": "type", + "required": true, + "schema": { + "enum": [ + "tv", + "movie" + ], + "type": "string" + } + }, + { + "description": "Validator from this operator's prior application-owned display representation; weak tags, lists and * use GET weak comparison", + "in": "header", + "name": "If-None-Match", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TitleCast" + } + } + }, + "description": "OK", + "headers": { + "ETag": { + "description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes", + "schema": { + "type": "string" + } + } + } + }, + "304": { + "description": "Authenticated representation unchanged; no body. Reuse only the same person's application-owned display state.", + "headers": { + "ETag": { + "description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes", + "schema": { + "type": "string" + } + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Authentik sign-in required" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Operator or target is not permitted" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Identity verification or operation unavailable" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "authentikSession": [] + } + ], + "summary": "Movie or series cast performers" + } + }, + "/catalogue/titles/{tmdb}/{type}/display": { + "get": { + "description": "Authenticated Seerr metadata read for visible request posters. Contains no holding, files, mirrors, watch state, request status or ratings. Missing or unavailable artwork says nothing about a request or media availability. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers.", + "operationId": "get-catalogue-display", + "parameters": [ + { + "in": "path", + "name": "tmdb", + "required": true, + "schema": { + "format": "int64", + "minimum": 1, + "type": "integer" + } + }, + { + "in": "path", + "name": "type", + "required": true, + "schema": { + "enum": [ + "tv", + "movie" + ], + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Display" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Authentik sign-in required" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Operator or target is not permitted" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Identity verification or operation unavailable" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "authentikSession": [] + } + ], + "summary": "Public title presentation" + } + }, + "/catalogue/titles/{tmdb}/{type}/enrichment": { + "get": { + "description": "Authenticated fresh Seerr metadata and ratings for an already observed workspace or a visible hero. Reads no Arr, files, mirrors, memberships or watch state. Local holding and coverage remain unknown; this response never authorizes a title action. Metadata, providers and ratings preserve their independent unavailable states. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers.", + "operationId": "get-catalogue-enrichment", + "parameters": [ + { + "in": "path", + "name": "tmdb", + "required": true, + "schema": { + "format": "int64", + "minimum": 1, + "type": "integer" + } + }, + { + "in": "path", + "name": "type", + "required": true, + "schema": { + "enum": [ + "tv", + "movie" + ], + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Enrichment" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Authentik sign-in required" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Operator or target is not permitted" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Identity verification or operation unavailable" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "authentikSession": [] + } + ], + "summary": "Title metadata and source ratings" + } + }, + "/catalogue/titles/{tmdb}/{type}/metadata": { + "get": { + "description": "Reuses bounded observations from committed title metadata reads, restored from SQLite and served from memory, with zero upstream or holding acquisition in this request. A single paced background fill observes held titles; cold and evicted titles remain unobserved until admitted. Restart preserves committed facts, their age and failure outcomes. States describe the latest attempt; timestamps identify last-known genre/provider facts, not a freshness guarantee. Provider observation requires an explicit NL result: empty or NL-absent regional results are ambiguous and preserve prior offers/timestamps as unobserved, not complete coverage. NL offers are Seerr-reported regional catalogue metadata, not subscriptions, membership, request eligibility or playback proof. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers.", + "operationId": "get-catalogue-metadata", + "parameters": [ + { + "in": "path", + "name": "tmdb", + "required": true, + "schema": { + "format": "int64", + "minimum": 1, + "type": "integer" + } + }, + { + "in": "path", + "name": "type", + "required": true, + "schema": { + "enum": [ + "tv", + "movie" + ], + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TitleMetadata" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Authentik sign-in required" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Operator or target is not permitted" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Identity verification or operation unavailable" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "authentikSession": [] + } + ], + "summary": "Retained title genres and NL streaming offers" + } + }, + "/catalogue/titles/{tmdb}/{type}/ratings": { + "get": { + "description": "Authenticated ratings-only display read with the same source observations as enrichment. Server cache: 6 hours for positive results, 1 minute for missing results, at most 300 typed title identities; concurrent identical reads share a fetch. Provider failures are not cached. Expired entries are never served. Reads no Arr, files, mirrors, memberships or watch state and never authorizes a title action. Enrichment and committed title reads remain fresh. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers.", + "operationId": "get-catalogue-ratings", + "parameters": [ + { + "in": "path", + "name": "tmdb", + "required": true, + "schema": { + "format": "int64", + "minimum": 1, + "type": "integer" + } + }, + { + "in": "path", + "name": "type", + "required": true, + "schema": { + "enum": [ + "tv", + "movie" + ], + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TitleRatings" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Authentik sign-in required" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Operator or target is not permitted" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Identity verification or operation unavailable" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "authentikSession": [] + } + ], + "summary": "Cached title source ratings" + } + }, + "/catalogue/titles/{tmdb}/{type}/related": { + "get": { + "description": "Typed title-related provider suggestions kept separate from Conductarr's personalised ranking. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers. Conditional reads authenticate and authorize first. ETag hashes verified operator authority, request identity and exact serialized representation; matching If-None-Match returns bodyless 304. Cache-Control: no-store remains unchanged: HTTP caches must not store the response or validator. An application-owned, person-partitioned last-known-good display model may be revalidated explicitly; validators never permit cross-person reuse. Vary includes Cookie and Authorization. Reads, projection, encoding and hashing still run on 304; only repeat transfer is avoided.", + "operationId": "get-catalogue-related", + "parameters": [ + { + "in": "path", + "name": "tmdb", + "required": true, + "schema": { + "format": "int64", + "minimum": 1, + "type": "integer" + } + }, + { + "in": "path", + "name": "type", + "required": true, + "schema": { + "enum": [ + "tv", + "movie" + ], + "type": "string" + } + }, + { + "explode": false, + "in": "query", + "name": "page", + "schema": { + "default": 1, + "format": "int64", + "maximum": 500, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Validator from this operator's prior application-owned display representation; weak tags, lists and * use GET weak comparison", + "in": "header", + "name": "If-None-Match", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Page" + } + } + }, + "description": "OK", + "headers": { + "ETag": { + "description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes", + "schema": { + "type": "string" + } + } + } + }, + "304": { + "description": "Authenticated representation unchanged; no body. Reuse only the same person's application-owned display state.", + "headers": { + "ETag": { + "description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes", + "schema": { + "type": "string" + } + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Authentik sign-in required" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Operator or target is not permitted" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Identity verification or operation unavailable" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "authentikSession": [] + } + ], + "summary": "Related-title Seerr suggestions" + } + }, + "/catalogue/tv/{tmdb}/seasons/{season}": { + "get": { + "description": "Reads an explicitly selected provider season and joins local primary episode evidence without requesting missing seasons or claiming playback. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers. Conditional reads authenticate and authorize first. ETag hashes verified operator authority, request identity and exact serialized representation; matching If-None-Match returns bodyless 304. Cache-Control: no-store remains unchanged: HTTP caches must not store the response or validator. An application-owned, person-partitioned last-known-good display model may be revalidated explicitly; validators never permit cross-person reuse. Vary includes Cookie and Authorization. Reads, projection, encoding and hashing still run on 304; only repeat transfer is avoided.", + "operationId": "get-catalogue-season", + "parameters": [ + { + "in": "path", + "name": "tmdb", + "required": true, + "schema": { + "format": "int64", + "minimum": 1, + "type": "integer" + } + }, + { + "in": "path", + "name": "season", + "required": true, + "schema": { + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, + { + "description": "Validator from this operator's prior application-owned display representation; weak tags, lists and * use GET weak comparison", + "in": "header", + "name": "If-None-Match", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SeasonDetail" + } + } + }, + "description": "OK", + "headers": { + "ETag": { + "description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes", + "schema": { + "type": "string" + } + } + } + }, + "304": { + "description": "Authenticated representation unchanged; no body. Reuse only the same person's application-owned display state.", + "headers": { + "ETag": { + "description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes", + "schema": { + "type": "string" + } + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Authentik sign-in required" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Operator or target is not permitted" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Identity verification or operation unavailable" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "authentikSession": [] + } + ], + "summary": "Selected television season metadata and recorded coverage" + } + }, + "/curation/items": { + "get": { + "description": "Every enabled PRIMARY arr of the media type's kind joined with its bucket assignment, mapped to an owner (Justin/Mindel/Shared/Unassigned). The anime badge is SERVER-derived from the holding instance's is_anime flag (rename-proof). An arr that fails to list omits its items, never the whole view. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers.", + "operationId": "get-curation-items", + "parameters": [ + { + "description": "tv | movie (the TV/Movies toggle)", + "explode": false, + "in": "query", + "name": "media_type", + "schema": { + "default": "tv", + "description": "tv | movie (the TV/Movies toggle)", + "enum": [ + "tv", + "movie" + ], + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ItemsBody" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Authentik sign-in required" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Operator or target is not permitted" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Identity verification or operation unavailable" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "authentikSession": [] + } + ], + "summary": "Poster-grid items + owner-rail counts for one media type" + } + }, + "/curation/items/{tmdb}/tv/episodes": { + "get": { + "description": "The drill-in panel: seasons/episodes joined from the holding sonarr (SxxEyy, title, air date, monitored, has-file, per-episode file quality/size/release) + per-user Jellyfin watch chips. Users included are EXACTLY those whose sees: matrix covers the item's library (server-derived, owners-not-buckets). One episodes call + one files call + one Jellyfin list per user — never per-episode. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers.", + "operationId": "get-curation-episodes", + "parameters": [ + { + "description": "TMDB id of the series", + "in": "path", + "name": "tmdb", + "required": true, + "schema": { + "description": "TMDB id of the series", + "format": "int64", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EpisodeView" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Authentik sign-in required" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Operator or target is not permitted" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Identity verification or operation unavailable" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "authentikSession": [] + } + ], + "summary": "Season-grouped episodes + per-user watch state for a TV item" + } + }, + "/curation/items/{tmdb}/tv/fill-gaps": { + "post": { + "description": "Lists the holding sonarr's episodes ONCE, filters to the gaps (monitored, without a file, and already aired — unaired/future and unmonitored episodes excluded), and issues a single EpisodeSearch for their ids. Shares the one-in-flight-per-series guard with regrab (a second answers status=busy). Response carries searched_count; 0 with status=ok means there were no gaps (a quiet no-op). Library-management cockpit capability required (administrators included); this grants authority across household titles, not other viewers' watched-state or recommendation edits.", + "operationId": "post-curation-fill-gaps", + "parameters": [ + { + "description": "TMDB id of the series", + "in": "path", + "name": "tmdb", + "required": true, + "schema": { + "description": "TMDB id of the series", + "format": "int64", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FillGapsResult" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Authentik sign-in required" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Operator or target is not permitted" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Identity verification or operation unavailable" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "authentikSession": [] + } + ], + "summary": "Search the gap episodes of a TV series (monitored + fileless + aired)" + } + }, + "/curation/items/{tmdb}/{type}": { + "get": { + "description": "The always-open inspector's payload for a single tile (design §2.3): Identity (title/year/runtime/owners), File (size + media info), Source (arr/imported/indexer), Health (has-file/queued/failed/missing). Fetched ONLY on selection — the targeted per-item File/Source/Health reads never run across the grid (library-scale rule). The grid list carries owners + anime already; this adds the drill-in facts. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers.", + "operationId": "get-curation-item-detail", + "parameters": [ + { + "description": "TMDB id of the focused item", + "in": "path", + "name": "tmdb", + "required": true, + "schema": { + "description": "TMDB id of the focused item", + "format": "int64", + "type": "integer" + } + }, + { + "description": "tv | movie", + "in": "path", + "name": "type", + "required": true, + "schema": { + "description": "tv | movie", + "enum": [ + "tv", + "movie" + ], + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ItemDetail" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Authentik sign-in required" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Operator or target is not permitted" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Identity verification or operation unavailable" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "authentikSession": [] + } + ], + "summary": "Inspector detail for one focused item" + } + }, + "/curation/items/{tmdb}/{type}/delete": { + "post": { + "description": "Requires the scope_id of the deletion impact the operator confirmed. The current scope is observed again under the title lock; a different scope answers conflict and an unobservable one inconclusive, both before any effect. Only the confirmed primary and mirror records are FULL-removed (deleteFiles=true so the arr's own stale library symlink goes too; removeFromClient is NEVER sent — that GCs shared NzbDAV blobs the layer points at). The confirmed bucket-layer symlink(s) are pruned, the bucket_memberships rows dropped, and a targeted Jellyfin refresh issued. A mirror copy that cannot be removed after the primary is gone answers partial, never ok. Works for ANY provenance incl. usenet-streamed (the arr's on-disk file is its own symlink; the NzbDAV import tree is never touched). Bulk delete is a client-side fan-out over this endpoint — one confirmed scope and one result per item. Library-management cockpit capability required (administrators included); this grants authority across household titles, not other viewers' watched-state or recommendation edits.", + "operationId": "post-curation-delete", + "parameters": [ + { + "description": "TMDB id of the item to delete", + "in": "path", + "name": "tmdb", + "required": true, + "schema": { + "description": "TMDB id of the item to delete", + "format": "int64", + "type": "integer" + } + }, + { + "description": "tv | movie", + "in": "path", + "name": "type", + "required": true, + "schema": { + "description": "tv | movie", + "enum": [ + "tv", + "movie" + ], + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteBody" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Authentik sign-in required" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Operator or target is not permitted" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Identity verification or operation unavailable" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "authentikSession": [] + } + ], + "summary": "Delete one library item across all provenance (R12) within its confirmed scope" + } + }, + "/curation/items/{tmdb}/{type}/deletion-impact": { + "get": { + "description": "Read-only observation of the existing deletion owner's primary and rule-derived mirrors, exact membership and managed layer-link effects (including orphan collateral), and configured libraries/viewers. ready supplies the scope the operator confirms: its scope_id binds a later deletion request, which observes the scope again and deletes nothing when it differs. It is not a reservation or authorization. missing and inconclusive never supply a scope; unavailable reads cannot prove absence. Global deletion removes the confirmed Arr records with deleteFiles=true, prunes managed links and emptied directories, reconciles memberships/dispatch and requests a best-effort Jellyfin refresh. It never removes download-client payloads, blocklists, deletes NzbDAV blobs or erases watched state. Separate membership Remove is non-destructive. This read changes nothing. Library-management cockpit capability required (administrators included); this grants authority across household titles, not other viewers' watched-state or recommendation edits.", + "operationId": "get-curation-deletion-impact", + "parameters": [ + { + "in": "path", + "name": "tmdb", + "required": true, + "schema": { + "format": "int64", + "minimum": 1, + "type": "integer" + } + }, + { + "in": "path", + "name": "type", + "required": true, + "schema": { + "enum": [ + "tv", + "movie" + ], + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeletionImpact" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Authentik sign-in required" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Operator or target is not permitted" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Identity verification or operation unavailable" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "authentikSession": [] + } + ], + "summary": "Observe exact global media deletion impact without changing media" + } + }, + "/curation/items/{tmdb}/{type}/regrab": { + "post": { + "description": "Blocklists the grabbed release(s) (POST history/failed) + clears any queued grab (DELETE queue, removeFromClient=FALSE always, blocklist=true) then issues the replacement search (EpisodeSearch/SeasonSearch/MoviesSearch). Never touches the import tree; the arr replaces the file on the new import. One in-flight regrab per item (shared soak account) — a second answers status=busy. Library-management cockpit capability required (administrators included); this grants authority across household titles, not other viewers' watched-state or recommendation edits.", + "operationId": "post-curation-regrab", + "parameters": [ + { + "description": "TMDB id of the item", + "in": "path", + "name": "tmdb", + "required": true, + "schema": { + "description": "TMDB id of the item", + "format": "int64", + "type": "integer" + } + }, + { + "description": "tv | movie", + "in": "path", + "name": "type", + "required": true, + "schema": { + "description": "tv | movie", + "enum": [ + "tv", + "movie" + ], + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RegrabRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RegrabResult" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Authentik sign-in required" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Operator or target is not permitted" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Identity verification or operation unavailable" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "authentikSession": [] + } + ], + "summary": "Regrab episodes/season (tv) or the movie (R12 regrab, pulled forward)" + } + }, + "/curation/items/{tmdb}/{type}/watchstate": { + "post": { + "description": "Marks one user's episode (SxxEyy) or movie played/unplayed with a LIVE Jellyfin write, verified by read-back. Refuses a user not covered by the item's sees: matrix (status=forbidden). This is a direct operator write — NOT the capture/restore machinery — so it never touches the clobber guard. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers.", + "operationId": "post-curation-watchstate", + "parameters": [ + { + "description": "TMDB id of the item", + "in": "path", + "name": "tmdb", + "required": true, + "schema": { + "description": "TMDB id of the item", + "format": "int64", + "type": "integer" + } + }, + { + "description": "tv | movie", + "in": "path", + "name": "type", + "required": true, + "schema": { + "description": "tv | movie", + "enum": [ + "tv", + "movie" + ], + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WatchStateRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WatchStateResult" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Authentik sign-in required" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Operator or target is not permitted" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Identity verification or operation unavailable" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "authentikSession": [] + } + ], + "summary": "Direct operator watch-state toggle (live Jellyfin write + read-back)" + } + }, + "/curation/layer/movie-cutover": { + "get": { + "description": "Lists legacy movie links and started or failed cutovers that have a durable path plan and watch snapshot. RecoveryCutoverID identifies a bracket that can be resumed by posting the same TMDB id even if no legacy links remain. Administrative cockpit authority required.", + "operationId": "get-curation-layer-movie-cutover", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MovieCutoverPendingBody" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Authentik sign-in required" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Operator or target is not permitted" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Identity verification or operation unavailable" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "authentikSession": [] + } + ], + "summary": "List legacy movies and interrupted movie cutovers" + } + }, + "/curation/layer/movie-cutover/{tmdb}": { + "get": { + "description": "Read-only evidence, including a retained bracket after legacy links disappear. Snapshot and restored counts are durable reported counts, not a new capture or live playback proof. The existing POST re-observes scope and preserves its capture/restore policy. Library-management cockpit capability required (administrators included); this grants authority across household titles, not other viewers' watched-state or recommendation edits.", + "operationId": "get-title-movie-cutover", + "parameters": [ + { + "description": "TMDB id of the movie to convert", + "in": "path", + "name": "tmdb", + "required": true, + "schema": { + "description": "TMDB id of the movie to convert", + "format": "int64", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MovieCutoverEvidence" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Authentik sign-in required" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Operator or target is not permitted" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Identity verification or operation unavailable" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "authentikSession": [] + } + ], + "summary": "Read one movie's legacy links and latest durable cutover" + }, + "post": { + "description": "A fresh cutover requires an exact indexed Movie at each old primary path for every relevant viewer, then persists the planned old-to-new paths and per-user snapshot before converting any entry. Re-posting an unfinished bracket re-indexes the planned new paths and field-wise merges the durable snapshot with current live UserData only for indexed targets. Partial indexing restores the reachable subset and reports unresolved paths for another retry; any still-legacy entries need a fresh capture before conversion. Library-management cockpit capability required (administrators included); this grants authority across household titles, not other viewers' watched-state or recommendation edits.", + "operationId": "post-curation-layer-movie-cutover", + "parameters": [ + { + "description": "TMDB id of the movie to convert", + "in": "path", + "name": "tmdb", + "required": true, + "schema": { + "description": "TMDB id of the movie to convert", + "format": "int64", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MovieCutoverBody" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Authentik sign-in required" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Operator or target is not permitted" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Identity verification or operation unavailable" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "authentikSession": [] + } + ], + "summary": "Convert or safely resume one movie cutover" + } + }, + "/curation/library": { + "get": { + "description": "Unpaged retained-metadata filtering. Items are confirmed matches; unresolved contains possible matches, never known exclusions. Coverage counts the entire named/my/all, type and text scope before genre/service/watched predicates, not a rendered page. Counts and source statistics cover the captured media-type collection. Source observation time is explicit; membership and configured viewers are rejoined on every read. A snapshot reuses the last collection across scopes; omit it for a fresh capture. Watched filtering indexes only the verified viewer's collection once per snapshot. Facts retain independent latest genre/provider outcomes and last-success timestamps: no TTL or freshness guarantee. Committed metadata survives restart; cold, evicted and capacity-overflow identities remain unobserved until admitted. A single paced background fill observes held titles; this request never hydrates metadata. Filter completeness is not complete metadata coverage, subscription, request eligibility or playback proof. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers.", + "operationId": "get-household-library", + "parameters": [ + { + "description": "Title or year search across household libraries by default", + "explode": false, + "in": "query", + "name": "q", + "schema": { + "description": "Title or year search across household libraries by default", + "maxLength": 300, + "type": "string" + } + }, + { + "description": "my/all (reserved), an enabled exact library display, or a legacy owner/Unassigned label; configured named libraries take precedence over owner labels", + "explode": false, + "in": "query", + "name": "library", + "schema": { + "description": "my/all (reserved), an enabled exact library display, or a legacy owner/Unassigned label; configured named libraries take precedence over owner labels", + "type": "string" + } + }, + { + "explode": false, + "in": "query", + "name": "media_type", + "schema": { + "default": "all", + "enum": [ + "all", + "tv", + "movie" + ], + "type": "string" + } + }, + { + "description": "Observed genre ID in each title's movie/TV namespace; zero disables", + "explode": false, + "in": "query", + "name": "genre_id", + "schema": { + "description": "Observed genre ID in each title's movie/TV namespace; zero disables", + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, + { + "description": "Observed NL flatrate provider ID; zero disables", + "explode": false, + "in": "query", + "name": "service_id", + "schema": { + "description": "Observed NL flatrate provider ID; zero disables", + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, + { + "description": "Verified operator's Jellyfin Played flag, never a submitted viewer identity", + "explode": false, + "in": "query", + "name": "watched", + "schema": { + "default": "all", + "description": "Verified operator's Jellyfin Played flag, never a submitted viewer identity", + "enum": [ + "all", + "watched", + "unwatched" + ], + "type": "string" + } + }, + { + "description": "Reuse the last retained collection without Arr reads; omitted captures anew, replaced or restarted snapshots return 409", + "explode": false, + "in": "query", + "name": "snapshot", + "schema": { + "description": "Reuse the last retained collection without Arr reads; omitted captures anew, replaced or restarted snapshots return 409", + "type": "string" + } + }, + { + "description": "Force a new Arr acquisition instead of the shared one taken within the last few seconds; ignored with a snapshot", + "explode": false, + "in": "query", + "name": "refresh", + "schema": { + "description": "Force a new Arr acquisition instead of the shared one taken within the last few seconds; ignored with a snapshot", + "type": "boolean" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LibraryResult" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Authentik sign-in required" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Operator or target is not permitted" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Identity verification or operation unavailable" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "authentikSession": [] + } + ], + "summary": "Unified held title search and configured household visibility" + } + }, + "/curation/library-audiences": { + "get": { + "description": "Projects named membership routes, enabled Jellyfin libraries and users' configured sees by media type and anime scope. Includes empty libraries and scopes with no configured viewer. This read does not inspect current title unions, watch-write permissions or live media, and grants no mutation authority. An unwired audience read is unavailable, never an empty preview. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers. Conditional reads authenticate and authorize first. ETag hashes verified operator authority, request identity and exact serialized representation; matching If-None-Match returns bodyless 304. Cache-Control: no-store remains unchanged: HTTP caches must not store the response or validator. An application-owned, person-partitioned last-known-good display model may be revalidated explicitly; validators never permit cross-person reuse. Vary includes Cookie and Authorization. Reads, projection, encoding and hashing still run on 304; only repeat transfer is avoided.", + "operationId": "get-library-audiences", + "parameters": [ + { + "description": "Validator from this operator's prior application-owned display representation; weak tags, lists and * use GET weak comparison", + "in": "header", + "name": "If-None-Match", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LibraryAudiencesBody" + } + } + }, + "description": "OK", + "headers": { + "ETag": { + "description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes", + "schema": { + "type": "string" + } + } + } + }, + "304": { + "description": "Authenticated representation unchanged; no body. Reuse only the same person's application-owned display state.", + "headers": { + "ETag": { + "description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes", + "schema": { + "type": "string" + } + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Authentik sign-in required" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Operator or target is not permitted" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Identity verification or operation unavailable" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "authentikSession": [] + } + ], + "summary": "Configured viewing audience for every enabled household membership scope" + } + }, + "/curation/library/activity": { + "get": { + "description": "Acquired separately from the title collection so a slow queue or history read never delays title presence. Each source reports whether its queue and history answered; a count is only meaningful for sources that did, and an unanswered source is never a zero. Per-source read times are explicit and may predate the request by the shared-acquisition window; refresh forces a new read. Administrative cockpit authority required.", + "operationId": "get-library-activity", + "parameters": [ + { + "explode": false, + "in": "query", + "name": "media_type", + "schema": { + "default": "all", + "enum": [ + "all", + "tv", + "movie" + ], + "type": "string" + } + }, + { + "description": "Force a new queue and history read instead of the shared one taken within the last few seconds", + "explode": false, + "in": "query", + "name": "refresh", + "schema": { + "description": "Force a new queue and history read instead of the shared one taken within the last few seconds", + "type": "boolean" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LibraryActivity" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Authentik sign-in required" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Operator or target is not permitted" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Identity verification or operation unavailable" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "authentikSession": [] + } + ], + "summary": "Queue depth and import history of the primaries serving a media type" + } + }, + "/curation/library/{tmdb}/{type}": { + "get": { + "description": "Reads one movie or series identity, its canonical household memberships/viewers, Arr-recorded primary and mirror files, and the existing watched-state interface. A missing title requires every relevant primary to answer; unavailable or partially answered evidence is not missing media or playback proof. Reads are performed only after explicit detail navigation, never pointer hover or bulk selection. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers.", + "operationId": "get-held-title", + "parameters": [ + { + "description": "TMDB id of the focused item", + "in": "path", + "name": "tmdb", + "required": true, + "schema": { + "description": "TMDB id of the focused item", + "format": "int64", + "type": "integer" + } + }, + { + "description": "tv | movie", + "in": "path", + "name": "type", + "required": true, + "schema": { + "description": "tv | movie", + "enum": [ + "tv", + "movie" + ], + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HeldTitle" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Authentik sign-in required" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Operator or target is not permitted" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Identity verification or operation unavailable" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "authentikSession": [] + } + ], + "summary": "Committed held title workspace with seasons, versions and configured viewers" + } + }, + "/curation/membership-jobs": { + "get": { + "description": " Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers.", + "operationId": "get-curation-memberships", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MembershipJobsBody" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Authentik sign-in required" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Operator or target is not permitted" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Identity verification or operation unavailable" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "authentikSession": [] + } + ], + "summary": "Read recent membership commands" + } + }, + "/curation/memberships": { + "post": { + "description": "Validates the unchanged admission rules and durably queues the change without waiting on Arr or Jellyfin. The existing intake worker applies watch-state capture, materialization, convergence and read-back. Poll the returned receipt ID for per-title outcomes; acceptance is not completion. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers.", + "operationId": "post-curation-memberships", + "parameters": [ + { + "description": "Reuse this key only for the same confirmed membership change", + "in": "header", + "name": "Idempotency-Key", + "schema": { + "description": "Reuse this key only for the same confirmed membership change", + "maxLength": 80, + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MembershipInputBody" + } + } + }, + "required": true + }, + "responses": { + "202": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MembershipJob" + } + } + }, + "description": "Accepted" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Authentik sign-in required" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Operator or target is not permitted" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Identity verification or operation unavailable" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "authentikSession": [] + } + ], + "summary": "Dispatch a curation membership change" + } + }, + "/curation/memberships/{id}": { + "get": { + "description": " Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers.", + "operationId": "get-curation-membership", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MembershipJob" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Authentik sign-in required" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Operator or target is not permitted" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Identity verification or operation unavailable" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "authentikSession": [] + } + ], + "summary": "Read membership command completion" + } + }, + "/curation/rename/{tmdb}": { + "get": { + "description": "Sonarr's own GET /api/v3/rename for the holding primary: every episode file whose on-disk name differs from what the naming config builds, with its existing and new series-folder-relative path. Changes nothing. An EMPTY files list means the series is already converged, and the POST will refuse. 503 while arr_naming.rename_episodes is false. Library-management cockpit capability required (administrators included); this grants authority across household titles, not other viewers' watched-state or recommendation edits.", + "operationId": "get-curation-rename", + "parameters": [ + { + "description": "TMDB id of the series to rename (television only)", + "in": "path", + "name": "tmdb", + "required": true, + "schema": { + "description": "TMDB id of the series to rename (television only)", + "format": "int64", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RenamePlanBody" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Authentik sign-in required" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Operator or target is not permitted" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Identity verification or operation unavailable" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "authentikSession": [] + } + ], + "summary": "Preview the Sonarr rename pass for one series" + }, + "post": { + "description": "Renames ONE series' episode files to the naming config's canonical names and carries its watch-state across: durable scoped snapshot plus a same-operation capture of every user who sees the item's buckets, Sonarr's RenameFiles command over exactly the previewed episodeFileIds (waited to a terminal status), per-file layer links repointed and renamed where a bucket entry is a real directory rather than a whole-title symlink, a Jellyfin notify + structural validate + bounded re-index poll, then the capture remapped onto the new item ids BY PATH and restored through the live-wins merge with read-back. Refuses an empty preview (409) and never runs automatically. Library-management cockpit capability required (administrators included); this grants authority across household titles, not other viewers' watched-state or recommendation edits.", + "operationId": "post-curation-rename", + "parameters": [ + { + "description": "TMDB id of the series to rename (television only)", + "in": "path", + "name": "tmdb", + "required": true, + "schema": { + "description": "TMDB id of the series to rename (television only)", + "format": "int64", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RenameResultBody" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Authentik sign-in required" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Operator or target is not permitted" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Identity verification or operation unavailable" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "authentikSession": [] + } + ], + "summary": "Run the Sonarr rename pass for one series" + } + }, + "/curation/rename/{tmdb}/passes": { + "get": { + "description": "Newest first, including unfinished passes, independently of preview availability. Files is a planned count, not actual renames. Restored and repointed counts are retained; skipped files, convergence and the full response are not. A retained request_id matches the originating POST's optional X-Request-ID transport metadata; absent or different identities cannot prove that original finished. This is not idempotency or a reconstructed response. Library-management cockpit capability required (administrators included); this grants authority across household titles, not other viewers' watched-state or recommendation edits.", + "operationId": "get-curation-rename-passes", + "parameters": [ + { + "description": "TMDB id of the series to rename (television only)", + "in": "path", + "name": "tmdb", + "required": true, + "schema": { + "description": "TMDB id of the series to rename (television only)", + "format": "int64", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RenameHistoryOutputBody" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Authentik sign-in required" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Operator or target is not permitted" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Identity verification or operation unavailable" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "authentikSession": [] + } + ], + "summary": "Read all durable rename passes for one television title" + } + }, + "/curation/watch-config": { + "post": { + "description": "Operator override for the boot-seeded watch guard. Resolves only enabled config Jellyfin usernames to runtime ids and atomically rewires reassign watch-state preservation. Boot retries this wiring while Jellyfin is unavailable. Answers 503 until serve supplies the resolver. Administrative cockpit authority required.", + "operationId": "post-curation-watch-config", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WatchConfigBody" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Authentik sign-in required" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Operator or target is not permitted" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Identity verification or operation unavailable" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "authentikSession": [] + } + ], + "summary": "Rewire the reassign watch-guard to enabled Jellyfin users" + } + }, + "/dispatch/dry-run": { + "get": { + "description": "Returns the rule, routed primary arr and rule-derived mirror instances an add would fan out to. No side effects. Administrative cockpit authority required.", + "operationId": "get-dispatch-dry-run", + "parameters": [ + { + "description": "TMDB id to resolve", + "explode": false, + "in": "query", + "name": "tmdb_id", + "required": true, + "schema": { + "description": "TMDB id to resolve", + "format": "int64", + "type": "integer" + } + }, + { + "description": "tv | movie", + "explode": false, + "in": "query", + "name": "media_type", + "schema": { + "default": "tv", + "description": "tv | movie", + "enum": [ + "tv", + "movie" + ], + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DryRunPlan" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Authentik sign-in required" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Operator or target is not permitted" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Identity verification or operation unavailable" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "authentikSession": [] + } + ], + "summary": "Resolve routing for an item without adding (engine.Plan as data)" + } + }, + "/dispatch/missing": { + "get": { + "description": "Compares each dispatch_log success against live arr state INCLUDING rule-derived mirror membership. An item deleted from an arr after its success row is surfaced here — the forever-dedup would otherwise never retry it. Administrative cockpit authority required.", + "operationId": "get-dispatch-missing", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MissingOutputBody" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Authentik sign-in required" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Operator or target is not permitted" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Identity verification or operation unavailable" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "authentikSession": [] + } + ], + "summary": "Success rows whose item is absent from an arr (incl. rule-derived mirrors)" + }, + "post": { + "description": "Forces a re-dispatch for the given items so a mirror deleted after a success row is re-added. The primary re-add is a safe 400-already_added. Library-management cockpit capability required (administrators included); this grants authority across household titles, not other viewers' watched-state or recommendation edits.", + "operationId": "post-dispatch-missing-redispatch", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RedispatchInputBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RedispatchOutputBody" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Authentik sign-in required" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Operator or target is not permitted" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Identity verification or operation unavailable" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "authentikSession": [] + } + ], + "summary": "Re-dispatch missing items (bypasses the success dedup)" + } + }, + "/dispatch/recent": { + "get": { + "description": " Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers.", + "operationId": "get-dispatch-recent", + "parameters": [ + { + "description": "max rows (newest first)", + "explode": false, + "in": "query", + "name": "limit", + "schema": { + "default": 50, + "description": "max rows (newest first)", + "format": "int64", + "maximum": 500, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "filter by outcome (empty = all)", + "explode": false, + "in": "query", + "name": "outcome", + "schema": { + "description": "filter by outcome (empty = all)", + "enum": [ + "success", + "partial", + "failed" + ], + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RecentOutputBody" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Authentik sign-in required" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Operator or target is not permitted" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Identity verification or operation unavailable" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "authentikSession": [] + } + ], + "summary": "Paginated dispatch_log (newest first)" + } + }, + "/downloads": { + "get": { + "description": "Groups Arr queue records by download id so a pack is one row, joins qBittorrent by infohash, and classifies active, client-queued, delay-pending, import-blocked, and imported-awaiting-seed-cleanup states server-side. Also returns a cached library provenance snapshot (torrent, spot, manual, usenet-streamed) with bounded on-demand local-dataset usage. Primary Arrs are listed once and indexed; no per-item API calls or per-poll filesystem walks. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers.", + "operationId": "get-downloads", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DownloadsBody" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Authentik sign-in required" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Operator or target is not permitted" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Identity verification or operation unavailable" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "authentikSession": [] + } + ], + "summary": "Grouped download activity and library storage provenance" + } + }, + "/healthz": { + "get": { + "description": "HTTP 503 only when the aggregate is unhealthy (a restart may fix it); degraded states stay 200 — restart churn must not destroy healthy state.", + "operationId": "get-healthz", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HealthzBody" + } + } + }, + "description": "OK" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HealthzBody" + } + } + }, + "description": "Aggregate unhealthy (a restart may fix it)" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "summary": "Aggregate + per-module health" + } + }, + "/healthz/history": { + "get": { + "description": "Reads the health-history ring table (a clock-driven sampler snapshots the registry's cached states ~every 60s, retained 14 days) and returns, per module, the ordered per-sample state series (the supervision-panel sparkline input), the uptime% (fraction of present samples healthy), and the restart-transition count (recoveries into healthy). Window is a Go duration query param (default 24h, clamped to the 14-day retention). Purely derived from the ring — no live probing. Administrative cockpit authority required.", + "operationId": "get-healthz-history", + "parameters": [ + { + "description": "look-back window as a Go duration (e.g. 24h, 6h); clamped to the 14-day retention", + "explode": false, + "in": "query", + "name": "window", + "schema": { + "default": "24h", + "description": "look-back window as a Go duration (e.g. 24h, 6h); clamped to the 14-day retention", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HistoryReport" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Authentik sign-in required" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Operator or target is not permitted" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Identity verification or operation unavailable" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "authentikSession": [] + } + ], + "summary": "Per-module health series + uptime%/restart-transitions" + } + }, + "/import-health/candidates": { + "get": { + "description": "The intake triage panel: every open candidate grouped by health (active = searching/downloading/source_complete/import_pending/retry_wait/regrab_eligible; issues = import_blocked/blocked_ambiguous; exhausted; resolved = an operator-resolved candidate). Optional group query filter. Read ONCE from the ledger — never a per-candidate query. Administrative cockpit authority required.", + "operationId": "get-import-health-candidates", + "parameters": [ + { + "description": "triage group filter (empty = all groups)", + "explode": false, + "in": "query", + "name": "group", + "schema": { + "description": "triage group filter (empty = all groups)", + "enum": [ + "active", + "issues", + "exhausted", + "resolved" + ], + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TriageResult" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Authentik sign-in required" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Operator or target is not permitted" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Identity verification or operation unavailable" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "authentikSession": [] + } + ], + "summary": "Candidate triage grouped active/issues/exhausted/resolved" + } + }, + "/import-health/candidates/{key}": { + "get": { + "description": "The inspector's per-item health view: the candidate's current health state + reason and its full append-only transition trail. Two indexed reads (get + history). Library-management cockpit capability required (administrators included); this grants authority across household titles, not other viewers' watched-state or recommendation edits.", + "operationId": "get-import-health-candidate", + "parameters": [ + { + "description": "the candidate's idempotent key", + "in": "path", + "name": "key", + "required": true, + "schema": { + "description": "the candidate's idempotent key", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TriageDetail" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Authentik sign-in required" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Operator or target is not permitted" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Identity verification or operation unavailable" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "authentikSession": [] + } + ], + "summary": "Current health + transition history for one candidate" + } + }, + "/import-health/candidates/{key}/abandon": { + "post": { + "description": "Resolves ONE exhausted candidate with a required non-empty operator reason (audited in the ledger). The ledger enforces exhausted-only; a non-exhausted attempt answers 409 and an empty reason answers 400. Library-management cockpit capability required (administrators included); this grants authority across household titles, not other viewers' watched-state or recommendation edits.", + "operationId": "post-import-health-abandon", + "parameters": [ + { + "description": "the candidate's idempotent key (exactly one candidate per call)", + "in": "path", + "name": "key", + "required": true, + "schema": { + "description": "the candidate's idempotent key (exactly one candidate per call)", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AbandonInputBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActionResult" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Authentik sign-in required" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Operator or target is not permitted" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Identity verification or operation unavailable" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "authentikSession": [] + } + ], + "summary": "Audited abandon of an exhausted candidate" + } + }, + "/import-health/candidates/{key}/accept-review-mapping": { + "post": { + "description": "Operator-confirmed form of the shipped confident remap. The request carries no episode id. Under the shared exact-import guard, the handler re-probes the Arr and requires exactly one file mapped to exactly one aired, monitored, fileless episode of the candidate's series with only numbering-confidence rejections. It submits that Arr episode id through the existing exact ManualImport seam and records import_pending. No queue, blocklist, file-delete, or guessed-id path is reachable. Library-management cockpit capability required (administrators included); this grants authority across household titles, not other viewers' watched-state or recommendation edits.", + "operationId": "post-import-health-accept-review-mapping", + "parameters": [ + { + "description": "the candidate's idempotent key (exactly one candidate per call)", + "in": "path", + "name": "key", + "required": true, + "schema": { + "description": "the candidate's idempotent key (exactly one candidate per call)", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActionResult" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Authentik sign-in required" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Operator or target is not permitted" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Identity verification or operation unavailable" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "authentikSession": [] + } + ], + "summary": "Accept the Arr's current 1:1 episode mapping" + } + }, + "/import-health/candidates/{key}/clear-payload-sanitize-block": { + "post": { + "description": "Removes ONE candidate's payload-name-sanitize refusal marker. The automatic class records a marker only when the server's answer was evidence that the rename must not be repeated, and it never re-arms itself; this is the only way back. It performs no arr call, no queue mutation, no rename, and no candidate state change — the next sweep simply re-proves eligibility from scratch and may refuse again. A candidate carrying no marker answers not_eligible. Administrative cockpit authority required.", + "operationId": "post-import-health-clear-payload-sanitize-block", + "parameters": [ + { + "description": "the candidate's idempotent key (exactly one candidate per call)", + "in": "path", + "name": "key", + "required": true, + "schema": { + "description": "the candidate's idempotent key (exactly one candidate per call)", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActionResult" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Authentik sign-in required" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Operator or target is not permitted" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Identity verification or operation unavailable" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "authentikSession": [] + } + ], + "summary": "Re-arm the automatic payload-name sanitizer for one candidate" + } + }, + "/import-health/candidates/{key}/episode-options": { + "get": { + "description": "Joins ONE scoped manual-import candidate probe to ONE series episode read and marks each episode selectable only when it is in the payload, belongs to the expected series, is aired + monitored, and has no file. A probe failure returns the series rows with in_payload=false for the raw-id fallback. This read never authorizes an import; select-episodes re-verifies every posted id server-side. Library-management cockpit capability required (administrators included); this grants authority across household titles, not other viewers' watched-state or recommendation edits.", + "operationId": "get-import-health-episode-options", + "parameters": [ + { + "description": "the candidate's idempotent key", + "in": "path", + "name": "key", + "required": true, + "schema": { + "description": "the candidate's idempotent key", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/EpisodeOption" + }, + "type": [ + "array", + "null" + ] + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Authentik sign-in required" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Operator or target is not permitted" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Identity verification or operation unavailable" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "authentikSession": [] + } + ], + "summary": "Browsable advisory episode options for one TV candidate" + } + }, + "/import-health/candidates/{key}/regrab": { + "post": { + "description": "Allowed ONLY for a regrab_eligible candidate: re-searches its recorded primary Arr and exact candidate episode scope through the existing candidate curation primitive, never current membership or a whole-series fallback. Fresh recorded identity and monitored/fileless target proof precede mutation. The shared one-in-flight guard preserves removeFromClient=FALSE and skipRedownload=TRUE while blocklisting the failed release, then records the retry_wait cooldown. Any other health state returns not_eligible with the state named and never touches the queue — a usable/ambiguous payload is an import problem, not a grab problem. Library-management cockpit capability required (administrators included); this grants authority across household titles, not other viewers' watched-state or recommendation edits.", + "operationId": "post-import-health-regrab", + "parameters": [ + { + "description": "the candidate's idempotent key (exactly one candidate per call)", + "in": "path", + "name": "key", + "required": true, + "schema": { + "description": "the candidate's idempotent key (exactly one candidate per call)", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActionResult" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Authentik sign-in required" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Operator or target is not permitted" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Identity verification or operation unavailable" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "authentikSession": [] + } + ], + "summary": "Eligible regrab/retry through the curation regrab primitive" + } + }, + "/import-health/candidates/{key}/remove-from-queue": { + "post": { + "description": "Eligible only for import_blocked, blocked_ambiguous, or exhausted. DownloadID locates a whole queue group; it is not removal authority. With or without a DownloadID, requires the verified recorded movie or series identity and, for TV, the complete expected episode set. Every row of a located download must match that scope before any DELETE; without a DownloadID, requires one exact fallback group. Shares the regrab guard and calls only queue delete with removeFromClient structurally false; the library deleteFiles path is unreachable. then=reopen transitions to regrab_eligible; then=resolve records a same-health audited resolution. Wrong health state or insufficient/conflicting queue scope answers 409 without mutation; refresh evidence and review the full download on a scope refusal. Library-management cockpit capability required (administrators included); this grants authority across household titles, not other viewers' watched-state or recommendation edits.", + "operationId": "post-import-health-remove-from-queue", + "parameters": [ + { + "description": "the candidate's idempotent key (exactly one candidate per call)", + "in": "path", + "name": "key", + "required": true, + "schema": { + "description": "the candidate's idempotent key (exactly one candidate per call)", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RemoveFromQueueInputBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActionResult" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Authentik sign-in required" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Operator or target is not permitted" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Identity verification or operation unavailable" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "authentikSession": [] + } + ], + "summary": "Reviewed operator removal of one blocked candidate's queue slot" + } + }, + "/import-health/candidates/{key}/retry-import": { + "post": { + "description": "Re-probes the arr's manual-import read-back for ONE candidate, rebuilds the expectation from the arr's own records, re-evaluates exact, and submits ONLY a go-decision (import_pending). A still-failing result re-persists the typed reason (import_blocked/blocked_ambiguous) and issues NO import and NO regrab. Eligible only for import_blocked/blocked_ambiguous/source_complete; any other state answers 409 with the state named. Library-management cockpit capability required (administrators included); this grants authority across household titles, not other viewers' watched-state or recommendation edits.", + "operationId": "post-import-health-retry-import", + "parameters": [ + { + "description": "the candidate's idempotent key (exactly one candidate per call)", + "in": "path", + "name": "key", + "required": true, + "schema": { + "description": "the candidate's idempotent key (exactly one candidate per call)", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActionResult" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Authentik sign-in required" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Operator or target is not permitted" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Identity verification or operation unavailable" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "authentikSession": [] + } + ], + "summary": "Operator re-drive of a blocked/complete candidate's exact import" + } + }, + "/import-health/candidates/{key}/review-mapping": { + "get": { + "description": "Returns available=true only when the Arr currently reports exactly one payload file mapped to exactly one aired, monitored, fileless episode of the candidate's series and every blocking rejection is a numbering-confidence rejection. This endpoint is read-only. The Accept request carries no episode id; its handler re-probes and submits the Arr's current mapping server-side. Library-management cockpit capability required (administrators included); this grants authority across household titles, not other viewers' watched-state or recommendation edits.", + "operationId": "get-import-health-review-mapping", + "parameters": [ + { + "description": "the candidate's idempotent key", + "in": "path", + "name": "key", + "required": true, + "schema": { + "description": "the candidate's idempotent key", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CandidateReviewMapping" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Authentik sign-in required" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Operator or target is not permitted" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Identity verification or operation unavailable" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "authentikSession": [] + } + ], + "summary": "Arr-proven 1:1 episode mapping for operator review" + } + }, + "/import-health/candidates/{key}/select-episodes": { + "post": { + "description": "Rebuilds a TV candidate's expectation with EXACTLY the posted episode ids, re-verified against the arr's own records (each must exist, belong to the series, be monitored + aired, and have no file) — the posted ids are never trusted blind. Any cross-series / unaired / unmonitored / file-backed / unknown id rejects the whole request with no submission. On a go-decision, submits exactly those ids. Library-management cockpit capability required (administrators included); this grants authority across household titles, not other viewers' watched-state or recommendation edits.", + "operationId": "post-import-health-select-episodes", + "parameters": [ + { + "description": "the candidate's idempotent key (exactly one candidate per call)", + "in": "path", + "name": "key", + "required": true, + "schema": { + "description": "the candidate's idempotent key (exactly one candidate per call)", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SelectEpisodesInputBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActionResult" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Authentik sign-in required" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Operator or target is not permitted" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Identity verification or operation unavailable" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "authentikSession": [] + } + ], + "summary": "Operator-supplied exact TV episode mapping (re-verified)" + } + }, + "/media-health/breaker/hold": { + "post": { + "description": "The only operator override of the detection breaker, and it is fail-safe by construction: it can force the breaker open (suspending proposals and repairs) and nothing else. Authenticated operator attribution and a nonempty reason are required. Holding an already-held breaker re-attributes it. No request to this API can close the breaker, credit a control epoch, or shorten the two-clean-epoch recovery. Administrative cockpit authority required.", + "operationId": "post-media-health-breaker-hold", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MediaHealthBreakerHoldInputBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MediaHealthBreakerResult" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Authentik sign-in required" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Operator or target is not permitted" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Identity verification or operation unavailable" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "authentikSession": [] + } + ], + "summary": "Force the detector breaker OPEN under operator attribution" + } + }, + "/media-health/breaker/release": { + "post": { + "description": "Clears the hold attribution only. The breaker stays OPEN with zero healthy control epochs, so the standard two-clean-epoch recovery runs from scratch afterwards — this endpoint uses authenticated operator attribution; releasing a breaker that is not held is a no-op. Administrative cockpit authority required.", + "operationId": "post-media-health-breaker-release", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MediaHealthBreakerResult" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Authentik sign-in required" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Operator or target is not permitted" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Identity verification or operation unavailable" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "authentikSession": [] + } + ], + "summary": "Release an operator hold WITHOUT closing the breaker" + } + }, + "/media-health/probe": { + "post": { + "description": "Persists one operator-requested probe for exactly one blob. Use blob_id or webdav_path, but never both. The request survives restart, is selected before scheduled targets within the same budget, and is consumed with the next recorded probe outcome. A disabled detector answers 503. Library-management cockpit capability required (administrators included); this grants authority across household titles, not other viewers' watched-state or recommendation edits.", + "operationId": "post-media-health-probe", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MediaHealthProbeInputBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MediaHealthProbeResult" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Authentik sign-in required" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Operator or target is not permitted" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Identity verification or operation unavailable" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "authentikSession": [] + } + ], + "summary": "Request ONE operator media-health probe" + } + }, + "/media-health/proposals": { + "get": { + "description": "The media-health review panel: every confirmed NzbDAV fault the detector proposed a repair for, grouped by whether it awaits a decision, is being repaired, or is finished. Each proposal carries the COMPLETE consumer snapshot, so a season pack shows every episode the repair covers. The response also reports the detector's global breaker: while it is open, an approval is recorded but no repair executes. Read once from the ledger. Administrative cockpit authority required.", + "operationId": "get-media-health-proposals", + "parameters": [ + { + "description": "group filter (empty = all groups)", + "explode": false, + "in": "query", + "name": "group", + "schema": { + "description": "group filter (empty = all groups)", + "enum": [ + "review", + "repairing", + "closed" + ], + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MediaHealthReviewResult" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Authentik sign-in required" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Operator or target is not permitted" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Identity verification or operation unavailable" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "authentikSession": [] + } + ], + "summary": "Media-fault repair proposals grouped review/repairing/closed" + } + }, + "/media-health/proposals/{id}": { + "get": { + "description": "The inspector view: the proposal's blob identity, every Arr record it backs, and the append-only stage trail of its repair (queued, deleting, searching, awaiting, verifying, done/failed) with the reason recorded at each transition. Library-management cockpit capability required (administrators included); this grants authority across household titles, not other viewers' watched-state or recommendation edits.", + "operationId": "get-media-health-proposal", + "parameters": [ + { + "description": "the proposal id (exactly one proposal per call)", + "in": "path", + "name": "id", + "required": true, + "schema": { + "description": "the proposal id (exactly one proposal per call)", + "format": "int64", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MediaHealthDetail" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Authentik sign-in required" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Operator or target is not permitted" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Identity verification or operation unavailable" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "authentikSession": [] + } + ], + "summary": "One proposal with its repair transition trail" + } + }, + "/media-health/proposals/{id}/approve": { + "post": { + "description": "Records operator consent for exactly one proposal and opens its repair at the queued stage. NO Arr call is made here: the fault-kind verifier re-probes immediately before acting and abandons the repair if its proof no longer holds. Only a pending proposal is eligible; an already decided, executing, or retracted one answers 409. Library-management cockpit capability required (administrators included); this grants authority across household titles, not other viewers' watched-state or recommendation edits.", + "operationId": "post-media-health-approve", + "parameters": [ + { + "description": "the proposal id (exactly one proposal per call)", + "in": "path", + "name": "id", + "required": true, + "schema": { + "description": "the proposal id (exactly one proposal per call)", + "format": "int64", + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MediaHealthDecisionInputBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MediaHealthDecisionResult" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Authentik sign-in required" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Operator or target is not permitted" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Identity verification or operation unavailable" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "authentikSession": [] + } + ], + "summary": "Approve the repair of ONE media fault" + } + }, + "/media-health/proposals/{id}/reject": { + "post": { + "description": "Closes exactly one proposal with a required non-empty audited reason. No repair row is created and no Arr delete, blocklist, queue removal, search, or grab is ever issued for it. An empty reason answers 400; a non-pending proposal answers 409. The blob stays in the detector's inventory and re-surfaces only if its same fault kind is confirmed again. Library-management cockpit capability required (administrators included); this grants authority across household titles, not other viewers' watched-state or recommendation edits.", + "operationId": "post-media-health-reject", + "parameters": [ + { + "description": "the proposal id (exactly one proposal per call)", + "in": "path", + "name": "id", + "required": true, + "schema": { + "description": "the proposal id (exactly one proposal per call)", + "format": "int64", + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MediaHealthDecisionInputBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MediaHealthDecisionResult" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Authentik sign-in required" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Operator or target is not permitted" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Identity verification or operation unavailable" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "authentikSession": [] + } + ], + "summary": "Reject the repair of ONE media fault" + } + }, + "/notify/recent": { + "get": { + "description": "Administrator-only notify_log feed: media_arrived, media_stalled, media_regrab, and media_import_issue pushes with the descriptor recorded at send time (title, year, owner, hint). Administrative cockpit authority required.", + "operationId": "get-notify-recent", + "parameters": [ + { + "description": "max rows (newest first)", + "explode": false, + "in": "query", + "name": "limit", + "schema": { + "default": 15, + "description": "max rows (newest first)", + "format": "int64", + "maximum": 200, + "minimum": 1, + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotifyRecentOutputBody" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Authentik sign-in required" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Operator or target is not permitted" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Identity verification or operation unavailable" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "authentikSession": [] + } + ], + "summary": "Recent notifications pushed to Home Assistant (newest first)" + } + }, + "/operations/titles/{tmdb}/{type}": { + "get": { + "description": "Administrative read only. Includes terminal candidates and complete retained histories. Import, dispatch and current route-presence observations fail independently; unavailable evidence never proves empty, imported or playable media. Library-management cockpit capability required (administrators included); this grants authority across household titles, not other viewers' watched-state or recommendation edits.", + "operationId": "get-title-diagnosis", + "parameters": [ + { + "in": "path", + "name": "tmdb", + "required": true, + "schema": { + "format": "int64", + "minimum": 1, + "type": "integer" + } + }, + { + "in": "path", + "name": "type", + "required": true, + "schema": { + "enum": [ + "movie", + "tv" + ], + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TitleDiagnosis" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Authentik sign-in required" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Operator or target is not permitted" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Identity verification or operation unavailable" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "authentikSession": [] + } + ], + "summary": "Read exact retained import and dispatch evidence for a typed title" + } + }, + "/operations/titles/{tmdb}/{type}/media-health": { + "get": { + "description": "Read only. Matches any recorded consumer and retains the entire proposal scope, including other titles and mirrors. File identities are proposal snapshots, not fresh Arr proof. No probe, repair, retry or policy change occurs. Library-management cockpit capability required (administrators included); this grants authority across household titles, not other viewers' watched-state or recommendation edits.", + "operationId": "get-title-media-health", + "parameters": [ + { + "in": "path", + "name": "tmdb", + "required": true, + "schema": { + "format": "int64", + "minimum": 1, + "type": "integer" + } + }, + { + "in": "path", + "name": "type", + "required": true, + "schema": { + "enum": [ + "movie", + "tv" + ], + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TitleMediaHealth" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Authentik sign-in required" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Operator or target is not permitted" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Identity verification or operation unavailable" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "authentikSession": [] + } + ], + "summary": "Read complete media-health proposals and repair evidence for one typed title" + } + }, + "/reconcile/replacement-holds": { + "get": { + "description": "Lists replacement holds whose expiry is still in the future. Administrative cockpit authority required.", + "operationId": "list-replacement-holds", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReplacementHoldsResult" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Authentik sign-in required" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Operator or target is not permitted" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Identity verification or operation unavailable" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "authentikSession": [] + } + ], + "summary": "List active operator replacement holds" + }, + "post": { + "description": "Creates or replaces a TTL-bound hold for exactly one primary Radarr movie or a set of primary Sonarr episodes. A hold prevents auto-resolve from clearing the matching Arr queue candidate; it does not import the replacement. Administrative cockpit authority required.", + "operationId": "create-replacement-holds", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateReplacementHoldInputBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReplacementHoldsResult" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Authentik sign-in required" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Operator or target is not permitted" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Identity verification or operation unavailable" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "authentikSession": [] + } + ], + "summary": "Hold satisfied candidate auto-resolve for a replacement grab" + } + }, + "/reconcile/replacement-holds/{arr}/{record_type}/{record_id}": { + "delete": { + "description": "Deletes the hold for one Arr record. Releasing a missing hold is an idempotent no-op. Administrative cockpit authority required.", + "operationId": "release-replacement-hold", + "parameters": [ + { + "description": "Arr instance name", + "in": "path", + "name": "arr", + "required": true, + "schema": { + "description": "Arr instance name", + "type": "string" + } + }, + { + "description": "held record type", + "in": "path", + "name": "record_type", + "required": true, + "schema": { + "description": "held record type", + "enum": [ + "episode", + "movie" + ], + "type": "string" + } + }, + { + "description": "Arr episode or movie id", + "in": "path", + "name": "record_id", + "required": true, + "schema": { + "description": "Arr episode or movie id", + "format": "int64", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReplacementHoldReleaseResult" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Authentik sign-in required" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Operator or target is not permitted" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Identity verification or operation unavailable" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "authentikSession": [] + } + ], + "summary": "Release one operator replacement hold" + } + }, + "/recovery/layer/actions": { + "get": { + "description": "Returns durable MaterializeAndRefresh proposals and execution stages. Paired tmdb_id/media_type selects complete retained title history, including terminal actions, without the global worklist limit. Approval is a separate, explicitly named POST. Library-management cockpit capability required (administrators included); this grants authority across household titles, not other viewers' watched-state or recommendation edits. Non-administrators must supply paired positive tmdb_id and movie/tv media_type filters; global worklists remain administrative.", + "operationId": "get-recovery-layer-actions", + "parameters": [ + { + "description": "exact title TMDB identity; requires media_type", + "explode": false, + "in": "query", + "name": "tmdb_id", + "schema": { + "description": "exact title TMDB identity; requires media_type", + "format": "int64", + "minimum": 1, + "type": "integer" + } + }, + { + "description": "exact title type; requires tmdb_id", + "explode": false, + "in": "query", + "name": "media_type", + "schema": { + "description": "exact title type; requires tmdb_id", + "enum": [ + "movie", + "tv" + ], + "type": "string" + } + }, + { + "description": "exact durable stage filter (empty = all)", + "explode": false, + "in": "query", + "name": "stage", + "schema": { + "description": "exact durable stage filter (empty = all)", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LayerVisibilityActionsOutputBody" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Authentik sign-in required" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Operator or target is not permitted" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Identity verification or operation unavailable" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "authentikSession": [] + } + ], + "summary": "Read reviewed layer convergence actions" + } + }, + "/recovery/layer/actions/{action_id}": { + "get": { + "description": "Returns one stable action identity with its latest exact represented-child evidence and append-only durable stage history. Library-management cockpit capability required (administrators included); this grants authority across household titles, not other viewers' watched-state or recommendation edits.", + "operationId": "get-recovery-layer-action", + "parameters": [ + { + "description": "stable reviewed action identity", + "in": "path", + "name": "action_id", + "required": true, + "schema": { + "description": "stable reviewed action identity", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LayerVisibilityActionOutputBody" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Authentik sign-in required" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Operator or target is not permitted" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Identity verification or operation unavailable" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "authentikSession": [] + } + ], + "summary": "Read one layer action, exact proof, and history" + } + }, + "/recovery/layer/actions/{action_id}/approve": { + "post": { + "description": "Runs the reviewed MaterializeAndRefresh for exactly one proposed action IN THE SERVING PROCESS, and returns the stage the executor left it in. Every stage is a durable compare-and-set commit, so the trail is readable from the action route while this call is in flight. The executor re-reads fresh proof and re-confirms the content behind the item before it materialises anything: an already-converged item closes without remote work, and unreadable content aborts as execution_uncertain rather than re-advertising a broken item. An unknown id answers 404, an action that is not proposed answers 409, and an unwired executor answers 503. There is no bulk approval by construction. Library-management cockpit capability required (administrators included); this grants authority across household titles, not other viewers' watched-state or recommendation edits.", + "operationId": "post-recovery-layer-approve", + "parameters": [ + { + "description": "stable reviewed action identity", + "in": "path", + "name": "action_id", + "required": true, + "schema": { + "description": "stable reviewed action identity", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LayerVisibilityApprovalView" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Authentik sign-in required" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Operator or target is not permitted" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Identity verification or operation unavailable" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "authentikSession": [] + } + ], + "summary": "Approve and execute ONE layer convergence action" + } + }, + "/recovery/layer/observations": { + "get": { + "description": "Returns exact Arr-to-owned-link-to-Jellyfin observations. Paired tmdb_id/media_type selects every recorded bucket for that title without the global worklist limit. Parent Series, Season, and watch metadata are not convergence proof. Library-management cockpit capability required (administrators included); this grants authority across household titles, not other viewers' watched-state or recommendation edits. Non-administrators must supply paired positive tmdb_id and movie/tv media_type filters; global worklists remain administrative.", + "operationId": "get-recovery-layer-observations", + "parameters": [ + { + "description": "exact title TMDB identity; requires media_type", + "explode": false, + "in": "query", + "name": "tmdb_id", + "schema": { + "description": "exact title TMDB identity; requires media_type", + "format": "int64", + "minimum": 1, + "type": "integer" + } + }, + { + "description": "exact title type; requires tmdb_id", + "explode": false, + "in": "query", + "name": "media_type", + "schema": { + "description": "exact title type; requires tmdb_id", + "enum": [ + "movie", + "tv" + ], + "type": "string" + } + }, + { + "description": "status filter (empty = all)", + "explode": false, + "in": "query", + "name": "status", + "schema": { + "description": "status filter (empty = all)", + "enum": [ + "converged", + "drift", + "scanner_pending", + "unknown", + "no_files", + "unreleased", + "needs_operator" + ], + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LayerVisibilityObservationsOutputBody" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Authentik sign-in required" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Operator or target is not permitted" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Identity verification or operation unavailable" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "authentikSession": [] + } + ], + "summary": "Read current layer and Jellyfin convergence evidence" + } + }, + "/recovery/spottarr/freshness": { + "get": { + "description": "Returns the cached read-only SQLite freshness observation: newest IndexedAt/UpdatedAt timestamp, age, and row count. HTTP liveness is not indexing proof. Administrative cockpit authority required.", + "operationId": "get-recovery-spottarr-freshness", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SpottarrFreshnessView" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Authentik sign-in required" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Operator or target is not permitted" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Identity verification or operation unavailable" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "authentikSession": [] + } + ], + "summary": "Read cached Spottarr index freshness" + } + }, + "/seerr/webhook": { + "post": { + "description": "Classifies notification_type: MEDIA_APPROVED/MEDIA_AUTO_APPROVED are inserted into webhook_dedup and answered 200 immediately (the worker runs the dispatch engine). TEST_NOTIFICATION answers 200 and does nothing. MEDIA_DECLINED and everything else are ignored (deletions are out of scope). Restricted to direct loopback bundled-service callers without browser/session/proxy headers; no cockpit role is granted.", + "operationId": "post-seerr-webhook", + "requestBody": { + "content": { + "application/octet-stream": { + "schema": { + "contentMediaType": "application/octet-stream", + "format": "binary", + "type": "string" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebhookAck" + } + } + }, + "description": "OK" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Bundled-service caller required" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "summary": "Jellyseerr approval webhook (insert + 200; worker dispatches)" + } + }, + "/supervise/restart/{target}": { + "post": { + "description": "Cycles a process-backed bundled service (nzbdav, an arr, prowlarr, seerr) via the supervisor, or restarts an allowlisted sibling container through the socket-proxy (degrading cleanly when the proxy is absent). Worker-loop modules (dispatch-sweep, webhook-worker) are NOT restartable — they ride the conductarr process — and answer 400. Administrative cockpit authority required.", + "operationId": "post-supervise-restart", + "parameters": [ + { + "description": "module or sibling name to restart", + "in": "path", + "name": "target", + "required": true, + "schema": { + "description": "module or sibling name to restart", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RestartResult" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Authentik sign-in required" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Operator or target is not permitted" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Identity verification or operation unavailable" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "authentikSession": [] + } + ], + "summary": "Restart a bundled service or managed sibling" + } + }, + "/torrent/arm": { + "post": { + "description": "Persists the armed state on the plan revision the owner read in the arm confirm; the next start pushes its pick. Answers 409 when that revision is no longer the current plan, and 503 while features.torrent_fallback is off. No restart is involved. Administrative cockpit authority required.", + "operationId": "arm-torrent-lane", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TorrentArmInputBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TorrentStatusResult" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Authentik sign-in required" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Operator or target is not permitted" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Identity verification or operation unavailable" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "authentikSession": [] + } + ], + "summary": "Arm the torrent lane" + } + }, + "/torrent/candidates": { + "get": { + "description": "The joined candidates table of the torrent lane: every dead-file record of a repair proposal and every missing monitored record on a primary Arr, with the ledger that tracks it, its answered-silent days, whether the count was seeded from history, and whether it qualifies. Read from the lane's ledger alone; answers 503 while features.torrent_fallback is off. Administrative cockpit authority required.", + "operationId": "get-torrent-candidates", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TorrentCandidatesResult" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Authentik sign-in required" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Operator or target is not permitted" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Identity verification or operation unavailable" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "authentikSession": [] + } + ], + "summary": "Torrent candidates with their silence clocks" + } + }, + "/torrent/disarm": { + "post": { + "description": "Clears the armed state: the next start is a dry run again, and pushed handoffs run to their terminal. Answers 503 while features.torrent_fallback is off. Administrative cockpit authority required.", + "operationId": "disarm-torrent-lane", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TorrentStatusResult" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Authentik sign-in required" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Operator or target is not permitted" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Identity verification or operation unavailable" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "authentikSession": [] + } + ], + "summary": "Return the torrent lane to shadow" + } + }, + "/torrent/history": { + "get": { + "description": "Returns recorded handoffs from the lane ledger. Answers 503 while features.torrent_fallback is off. Administrative cockpit authority required.", + "operationId": "get-torrent-history", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TorrentHistoryResult" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Authentik sign-in required" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Operator or target is not permitted" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Identity verification or operation unavailable" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "authentikSession": [] + } + ], + "summary": "Torrent handoff history" + } + }, + "/torrent/plan": { + "get": { + "description": "Returns the latest cached dry-run plan and its row decisions without issuing grabs. Answers 503 while features.torrent_fallback is off. Administrative cockpit authority required.", + "operationId": "get-torrent-plan", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TorrentPlanResult" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Authentik sign-in required" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Operator or target is not permitted" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Identity verification or operation unavailable" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "authentikSession": [] + } + ], + "summary": "Latest torrent shadow plan" + } + }, + "/torrent/status": { + "get": { + "description": "Returns cached lane mode and handoff capacity. Answers 503 while features.torrent_fallback is off. Administrative cockpit authority required.", + "operationId": "get-torrent-status", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TorrentStatusResult" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Authentik sign-in required" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Operator or target is not permitted" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Identity verification or operation unavailable" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "authentikSession": [] + } + ], + "summary": "Torrent lane shadow status" + } + }, + "/torrent/summary": { + "get": { + "description": "Sums the current plan's rows: torrent releases each acceptability rule dropped per indexer, coverage by kind and anime flag, rotation over the day-one set, usenet wins, the disk of the next starts against the floor, and whether the arming floor holds per kind. Reads persisted state only; answers 503 while features.torrent_fallback is off. Administrative cockpit authority required.", + "operationId": "get-torrent-summary", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TorrentSummaryResult" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Authentik sign-in required" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Operator or target is not permitted" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Identity verification or operation unavailable" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "authentikSession": [] + } + ], + "summary": "Torrent shadow summary" + } + } + } +} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/CachePolicy.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/CachePolicy.kt new file mode 100644 index 00000000..59e0d163 --- /dev/null +++ b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/CachePolicy.kt @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.conductarr + +/** Caller lifecycle events; no background poller or UI is owned by the client. */ +enum class ReadTrigger { IF_STALE, LAUNCH, FOREGROUND, OPEN, POLL, FORCE, CACHE_ONLY } + +/** Refresh lifetimes do not expire last-known-good data or delete it on failure. */ +enum class CachePolicy(internal val lifetimeMillis: Long, internal val capacity: Int? = null) { + LAUNCH(Long.MAX_VALUE), + DAILY(24 * 60 * 60 * 1000L), + FEED(0), + FOREGROUND(Long.MAX_VALUE), + DISCOVERY(30 * 60 * 1000L), + RATINGS(6 * 60 * 60 * 1000L, 300), + ON_OPEN(Long.MAX_VALUE), + SEASON(6 * 60 * 60 * 1000L, 300), + ACTIVITY(30 * 1000L), + NO_CACHE(0), + ; + + internal fun shouldRefresh(trigger: ReadTrigger, entry: CacheEntry?, now: Long): Boolean { + if (trigger == ReadTrigger.CACHE_ONLY) return false + if (entry == null || trigger == ReadTrigger.FORCE) return true + if (this == LAUNCH && trigger == ReadTrigger.LAUNCH) return true + if ((this == FOREGROUND || this == ACTIVITY) && trigger == ReadTrigger.FOREGROUND) return true + if ((this == ON_OPEN || this == SEASON) && trigger == ReadTrigger.OPEN) return true + val expiry = entry.expiresAtMillis + return if (this == FEED) expiry == null || now >= expiry + else lifetimeMillis != Long.MAX_VALUE && now - entry.validatedAtMillis >= lifetimeMillis + } +} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/CacheStore.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/CacheStore.kt new file mode 100644 index 00000000..3bc1c794 --- /dev/null +++ b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/CacheStore.kt @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.conductarr + +import kotlinx.serialization.Serializable + +/** A validated response; saved time is preserved across ETag revalidation. */ +@Serializable +data class CacheEntry( + val body: String, + val etag: String?, + val savedAtMillis: Long, + val validatedAtMillis: Long, + val expiresAtMillis: Long? = null, + val absent: Boolean = false, + val group: String, +) + +/** Storage is partitioned by server and credential digest; implementations never receive tokens. */ +interface CacheStore { + fun read(scope: String, key: String): CacheEntry? + fun write(scope: String, key: String, entry: CacheEntry, capacity: Int? = null) + fun remove(scope: String, key: String) + fun clear(scope: String) +} + +/** Deterministic fake with the same group capacity semantics as the disk store. */ +class MemoryCacheStore : CacheStore { + private val entries = mutableMapOf<Pair<String, String>, CacheEntry>() + + @Synchronized override fun read(scope: String, key: String): CacheEntry? = entries[scope to key] + + @Synchronized override fun write(scope: String, key: String, entry: CacheEntry, capacity: Int?) { + entries[scope to key] = entry + if (capacity != null) { + entries.filter { it.key.first == scope && it.value.group == entry.group } + .entries.sortedByDescending { it.value.validatedAtMillis } + .drop(capacity).forEach { entries.remove(it.key) } + } + } + + @Synchronized override fun remove(scope: String, key: String) { entries.remove(scope to key) } + @Synchronized override fun clear(scope: String) { entries.keys.removeAll { it.first == scope } } +} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/ConductarrClient.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/ConductarrClient.kt new file mode 100644 index 00000000..5559f15a --- /dev/null +++ b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/ConductarrClient.kt @@ -0,0 +1,232 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.conductarr + +import java.io.Closeable +import java.io.IOException +import java.time.OffsetDateTime +import java.time.format.DateTimeParseException +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.coroutines.resumeWithException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import kotlinx.coroutines.withContext +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import okhttp3.Call +import okhttp3.Callback +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response + +/** Failures contain no response bodies, credentials or transport exception text. */ +sealed interface ClientError { + data object NeedsSignIn : ClientError + data class Http(val status: Int) : ClientError + data object Unreachable : ClientError + data object InvalidResponse : ClientError +} + +/** A successful copy includes the time needed by the Saved HH:MM indicator. */ +data class Saved<T>(val value: T?, val savedAtMillis: Long, val fromDisk: Boolean, val absent: Boolean = false) + +/** Saved arrives before network work; a refresh failure retains it except on 401. */ +sealed interface ReadResult<out T> { + data class Value<T>(val saved: Saved<T>) : ReadResult<T> + data class Failure<T>(val error: ClientError, val saved: Saved<T>? = null) : ReadResult<T> +} + +/** Mutation results deliberately cannot contain a cached receipt. */ +sealed interface MutationResult<out T> { + data class Success<T>(val value: T) : MutationResult<T> + data class Failure(val error: ClientError) : MutationResult<Nothing> +} + +/** Safe diagnostic vocabulary: there is no string sink that could receive a token. */ +enum class ClientEvent { REFRESHED, REVALIDATED, NEEDS_SIGN_IN, HTTP_FAILURE, UNREACHABLE, INVALID_RESPONSE, CACHE_UNAVAILABLE } + +/** Time limits apply to the entire call as well as connection and response I/O. */ +data class ClientTimeouts(val connectSeconds: Long = 10, val readSeconds: Long = 20, val callSeconds: Long = 30) + +/** An immutable identity-scoped client; create another client when the selected person changes. */ +class ConductarrClient( + baseUrl: String, + private val jellyfinToken: String, + private val cache: CacheStore, + timeouts: ClientTimeouts = ClientTimeouts(), + private val nowMillis: () -> Long = System::currentTimeMillis, + private val onEvent: (ClientEvent) -> Unit = {}, +) : Closeable { + private val base = baseUrl.toHttpUrl() + private val scope: String + private val revoked = AtomicBoolean(false) + private val cacheIdentityLock = Any() + private val catalogueReads = Semaphore(2) + private val http = OkHttpClient.Builder() + .connectTimeout(timeouts.connectSeconds, TimeUnit.SECONDS) + .readTimeout(timeouts.readSeconds, TimeUnit.SECONDS) + .callTimeout(timeouts.callSeconds, TimeUnit.SECONDS) + .followRedirects(false) + .followSslRedirects(false) + .retryOnConnectionFailure(false) + .build() + + init { + require(base.username.isEmpty() && base.password.isEmpty() && base.query == null && base.fragment == null) { "Base URL must not contain credentials, query or fragment" } + require(base.isHttps || base.host in setOf("localhost", "127.0.0.1", "::1")) { "Conductarr requires HTTPS" } + require(jellyfinToken.matches(Regex("[0-9a-fA-F]{32}"))) { "Expected a Jellyfin access token" } + scope = digest("$base\n$jellyfinToken") + } + + /** Emit disk LKG first, then revalidate when the endpoint lifetime or caller event requires it. */ + fun <T> read(call: ReadCall<T>, trigger: ReadTrigger = ReadTrigger.IF_STALE): Flow<ReadResult<T>> = flow { + if (revoked.get()) { + emit(ReadResult.Failure(ClientError.NeedsSignIn)) + return@flow + } + val url = url(call.contract, call.segments, call.query) + val group = call.contract.path + if (call.policy == CachePolicy.RATINGS) "-${call.segments["type"]}" else "" + val key = "$group:${digest(url.toString())}" + val entry = if (call.policy != CachePolicy.NO_CACHE) cacheRead(key) else null + val saved = entry?.let { decodeSaved(call.serializer, it, true) } + if (saved != null) emit(ReadResult.Value(saved)) + if (!call.policy.shouldRefresh(trigger, if (saved != null) entry else null, nowMillis())) return@flow + val request = request(url).apply { if (saved != null) entry.etag?.let { header("If-None-Match", it) } }.build() + val result = if (call.contract.path.startsWith("/catalogue/")) { + catalogueReads.withPermit { refresh(call, request, key, group, entry.takeIf { saved != null }, saved) } + } else refresh(call, request, key, group, entry.takeIf { saved != null }, saved) + emit(result) + }.flowOn(Dispatchers.IO) + + /** Execute once; an uncertain request outcome must be checked with the title-requests read. */ + suspend fun <T> mutate(call: MutationCall<T>): MutationResult<T> = withContext(Dispatchers.IO) { + if (revoked.get()) return@withContext MutationResult.Failure(ClientError.NeedsSignIn) + val body = call.body?.toRequestBody("application/json".toMediaType()) + val request = request(url(call.contract, call.segments, emptyMap())).method(call.contract.method, body).build() + try { + http.newCall(request).awaitResponse().use { response -> + if (response.code == 401) return@withContext MutationResult.Failure(revoke()) + if (!response.isSuccessful) return@withContext MutationResult.Failure(ClientError.Http(response.code)) + val value = WireJson.decodeFromString(call.serializer, response.body.string()) + if (revoked.get()) MutationResult.Failure(ClientError.NeedsSignIn) + else MutationResult.Success(value) + } + } catch (_: IOException) { + onEvent(ClientEvent.UNREACHABLE) + MutationResult.Failure(ClientError.Unreachable) + } catch (_: SerializationException) { + onEvent(ClientEvent.INVALID_RESPONSE) + MutationResult.Failure(ClientError.InvalidResponse) + } + } + + private suspend fun <T> refresh(call: ReadCall<T>, request: Request, key: String, group: String, entry: CacheEntry?, saved: Saved<T>?): ReadResult<T> { + return try { + http.newCall(request).awaitResponse().use { response -> + if (response.code == 401) return ReadResult.Failure(revoke()) + if (revoked.get()) return ReadResult.Failure(ClientError.NeedsSignIn) + if (response.code == 304) { + if (entry == null || saved == null) return ReadResult.Failure(ClientError.InvalidResponse) + cacheWrite(key, entry.copy(etag = response.header("ETag") ?: entry.etag, validatedAtMillis = nowMillis()), call.policy) + onEvent(ClientEvent.REVALIDATED) + return ReadResult.Value(saved.copy(fromDisk = false)) + } + if (response.code == 404 && call.policy == CachePolicy.RATINGS) { + val absent = CacheEntry("", null, nowMillis(), nowMillis(), absent = true, group = group) + cacheWrite(key, absent, call.policy) + return ReadResult.Value(Saved(null, absent.savedAtMillis, false, true)) + } + if (!response.isSuccessful) { + onEvent(ClientEvent.HTTP_FAILURE) + return ReadResult.Failure(ClientError.Http(response.code), saved) + } + val body = response.body.string() + val value = WireJson.decodeFromString(call.serializer, body) + val timestamp = nowMillis() + val updated = CacheEntry(body, response.header("ETag"), timestamp, timestamp, feedExpiry(call.policy, body), group = group) + if (call.policy != CachePolicy.NO_CACHE) cacheWrite(key, updated, call.policy) + onEvent(ClientEvent.REFRESHED) + ReadResult.Value(Saved(value, timestamp, false)) + } + } catch (_: IOException) { + onEvent(ClientEvent.UNREACHABLE) + ReadResult.Failure(ClientError.Unreachable, saved) + } catch (_: SerializationException) { + onEvent(ClientEvent.INVALID_RESPONSE) + ReadResult.Failure(ClientError.InvalidResponse, saved) + } + } + + private fun request(url: HttpUrl): Request.Builder = Request.Builder().url(url) + .header("Authorization", "MediaBrowser Token=\"$jellyfinToken\"") + .header("Accept", "application/json") + + private fun url(contract: RouteContract, segments: Map<String, String>, query: Map<String, String>): HttpUrl { + val builder = base.newBuilder() + for (segment in contract.path.trimStart('/').split('/')) { + builder.addPathSegment(if (segment.startsWith('{')) segments.getValue(segment.removeSurrounding("{", "}")) else segment) + } + query.toSortedMap().forEach { (name, value) -> builder.addQueryParameter(name, value) } + return builder.build() + } + + private fun <T> decodeSaved(serializer: KSerializer<T>, entry: CacheEntry, fromDisk: Boolean): Saved<T>? = try { + Saved(if (entry.absent) null else WireJson.decodeFromString(serializer, entry.body), entry.savedAtMillis, fromDisk, entry.absent) + } catch (_: SerializationException) { null } + + private fun cacheRead(key: String): CacheEntry? = try { cache.read(scope, key) } catch (_: IOException) { + onEvent(ClientEvent.CACHE_UNAVAILABLE) + null + } + + private fun cacheWrite(key: String, entry: CacheEntry, policy: CachePolicy) = synchronized(cacheIdentityLock) { + if (!revoked.get()) { + try { cache.write(scope, key, entry, policy.capacity) } catch (_: IOException) { onEvent(ClientEvent.CACHE_UNAVAILABLE) } + } + } + + private fun revoke(): ClientError { + synchronized(cacheIdentityLock) { + revoked.set(true) + try { cache.clear(scope) } catch (_: IOException) { onEvent(ClientEvent.CACHE_UNAVAILABLE) } + } + onEvent(ClientEvent.NEEDS_SIGN_IN) + return ClientError.NeedsSignIn + } + + private fun feedExpiry(policy: CachePolicy, body: String): Long? { + if (policy != CachePolicy.FEED) return null + val expiry = WireJson.parseToJsonElement(body).jsonObject["expires_at"]?.jsonPrimitive?.content ?: return null + return try { OffsetDateTime.parse(expiry).toInstant().toEpochMilli() } catch (_: DateTimeParseException) { null } + } + + /** Release this instance's network resources; disk LKG survives. */ + override fun close() { + http.dispatcher.cancelAll() + http.dispatcher.executorService.shutdown() + http.connectionPool.evictAll() + } +} + +private suspend fun Call.awaitResponse(): Response = suspendCancellableCoroutine { continuation -> + continuation.invokeOnCancellation { cancel() } + enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + if (!continuation.isCancelled) continuation.resumeWithException(e) + } + override fun onResponse(call: Call, response: Response) { + continuation.resume(response) { _, value, _ -> value.close() } + } + }) +} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/FileCacheStore.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/FileCacheStore.kt new file mode 100644 index 00000000..fe34a9b2 --- /dev/null +++ b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/FileCacheStore.kt @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.conductarr + +import java.io.File +import java.io.IOException +import java.security.MessageDigest +import kotlinx.serialization.SerializationException + +/** App-private disk cache, safe to reopen after process death; one store instance per directory. */ +class FileCacheStore(private val directory: File) : CacheStore { + @Synchronized override fun read(scope: String, key: String): CacheEntry? { + val file = entryFile(scope, key) + if (!file.exists()) return null + return try { + WireJson.decodeFromString<CacheEntry>(file.readText()) + } catch (_: SerializationException) { + file.delete() + null + } + } + + @Synchronized override fun write(scope: String, key: String, entry: CacheEntry, capacity: Int?) { + val target = entryFile(scope, key) + val parent = target.parentFile!! + if (!parent.isDirectory && !parent.mkdirs()) throw IOException("Cache directory unavailable") + val temporary = File.createTempFile("entry-", ".tmp", parent) + try { + temporary.outputStream().use { output -> + output.write(WireJson.encodeToString(CacheEntry.serializer(), entry).toByteArray(Charsets.UTF_8)) + output.fd.sync() + } + if (!temporary.renameTo(target)) throw IOException("Cache replacement failed") + target.setLastModified(entry.validatedAtMillis) + } finally { + temporary.delete() + } + if (capacity != null) { + parent.listFiles { file -> file.extension == "json" } + ?.sortedByDescending { it.lastModified() }?.drop(capacity)?.forEach { it.delete() } + } + } + + @Synchronized override fun remove(scope: String, key: String) { entryFile(scope, key).delete() } + @Synchronized override fun clear(scope: String) { File(directory, digest(scope)).deleteRecursively() } + + private fun entryFile(scope: String, key: String): File = + File(File(File(directory, digest(scope)), digest(key.substringBefore(':'))), digest(key) + ".json") +} + +/** Opaque storage names never expose credentials, query parameters or household names. */ +internal fun digest(value: String): String { + val bytes = MessageDigest.getInstance("SHA-256").digest(value.toByteArray(Charsets.UTF_8)) + val hex = "0123456789abcdef" + val characters = CharArray(bytes.size * 2) + bytes.forEachIndexed { index, byte -> + val number = byte.toInt() and 0xff + characters[index * 2] = hex[number ushr 4] + characters[index * 2 + 1] = hex[number and 0x0f] + } + return String(characters) +} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/TvRoutes.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/TvRoutes.kt new file mode 100644 index 00000000..e2f3bd03 --- /dev/null +++ b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/TvRoutes.kt @@ -0,0 +1,141 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.conductarr + +import kotlinx.serialization.KSerializer +import org.justinvisser.wolfskin.conductarr.model.* + +/** A TV-used operation and its pinned request/response contract. */ +class RouteContract internal constructor( + val method: String, + val path: String, + val responseSchema: String, + val requestSchema: String? = null, +) + +/** A typed read; only TvRoutes can construct paths and policies. */ +class ReadCall<T> internal constructor( + internal val contract: RouteContract, + internal val serializer: KSerializer<T>, + internal val segments: Map<String, String> = emptyMap(), + internal val query: Map<String, String> = emptyMap(), + internal val policy: CachePolicy, +) + +/** A single mutation; failures are returned without automatic retry or replay. */ +class MutationCall<T> internal constructor( + internal val contract: RouteContract, + internal val serializer: KSerializer<T>, + internal val segments: Map<String, String>, + internal val body: String? = null, +) + +/** The catalogue media type accepted by TV operations. */ +enum class MediaType(internal val wire: String) { MOVIE("movie"), TV("tv") } + +/** Feed filter; the unfiltered feed uses all. */ +enum class MediaFilter(internal val wire: String) { ALL("all"), MOVIE("movie"), TV("tv") } + +/** The four discovery sorts used by the TV. */ +enum class DiscoverSort(internal val wire: String) { POPULAR("popular"), NEW("new"), TOP("top"), UPCOMING("upcoming") } + +/** The complete TV route allowlist; caller identity is never a request body field. */ +object TvRoutes { + private val operator = RouteContract("GET", "/api/v1/operator", "Operator") + private val profiles = RouteContract("GET", "/api/v1/recommendations/profiles", "RecommendationProfiles") + private val feed = RouteContract("GET", "/api/v1/recommendations/profiles/{profile}/feed", "RecommendationFeed") + private val dismissals = RouteContract("GET", "/api/v1/recommendations/profiles/{profile}/dismissals", "RecommendationDismissals") + private val hide = RouteContract("POST", dismissals.path, "RecommendationHideResult", "RecommendationHideInputBody") + private val restore = RouteContract("DELETE", "${dismissals.path}/{type}/{tmdb}", "RecommendationRestoreResult") + private val options = RouteContract("GET", "/catalogue/options", "Options") + private val discover = RouteContract("GET", "/catalogue/discover", "Page") + private val title = RouteContract("GET", "/catalogue/titles/{tmdb}/{type}", "Detail") + private val cast = RouteContract("GET", "${title.path}/cast", "TitleCast") + private val related = RouteContract("GET", "${title.path}/related", "Page") + private val enrichment = RouteContract("GET", "${title.path}/enrichment", "Enrichment") + private val ratings = RouteContract("GET", "${title.path}/ratings", "TitleRatings") + private val display = RouteContract("GET", "${title.path}/display", "Display") + private val season = RouteContract("GET", "/catalogue/tv/{tmdb}/seasons/{season}", "SeasonDetail") + private val performer = RouteContract("GET", "/catalogue/performers/{performer}", "PerformerDetail") + private val filmography = RouteContract("GET", "${performer.path}/filmography", "FilmographyPage") + private val activity = RouteContract("GET", "/api/v1/activity", "ActivityFeed") + private val readiness = RouteContract("GET", "/api/v1/activity/titles/{tmdb}/{type}", "ViewerReadiness") + private val requests = RouteContract("GET", "/api/v1/requests/titles/{tmdb}/{type}", "TitleRequestsBody") + private val request = RouteContract("POST", "/api/v1/requests", "RequestReceipt", "RequestIntentBody") + private val audiences = RouteContract("GET", "/curation/library-audiences", "LibraryAudiencesBody") + + val contracts: List<RouteContract> = listOf( + operator, profiles, feed, dismissals, hide, restore, options, discover, title, cast, + related, enrichment, ratings, display, season, performer, filmography, activity, readiness, requests, request, audiences, + ) + + fun operator() = ReadCall(operator, Operator.serializer(), policy = CachePolicy.LAUNCH) + fun profiles() = ReadCall(profiles, RecommendationProfiles.serializer(), policy = CachePolicy.LAUNCH) + fun audiences() = ReadCall(audiences, LibraryAudiencesBody.serializer(), policy = CachePolicy.DAILY) + + fun feed(profile: String, media: MediaFilter = MediaFilter.ALL) = ReadCall( + feed, RecommendationFeed.serializer(), mapOf("profile" to profile), + mapOf("media_type" to media.wire), CachePolicy.FEED, + ) + + fun dismissals(profile: String) = ReadCall( + dismissals, RecommendationDismissals.serializer(), mapOf("profile" to profile), policy = CachePolicy.ON_OPEN, + ) + + fun options(media: MediaType) = ReadCall( + options, Options.serializer(), query = mapOf("media_type" to media.wire), policy = CachePolicy.FOREGROUND, + ) + + fun discover(media: MediaType, page: Int = 1, genreId: Long? = null, serviceId: Long? = null, sort: DiscoverSort = DiscoverSort.POPULAR): ReadCall<Page> { + require(page in 1..500) + val query = buildMap { + put("media_type", media.wire) + put("page", page.toString()) + put("sort", sort.wire) + genreId?.let { put("genre_id", it.toString()) } + serviceId?.let { put("service_id", it.toString()) } + } + return ReadCall(discover, Page.serializer(), query = query, policy = CachePolicy.DISCOVERY) + } + + fun title(tmdb: Long, media: MediaType) = ReadCall(title, Detail.serializer(), titleSegments(tmdb, media), policy = CachePolicy.ON_OPEN) + fun cast(tmdb: Long, media: MediaType) = ReadCall(cast, TitleCast.serializer(), titleSegments(tmdb, media), policy = CachePolicy.ON_OPEN) + fun enrichment(tmdb: Long, media: MediaType) = ReadCall(enrichment, Enrichment.serializer(), titleSegments(tmdb, media), policy = CachePolicy.ON_OPEN) + fun ratings(tmdb: Long, media: MediaType) = ReadCall(ratings, TitleRatings.serializer(), titleSegments(tmdb, media), policy = CachePolicy.RATINGS) + fun display(tmdb: Long, media: MediaType) = ReadCall(display, Display.serializer(), titleSegments(tmdb, media), policy = CachePolicy.ON_OPEN) + fun related(tmdb: Long, media: MediaType, page: Int = 1) = ReadCall( + related, Page.serializer(), titleSegments(tmdb, media), mapOf("page" to page.toString()), CachePolicy.ON_OPEN, + ) + fun season(tmdb: Long, season: Int): ReadCall<SeasonDetail> { + require(tmdb > 0 && season >= 0) + return ReadCall(this.season, SeasonDetail.serializer(), mapOf("tmdb" to tmdb.toString(), "season" to season.toString()), policy = CachePolicy.SEASON) + } + fun performer(performer: Long) = ReadCall( + this.performer, PerformerDetail.serializer(), mapOf("performer" to performer.toString()), policy = CachePolicy.ON_OPEN, + ) + fun filmography(performer: Long, page: Int = 1, pageSize: Int = 20) = ReadCall( + filmography, FilmographyPage.serializer(), mapOf("performer" to performer.toString()), + mapOf("page" to page.toString(), "page_size" to pageSize.toString()), CachePolicy.ON_OPEN, + ) + fun activity() = ReadCall(activity, ActivityFeed.serializer(), query = mapOf("requester" to "all"), policy = CachePolicy.ACTIVITY) + fun readiness(tmdb: Long, media: MediaType, viewer: String) = ReadCall( + readiness, ViewerReadiness.serializer(), titleSegments(tmdb, media), mapOf("viewer" to viewer), CachePolicy.ON_OPEN, + ) + fun requests(tmdb: Long, media: MediaType) = ReadCall( + requests, TitleRequestsBody.serializer(), titleSegments(tmdb, media), policy = CachePolicy.NO_CACHE, + ) + fun request(body: RequestIntentBody) = MutationCall( + request, RequestReceipt.serializer(), emptyMap(), WireJson.encodeToString(RequestIntentBody.serializer(), body), + ) + fun hide(profile: String, body: RecommendationHideInputBody) = MutationCall( + hide, RecommendationHideResult.serializer(), mapOf("profile" to profile), + WireJson.encodeToString(RecommendationHideInputBody.serializer(), body), + ) + fun restore(profile: String, tmdb: Long, media: MediaType) = MutationCall( + restore, RecommendationRestoreResult.serializer(), titleSegments(tmdb, media) + ("profile" to profile), + ) + + private fun titleSegments(tmdb: Long, media: MediaType): Map<String, String> { + require(tmdb > 0) + return mapOf("tmdb" to tmdb.toString(), "type" to media.wire) + } +} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/WireJson.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/WireJson.kt new file mode 100644 index 00000000..1e80eb25 --- /dev/null +++ b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/WireJson.kt @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.conductarr + +import kotlinx.serialization.json.Json + +/** Shared wire encoding omits absent optional request fields. */ +internal val WireJson = Json { + ignoreUnknownKeys = true + explicitNulls = false + encodeDefaults = false +} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/ActingCredit.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/ActingCredit.kt new file mode 100644 index 00000000..fe181459 --- /dev/null +++ b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/ActingCredit.kt @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.conductarr.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** ActingCredit wire contract from the pinned Conductarr schema. */ +@Serializable +data class ActingCredit( + @SerialName("character") val character: String, + @SerialName("credit_id") val creditId: String, + @SerialName("episode_count") val episodeCount: Long? = null, +) diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/ActivityFeed.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/ActivityFeed.kt new file mode 100644 index 00000000..8f36f041 --- /dev/null +++ b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/ActivityFeed.kt @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.conductarr.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** ActivityFeed wire contract from the pinned Conductarr schema. */ +@Serializable +data class ActivityFeed( + @SerialName("items") val items: List<RequestProgress>?, + @SerialName("requester") val requester: String, + @SerialName("sources") val sources: List<EvidenceSource>?, + @SerialName("truncated") val truncated: Boolean, +) diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Actor.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Actor.kt new file mode 100644 index 00000000..36ff72fd --- /dev/null +++ b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Actor.kt @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.conductarr.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** Actor wire contract from the pinned Conductarr schema. */ +@Serializable +data class Actor( + @SerialName("name") val name: String, + @SerialName("requester") val requester: String, + @SerialName("role") val role: Role, +) { + /** Wire values for role. */ + @Serializable + enum class Role { + @SerialName("admin") ADMIN, + @SerialName("household") HOUSEHOLD, + } +} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Candidate.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Candidate.kt new file mode 100644 index 00000000..81ef5607 --- /dev/null +++ b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Candidate.kt @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.conductarr.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** Candidate wire contract from the pinned Conductarr schema. */ +@Serializable +data class Candidate( + @SerialName("backdrop_path") val backdropPath: String? = null, + @SerialName("contributions") val contributions: List<SeedContribution>?, + @SerialName("genre_ids") val genreIds: List<Long>? = null, + @SerialName("media_type") val mediaType: MediaType, + @SerialName("overview") val overview: String, + @SerialName("poster_path") val posterPath: String, + @SerialName("score") val score: Double, + @SerialName("title") val title: String, + @SerialName("tmdb_id") val tmdbId: Long, + @SerialName("tvdb_id") val tvdbId: Long, + @SerialName("year") val year: Long, +) { + /** Wire values for media_type. */ + @Serializable + enum class MediaType { + @SerialName("movie") MOVIE, + @SerialName("tv") TV, + } +} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/CastMember.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/CastMember.kt new file mode 100644 index 00000000..9dca14e1 --- /dev/null +++ b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/CastMember.kt @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.conductarr.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** CastMember wire contract from the pinned Conductarr schema. */ +@Serializable +data class CastMember( + @SerialName("character") val character: String, + @SerialName("credit_id") val creditId: String, + @SerialName("name") val name: String, + @SerialName("order") val order: Long, + @SerialName("performer_id") val performerId: Long, + @SerialName("profile_url") val profileUrl: String? = null, +) diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Detail.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Detail.kt new file mode 100644 index 00000000..85cd9a88 --- /dev/null +++ b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Detail.kt @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.conductarr.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** Detail wire contract from the pinned Conductarr schema. */ +@Serializable +data class Detail( + @SerialName("genres") val genres: List<String>?, + @SerialName("held_title") val heldTitle: HeldTitle? = null, + @SerialName("metadata") val metadata: TitleMetadata, + @SerialName("metadata_state") val metadataState: MetadataState, + @SerialName("observations") val observations: List<LibraryObservation>?, + @SerialName("ratings") val ratings: List<Rating>? = null, + @SerialName("runtime") val runtime: Long? = null, + @SerialName("seasons") val seasons: List<Season>?, + @SerialName("state") val state: State, + @SerialName("title") val title: Title, +) { + /** Wire values for metadata_state. */ + @Serializable + enum class MetadataState { + @SerialName("observed") OBSERVED, + @SerialName("missing") MISSING, + @SerialName("unavailable") UNAVAILABLE, + } + + /** Wire values for state. */ + @Serializable + enum class State { + @SerialName("observed") OBSERVED, + @SerialName("partial") PARTIAL, + @SerialName("unavailable") UNAVAILABLE, + } +} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Discovery.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Discovery.kt new file mode 100644 index 00000000..a198abd1 --- /dev/null +++ b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Discovery.kt @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.conductarr.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** Discovery wire contract from the pinned Conductarr schema. */ +@Serializable +data class Discovery( + @SerialName("genre_id") val genreId: Long, + @SerialName("next") val next: String? = null, + @SerialName("region") val region: String, + @SerialName("service_id") val serviceId: Long, + @SerialName("sort") val sort: Sort, + @SerialName("state") val state: State, +) { + /** Wire values for sort. */ + @Serializable + enum class Sort { + @SerialName("trending") TRENDING, + @SerialName("popular") POPULAR, + @SerialName("new") NEW, + @SerialName("top") TOP, + @SerialName("upcoming") UPCOMING, + } + + /** Wire values for state. */ + @Serializable + enum class State { + @SerialName("observed") OBSERVED, + @SerialName("empty") EMPTY, + @SerialName("partial") PARTIAL, + @SerialName("exhausted") EXHAUSTED, + } +} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Display.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Display.kt new file mode 100644 index 00000000..2837856e --- /dev/null +++ b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Display.kt @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.conductarr.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** Display wire contract from the pinned Conductarr schema. */ +@Serializable +data class Display( + @SerialName("media_type") val mediaType: MediaType, + @SerialName("metadata_state") val metadataState: MetadataState, + @SerialName("poster_url") val posterUrl: String? = null, + @SerialName("title") val title: String? = null, + @SerialName("tmdb_id") val tmdbId: Long, +) { + @Serializable + enum class MediaType { @SerialName("movie") MOVIE, @SerialName("tv") TV } + @Serializable + enum class MetadataState { + @SerialName("observed") OBSERVED, + @SerialName("missing") MISSING, + @SerialName("unavailable") UNAVAILABLE, + } +} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Enrichment.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Enrichment.kt new file mode 100644 index 00000000..4329d346 --- /dev/null +++ b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Enrichment.kt @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.conductarr.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** Enrichment wire contract from the pinned Conductarr schema. */ +@Serializable +data class Enrichment( + @SerialName("genres") val genres: List<String>?, + @SerialName("metadata") val metadata: TitleMetadata, + @SerialName("metadata_state") val metadataState: MetadataState, + @SerialName("observations") val observations: List<LibraryObservation>?, + @SerialName("ratings") val ratings: List<Rating>?, + @SerialName("runtime") val runtime: Long? = null, + @SerialName("seasons") val seasons: List<Season>?, + @SerialName("state") val state: State, + @SerialName("title") val title: Title, +) { + /** Wire values for metadata_state. */ + @Serializable + enum class MetadataState { + @SerialName("observed") OBSERVED, + @SerialName("missing") MISSING, + @SerialName("unavailable") UNAVAILABLE, + } + + /** Wire values for state. */ + @Serializable + enum class State { + @SerialName("observed") OBSERVED, + @SerialName("unavailable") UNAVAILABLE, + } +} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Episode.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Episode.kt new file mode 100644 index 00000000..494d0094 --- /dev/null +++ b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Episode.kt @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.conductarr.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** Episode wire contract from the pinned Conductarr schema. */ +@Serializable +data class Episode( + @SerialName("air_date") val airDate: String? = null, + @SerialName("name") val name: String, + @SerialName("number") val number: Long, + @SerialName("overview") val overview: String, + @SerialName("recorded") val recorded: Recorded, + @SerialName("vote_average") val voteAverage: Double? = null, + @SerialName("vote_count") val voteCount: Long? = null, +) { + /** Wire values for recorded. */ + @Serializable + enum class Recorded { + @SerialName("recorded") RECORDED, + @SerialName("missing") MISSING, + @SerialName("unknown") UNKNOWN, + } +} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/EpisodeFileChip.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/EpisodeFileChip.kt new file mode 100644 index 00000000..b0c2a091 --- /dev/null +++ b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/EpisodeFileChip.kt @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.conductarr.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** EpisodeFileChip wire contract from the pinned Conductarr schema. */ +@Serializable +data class EpisodeFileChip( + @SerialName("quality") val quality: String? = null, + @SerialName("release_name") val releaseName: String? = null, + @SerialName("resolution") val resolution: String? = null, + @SerialName("size_bytes") val sizeBytes: Long, +) diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/EpisodeRow.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/EpisodeRow.kt new file mode 100644 index 00000000..1a56793b --- /dev/null +++ b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/EpisodeRow.kt @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.conductarr.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** EpisodeRow wire contract from the pinned Conductarr schema. */ +@Serializable +data class EpisodeRow( + @SerialName("air_date") val airDate: String? = null, + @SerialName("episode") val episode: Long, + @SerialName("file") val file: EpisodeFileChip? = null, + @SerialName("has_file") val hasFile: Boolean, + @SerialName("key") val key: String, + @SerialName("monitored") val monitored: Boolean, + @SerialName("queue_state") val queueState: String? = null, + @SerialName("runtime_minutes") val runtimeMinutes: Long? = null, + @SerialName("season") val season: Long, + @SerialName("title") val title: String? = null, + @SerialName("watched") val watched: Map<String,Boolean>, +) diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/EpisodeView.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/EpisodeView.kt new file mode 100644 index 00000000..aab5e114 --- /dev/null +++ b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/EpisodeView.kt @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.conductarr.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** EpisodeView wire contract from the pinned Conductarr schema. */ +@Serializable +data class EpisodeView( + @SerialName("owners") val owners: List<String>?, + @SerialName("seasons") val seasons: List<SeasonGroup>?, + @SerialName("title") val title: String, + @SerialName("tmdb_id") val tmdbId: Long, + @SerialName("users") val users: List<String>?, +) diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/ErrorDetail.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/ErrorDetail.kt new file mode 100644 index 00000000..3aef3a95 --- /dev/null +++ b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/ErrorDetail.kt @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.conductarr.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement + +/** ErrorDetail wire contract from the pinned Conductarr schema. */ +@Serializable +data class ErrorDetail( + @SerialName("location") val location: String? = null, + @SerialName("message") val message: String? = null, + @SerialName("value") val `value`: JsonElement? = null, +) diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/ErrorModel.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/ErrorModel.kt new file mode 100644 index 00000000..1c926492 --- /dev/null +++ b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/ErrorModel.kt @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.conductarr.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** ErrorModel wire contract from the pinned Conductarr schema. */ +@Serializable +data class ErrorModel( + @SerialName("detail") val detail: String? = null, + @SerialName("errors") val errors: List<ErrorDetail>? = null, + @SerialName("instance") val instance: String? = null, + @SerialName("status") val status: Long? = null, + @SerialName("title") val title: String? = null, + @SerialName("type") val type: String? = null, +) diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/EvidenceSource.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/EvidenceSource.kt new file mode 100644 index 00000000..3d3fd6e5 --- /dev/null +++ b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/EvidenceSource.kt @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.conductarr.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** EvidenceSource wire contract from the pinned Conductarr schema. */ +@Serializable +data class EvidenceSource( + @SerialName("detail") val detail: String? = null, + @SerialName("name") val name: String, + @SerialName("state") val state: State, +) { + /** Wire values for state. */ + @Serializable + enum class State { + @SerialName("observed") OBSERVED, + @SerialName("unavailable") UNAVAILABLE, + } +} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/FileDetail.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/FileDetail.kt new file mode 100644 index 00000000..5fc58321 --- /dev/null +++ b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/FileDetail.kt @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.conductarr.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** FileDetail wire contract from the pinned Conductarr schema. */ +@Serializable +data class FileDetail( + @SerialName("audio") val audio: String? = null, + @SerialName("container") val container: String? = null, + @SerialName("quality") val quality: String? = null, + @SerialName("release_name") val releaseName: String? = null, + @SerialName("resolution") val resolution: String? = null, + @SerialName("size_bytes") val sizeBytes: Long, + @SerialName("video") val video: String? = null, +) diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/FilmographyPage.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/FilmographyPage.kt new file mode 100644 index 00000000..22328337 --- /dev/null +++ b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/FilmographyPage.kt @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.conductarr.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** FilmographyPage wire contract from the pinned Conductarr schema. */ +@Serializable +data class FilmographyPage( + @SerialName("completeness") val completeness: Completeness, + @SerialName("items") val items: List<FilmographyTitle>?, + @SerialName("metadata_state") val metadataState: MetadataState, + @SerialName("observations") val observations: List<LibraryObservation>?, + @SerialName("page") val page: Long, + @SerialName("page_size") val pageSize: Long, + @SerialName("performer_id") val performerId: Long, + @SerialName("source") val source: Source, + @SerialName("source_paging") val sourcePaging: SourcePaging, + @SerialName("total_pages") val totalPages: Long? = null, + @SerialName("total_results") val totalResults: Long? = null, +) { + /** Wire values for completeness. */ + @Serializable + enum class Completeness { + @SerialName("returned-non-adult-cast") RETURNED_NON_ADULT_CAST, + } + + /** Wire values for metadata_state. */ + @Serializable + enum class MetadataState { + @SerialName("observed") OBSERVED, + @SerialName("missing") MISSING, + @SerialName("unavailable") UNAVAILABLE, + } + + /** Wire values for source. */ + @Serializable + enum class Source { + @SerialName("seerr") SEERR, + } + + /** Wire values for source_paging. */ + @Serializable + enum class SourcePaging { + @SerialName("unpaged") UNPAGED, + } +} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/FilmographyTitle.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/FilmographyTitle.kt new file mode 100644 index 00000000..69115f23 --- /dev/null +++ b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/FilmographyTitle.kt @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.conductarr.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** FilmographyTitle wire contract from the pinned Conductarr schema. */ +@Serializable +data class FilmographyTitle( + @SerialName("credits") val credits: List<ActingCredit>?, + @SerialName("title") val title: Title, + @SerialName("users") val users: List<String>?, + @SerialName("visibility_state") val visibilityState: VisibilityState, +) { + /** Wire values for visibility_state. */ + @Serializable + enum class VisibilityState { + @SerialName("configured") CONFIGURED, + @SerialName("unknown") UNKNOWN, + } +} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Genre.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Genre.kt new file mode 100644 index 00000000..fff1dbad --- /dev/null +++ b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Genre.kt @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.conductarr.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** Genre wire contract from the pinned Conductarr schema. */ +@Serializable +data class Genre( + @SerialName("id") val id: Long, + @SerialName("name") val name: String, +) diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/HealthDetail.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/HealthDetail.kt new file mode 100644 index 00000000..76247a3a --- /dev/null +++ b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/HealthDetail.kt @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.conductarr.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** HealthDetail wire contract from the pinned Conductarr schema. */ +@Serializable +data class HealthDetail( + @SerialName("failed") val failed: Boolean, + @SerialName("has_file") val hasFile: Boolean, + @SerialName("in_queue") val inQueue: Boolean, + @SerialName("missing") val missing: Boolean, +) diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/HeldTitle.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/HeldTitle.kt new file mode 100644 index 00000000..e3e6fbba --- /dev/null +++ b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/HeldTitle.kt @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.conductarr.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** HeldTitle wire contract from the pinned Conductarr schema. */ +@Serializable +data class HeldTitle( + @SerialName("detail") val detail: ItemDetail, + @SerialName("episodes") val episodes: EpisodeView? = null, + @SerialName("libraries") val libraries: List<String>?, + @SerialName("observations") val observations: List<LibraryObservation>?, + @SerialName("state") val state: State, + @SerialName("versions") val versions: List<TitleVersion>?, + @SerialName("watched") val watched: Map<String,Boolean>, +) { + /** Wire values for state. */ + @Serializable + enum class State { + @SerialName("observed") OBSERVED, + @SerialName("partial") PARTIAL, + } +} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/ItemDetail.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/ItemDetail.kt new file mode 100644 index 00000000..17b7357b --- /dev/null +++ b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/ItemDetail.kt @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.conductarr.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** ItemDetail wire contract from the pinned Conductarr schema. */ +@Serializable +data class ItemDetail( + @SerialName("backdrop_ref") val backdropRef: String? = null, + @SerialName("file") val file: FileDetail? = null, + @SerialName("health") val health: HealthDetail, + @SerialName("is_anime") val isAnime: Boolean, + @SerialName("media_type") val mediaType: String, + @SerialName("owners") val owners: List<String>?, + @SerialName("poster_ref") val posterRef: String? = null, + @SerialName("runtime") val runtime: Long? = null, + @SerialName("source") val source: SourceDetail, + @SerialName("title") val title: String, + @SerialName("tmdb_id") val tmdbId: Long, + @SerialName("users") val users: List<String>?, + @SerialName("year") val year: Long? = null, +) diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/LibraryAudience.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/LibraryAudience.kt new file mode 100644 index 00000000..11c69a33 --- /dev/null +++ b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/LibraryAudience.kt @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.conductarr.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** LibraryAudience wire contract from the pinned Conductarr schema. */ +@Serializable +data class LibraryAudience( + @SerialName("is_anime") val isAnime: Boolean, + @SerialName("libraries") val libraries: List<String>?, + @SerialName("media_type") val mediaType: MediaType, + @SerialName("owner") val owner: String, + @SerialName("users") val users: List<String>?, +) { + /** Wire values for media_type. */ + @Serializable + enum class MediaType { + @SerialName("movie") MOVIE, + @SerialName("tv") TV, + } +} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/LibraryAudiencesBody.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/LibraryAudiencesBody.kt new file mode 100644 index 00000000..b3ed0ebf --- /dev/null +++ b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/LibraryAudiencesBody.kt @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.conductarr.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** LibraryAudiencesBody wire contract from the pinned Conductarr schema. */ +@Serializable +data class LibraryAudiencesBody( + @SerialName("audiences") val audiences: List<LibraryAudience>?, +) diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/LibraryObservation.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/LibraryObservation.kt new file mode 100644 index 00000000..e439b750 --- /dev/null +++ b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/LibraryObservation.kt @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.conductarr.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** LibraryObservation wire contract from the pinned Conductarr schema. */ +@Serializable +data class LibraryObservation( + @SerialName("detail") val detail: String? = null, + @SerialName("observed_at") val observedAt: String? = null, + @SerialName("source") val source: String, + @SerialName("state") val state: State, +) { + /** Wire values for state. */ + @Serializable + enum class State { + @SerialName("observed") OBSERVED, + @SerialName("partial") PARTIAL, + @SerialName("unavailable") UNAVAILABLE, + } +} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/OpenLink.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/OpenLink.kt new file mode 100644 index 00000000..75d1e996 --- /dev/null +++ b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/OpenLink.kt @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.conductarr.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** OpenLink wire contract from the pinned Conductarr schema. */ +@Serializable +data class OpenLink( + @SerialName("detail") val detail: String? = null, + @SerialName("state") val state: State, + @SerialName("url") val url: String? = null, +) { + /** Wire values for state. */ + @Serializable + enum class State { + @SerialName("observed") OBSERVED, + @SerialName("missing") MISSING, + @SerialName("unavailable") UNAVAILABLE, + @SerialName("unconfigured") UNCONFIGURED, + @SerialName("not_permitted") NOT_PERMITTED, + } +} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Operator.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Operator.kt new file mode 100644 index 00000000..edb9f0cf --- /dev/null +++ b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Operator.kt @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.conductarr.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** Operator wire contract from the pinned Conductarr schema. */ +@Serializable +data class Operator( + @SerialName("capabilities") val capabilities: OperatorCapabilities, + @SerialName("name") val name: String, + @SerialName("recommendation_profile") val recommendationProfile: String, + @SerialName("requester") val requester: String, + @SerialName("role") val role: Role, + @SerialName("subject") val subject: String, + @SerialName("watch_user") val watchUser: String, +) { + /** Wire values for role. */ + @Serializable + enum class Role { + @SerialName("admin") ADMIN, + @SerialName("household") HOUSEHOLD, + } +} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/OperatorCapabilities.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/OperatorCapabilities.kt new file mode 100644 index 00000000..777f1c03 --- /dev/null +++ b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/OperatorCapabilities.kt @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.conductarr.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** OperatorCapabilities wire contract from the pinned Conductarr schema. */ +@Serializable +data class OperatorCapabilities( + @SerialName("administer") val administer: Boolean, + @SerialName("manage_library") val manageLibrary: Boolean, + @SerialName("manage_memberships") val manageMemberships: Boolean, + @SerialName("recommendation_profiles") val recommendationProfiles: List<String>?, + @SerialName("request_media") val requestMedia: Boolean, + @SerialName("requesters") val requesters: List<String>?, + @SerialName("watch_users") val watchUsers: List<String>?, +) diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Option.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Option.kt new file mode 100644 index 00000000..ebd7b132 --- /dev/null +++ b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Option.kt @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.conductarr.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** Option wire contract from the pinned Conductarr schema. */ +@Serializable +data class Option( + @SerialName("id") val id: Long, + @SerialName("logo_url") val logoUrl: String? = null, + @SerialName("name") val name: String, +) diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Options.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Options.kt new file mode 100644 index 00000000..b0a9839b --- /dev/null +++ b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Options.kt @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.conductarr.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** Options wire contract from the pinned Conductarr schema. */ +@Serializable +data class Options( + @SerialName("genres") val genres: List<Option>?, + @SerialName("media_type") val mediaType: MediaType, + @SerialName("observations") val observations: List<LibraryObservation>?, + @SerialName("region") val region: String, + @SerialName("services") val services: List<Option>?, + @SerialName("state") val state: State, +) { + /** Wire values for media_type. */ + @Serializable + enum class MediaType { + @SerialName("movie") MOVIE, + @SerialName("tv") TV, + } + + /** Wire values for state. */ + @Serializable + enum class State { + @SerialName("observed") OBSERVED, + @SerialName("partial") PARTIAL, + @SerialName("unavailable") UNAVAILABLE, + } +} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Page.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Page.kt new file mode 100644 index 00000000..816ea50f --- /dev/null +++ b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Page.kt @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.conductarr.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** Page wire contract from the pinned Conductarr schema. */ +@Serializable +data class Page( + @SerialName("discovery") val discovery: Discovery? = null, + @SerialName("items") val items: List<Title>?, + @SerialName("media_type") val mediaType: MediaType, + @SerialName("observations") val observations: List<LibraryObservation>?, + @SerialName("page") val page: Long, + @SerialName("query") val query: String, + @SerialName("source") val source: Source, + @SerialName("state") val state: State, + @SerialName("total_pages") val totalPages: Long, + @SerialName("total_results") val totalResults: Long, +) { + /** Wire values for media_type. */ + @Serializable + enum class MediaType { + @SerialName("all") ALL, + @SerialName("movie") MOVIE, + @SerialName("tv") TV, + } + + /** Wire values for source. */ + @Serializable + enum class Source { + @SerialName("search") SEARCH, + @SerialName("general") GENERAL, + @SerialName("related") RELATED, + } + + /** Wire values for state. */ + @Serializable + enum class State { + @SerialName("observed") OBSERVED, + @SerialName("partial") PARTIAL, + } +} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/PerformerDetail.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/PerformerDetail.kt new file mode 100644 index 00000000..e5773b1a --- /dev/null +++ b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/PerformerDetail.kt @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.conductarr.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** PerformerDetail wire contract from the pinned Conductarr schema. */ +@Serializable +data class PerformerDetail( + @SerialName("also_known_as") val alsoKnownAs: List<String>? = null, + @SerialName("biography") val biography: String? = null, + @SerialName("birthday") val birthday: String? = null, + @SerialName("deathday") val deathday: String? = null, + @SerialName("known_for_department") val knownForDepartment: String? = null, + @SerialName("metadata_state") val metadataState: MetadataState, + @SerialName("name") val name: String? = null, + @SerialName("performer_id") val performerId: Long, + @SerialName("place_of_birth") val placeOfBirth: String? = null, + @SerialName("profile_url") val profileUrl: String? = null, + @SerialName("source") val source: Source, +) { + /** Wire values for metadata_state. */ + @Serializable + enum class MetadataState { + @SerialName("observed") OBSERVED, + @SerialName("missing") MISSING, + @SerialName("unavailable") UNAVAILABLE, + } + + /** Wire values for source. */ + @Serializable + enum class Source { + @SerialName("seerr") SEERR, + } +} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Person.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Person.kt new file mode 100644 index 00000000..f65abc82 --- /dev/null +++ b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Person.kt @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.conductarr.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** Person wire contract from the pinned Conductarr schema. */ +@Serializable +data class Person( + @SerialName("key") val key: String, + @SerialName("name") val name: String, +) diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Provider.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Provider.kt new file mode 100644 index 00000000..6c19c15a --- /dev/null +++ b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Provider.kt @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.conductarr.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** Provider wire contract from the pinned Conductarr schema. */ +@Serializable +data class Provider( + @SerialName("id") val id: Long, + @SerialName("logo_url") val logoUrl: String? = null, + @SerialName("name") val name: String, +) diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Rating.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Rating.kt new file mode 100644 index 00000000..741b856d --- /dev/null +++ b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Rating.kt @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.conductarr.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** Rating wire contract from the pinned Conductarr schema. */ +@Serializable +data class Rating( + @SerialName("score") val score: Double? = null, + @SerialName("source") val source: Source, + @SerialName("state") val state: State, + @SerialName("status") val status: String? = null, + @SerialName("url") val url: String? = null, + @SerialName("votes") val votes: Long? = null, +) { + /** Wire values for source. */ + @Serializable + enum class Source { + @SerialName("rt_critics") RT_CRITICS, + @SerialName("rt_audience") RT_AUDIENCE, + @SerialName("imdb") IMDB, + @SerialName("tmdb") TMDB, + } + + /** Wire values for state. */ + @Serializable + enum class State { + @SerialName("observed") OBSERVED, + @SerialName("unrated") UNRATED, + @SerialName("missing") MISSING, + @SerialName("unavailable") UNAVAILABLE, + } +} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/ReadProfile.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/ReadProfile.kt new file mode 100644 index 00000000..cb39424f --- /dev/null +++ b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/ReadProfile.kt @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.conductarr.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** ReadProfile wire contract from the pinned Conductarr schema. */ +@Serializable +data class ReadProfile( + @SerialName("key") val key: String, + @SerialName("name") val name: String, +) diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/ReadinessStage.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/ReadinessStage.kt new file mode 100644 index 00000000..1192a152 --- /dev/null +++ b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/ReadinessStage.kt @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.conductarr.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** ReadinessStage wire contract from the pinned Conductarr schema. */ +@Serializable +data class ReadinessStage( + @SerialName("name") val name: Name, + @SerialName("observed_at") val observedAt: String? = null, + @SerialName("source") val source: String, + @SerialName("state") val state: State, + @SerialName("summary") val summary: String, +) { + /** Wire values for name. */ + @Serializable + enum class Name { + @SerialName("request") REQUEST, + @SerialName("acquisition") ACQUISITION, + @SerialName("visibility") VISIBILITY, + @SerialName("playback") PLAYBACK, + } + + /** Wire values for state. */ + @Serializable + enum class State { + @SerialName("done") DONE, + @SerialName("partial") PARTIAL, + @SerialName("in_progress") IN_PROGRESS, + @SerialName("none") NONE, + @SerialName("unknown") UNKNOWN, + @SerialName("stale") STALE, + @SerialName("conflicting") CONFLICTING, + @SerialName("failed") FAILED, + } +} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RecommendationCoverage.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RecommendationCoverage.kt new file mode 100644 index 00000000..583b5933 --- /dev/null +++ b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RecommendationCoverage.kt @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.conductarr.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** RecommendationCoverage wire contract from the pinned Conductarr schema. */ +@Serializable +data class RecommendationCoverage( + @SerialName("complete") val complete: Boolean, + @SerialName("excluded") val excluded: Long, + @SerialName("matched") val matched: Long, + @SerialName("total") val total: Long, + @SerialName("unresolved") val unresolved: Long, +) diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RecommendationDismissal.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RecommendationDismissal.kt new file mode 100644 index 00000000..2364e335 --- /dev/null +++ b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RecommendationDismissal.kt @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.conductarr.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** RecommendationDismissal wire contract from the pinned Conductarr schema. */ +@Serializable +data class RecommendationDismissal( + @SerialName("hidden_at") val hiddenAt: String, + @SerialName("hidden_by") val hiddenBy: String, + @SerialName("media_type") val mediaType: MediaType, + @SerialName("poster_path") val posterPath: String, + @SerialName("title") val title: String, + @SerialName("tmdb_id") val tmdbId: Long, + @SerialName("year") val year: Long, +) { + /** Wire values for media_type. */ + @Serializable + enum class MediaType { + @SerialName("movie") MOVIE, + @SerialName("tv") TV, + } +} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RecommendationDismissals.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RecommendationDismissals.kt new file mode 100644 index 00000000..0ac1c48f --- /dev/null +++ b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RecommendationDismissals.kt @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.conductarr.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** RecommendationDismissals wire contract from the pinned Conductarr schema. */ +@Serializable +data class RecommendationDismissals( + @SerialName("dismissals") val dismissals: List<RecommendationDismissal>?, + @SerialName("profile") val profile: String, +) diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RecommendationFact.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RecommendationFact.kt new file mode 100644 index 00000000..d4114c9f --- /dev/null +++ b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RecommendationFact.kt @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.conductarr.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** RecommendationFact wire contract from the pinned Conductarr schema. */ +@Serializable +data class RecommendationFact( + @SerialName("genre_source") val genreSource: GenreSource, + @SerialName("match") val match: Match, + @SerialName("metadata") val metadata: TitleMetadata, +) { + /** Wire values for genre_source. */ + @Serializable + enum class GenreSource { + @SerialName("ranked") RANKED, + @SerialName("metadata") METADATA, + @SerialName("unknown") UNKNOWN, + } + + /** Wire values for match. */ + @Serializable + enum class Match { + @SerialName("matched") MATCHED, + @SerialName("excluded") EXCLUDED, + @SerialName("unresolved") UNRESOLVED, + } +} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RecommendationFeed.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RecommendationFeed.kt new file mode 100644 index 00000000..4f75e359 --- /dev/null +++ b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RecommendationFeed.kt @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.conductarr.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** RecommendationFeed wire contract from the pinned Conductarr schema. */ +@Serializable +data class RecommendationFeed( + @SerialName("attempted_at") val attemptedAt: String? = null, + @SerialName("expires_at") val expiresAt: String? = null, + @SerialName("failure") val failure: Failure? = null, + @SerialName("materialization") val materialization: Materialization, + @SerialName("movies") val movies: List<Candidate>?, + @SerialName("observed_at") val observedAt: String? = null, + @SerialName("outcome") val outcome: Outcome, + @SerialName("profile") val profile: ReadProfile, + @SerialName("projection") val projection: RecommendationProjection, + @SerialName("shows") val shows: List<Candidate>?, + @SerialName("state") val state: State, +) { + /** Wire values for failure. */ + @Serializable + enum class Failure { + @SerialName("profile_resolution") PROFILE_RESOLUTION, + @SerialName("memberships") MEMBERSHIPS, + @SerialName("history") HISTORY, + @SerialName("candidates") CANDIDATES, + @SerialName("cycle") CYCLE, + } + + /** Wire values for materialization. */ + @Serializable + enum class Materialization { + @SerialName("unknown") UNKNOWN, + @SerialName("pending") PENDING, + @SerialName("current") CURRENT, + @SerialName("failed") FAILED, + } + + /** Wire values for outcome. */ + @Serializable + enum class Outcome { + @SerialName("unknown") UNKNOWN, + @SerialName("ranked") RANKED, + @SerialName("no_seeds") NO_SEEDS, + @SerialName("empty") EMPTY, + } + + /** Wire values for state. */ + @Serializable + enum class State { + @SerialName("current") CURRENT, + @SerialName("stale") STALE, + @SerialName("unavailable") UNAVAILABLE, + } +} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RecommendationHideInputBody.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RecommendationHideInputBody.kt new file mode 100644 index 00000000..8d25fa8f --- /dev/null +++ b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RecommendationHideInputBody.kt @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.conductarr.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** RecommendationHideInputBody wire contract from the pinned Conductarr schema. */ +@Serializable +data class RecommendationHideInputBody( + @SerialName("media_type") val mediaType: MediaType, + @SerialName("poster_path") val posterPath: String? = null, + @SerialName("title") val title: String? = null, + @SerialName("tmdb_id") val tmdbId: Long, + @SerialName("year") val year: Long? = null, +) { + /** Wire values for media_type. */ + @Serializable + enum class MediaType { + @SerialName("movie") MOVIE, + @SerialName("tv") TV, + } +} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RecommendationHideResult.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RecommendationHideResult.kt new file mode 100644 index 00000000..eb9bb3f1 --- /dev/null +++ b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RecommendationHideResult.kt @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.conductarr.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** RecommendationHideResult wire contract from the pinned Conductarr schema. */ +@Serializable +data class RecommendationHideResult( + @SerialName("created") val created: Boolean, + @SerialName("dismissal") val dismissal: RecommendationDismissal, + @SerialName("materialization") val materialization: Materialization, + @SerialName("profile") val profile: String, +) { + /** Wire values for materialization. */ + @Serializable + enum class Materialization { + @SerialName("current") CURRENT, + @SerialName("failed") FAILED, + @SerialName("unchanged") UNCHANGED, + } +} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RecommendationProfiles.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RecommendationProfiles.kt new file mode 100644 index 00000000..d6245a99 --- /dev/null +++ b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RecommendationProfiles.kt @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.conductarr.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** RecommendationProfiles wire contract from the pinned Conductarr schema. */ +@Serializable +data class RecommendationProfiles( + @SerialName("default_profile") val defaultProfile: String, + @SerialName("profiles") val profiles: List<ReadProfile>?, +) diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RecommendationProjection.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RecommendationProjection.kt new file mode 100644 index 00000000..a161ae72 --- /dev/null +++ b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RecommendationProjection.kt @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.conductarr.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** RecommendationProjection wire contract from the pinned Conductarr schema. */ +@Serializable +data class RecommendationProjection( + @SerialName("coverage") val coverage: RecommendationCoverage, + @SerialName("facts") val facts: List<RecommendationFact>?, + @SerialName("genre_id") val genreId: Long, + @SerialName("media_type") val mediaType: MediaType, + @SerialName("service_id") val serviceId: Long, + @SerialName("unresolved") val unresolved: List<Candidate>?, +) { + /** Wire values for media_type. */ + @Serializable + enum class MediaType { + @SerialName("all") ALL, + @SerialName("movie") MOVIE, + @SerialName("tv") TV, + } +} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RecommendationRestoreResult.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RecommendationRestoreResult.kt new file mode 100644 index 00000000..32a1d401 --- /dev/null +++ b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RecommendationRestoreResult.kt @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.conductarr.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** RecommendationRestoreResult wire contract from the pinned Conductarr schema. */ +@Serializable +data class RecommendationRestoreResult( + @SerialName("media_type") val mediaType: MediaType, + @SerialName("profile") val profile: String, + @SerialName("restored") val restored: Boolean, + @SerialName("tmdb_id") val tmdbId: Long, +) { + /** Wire values for media_type. */ + @Serializable + enum class MediaType { + @SerialName("movie") MOVIE, + @SerialName("tv") TV, + } +} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RequestIntentBody.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RequestIntentBody.kt new file mode 100644 index 00000000..c225a816 --- /dev/null +++ b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RequestIntentBody.kt @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.conductarr.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** RequestIntentBody wire contract from the pinned Conductarr schema. */ +@Serializable +data class RequestIntentBody( + @SerialName("media_type") val mediaType: MediaType, + @SerialName("recommendation_profile") val recommendationProfile: String? = null, + @SerialName("requester") val requester: String? = null, + @SerialName("seasons") val seasons: List<Long>? = null, + @SerialName("source") val source: Source? = null, + @SerialName("tmdb_id") val tmdbId: Long, +) { + /** Wire values for media_type. */ + @Serializable + enum class MediaType { + @SerialName("movie") MOVIE, + @SerialName("tv") TV, + } + + /** Wire values for source. */ + @Serializable + enum class Source { + @SerialName("title") TITLE, + @SerialName("recommendation") RECOMMENDATION, + } +} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RequestProgress.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RequestProgress.kt new file mode 100644 index 00000000..e1a6c63e --- /dev/null +++ b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RequestProgress.kt @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.conductarr.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** RequestProgress wire contract from the pinned Conductarr schema. */ +@Serializable +data class RequestProgress( + @SerialName("actor") val actor: Actor? = null, + @SerialName("awaiting_approval") val awaitingApproval: Boolean? = null, + @SerialName("blocking_stage") val blockingStage: BlockingStage? = null, + @SerialName("filed_by_you") val filedByYou: Boolean, + @SerialName("id") val id: String, + @SerialName("media_type") val mediaType: MediaType, + @SerialName("on_behalf") val onBehalf: Boolean, + @SerialName("requested_at") val requestedAt: String? = null, + @SerialName("requester") val requester: Person, + @SerialName("seasons") val seasons: List<Long>?, + @SerialName("stages") val stages: List<ReadinessStage>?, + @SerialName("title") val title: String? = null, + @SerialName("tmdb_id") val tmdbId: Long, +) { + /** Wire values for blocking_stage. */ + @Serializable + enum class BlockingStage { + @SerialName("request") REQUEST, + @SerialName("acquisition") ACQUISITION, + @SerialName("visibility") VISIBILITY, + @SerialName("playback") PLAYBACK, + } + + /** Wire values for media_type. */ + @Serializable + enum class MediaType { + @SerialName("movie") MOVIE, + @SerialName("tv") TV, + } +} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RequestReceipt.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RequestReceipt.kt new file mode 100644 index 00000000..900a2e63 --- /dev/null +++ b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RequestReceipt.kt @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.conductarr.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** RequestReceipt wire contract from the pinned Conductarr schema. */ +@Serializable +data class RequestReceipt( + @SerialName("actor") val actor: RequestReceiptActor, + @SerialName("attempts") val attempts: Long, + @SerialName("coverage") val coverage: List<RequestReceiptCoverage>?, + @SerialName("created_at") val createdAt: String, + @SerialName("detail") val detail: String? = null, + @SerialName("id") val id: Long, + @SerialName("media_type") val mediaType: MediaType, + @SerialName("membership") val membership: RequestReceiptMembership, + @SerialName("on_behalf") val onBehalf: Boolean, + @SerialName("recommendation_profile") val recommendationProfile: String? = null, + @SerialName("requester") val requester: String, + @SerialName("requester_username") val requesterUsername: String, + @SerialName("seasons") val seasons: List<Long>?, + @SerialName("seerr") val seerr: RequestReceiptSeerr, + @SerialName("source") val source: Source, + @SerialName("state") val state: State, + @SerialName("tmdb_id") val tmdbId: Long, + @SerialName("updated_at") val updatedAt: String, +) { + /** Wire values for media_type. */ + @Serializable + enum class MediaType { + @SerialName("movie") MOVIE, + @SerialName("tv") TV, + } + + /** Wire values for source. */ + @Serializable + enum class Source { + @SerialName("title") TITLE, + @SerialName("recommendation") RECOMMENDATION, + } + + /** Wire values for state. */ + @Serializable + enum class State { + @SerialName("settled") SETTLED, + @SerialName("pending") PENDING, + @SerialName("retired") RETIRED, + } +} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RequestReceiptActor.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RequestReceiptActor.kt new file mode 100644 index 00000000..5a0b3059 --- /dev/null +++ b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RequestReceiptActor.kt @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.conductarr.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** RequestReceiptActor wire contract from the pinned Conductarr schema. */ +@Serializable +data class RequestReceiptActor( + @SerialName("name") val name: String, + @SerialName("requester") val requester: String, + @SerialName("role") val role: Role, +) { + /** Wire values for role. */ + @Serializable + enum class Role { + @SerialName("admin") ADMIN, + @SerialName("household") HOUSEHOLD, + } +} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RequestReceiptCoverage.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RequestReceiptCoverage.kt new file mode 100644 index 00000000..80b87b51 --- /dev/null +++ b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RequestReceiptCoverage.kt @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.conductarr.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** RequestReceiptCoverage wire contract from the pinned Conductarr schema. */ +@Serializable +data class RequestReceiptCoverage( + @SerialName("outcome") val outcome: Outcome, + @SerialName("request_id") val requestId: Long? = null, + @SerialName("season") val season: Long, +) { + /** Wire values for outcome. */ + @Serializable + enum class Outcome { + @SerialName("requested") REQUESTED, + @SerialName("already_requested") ALREADY_REQUESTED, + @SerialName("already_held") ALREADY_HELD, + @SerialName("unresolved") UNRESOLVED, + } +} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RequestReceiptMembership.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RequestReceiptMembership.kt new file mode 100644 index 00000000..d5b005c4 --- /dev/null +++ b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RequestReceiptMembership.kt @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.conductarr.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** RequestReceiptMembership wire contract from the pinned Conductarr schema. */ +@Serializable +data class RequestReceiptMembership( + @SerialName("bucket") val bucket: String? = null, + @SerialName("dispatch_log_id") val dispatchLogId: Long? = null, + @SerialName("status") val status: Status, +) { + /** Wire values for status. */ + @Serializable + enum class Status { + @SerialName("added") ADDED, + @SerialName("already_owned") ALREADY_OWNED, + @SerialName("pending_dispatch") PENDING_DISPATCH, + @SerialName("conflict") CONFLICT, + @SerialName("failed") FAILED, + } +} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RequestReceiptSeerr.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RequestReceiptSeerr.kt new file mode 100644 index 00000000..638d156d --- /dev/null +++ b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RequestReceiptSeerr.kt @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.conductarr.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** RequestReceiptSeerr wire contract from the pinned Conductarr schema. */ +@Serializable +data class RequestReceiptSeerr( + @SerialName("outcome") val outcome: Outcome, + @SerialName("request_id") val requestId: Long? = null, +) { + /** Wire values for outcome. */ + @Serializable + enum class Outcome { + @SerialName("requested") REQUESTED, + @SerialName("already_requested") ALREADY_REQUESTED, + @SerialName("not_needed") NOT_NEEDED, + @SerialName("not_attempted") NOT_ATTEMPTED, + @SerialName("unresolved") UNRESOLVED, + } +} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Season.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Season.kt new file mode 100644 index 00000000..28b78b93 --- /dev/null +++ b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Season.kt @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.conductarr.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** Season wire contract from the pinned Conductarr schema. */ +@Serializable +data class Season( + @SerialName("air_date") val airDate: String? = null, + @SerialName("coverage") val coverage: Coverage, + @SerialName("episode_count") val episodeCount: Long, + @SerialName("name") val name: String, + @SerialName("number") val number: Long, + @SerialName("overview") val overview: String, + @SerialName("recorded_episodes") val recordedEpisodes: Long, + @SerialName("request_state") val requestState: RequestState, + @SerialName("seerr_state") val seerrState: SeerrState, +) { + /** Wire values for coverage. */ + @Serializable + enum class Coverage { + @SerialName("none") NONE, + @SerialName("partial") PARTIAL, + @SerialName("complete") COMPLETE, + @SerialName("unknown") UNKNOWN, + } + + /** Wire values for request_state. */ + @Serializable + enum class RequestState { + @SerialName("none") NONE, + @SerialName("requested") REQUESTED, + @SerialName("unknown") UNKNOWN, + } + + /** Wire values for seerr_state. */ + @Serializable + enum class SeerrState { + @SerialName("untracked") UNTRACKED, + @SerialName("unknown") UNKNOWN, + @SerialName("pending") PENDING, + @SerialName("processing") PROCESSING, + @SerialName("partial") PARTIAL, + @SerialName("available") AVAILABLE, + @SerialName("blocklisted") BLOCKLISTED, + @SerialName("deleted") DELETED, + } +} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/SeasonCoverage.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/SeasonCoverage.kt new file mode 100644 index 00000000..87ad626a --- /dev/null +++ b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/SeasonCoverage.kt @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.conductarr.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** SeasonCoverage wire contract from the pinned Conductarr schema. */ +@Serializable +data class SeasonCoverage( + @SerialName("coverage") val coverage: Coverage, + @SerialName("season") val season: Long, +) { + /** Wire values for coverage. */ + @Serializable + enum class Coverage { + @SerialName("complete") COMPLETE, + @SerialName("partial") PARTIAL, + @SerialName("none") NONE, + @SerialName("unknown") UNKNOWN, + } +} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/SeasonDetail.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/SeasonDetail.kt new file mode 100644 index 00000000..e8765ed2 --- /dev/null +++ b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/SeasonDetail.kt @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.conductarr.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** SeasonDetail wire contract from the pinned Conductarr schema. */ +@Serializable +data class SeasonDetail( + @SerialName("episodes") val episodes: List<Episode>?, + @SerialName("media_type") val mediaType: MediaType, + @SerialName("metadata_state") val metadataState: MetadataState, + @SerialName("observations") val observations: List<LibraryObservation>?, + @SerialName("season") val season: Season, + @SerialName("tmdb_id") val tmdbId: Long, +) { + /** Wire values for media_type. */ + @Serializable + enum class MediaType { + @SerialName("tv") TV, + } + + /** Wire values for metadata_state. */ + @Serializable + enum class MetadataState { + @SerialName("observed") OBSERVED, + @SerialName("missing") MISSING, + @SerialName("unavailable") UNAVAILABLE, + } +} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/SeasonGroup.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/SeasonGroup.kt new file mode 100644 index 00000000..9277b43d --- /dev/null +++ b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/SeasonGroup.kt @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.conductarr.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** SeasonGroup wire contract from the pinned Conductarr schema. */ +@Serializable +data class SeasonGroup( + @SerialName("episodes") val episodes: List<EpisodeRow>?, + @SerialName("season") val season: Long, +) diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/SeedContribution.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/SeedContribution.kt new file mode 100644 index 00000000..9fe455af --- /dev/null +++ b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/SeedContribution.kt @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.conductarr.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** SeedContribution wire contract from the pinned Conductarr schema. */ +@Serializable +data class SeedContribution( + @SerialName("added") val added: Boolean, + @SerialName("media_type") val mediaType: MediaType, + @SerialName("title") val title: String? = null, + @SerialName("tmdb_id") val tmdbId: Long, + @SerialName("watched") val watched: Boolean, +) { + /** Wire values for media_type. */ + @Serializable + enum class MediaType { + @SerialName("movie") MOVIE, + @SerialName("tv") TV, + } +} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/SeerrObservation.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/SeerrObservation.kt new file mode 100644 index 00000000..80773fc8 --- /dev/null +++ b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/SeerrObservation.kt @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.conductarr.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** SeerrObservation wire contract from the pinned Conductarr schema. */ +@Serializable +data class SeerrObservation( + @SerialName("detail") val detail: String? = null, + @SerialName("requests") val requests: List<SeerrRequest>?, + @SerialName("state") val state: State, +) { + /** Wire values for state. */ + @Serializable + enum class State { + @SerialName("observed") OBSERVED, + @SerialName("unavailable") UNAVAILABLE, + } +} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/SeerrRequest.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/SeerrRequest.kt new file mode 100644 index 00000000..ac6b7e08 --- /dev/null +++ b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/SeerrRequest.kt @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.conductarr.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** SeerrRequest wire contract from the pinned Conductarr schema. */ +@Serializable +data class SeerrRequest( + @SerialName("id") val id: Long, + @SerialName("status") val status: Status, +) { + /** Wire values for status. */ + @Serializable + enum class Status { + @SerialName("pending") PENDING, + @SerialName("approved") APPROVED, + @SerialName("declined") DECLINED, + @SerialName("failed") FAILED, + @SerialName("completed") COMPLETED, + @SerialName("unknown") UNKNOWN, + } +} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/SourceDetail.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/SourceDetail.kt new file mode 100644 index 00000000..8e30462b --- /dev/null +++ b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/SourceDetail.kt @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.conductarr.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** SourceDetail wire contract from the pinned Conductarr schema. */ +@Serializable +data class SourceDetail( + @SerialName("arr") val arr: String, + @SerialName("imported") val imported: String? = null, + @SerialName("indexer") val indexer: String? = null, +) diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Title.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Title.kt new file mode 100644 index 00000000..29b35973 --- /dev/null +++ b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Title.kt @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.conductarr.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** Title wire contract from the pinned Conductarr schema. */ +@Serializable +data class Title( + @SerialName("backdrop_url") val backdropUrl: String? = null, + @SerialName("genre_ids") val genreIds: List<Long>? = null, + @SerialName("held") val held: Held, + @SerialName("libraries") val libraries: List<String>?, + @SerialName("media_type") val mediaType: MediaType, + @SerialName("overview") val overview: String, + @SerialName("poster_url") val posterUrl: String? = null, + @SerialName("release_date") val releaseDate: String? = null, + @SerialName("request_state") val requestState: RequestState, + @SerialName("seerr_state") val seerrState: SeerrState, + @SerialName("title") val title: String, + @SerialName("tmdb_id") val tmdbId: Long, +) { + /** Wire values for held. */ + @Serializable + enum class Held { + @SerialName("held") HELD, + @SerialName("unheld") UNHELD, + @SerialName("unknown") UNKNOWN, + } + + /** Wire values for media_type. */ + @Serializable + enum class MediaType { + @SerialName("movie") MOVIE, + @SerialName("tv") TV, + } + + /** Wire values for request_state. */ + @Serializable + enum class RequestState { + @SerialName("none") NONE, + @SerialName("requested") REQUESTED, + @SerialName("unknown") UNKNOWN, + } + + /** Wire values for seerr_state. */ + @Serializable + enum class SeerrState { + @SerialName("untracked") UNTRACKED, + @SerialName("unknown") UNKNOWN, + @SerialName("pending") PENDING, + @SerialName("processing") PROCESSING, + @SerialName("partial") PARTIAL, + @SerialName("available") AVAILABLE, + @SerialName("blocklisted") BLOCKLISTED, + @SerialName("deleted") DELETED, + } +} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/TitleCast.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/TitleCast.kt new file mode 100644 index 00000000..be3f0422 --- /dev/null +++ b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/TitleCast.kt @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.conductarr.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** TitleCast wire contract from the pinned Conductarr schema. */ +@Serializable +data class TitleCast( + @SerialName("credit_context") val creditContext: CreditContext, + @SerialName("items") val items: List<CastMember>?, + @SerialName("media_type") val mediaType: MediaType, + @SerialName("metadata_state") val metadataState: MetadataState, + @SerialName("paging") val paging: Paging, + @SerialName("source") val source: Source, + @SerialName("tmdb_id") val tmdbId: Long, +) { + /** Wire values for credit_context. */ + @Serializable + enum class CreditContext { + @SerialName("movie-character") MOVIE_CHARACTER, + @SerialName("tv-leading-character") TV_LEADING_CHARACTER, + } + + /** Wire values for media_type. */ + @Serializable + enum class MediaType { + @SerialName("movie") MOVIE, + @SerialName("tv") TV, + } + + /** Wire values for metadata_state. */ + @Serializable + enum class MetadataState { + @SerialName("observed") OBSERVED, + @SerialName("missing") MISSING, + @SerialName("unavailable") UNAVAILABLE, + } + + /** Wire values for paging. */ + @Serializable + enum class Paging { + @SerialName("unpaged") UNPAGED, + } + + /** Wire values for source. */ + @Serializable + enum class Source { + @SerialName("seerr") SEERR, + } +} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/TitleMetadata.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/TitleMetadata.kt new file mode 100644 index 00000000..38b7aac9 --- /dev/null +++ b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/TitleMetadata.kt @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.conductarr.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** TitleMetadata wire contract from the pinned Conductarr schema. */ +@Serializable +data class TitleMetadata( + @SerialName("checked_at") val checkedAt: String? = null, + @SerialName("genre_state") val genreState: GenreState, + @SerialName("genres") val genres: List<Genre>?, + @SerialName("genres_observed_at") val genresObservedAt: String? = null, + @SerialName("media_type") val mediaType: MediaType, + @SerialName("provider_state") val providerState: ProviderState, + @SerialName("providers") val providers: List<Provider>?, + @SerialName("providers_observed_at") val providersObservedAt: String? = null, + @SerialName("region") val region: Region, + @SerialName("tmdb_id") val tmdbId: Long, +) { + /** Wire values for genre_state. */ + @Serializable + enum class GenreState { + @SerialName("unobserved") UNOBSERVED, + @SerialName("observed") OBSERVED, + @SerialName("missing") MISSING, + @SerialName("unavailable") UNAVAILABLE, + } + + /** Wire values for media_type. */ + @Serializable + enum class MediaType { + @SerialName("movie") MOVIE, + @SerialName("tv") TV, + } + + /** Wire values for provider_state. */ + @Serializable + enum class ProviderState { + @SerialName("unobserved") UNOBSERVED, + @SerialName("observed") OBSERVED, + @SerialName("missing") MISSING, + @SerialName("unavailable") UNAVAILABLE, + } + + /** Wire values for region. */ + @Serializable + enum class Region { + @SerialName("NL") NL, + } +} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/TitleRatings.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/TitleRatings.kt new file mode 100644 index 00000000..fa754de0 --- /dev/null +++ b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/TitleRatings.kt @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.conductarr.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** TitleRatings wire contract from the pinned Conductarr schema. */ +@Serializable +data class TitleRatings( + @SerialName("ratings") val ratings: List<Rating>?, +) diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/TitleRequestsBody.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/TitleRequestsBody.kt new file mode 100644 index 00000000..ffdba7f6 --- /dev/null +++ b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/TitleRequestsBody.kt @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.conductarr.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** TitleRequestsBody wire contract from the pinned Conductarr schema. */ +@Serializable +data class TitleRequestsBody( + @SerialName("requests") val requests: List<RequestReceipt>?, +) diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/TitleVersion.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/TitleVersion.kt new file mode 100644 index 00000000..de1c7feb --- /dev/null +++ b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/TitleVersion.kt @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.conductarr.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** TitleVersion wire contract from the pinned Conductarr schema. */ +@Serializable +data class TitleVersion( + @SerialName("file") val file: FileDetail? = null, + @SerialName("role") val role: Role, + @SerialName("seasons") val seasons: List<SeasonGroup>? = null, + @SerialName("source") val source: String, +) { + /** Wire values for role. */ + @Serializable + enum class Role { + @SerialName("primary") PRIMARY, + @SerialName("mirror") MIRROR, + } +} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/ViewerReadiness.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/ViewerReadiness.kt new file mode 100644 index 00000000..5828e51f --- /dev/null +++ b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/ViewerReadiness.kt @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.conductarr.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** ViewerReadiness wire contract from the pinned Conductarr schema. */ +@Serializable +data class ViewerReadiness( + @SerialName("blocking_stage") val blockingStage: BlockingStage? = null, + @SerialName("coverage") val coverage: List<SeasonCoverage>?, + @SerialName("media_type") val mediaType: MediaType, + @SerialName("open_link") val openLink: OpenLink, + @SerialName("seerr") val seerr: SeerrObservation, + @SerialName("sources") val sources: List<EvidenceSource>?, + @SerialName("stages") val stages: List<ReadinessStage>?, + @SerialName("title") val title: String? = null, + @SerialName("tmdb_id") val tmdbId: Long, + @SerialName("viewer") val viewer: Person, +) { + /** Wire values for blocking_stage. */ + @Serializable + enum class BlockingStage { + @SerialName("request") REQUEST, + @SerialName("acquisition") ACQUISITION, + @SerialName("visibility") VISIBILITY, + @SerialName("playback") PLAYBACK, + } + + /** Wire values for media_type. */ + @Serializable + enum class MediaType { + @SerialName("movie") MOVIE, + @SerialName("tv") TV, + } +} diff --git a/conductarr-client/src/test/kotlin/org/justinvisser/wolfskin/conductarr/CacheTest.kt b/conductarr-client/src/test/kotlin/org/justinvisser/wolfskin/conductarr/CacheTest.kt new file mode 100644 index 00000000..4e51ba98 --- /dev/null +++ b/conductarr-client/src/test/kotlin/org/justinvisser/wolfskin/conductarr/CacheTest.kt @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.conductarr + +import java.nio.file.Files +import org.junit.Assert.* +import org.junit.Test + +/** Lifetime boundaries and storage behavior do not depend on wall-clock sleeps. */ +class CacheTest { + @Test fun `per endpoint lifetime boundaries preserve expired LKG for failure fallback`() { + val entry = CacheEntry("body", "tag", 1_000, 1_000, expiresAtMillis = 5_000, group = "group") + val rows = listOf( + Triple(CachePolicy.DAILY, ReadTrigger.IF_STALE, 86_400_000L), + Triple(CachePolicy.DISCOVERY, ReadTrigger.IF_STALE, 1_800_000L), + Triple(CachePolicy.RATINGS, ReadTrigger.IF_STALE, 21_600_000L), + Triple(CachePolicy.SEASON, ReadTrigger.IF_STALE, 21_600_000L), + Triple(CachePolicy.ACTIVITY, ReadTrigger.POLL, 30_000L), + ) + for ((policy, trigger, lifetime) in rows) { + assertFalse(policy.name, policy.shouldRefresh(trigger, entry, 1_000 + lifetime - 1)) + assertTrue(policy.name, policy.shouldRefresh(trigger, entry, 1_000 + lifetime)) + } + assertFalse(CachePolicy.FEED.shouldRefresh(ReadTrigger.IF_STALE, entry, 4_999)) + assertTrue(CachePolicy.FEED.shouldRefresh(ReadTrigger.IF_STALE, entry, 5_000)) + assertFalse(CachePolicy.LAUNCH.shouldRefresh(ReadTrigger.IF_STALE, entry, Long.MAX_VALUE)) + assertTrue(CachePolicy.LAUNCH.shouldRefresh(ReadTrigger.LAUNCH, entry, 1_001)) + assertTrue(CachePolicy.FOREGROUND.shouldRefresh(ReadTrigger.FOREGROUND, entry, 1_001)) + assertTrue(CachePolicy.ON_OPEN.shouldRefresh(ReadTrigger.OPEN, entry, 1_001)) + assertTrue(CachePolicy.SEASON.shouldRefresh(ReadTrigger.OPEN, entry, 1_001)) + assertFalse(CachePolicy.DAILY.shouldRefresh(ReadTrigger.CACHE_ONLY, null, Long.MAX_VALUE)) + } + + @Test fun `disk and memory retain newest group entries while isolating other identities and media`() { + val directory = Files.createTempDirectory("conductarr-capacity-").toFile() + try { + for (store in listOf(MemoryCacheStore(), FileCacheStore(directory))) { + for (index in 0..300) { + store.write("person-a", "movies:$index", CacheEntry("$index", null, index.toLong(), index.toLong(), group = "movies"), 300) + } + store.write("person-a", "tv:1", CacheEntry("tv", null, 0, 0, group = "tv"), 300) + store.write("person-b", "movies:0", CacheEntry("other", null, 0, 0, group = "movies"), 300) + assertNull(store.read("person-a", "movies:0")) + assertEquals("300", store.read("person-a", "movies:300")!!.body) + assertEquals("tv", store.read("person-a", "tv:1")!!.body) + store.clear("person-a") + assertNull(store.read("person-a", "tv:1")) + assertEquals("other", store.read("person-b", "movies:0")!!.body) + } + } finally { directory.deleteRecursively() } + } + + @Test fun `corrupt saved entries become a cache miss rather than blocking recovery`() { + val directory = Files.createTempDirectory("conductarr-corrupt-").toFile() + try { + val store = FileCacheStore(directory) + store.write("person", "profiles:1", CacheEntry("valid", null, 0, 0, group = "profiles")) + directory.walkTopDown().first { it.extension == "json" }.writeText("truncated{") + assertNull(FileCacheStore(directory).read("person", "profiles:1")) + val repaired = CacheEntry("new", null, 1, 1, group = "profiles") + store.write("person", "profiles:1", repaired) + assertEquals(repaired, FileCacheStore(directory).read("person", "profiles:1")) + } finally { directory.deleteRecursively() } + } +} diff --git a/conductarr-client/src/test/kotlin/org/justinvisser/wolfskin/conductarr/ClientSmoke.kt b/conductarr-client/src/test/kotlin/org/justinvisser/wolfskin/conductarr/ClientSmoke.kt new file mode 100644 index 00000000..98173fce --- /dev/null +++ b/conductarr-client/src/test/kotlin/org/justinvisser/wolfskin/conductarr/ClientSmoke.kt @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.conductarr + +import java.nio.file.Files +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.runBlocking +import mockwebserver3.MockResponse +import mockwebserver3.MockWebServer + +/** Exercise the production facade with a real local socket and a reopened disk cache. */ +fun main() = runBlocking { + val directory = Files.createTempDirectory("conductarr-smoke-").toFile() + try { + MockWebServer().use { server -> + server.start() + val baseUrl = server.url("/").toString() + server.enqueue(MockResponse.Builder().body(PROFILES_JSON).addHeader("ETag", "\"smoke\"").build()) + ConductarrClient(baseUrl, TEST_TOKEN, FileCacheStore(directory)).use { client -> + val live = client.read(TvRoutes.profiles()).toList().single() as ReadResult.Value + check(live.saved.value!!.profiles!!.single().name == "Justin") + } + ConductarrClient(baseUrl, TEST_TOKEN, FileCacheStore(directory)).use { client -> + val saved = client.read(TvRoutes.profiles(), ReadTrigger.CACHE_ONLY).first() as ReadResult.Value + check(saved.saved.fromDisk) + server.enqueue(MockResponse.Builder().code(304).build()) + val revalidated = client.read(TvRoutes.profiles(), ReadTrigger.LAUNCH).toList().last() as ReadResult.Value + check(revalidated.saved.value == saved.saved.value) + server.enqueue(MockResponse.Builder().code(503).body("offline").build()) + val failure = client.read(TvRoutes.profiles(), ReadTrigger.LAUNCH).toList().last() as ReadResult.Failure + check(failure.error == ClientError.Http(503)) + check(failure.saved!!.value!!.defaultProfile == "justin") + println("SMOKE: live profile Justin -> disk reopen -> ETag 304 -> HTTP 503 retains Justin; requests=${server.requestCount}") + } + } + } finally { directory.deleteRecursively() } +} diff --git a/conductarr-client/src/test/kotlin/org/justinvisser/wolfskin/conductarr/ClientTest.kt b/conductarr-client/src/test/kotlin/org/justinvisser/wolfskin/conductarr/ClientTest.kt new file mode 100644 index 00000000..937543a9 --- /dev/null +++ b/conductarr-client/src/test/kotlin/org/justinvisser/wolfskin/conductarr/ClientTest.kt @@ -0,0 +1,213 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.conductarr + +import java.io.File +import java.nio.file.Files +import java.util.logging.Handler +import java.util.logging.LogRecord +import java.util.logging.Logger +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.runBlocking +import mockwebserver3.MockResponse +import mockwebserver3.MockWebServer +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.justinvisser.wolfskin.conductarr.model.* + +internal const val TEST_TOKEN = "0123456789abcdef0123456789abcdef" +internal const val PROFILES_JSON = """{"profiles":[{"key":"justin","name":"Justin"}],"default_profile":"justin"}""" + +/** Local network tests exercise identity, conditional reads and durable failure recovery. */ +class ClientTest { + private lateinit var server: MockWebServer + private val clients = mutableListOf<ConductarrClient>() + private lateinit var directory: File + private var now = 1_700_000_000_000L + + @Before fun setUp() { + server = MockWebServer() + server.start() + directory = Files.createTempDirectory("conductarr-test-").toFile() + } + + @After fun tearDown() { + clients.forEach { it.close() } + server.close() + directory.deleteRecursively() + } + + @Test fun `401 means sign in clears saved identity and never returns stale fallback`() = runBlocking { + val cache = FileCacheStore(directory) + val client = client(cache) + server.enqueue(response(PROFILES_JSON)) + client.read(TvRoutes.profiles()).toList() + server.enqueue(response("unauthorized", 401)) + val results = client.read(TvRoutes.profiles(), ReadTrigger.LAUNCH).toList() + assertEquals("Justin", (results.first() as ReadResult.Value).saved.value!!.profiles!!.first().name) + val failure = results.last() as ReadResult.Failure + assertEquals(ClientError.NeedsSignIn, failure.error) + assertNull(failure.saved) + assertEquals(listOf(ReadResult.Failure<RecommendationProfiles>(ClientError.NeedsSignIn)), client.read(TvRoutes.profiles()).toList()) + server.enqueue(response("unauthorized", 401)) + val reopened = client(cache).read(TvRoutes.profiles()).toList() + assertEquals(1, reopened.size) + assertEquals(ClientError.NeedsSignIn, (reopened.single() as ReadResult.Failure).error) + assertEquals(3, server.requestCount) + } + + @Test fun `ETag 304 reuses body across disk reopen without changing saved time`() = runBlocking { + val first = client(FileCacheStore(directory)) + server.enqueue(MockResponse.Builder().body(PROFILES_JSON).addHeader("ETag", "\"profiles-v1\"").build()) + val original = (first.read(TvRoutes.profiles()).toList().single() as ReadResult.Value).saved + val firstRequest = server.takeRequest() + assertEquals("MediaBrowser Token=\"$TEST_TOKEN\"", firstRequest.headers["Authorization"]) + assertEquals("/api/v1/recommendations/profiles", firstRequest.url.encodedPath) + now += 60_000 + val reopened = client(FileCacheStore(directory)) + val painted = reopened.read(TvRoutes.profiles(), ReadTrigger.LAUNCH).first() as ReadResult.Value + assertEquals(original.value, painted.saved.value) + assertTrue(painted.saved.fromDisk) + assertEquals(1, server.requestCount) + server.enqueue(response("", 304)) + val results = reopened.read(TvRoutes.profiles(), ReadTrigger.LAUNCH).toList() + assertEquals(2, results.size) + val validated = (results.last() as ReadResult.Value).saved + assertEquals(original.value, validated.value) + assertEquals(original.savedAtMillis, validated.savedAtMillis) + assertFalse(validated.fromDisk) + assertEquals("\"profiles-v1\"", server.takeRequest().headers["If-None-Match"]) + } + + @Test fun `HTTP and invalid JSON failures preserve last known good on disk`() = runBlocking { + val cache = FileCacheStore(directory) + server.enqueue(response(PROFILES_JSON)) + client(cache).read(TvRoutes.profiles()).toList() + for ((body, status, expected) in listOf( + Triple("down", 503, ClientError.Http(503)), + Triple("{bad json", 200, ClientError.InvalidResponse), + )) { + server.enqueue(response(body, status)) + val result = client(FileCacheStore(directory)).read(TvRoutes.profiles(), ReadTrigger.LAUNCH).toList().last() as ReadResult.Failure + assertEquals(expected, result.error) + assertEquals("justin", result.saved!!.value!!.defaultProfile) + } + val saved = client(FileCacheStore(directory)).read(TvRoutes.profiles(), ReadTrigger.CACHE_ONLY).first() as ReadResult.Value + assertEquals("Justin", saved.saved.value!!.profiles!!.single().name) + } + + @Test fun `network failure retains LKG and maps to unreachable`() = runBlocking { + val client = client() + server.enqueue(response(PROFILES_JSON)) + client.read(TvRoutes.profiles()).toList() + server.close() + val failure = client.read(TvRoutes.profiles(), ReadTrigger.LAUNCH).toList().last() as ReadResult.Failure + assertEquals(ClientError.Unreachable, failure.error) + assertEquals("justin", failure.saved!!.value!!.defaultProfile) + } + + @Test fun `identity partitions saved data by token and base URL`() = runBlocking { + val cache = FileCacheStore(directory) + server.enqueue(response(PROFILES_JSON)) + client(cache).read(TvRoutes.profiles()).toList() + val switched = client(cache, "fedcba9876543210fedcba9876543210") + assertTrue(switched.read(TvRoutes.profiles(), ReadTrigger.CACHE_ONLY).toList().isEmpty()) + val otherServer = ConductarrClient(server.url("/other/").toString(), TEST_TOKEN, cache).also { clients.add(it) } + assertTrue(otherServer.read(TvRoutes.profiles(), ReadTrigger.CACHE_ONLY).toList().isEmpty()) + assertFalse(directory.walkTopDown().filter { it.isFile }.any { it.readText().contains(TEST_TOKEN) || it.path.contains(TEST_TOKEN) }) + } + + @Test fun `ratings 404 is cached as none for six hours but server failure is not cached`() = runBlocking { + val client = client() + val call = TvRoutes.ratings(42, MediaType.MOVIE) + server.enqueue(response("missing", 404)) + assertTrue((client.read(call).toList().single() as ReadResult.Value).saved.absent) + now += 6 * 60 * 60 * 1000L - 1 + assertTrue((client.read(call).toList().single() as ReadResult.Value).saved.absent) + assertEquals(1, server.requestCount) + now++ + server.enqueue(response("down", 503)) + assertEquals(ClientError.Http(503), (client.read(call).toList().last() as ReadResult.Failure).error) + server.enqueue(response("missing", 404)) + client.read(call).toList() + assertEquals(3, server.requestCount) + } + + @Test fun `mutation is sent once without caller controlled actor or automatic replay`() = runBlocking { + val client = client() + val body = RequestIntentBody(mediaType = RequestIntentBody.MediaType.MOVIE, tmdbId = 42, requester = "mindel") + server.enqueue(response("uncertain", 503)) + assertEquals(MutationResult.Failure(ClientError.Http(503)), client.mutate(TvRoutes.request(body))) + assertEquals(1, server.requestCount) + val request = server.takeRequest() + assertEquals("POST", request.method) + val sent = WireJson.parseToJsonElement(request.body!!.utf8()).toString() + assertTrue(sent.contains("\"requester\":\"mindel\"")) + assertFalse(sent.contains("actor")) + assertFalse(sent.contains("seasons")) + assertTrue(client.read(TvRoutes.requests(42, MediaType.MOVIE), ReadTrigger.CACHE_ONLY).toList().isEmpty()) + } + + @Test fun `diagnostics and errors never log token or hostile response body`() = runBlocking { + val events = mutableListOf<ClientEvent>() + val logged = StringBuilder() + val handler = object : Handler() { + override fun publish(record: LogRecord) { logged.append(record.message).append(record.thrown?.toString().orEmpty()) } + override fun flush() = Unit + override fun close() = Unit + } + val logger = Logger.getLogger("") + logger.addHandler(handler) + try { + val client = ConductarrClient(server.url("/").toString(), TEST_TOKEN, MemoryCacheStore(), onEvent = { events.add(it) }).also { clients.add(it) } + server.enqueue(response("hostile response $TEST_TOKEN", 500)) + val error = client.read(TvRoutes.profiles()).toList().single() as ReadResult.Failure + assertEquals(ClientError.Http(500), error.error) + server.enqueue(response("$TEST_TOKEN", 401)) + val unauthorized = client.read(TvRoutes.profiles()).toList().single() as ReadResult.Failure + assertEquals(ClientError.NeedsSignIn, unauthorized.error) + assertEquals(listOf(ClientEvent.HTTP_FAILURE, ClientEvent.NEEDS_SIGN_IN), events) + assertFalse((logged.toString() + events + error + unauthorized).contains(TEST_TOKEN)) + } finally { logger.removeHandler(handler) } + } + + @Test fun `feed honours server expiry and encodes profile as one path segment`() = runBlocking { + val client = client() + val expiry = java.time.Instant.ofEpochMilli(now + 10_000).toString() + val feed = """{"profile":{"key":"justin","name":"Justin"},"projection":{"coverage":{"complete":true,"excluded":0,"matched":0,"total":0,"unresolved":0},"facts":null,"genre_id":0,"media_type":"all","service_id":0,"unresolved":null},"state":"current","outcome":"empty","materialization":"current","movies":null,"shows":null,"expires_at":"$expiry"}""" + server.enqueue(MockResponse.Builder().body(feed).addHeader("ETag", "\"feed-v1\"").build()) + val call = TvRoutes.feed("justin/a b") + client.read(call).toList() + now += 9_999 + assertEquals(RecommendationFeed.Outcome.EMPTY, (client.read(call).toList().single() as ReadResult.Value).saved.value!!.outcome) + assertEquals(1, server.requestCount) + val request = server.takeRequest() + assertEquals("/api/v1/recommendations/profiles/justin%2Fa%20b/feed", request.url.encodedPath) + now++ + server.enqueue(response("", 304)) + val result = client.read(call).toList().last() as ReadResult.Value + assertEquals(RecommendationFeed.Outcome.EMPTY, result.saved.value!!.outcome) + assertEquals("\"feed-v1\"", server.takeRequest().headers["If-None-Match"]) + } + + @Test fun `successful request returns typed receipt and title requests never use cache`() = runBlocking { + val receipt = """{"actor":{"name":"Justin","requester":"justin","role":"household"},"attempts":1,"coverage":null,"created_at":"2026-11-05T12:00:00Z","id":7,"media_type":"movie","membership":{"status":"added"},"on_behalf":true,"requester":"mindel","requester_username":"Mindel","seasons":null,"seerr":{"outcome":"requested","request_id":9},"source":"title","state":"settled","tmdb_id":42,"updated_at":"2026-11-05T12:00:00Z"}""" + val client = client() + server.enqueue(response(receipt)) + val result = client.mutate(TvRoutes.request(RequestIntentBody(mediaType = RequestIntentBody.MediaType.MOVIE, tmdbId = 42, requester = "mindel"))) as MutationResult.Success + assertEquals(7L, result.value.id) + assertEquals("justin", result.value.actor.requester) + assertTrue(result.value.onBehalf) + server.enqueue(response("""{"requests":[$receipt]}""")) + val receipts = client.read(TvRoutes.requests(42, MediaType.MOVIE)).toList().single() as ReadResult.Value + assertEquals(7L, receipts.saved.value!!.requests!!.single().id) + assertTrue(client.read(TvRoutes.requests(42, MediaType.MOVIE), ReadTrigger.CACHE_ONLY).toList().isEmpty()) + } + + private fun client(cache: CacheStore = MemoryCacheStore(), token: String = TEST_TOKEN): ConductarrClient = + ConductarrClient(server.url("/").toString(), token, cache, nowMillis = { now }).also { clients.add(it) } + + private fun response(body: String, status: Int = 200): MockResponse = MockResponse.Builder().code(status).body(body).build() +} diff --git a/conductarr-client/src/test/kotlin/org/justinvisser/wolfskin/conductarr/ContractTest.kt b/conductarr-client/src/test/kotlin/org/justinvisser/wolfskin/conductarr/ContractTest.kt new file mode 100644 index 00000000..fcebb09f --- /dev/null +++ b/conductarr-client/src/test/kotlin/org/justinvisser/wolfskin/conductarr/ContractTest.kt @@ -0,0 +1,145 @@ +// SPDX-License-Identifier: GPL-2.0-only +@file:OptIn(kotlinx.serialization.ExperimentalSerializationApi::class) +package org.justinvisser.wolfskin.conductarr + +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.SerialKind +import kotlinx.serialization.descriptors.StructureKind +import kotlinx.serialization.json.* +import org.junit.Assert.* +import org.junit.Test +import org.justinvisser.wolfskin.conductarr.model.ErrorDetail + +/** Schema structure, constraints and route coverage are independent of runtime transport tests. */ +class ContractTest { + private val document = resource("openapi.json") + private val schemas = document["components"]!!.jsonObject["schemas"]!!.jsonObject + private val serializers = modelSerializers.associateBy { it.descriptor.serialName.substringAfterLast('.') } + + @Test fun `every maintained model matches pinned schema fields types requiredness and enums`() { + for ((name, serializer) in serializers) { + compare(serializer.descriptor, schemas.getValue(name).jsonObject, name) + } + } + + @Test fun `all reviewed schema constraints fail on drift including formats limits and defaults`() { + val reviewed = resource("model-contracts.json") + assertEquals(serializers.keys, reviewed.keys) + for (name in reviewed.keys) assertEquals("Schema constraint drift: $name", reviewed[name], schemas[name]) + } + + @Test fun `all twenty two TV operations exist with their typed request and response contracts`() { + val expected = setOf( + "GET /api/v1/operator", "GET /api/v1/recommendations/profiles", + "GET /api/v1/recommendations/profiles/{profile}/feed", + "GET /api/v1/recommendations/profiles/{profile}/dismissals", + "POST /api/v1/recommendations/profiles/{profile}/dismissals", + "DELETE /api/v1/recommendations/profiles/{profile}/dismissals/{type}/{tmdb}", + "GET /catalogue/options", "GET /catalogue/discover", + "GET /catalogue/titles/{tmdb}/{type}", "GET /catalogue/titles/{tmdb}/{type}/cast", + "GET /catalogue/titles/{tmdb}/{type}/related", "GET /catalogue/titles/{tmdb}/{type}/enrichment", + "GET /catalogue/titles/{tmdb}/{type}/ratings", "GET /catalogue/titles/{tmdb}/{type}/display", + "GET /catalogue/tv/{tmdb}/seasons/{season}", "GET /catalogue/performers/{performer}", + "GET /catalogue/performers/{performer}/filmography", + "GET /api/v1/activity", "GET /api/v1/activity/titles/{tmdb}/{type}", + "GET /api/v1/requests/titles/{tmdb}/{type}", "POST /api/v1/requests", + "GET /curation/library-audiences", + ) + assertEquals(expected, TvRoutes.contracts.map { "${it.method} ${it.path}" }.toSet()) + assertEquals(expected.size, TvRoutes.contracts.size) + val operations = setOf( + "get-cockpit-operator", "get-recommendation-profiles", "get-library-audiences", + "get-recommendation-feed", "get-catalogue-options", "get-catalogue-discover", + "get-recommendation-dismissals", "post-recommendation-dismissal", "delete-recommendation-dismissal", + "get-catalogue-title", "get-catalogue-cast", "get-catalogue-related", "get-catalogue-season", + "get-catalogue-enrichment", "get-catalogue-display", "get-activity-title", "get-title-requests", + "get-catalogue-performer", "get-catalogue-filmography", "post-request-intent", "get-activity", + "get-catalogue-ratings", + ) + assertEquals(operations, TvRoutes.contracts.map { route -> + document.getValue("paths").jsonObject.getValue(route.path).jsonObject + .getValue(route.method.lowercase()).jsonObject.getValue("operationId").jsonPrimitive.content + }.toSet()) + val paths = document["paths"]!!.jsonObject + for (route in TvRoutes.contracts) { + val operation = paths.getValue(route.path).jsonObject.getValue(route.method.lowercase()).jsonObject + val response = operation["responses"]!!.jsonObject["200"]!!.jsonObject + assertEquals(route.responseSchema, schemaReference(response)) + assertTrue("Missing response model ${route.responseSchema}", serializers.containsKey(route.responseSchema)) + if (route.requestSchema != null) { + assertEquals(route.requestSchema, schemaReference(operation["requestBody"]!!.jsonObject)) + assertTrue(serializers.containsKey(route.requestSchema)) + } + } + } + + @Test fun `unconstrained error values preserve objects arrays scalars and null`() { + for (value in listOf("{\"nested\":[1,null,true]}", "[1,\"x\"]", "42", "true", "null")) { + val detail = WireJson.decodeFromString<ErrorDetail>("{\"value\":$value}") + assertEquals(Json.parseToJsonElement(value), detail.value ?: JsonNull) + } + } + + private fun compare(descriptor: SerialDescriptor, schema: JsonObject, location: String) { + schema["\$ref"]?.let { + val name = it.jsonPrimitive.content.substringAfterLast('/') + assertEquals(location, name, descriptor.serialName.substringAfterLast('.').removeSuffix("?")) + assertTrue("Unregistered referenced model $name", serializers.containsKey(name)) + return + } + if (schema["type"] == null) { + assertTrue(location, descriptor.serialName.startsWith("kotlinx.serialization.json.JsonElement")) + return + } + schema["enum"]?.let { values -> + assertEquals(location, SerialKind.ENUM, descriptor.kind) + assertEquals(location, values.jsonArray.map { it.jsonPrimitive.content }.toSet(), + (0 until descriptor.elementsCount).map { descriptor.getElementName(it) }.toSet()) + return + } + val types = when (val type = schema["type"]!!) { + is JsonArray -> type.map { it.jsonPrimitive.content } + else -> listOf(type.jsonPrimitive.content) + } + when (types.first { it != "null" }) { + "string" -> assertEquals(location, PrimitiveKind.STRING, descriptor.kind) + "integer" -> assertEquals(location, PrimitiveKind.LONG, descriptor.kind) + "number" -> assertEquals(location, PrimitiveKind.DOUBLE, descriptor.kind) + "boolean" -> assertEquals(location, PrimitiveKind.BOOLEAN, descriptor.kind) + "array" -> { + assertEquals(location, StructureKind.LIST, descriptor.kind) + compare(descriptor.getElementDescriptor(0), schema["items"]!!.jsonObject, "$location[]") + } + "object" -> if (schema["additionalProperties"] is JsonObject) { + assertEquals(location, StructureKind.MAP, descriptor.kind) + assertEquals(location, PrimitiveKind.STRING, descriptor.getElementDescriptor(0).kind) + compare(descriptor.getElementDescriptor(1), schema["additionalProperties"]!!.jsonObject, "$location{}") + } else compareFields(descriptor, schema, location) + else -> error("Unhandled schema type at $location") + } + } + + private fun compareFields(descriptor: SerialDescriptor, schema: JsonObject, location: String) { + assertEquals(location, StructureKind.CLASS, descriptor.kind) + val properties = schema["properties"]!!.jsonObject.filterValues { it.jsonObject["readOnly"] != JsonPrimitive(true) } + val required = schema["required"]?.jsonArray?.map { it.jsonPrimitive.content }?.toSet().orEmpty() + assertEquals(location, properties.keys, (0 until descriptor.elementsCount).map { descriptor.getElementName(it) }.toSet()) + for ((name, property) in properties) { + val index = descriptor.getElementIndex(name) + val field = descriptor.getElementDescriptor(index) + assertEquals("Requiredness drift: $location.$name", name !in required, descriptor.isElementOptional(index)) + val type = property.jsonObject["type"] + if (type is JsonArray) assertTrue("Nullable collection drift: $location.$name", field.isNullable) + else if (name in required) assertFalse("Nullability drift: $location.$name", field.isNullable) + compare(field, property.jsonObject, "$location.$name") + } + } + + private fun schemaReference(container: JsonObject): String = container.getValue("content").jsonObject + .getValue("application/json").jsonObject.getValue("schema").jsonObject.getValue("\$ref").jsonPrimitive.content.substringAfterLast('/') + + private fun resource(name: String): JsonObject = javaClass.classLoader.getResourceAsStream(name)!!.use { + Json.parseToJsonElement(it.bufferedReader().readText()).jsonObject + } +} diff --git a/conductarr-client/src/test/kotlin/org/justinvisser/wolfskin/conductarr/ModelContracts.kt b/conductarr-client/src/test/kotlin/org/justinvisser/wolfskin/conductarr/ModelContracts.kt new file mode 100644 index 00000000..a7384768 --- /dev/null +++ b/conductarr-client/src/test/kotlin/org/justinvisser/wolfskin/conductarr/ModelContracts.kt @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.conductarr + +import kotlinx.serialization.KSerializer +import org.justinvisser.wolfskin.conductarr.model.* + +/** Independently enumerated fallback models checked against the vendored schemas. */ +internal val modelSerializers: List<KSerializer<*>> = listOf( + ActingCredit.serializer(), + ActivityFeed.serializer(), + Actor.serializer(), + Candidate.serializer(), + CastMember.serializer(), + Detail.serializer(), + Discovery.serializer(), + Display.serializer(), + Enrichment.serializer(), + Episode.serializer(), + EpisodeFileChip.serializer(), + EpisodeRow.serializer(), + EpisodeView.serializer(), + ErrorDetail.serializer(), + ErrorModel.serializer(), + EvidenceSource.serializer(), + FileDetail.serializer(), + FilmographyPage.serializer(), + FilmographyTitle.serializer(), + Genre.serializer(), + HealthDetail.serializer(), + HeldTitle.serializer(), + ItemDetail.serializer(), + LibraryAudience.serializer(), + LibraryAudiencesBody.serializer(), + LibraryObservation.serializer(), + OpenLink.serializer(), + Operator.serializer(), + OperatorCapabilities.serializer(), + Option.serializer(), + Options.serializer(), + Page.serializer(), + PerformerDetail.serializer(), + Person.serializer(), + Provider.serializer(), + Rating.serializer(), + ReadProfile.serializer(), + ReadinessStage.serializer(), + RecommendationCoverage.serializer(), + RecommendationDismissal.serializer(), + RecommendationDismissals.serializer(), + RecommendationFact.serializer(), + RecommendationFeed.serializer(), + RecommendationHideInputBody.serializer(), + RecommendationHideResult.serializer(), + RecommendationProfiles.serializer(), + RecommendationProjection.serializer(), + RecommendationRestoreResult.serializer(), + RequestIntentBody.serializer(), + RequestProgress.serializer(), + RequestReceipt.serializer(), + RequestReceiptActor.serializer(), + RequestReceiptCoverage.serializer(), + RequestReceiptMembership.serializer(), + RequestReceiptSeerr.serializer(), + Season.serializer(), + SeasonCoverage.serializer(), + SeasonDetail.serializer(), + SeasonGroup.serializer(), + SeedContribution.serializer(), + SeerrObservation.serializer(), + SeerrRequest.serializer(), + SourceDetail.serializer(), + Title.serializer(), + TitleCast.serializer(), + TitleMetadata.serializer(), + TitleRequestsBody.serializer(), + TitleRatings.serializer(), + TitleVersion.serializer(), + ViewerReadiness.serializer(), +) diff --git a/conductarr-client/src/test/resources/model-contracts.json b/conductarr-client/src/test/resources/model-contracts.json new file mode 100644 index 00000000..41a0b411 --- /dev/null +++ b/conductarr-client/src/test/resources/model-contracts.json @@ -0,0 +1,3255 @@ +{ + "ActingCredit": { + "additionalProperties": false, + "properties": { + "character": { + "type": "string" + }, + "credit_id": { + "type": "string" + }, + "episode_count": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "credit_id", + "character" + ], + "type": "object" + }, + "ActivityFeed": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/ActivityFeed.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "items": { + "items": { + "$ref": "#/components/schemas/RequestProgress" + }, + "type": [ + "array", + "null" + ] + }, + "requester": { + "description": "Household key the feed is attributed to, or all for the household", + "type": "string" + }, + "sources": { + "items": { + "$ref": "#/components/schemas/EvidenceSource" + }, + "type": [ + "array", + "null" + ] + }, + "truncated": { + "description": "More recent requests exist than the window returned", + "type": "boolean" + } + }, + "required": [ + "requester", + "items", + "truncated", + "sources" + ], + "type": "object" + }, + "Actor": { + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + }, + "requester": { + "description": "The acting operator's own household requester key", + "type": "string" + }, + "role": { + "enum": [ + "admin", + "household" + ], + "type": "string" + } + }, + "required": [ + "name", + "role", + "requester" + ], + "type": "object" + }, + "Candidate": { + "additionalProperties": false, + "properties": { + "backdrop_path": { + "description": "TMDB-relative landscape artwork path; absent when none is observed", + "type": "string" + }, + "contributions": { + "items": { + "$ref": "#/components/schemas/SeedContribution" + }, + "type": [ + "array", + "null" + ] + }, + "genre_ids": { + "description": "Observed candidate genre IDs; absence does not prove no genres", + "items": { + "format": "int64", + "type": "integer" + }, + "type": [ + "array", + "null" + ] + }, + "media_type": { + "enum": [ + "movie", + "tv" + ], + "type": "string" + }, + "overview": { + "type": "string" + }, + "poster_path": { + "type": "string" + }, + "score": { + "format": "double", + "type": "number" + }, + "title": { + "type": "string" + }, + "tmdb_id": { + "format": "int64", + "type": "integer" + }, + "tvdb_id": { + "format": "int64", + "type": "integer" + }, + "year": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "tvdb_id", + "title", + "overview", + "poster_path", + "year", + "score", + "contributions", + "media_type", + "tmdb_id" + ], + "type": "object" + }, + "CastMember": { + "additionalProperties": false, + "properties": { + "character": { + "type": "string" + }, + "credit_id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "order": { + "format": "int64", + "type": "integer" + }, + "performer_id": { + "format": "int64", + "type": "integer" + }, + "profile_url": { + "type": "string" + } + }, + "required": [ + "performer_id", + "name", + "character", + "credit_id", + "order" + ], + "type": "object" + }, + "Detail": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/Detail.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "genres": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "held_title": { + "$ref": "#/components/schemas/HeldTitle" + }, + "metadata": { + "$ref": "#/components/schemas/TitleMetadata" + }, + "metadata_state": { + "enum": [ + "observed", + "missing", + "unavailable" + ], + "type": "string" + }, + "observations": { + "items": { + "$ref": "#/components/schemas/LibraryObservation" + }, + "type": [ + "array", + "null" + ] + }, + "ratings": { + "items": { + "$ref": "#/components/schemas/Rating" + }, + "type": [ + "array", + "null" + ] + }, + "runtime": { + "format": "int64", + "type": "integer" + }, + "seasons": { + "items": { + "$ref": "#/components/schemas/Season" + }, + "type": [ + "array", + "null" + ] + }, + "state": { + "enum": [ + "observed", + "partial", + "unavailable" + ], + "type": "string" + }, + "title": { + "$ref": "#/components/schemas/Title" + } + }, + "required": [ + "state", + "title", + "metadata_state", + "genres", + "seasons", + "metadata", + "observations" + ], + "type": "object" + }, + "Discovery": { + "additionalProperties": false, + "properties": { + "genre_id": { + "format": "int64", + "type": "integer" + }, + "next": { + "description": "Same selected query's next public page, bounded at page 500; absence alone does not prove catalogue exhaustion", + "type": "string" + }, + "region": { + "description": "Comma-separated selected service regions; NL for unfiltered discovery", + "type": "string" + }, + "service_id": { + "format": "int64", + "type": "integer" + }, + "sort": { + "enum": [ + "trending", + "popular", + "new", + "top", + "upcoming" + ], + "type": "string" + }, + "state": { + "description": "Empty is an answered zero-match lane; exhausted is an empty page beyond upstream total_pages; partial cannot prove absence", + "enum": [ + "observed", + "empty", + "partial", + "exhausted" + ], + "type": "string" + } + }, + "required": [ + "genre_id", + "service_id", + "sort", + "region", + "state" + ], + "type": "object" + }, + "Enrichment": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/Enrichment.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "genres": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "metadata": { + "$ref": "#/components/schemas/TitleMetadata" + }, + "metadata_state": { + "enum": [ + "observed", + "missing", + "unavailable" + ], + "type": "string" + }, + "observations": { + "items": { + "$ref": "#/components/schemas/LibraryObservation" + }, + "type": [ + "array", + "null" + ] + }, + "ratings": { + "items": { + "$ref": "#/components/schemas/Rating" + }, + "type": [ + "array", + "null" + ] + }, + "runtime": { + "format": "int64", + "type": "integer" + }, + "seasons": { + "items": { + "$ref": "#/components/schemas/Season" + }, + "type": [ + "array", + "null" + ] + }, + "state": { + "enum": [ + "observed", + "unavailable" + ], + "type": "string" + }, + "title": { + "$ref": "#/components/schemas/Title" + } + }, + "required": [ + "state", + "title", + "metadata_state", + "genres", + "seasons", + "metadata", + "observations", + "ratings" + ], + "type": "object" + }, + "Episode": { + "additionalProperties": false, + "properties": { + "air_date": { + "type": "string" + }, + "name": { + "type": "string" + }, + "number": { + "format": "int64", + "type": "integer" + }, + "overview": { + "type": "string" + }, + "recorded": { + "enum": [ + "recorded", + "missing", + "unknown" + ], + "type": "string" + }, + "vote_average": { + "description": "Provider episode score on a 0\u201310 scale; absence is not zero.", + "format": "double", + "type": "number" + }, + "vote_count": { + "description": "Provider episode vote count; explicit zero is retained.", + "format": "int64", + "type": "integer" + } + }, + "required": [ + "number", + "name", + "overview", + "recorded" + ], + "type": "object" + }, + "EpisodeFileChip": { + "additionalProperties": false, + "properties": { + "quality": { + "type": "string" + }, + "release_name": { + "type": "string" + }, + "resolution": { + "type": "string" + }, + "size_bytes": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "size_bytes" + ], + "type": "object" + }, + "EpisodeRow": { + "additionalProperties": false, + "properties": { + "air_date": { + "type": "string" + }, + "episode": { + "format": "int64", + "type": "integer" + }, + "file": { + "$ref": "#/components/schemas/EpisodeFileChip" + }, + "has_file": { + "type": "boolean" + }, + "key": { + "description": "SxxEyy", + "type": "string" + }, + "monitored": { + "type": "boolean" + }, + "queue_state": { + "description": "Arr queue status for this exact episode; omitted when no matching queue row was observed", + "type": "string" + }, + "runtime_minutes": { + "description": "Sonarr episode runtime in minutes; omitted when Sonarr reports none", + "format": "int64", + "type": "integer" + }, + "season": { + "format": "int64", + "type": "integer" + }, + "title": { + "type": "string" + }, + "watched": { + "additionalProperties": { + "type": "boolean" + }, + "type": "object" + } + }, + "required": [ + "season", + "episode", + "key", + "monitored", + "has_file", + "watched" + ], + "type": "object" + }, + "EpisodeView": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/EpisodeView.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "owners": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "seasons": { + "items": { + "$ref": "#/components/schemas/SeasonGroup" + }, + "type": [ + "array", + "null" + ] + }, + "title": { + "type": "string" + }, + "tmdb_id": { + "format": "int64", + "type": "integer" + }, + "users": { + "description": "jellyfin usernames whose sees: matrix covers this item (watch-chip columns)", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + } + }, + "required": [ + "tmdb_id", + "title", + "owners", + "users", + "seasons" + ], + "type": "object" + }, + "ErrorDetail": { + "additionalProperties": false, + "properties": { + "location": { + "description": "Where the error occurred, e.g. 'body.items[3].tags' or 'path.thing-id'", + "type": "string" + }, + "message": { + "description": "Error message text", + "type": "string" + }, + "value": { + "description": "The value at the given location" + } + }, + "type": "object" + }, + "ErrorModel": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/ErrorModel.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "detail": { + "description": "A human-readable explanation specific to this occurrence of the problem.", + "examples": [ + "Property foo is required but is missing." + ], + "type": "string" + }, + "errors": { + "description": "Optional list of individual error details", + "items": { + "$ref": "#/components/schemas/ErrorDetail" + }, + "type": [ + "array", + "null" + ] + }, + "instance": { + "description": "A URI reference that identifies the specific occurrence of the problem.", + "examples": [ + "https://example.com/error-log/abc123" + ], + "format": "uri", + "type": "string" + }, + "status": { + "description": "HTTP status code", + "examples": [ + 400 + ], + "format": "int64", + "type": "integer" + }, + "title": { + "description": "A short, human-readable summary of the problem type. This value should not change between occurrences of the error.", + "examples": [ + "Bad Request" + ], + "type": "string" + }, + "type": { + "default": "about:blank", + "description": "A URI reference to human-readable documentation for the error.", + "examples": [ + "https://example.com/errors/example" + ], + "format": "uri", + "type": "string" + } + }, + "type": "object" + }, + "EvidenceSource": { + "additionalProperties": false, + "properties": { + "detail": { + "type": "string" + }, + "name": { + "type": "string" + }, + "state": { + "enum": [ + "observed", + "unavailable" + ], + "type": "string" + } + }, + "required": [ + "name", + "state" + ], + "type": "object" + }, + "FileDetail": { + "additionalProperties": false, + "properties": { + "audio": { + "type": "string" + }, + "container": { + "type": "string" + }, + "quality": { + "type": "string" + }, + "release_name": { + "type": "string" + }, + "resolution": { + "type": "string" + }, + "size_bytes": { + "format": "int64", + "type": "integer" + }, + "video": { + "type": "string" + } + }, + "required": [ + "size_bytes" + ], + "type": "object" + }, + "FilmographyPage": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/FilmographyPage.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "completeness": { + "description": "Seerr filters adult credits and excludes crew here; not an exhaustive career", + "enum": [ + "returned-non-adult-cast" + ], + "type": "string" + }, + "items": { + "items": { + "$ref": "#/components/schemas/FilmographyTitle" + }, + "type": [ + "array", + "null" + ] + }, + "metadata_state": { + "enum": [ + "observed", + "missing", + "unavailable" + ], + "type": "string" + }, + "observations": { + "items": { + "$ref": "#/components/schemas/LibraryObservation" + }, + "type": [ + "array", + "null" + ] + }, + "page": { + "format": "int64", + "type": "integer" + }, + "page_size": { + "format": "int64", + "type": "integer" + }, + "performer_id": { + "format": "int64", + "type": "integer" + }, + "source": { + "enum": [ + "seerr" + ], + "type": "string" + }, + "source_paging": { + "enum": [ + "unpaged" + ], + "type": "string" + }, + "total_pages": { + "description": "Native pages of grouped typed titles; absent when source is unanswered", + "format": "int64", + "type": "integer" + }, + "total_results": { + "description": "Unique typed titles in this returned source set; absent when source is unanswered", + "format": "int64", + "type": "integer" + } + }, + "required": [ + "performer_id", + "metadata_state", + "source", + "source_paging", + "completeness", + "page", + "page_size", + "items", + "observations" + ], + "type": "object" + }, + "FilmographyTitle": { + "additionalProperties": false, + "properties": { + "credits": { + "items": { + "$ref": "#/components/schemas/ActingCredit" + }, + "type": [ + "array", + "null" + ] + }, + "title": { + "$ref": "#/components/schemas/Title" + }, + "users": { + "description": "Configured viewers when visibility_state is configured; otherwise unknown", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "visibility_state": { + "description": "Configured library audience only, never observed Jellyfin visibility or playback", + "enum": [ + "configured", + "unknown" + ], + "type": "string" + } + }, + "required": [ + "title", + "credits", + "visibility_state", + "users" + ], + "type": "object" + }, + "Genre": { + "additionalProperties": false, + "properties": { + "id": { + "format": "int64", + "type": "integer" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ], + "type": "object" + }, + "HealthDetail": { + "additionalProperties": false, + "properties": { + "failed": { + "type": "boolean" + }, + "has_file": { + "type": "boolean" + }, + "in_queue": { + "type": "boolean" + }, + "missing": { + "type": "boolean" + } + }, + "required": [ + "has_file", + "in_queue", + "failed", + "missing" + ], + "type": "object" + }, + "HeldTitle": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/HeldTitle.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "detail": { + "$ref": "#/components/schemas/ItemDetail" + }, + "episodes": { + "$ref": "#/components/schemas/EpisodeView" + }, + "libraries": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "observations": { + "items": { + "$ref": "#/components/schemas/LibraryObservation" + }, + "type": [ + "array", + "null" + ] + }, + "state": { + "enum": [ + "observed", + "partial" + ], + "type": "string" + }, + "versions": { + "items": { + "$ref": "#/components/schemas/TitleVersion" + }, + "type": [ + "array", + "null" + ] + }, + "watched": { + "additionalProperties": { + "type": "boolean" + }, + "type": "object" + } + }, + "required": [ + "state", + "detail", + "libraries", + "versions", + "observations", + "watched" + ], + "type": "object" + }, + "ItemDetail": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/ItemDetail.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "backdrop_ref": { + "description": "Public provider landscape artwork from the holding Arr", + "type": "string" + }, + "file": { + "$ref": "#/components/schemas/FileDetail" + }, + "health": { + "$ref": "#/components/schemas/HealthDetail" + }, + "is_anime": { + "type": "boolean" + }, + "media_type": { + "type": "string" + }, + "owners": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "poster_ref": { + "description": "Public provider poster artwork from the holding Arr", + "type": "string" + }, + "runtime": { + "description": "minutes", + "format": "int64", + "type": "integer" + }, + "source": { + "$ref": "#/components/schemas/SourceDetail" + }, + "title": { + "type": "string" + }, + "tmdb_id": { + "format": "int64", + "type": "integer" + }, + "users": { + "description": "jellyfin usernames whose sees: matrix covers this item (movie watch-chip columns)", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "year": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "tmdb_id", + "media_type", + "title", + "owners", + "is_anime", + "source", + "health", + "users" + ], + "type": "object" + }, + "LibraryAudience": { + "additionalProperties": false, + "properties": { + "is_anime": { + "type": "boolean" + }, + "libraries": { + "description": "Enabled configured household libraries covering this membership scope", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "media_type": { + "enum": [ + "movie", + "tv" + ], + "type": "string" + }, + "owner": { + "description": "Named household membership used by the existing add, move and remove interface", + "type": "string" + }, + "users": { + "description": "Enabled Jellyfin usernames whose configured sees covers this scope, not watch-write authority", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + } + }, + "required": [ + "owner", + "media_type", + "is_anime", + "libraries", + "users" + ], + "type": "object" + }, + "LibraryAudiencesBody": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/LibraryAudiencesBody.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "audiences": { + "items": { + "$ref": "#/components/schemas/LibraryAudience" + }, + "type": [ + "array", + "null" + ] + } + }, + "required": [ + "audiences" + ], + "type": "object" + }, + "LibraryObservation": { + "additionalProperties": false, + "properties": { + "detail": { + "type": "string" + }, + "observed_at": { + "format": "date-time", + "type": "string" + }, + "source": { + "type": "string" + }, + "state": { + "enum": [ + "observed", + "partial", + "unavailable" + ], + "type": "string" + } + }, + "required": [ + "source", + "state" + ], + "type": "object" + }, + "OpenLink": { + "additionalProperties": false, + "properties": { + "detail": { + "type": "string" + }, + "state": { + "enum": [ + "observed", + "missing", + "unavailable", + "unconfigured", + "not_permitted" + ], + "type": "string" + }, + "url": { + "description": "The viewer's own Jellyfin item page; the person signs in to Jellyfin as themself", + "type": "string" + } + }, + "required": [ + "state" + ], + "type": "object" + }, + "Operator": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/Operator.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "capabilities": { + "$ref": "#/components/schemas/OperatorCapabilities" + }, + "name": { + "type": "string" + }, + "recommendation_profile": { + "type": "string" + }, + "requester": { + "type": "string" + }, + "role": { + "enum": [ + "admin", + "household" + ], + "type": "string" + }, + "subject": { + "type": "string" + }, + "watch_user": { + "type": "string" + } + }, + "required": [ + "subject", + "name", + "role", + "requester", + "watch_user", + "recommendation_profile", + "capabilities" + ], + "type": "object" + }, + "OperatorCapabilities": { + "additionalProperties": false, + "properties": { + "administer": { + "type": "boolean" + }, + "manage_library": { + "type": "boolean" + }, + "manage_memberships": { + "type": "boolean" + }, + "recommendation_profiles": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "request_media": { + "type": "boolean" + }, + "requesters": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "watch_users": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + } + }, + "required": [ + "administer", + "manage_library", + "manage_memberships", + "request_media", + "watch_users", + "recommendation_profiles", + "requesters" + ], + "type": "object" + }, + "Option": { + "additionalProperties": false, + "properties": { + "id": { + "format": "int64", + "type": "integer" + }, + "logo_url": { + "description": "Validated TMDB CDN logo for a watch provider; absent for genres and when upstream has no safe logo", + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ], + "type": "object" + }, + "Options": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/Options.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "genres": { + "items": { + "$ref": "#/components/schemas/Option" + }, + "type": [ + "array", + "null" + ] + }, + "media_type": { + "enum": [ + "movie", + "tv" + ], + "type": "string" + }, + "observations": { + "items": { + "$ref": "#/components/schemas/LibraryObservation" + }, + "type": [ + "array", + "null" + ] + }, + "region": { + "description": "Comma-separated configured catalogue regions", + "type": "string" + }, + "services": { + "description": "Curated streaming providers observed in their configured regions, not title availability or playback evidence", + "items": { + "$ref": "#/components/schemas/Option" + }, + "type": [ + "array", + "null" + ] + }, + "state": { + "enum": [ + "observed", + "partial", + "unavailable" + ], + "type": "string" + } + }, + "required": [ + "state", + "media_type", + "region", + "genres", + "services", + "observations" + ], + "type": "object" + }, + "Page": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/Page.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "discovery": { + "$ref": "#/components/schemas/Discovery", + "description": "General discovery context and upstream page observation, independent of local holding freshness" + }, + "items": { + "items": { + "$ref": "#/components/schemas/Title" + }, + "type": [ + "array", + "null" + ] + }, + "media_type": { + "enum": [ + "all", + "movie", + "tv" + ], + "type": "string" + }, + "observations": { + "items": { + "$ref": "#/components/schemas/LibraryObservation" + }, + "type": [ + "array", + "null" + ] + }, + "page": { + "format": "int64", + "type": "integer" + }, + "query": { + "type": "string" + }, + "source": { + "enum": [ + "search", + "general", + "related" + ], + "type": "string" + }, + "state": { + "enum": [ + "observed", + "partial" + ], + "type": "string" + }, + "total_pages": { + "description": "Known pages; merged regional service lanes expose only the materialized prefix until exhaustion", + "format": "int64", + "type": "integer" + }, + "total_results": { + "description": "Upstream matches before type filtering, or the known deduplicated prefix for merged regional service lanes", + "format": "int64", + "type": "integer" + } + }, + "required": [ + "state", + "source", + "query", + "media_type", + "page", + "total_pages", + "total_results", + "items", + "observations" + ], + "type": "object" + }, + "PerformerDetail": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/PerformerDetail.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "also_known_as": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "biography": { + "type": "string" + }, + "birthday": { + "type": "string" + }, + "deathday": { + "type": "string" + }, + "known_for_department": { + "type": "string" + }, + "metadata_state": { + "enum": [ + "observed", + "missing", + "unavailable" + ], + "type": "string" + }, + "name": { + "type": "string" + }, + "performer_id": { + "format": "int64", + "type": "integer" + }, + "place_of_birth": { + "type": "string" + }, + "profile_url": { + "type": "string" + }, + "source": { + "enum": [ + "seerr" + ], + "type": "string" + } + }, + "required": [ + "performer_id", + "metadata_state", + "source" + ], + "type": "object" + }, + "Person": { + "additionalProperties": false, + "properties": { + "key": { + "type": "string" + }, + "name": { + "description": "Jellyfin username the household member is known by", + "type": "string" + } + }, + "required": [ + "key", + "name" + ], + "type": "object" + }, + "Provider": { + "additionalProperties": false, + "properties": { + "id": { + "format": "int64", + "type": "integer" + }, + "logo_url": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ], + "type": "object" + }, + "Rating": { + "additionalProperties": false, + "properties": { + "score": { + "description": "RT is 0\u2013100; IMDb and TMDB are 0\u201310. Absent is never zero.", + "format": "double", + "type": "number" + }, + "source": { + "enum": [ + "rt_critics", + "rt_audience", + "imdb", + "tmdb" + ], + "type": "string" + }, + "state": { + "enum": [ + "observed", + "unrated", + "missing", + "unavailable" + ], + "type": "string" + }, + "status": { + "description": "Observed RT critics or audience label, never inferred from a percentage.", + "type": "string" + }, + "url": { + "type": "string" + }, + "votes": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "source", + "state" + ], + "type": "object" + }, + "ReadProfile": { + "additionalProperties": false, + "properties": { + "key": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "key", + "name" + ], + "type": "object" + }, + "ReadinessStage": { + "additionalProperties": false, + "properties": { + "name": { + "enum": [ + "request", + "acquisition", + "visibility", + "playback" + ], + "type": "string" + }, + "observed_at": { + "description": "When the evidence was last observed (RFC 3339 UTC), when the owner records it", + "type": "string" + }, + "source": { + "description": "The evidence owner this stage reads; it never stands in for another stage", + "type": "string" + }, + "state": { + "enum": [ + "done", + "partial", + "in_progress", + "none", + "unknown", + "stale", + "conflicting", + "failed" + ], + "type": "string" + }, + "summary": { + "description": "What this stage's own evidence says, including why it is unknown, stale, conflicting or failed", + "type": "string" + } + }, + "required": [ + "name", + "state", + "summary", + "source" + ], + "type": "object" + }, + "RecommendationCoverage": { + "additionalProperties": false, + "properties": { + "complete": { + "description": "Successful ranked snapshot and all active predicates observed for its scoped candidates; not freshness, catalogue completeness or playback proof", + "type": "boolean" + }, + "excluded": { + "format": "int64", + "type": "integer" + }, + "matched": { + "format": "int64", + "type": "integer" + }, + "total": { + "format": "int64", + "type": "integer" + }, + "unresolved": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "total", + "matched", + "excluded", + "unresolved", + "complete" + ], + "type": "object" + }, + "RecommendationDismissal": { + "additionalProperties": false, + "properties": { + "hidden_at": { + "format": "date-time", + "type": "string" + }, + "hidden_by": { + "description": "Household requester key of the operator who hid it", + "type": "string" + }, + "media_type": { + "enum": [ + "movie", + "tv" + ], + "type": "string" + }, + "poster_path": { + "type": "string" + }, + "title": { + "description": "Display name captured when hidden; empty when it was unknown", + "type": "string" + }, + "tmdb_id": { + "format": "int64", + "type": "integer" + }, + "year": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "media_type", + "tmdb_id", + "title", + "year", + "poster_path", + "hidden_by", + "hidden_at" + ], + "type": "object" + }, + "RecommendationDismissals": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/RecommendationDismissals.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "dismissals": { + "items": { + "$ref": "#/components/schemas/RecommendationDismissal" + }, + "type": [ + "array", + "null" + ] + }, + "profile": { + "type": "string" + } + }, + "required": [ + "profile", + "dismissals" + ], + "type": "object" + }, + "RecommendationFact": { + "additionalProperties": false, + "properties": { + "genre_source": { + "description": "Ranked genre context takes precedence; metadata is a fallback only when ranked genres are unknown", + "enum": [ + "ranked", + "metadata", + "unknown" + ], + "type": "string" + }, + "match": { + "enum": [ + "matched", + "excluded", + "unresolved" + ], + "type": "string" + }, + "metadata": { + "$ref": "#/components/schemas/TitleMetadata" + } + }, + "required": [ + "metadata", + "match", + "genre_source" + ], + "type": "object" + }, + "RecommendationFeed": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/RecommendationFeed.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "attempted_at": { + "format": "date-time", + "type": "string" + }, + "expires_at": { + "description": "observed_at plus the configured feed interval; expiry only, not a scheduled refresh or retry", + "format": "date-time", + "type": "string" + }, + "failure": { + "enum": [ + "profile_resolution", + "memberships", + "history", + "candidates", + "cycle" + ], + "type": "string" + }, + "materialization": { + "enum": [ + "unknown", + "pending", + "current", + "failed" + ], + "type": "string" + }, + "movies": { + "items": { + "$ref": "#/components/schemas/Candidate" + }, + "type": [ + "array", + "null" + ] + }, + "observed_at": { + "format": "date-time", + "type": "string" + }, + "outcome": { + "enum": [ + "unknown", + "ranked", + "no_seeds", + "empty" + ], + "type": "string" + }, + "profile": { + "$ref": "#/components/schemas/ReadProfile" + }, + "projection": { + "$ref": "#/components/schemas/RecommendationProjection" + }, + "shows": { + "items": { + "$ref": "#/components/schemas/Candidate" + }, + "type": [ + "array", + "null" + ] + }, + "state": { + "enum": [ + "current", + "stale", + "unavailable" + ], + "type": "string" + } + }, + "required": [ + "projection", + "profile", + "state", + "outcome", + "materialization", + "movies", + "shows" + ], + "type": "object" + }, + "RecommendationHideInputBody": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/RecommendationHideInputBody.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "media_type": { + "enum": [ + "movie", + "tv" + ], + "type": "string" + }, + "poster_path": { + "maxLength": 200, + "type": "string" + }, + "title": { + "description": "Display hint, used only when the retained feed no longer carries the title", + "maxLength": 300, + "type": "string" + }, + "tmdb_id": { + "format": "int64", + "minimum": 1, + "type": "integer" + }, + "year": { + "format": "int64", + "maximum": 9999, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "media_type", + "tmdb_id" + ], + "type": "object" + }, + "RecommendationHideResult": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/RecommendationHideResult.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "created": { + "description": "False when the title was already hidden", + "type": "boolean" + }, + "dismissal": { + "$ref": "#/components/schemas/RecommendationDismissal" + }, + "materialization": { + "description": "Jellyfin recommendation folders after the hide; failed leaves the dismissal in force until the next cycle converges them", + "enum": [ + "current", + "failed", + "unchanged" + ], + "type": "string" + }, + "profile": { + "type": "string" + } + }, + "required": [ + "profile", + "dismissal", + "created", + "materialization" + ], + "type": "object" + }, + "RecommendationProfiles": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/RecommendationProfiles.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "default_profile": { + "type": "string" + }, + "profiles": { + "items": { + "$ref": "#/components/schemas/ReadProfile" + }, + "type": [ + "array", + "null" + ] + } + }, + "required": [ + "profiles", + "default_profile" + ], + "type": "object" + }, + "RecommendationProjection": { + "additionalProperties": false, + "properties": { + "coverage": { + "$ref": "#/components/schemas/RecommendationCoverage" + }, + "facts": { + "description": "Scoped candidates in original movie rank followed by original series rank; unknown observations never prove exclusion", + "items": { + "$ref": "#/components/schemas/RecommendationFact" + }, + "type": [ + "array", + "null" + ] + }, + "genre_id": { + "format": "int64", + "type": "integer" + }, + "media_type": { + "enum": [ + "all", + "movie", + "tv" + ], + "type": "string" + }, + "service_id": { + "format": "int64", + "type": "integer" + }, + "unresolved": { + "description": "Possible matches with unknown active predicates, in original per-media relative order", + "items": { + "$ref": "#/components/schemas/Candidate" + }, + "type": [ + "array", + "null" + ] + } + }, + "required": [ + "media_type", + "genre_id", + "service_id", + "coverage", + "facts", + "unresolved" + ], + "type": "object" + }, + "RecommendationRestoreResult": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/RecommendationRestoreResult.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "media_type": { + "enum": [ + "movie", + "tv" + ], + "type": "string" + }, + "profile": { + "type": "string" + }, + "restored": { + "description": "False when the title was not hidden", + "type": "boolean" + }, + "tmdb_id": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "profile", + "media_type", + "tmdb_id", + "restored" + ], + "type": "object" + }, + "RequestIntentBody": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/RequestIntentBody.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "media_type": { + "enum": [ + "movie", + "tv" + ], + "type": "string" + }, + "recommendation_profile": { + "type": "string" + }, + "requester": { + "description": "Household requester key to attribute the request to; defaults to the signed-in operator's own requester and must be one of the operator's capability requesters", + "type": "string" + }, + "seasons": { + "description": "Television only: the explicit seasons wanted (0 is specials). An empty selection is refused, never read as all seasons. Omit for a movie", + "items": { + "format": "int64", + "type": "integer" + }, + "type": [ + "array", + "null" + ] + }, + "source": { + "default": "title", + "description": "Recommendation requires recommendation_profile and settles that profile's own acceptance only when it is also the requester", + "enum": [ + "title", + "recommendation" + ], + "type": "string" + }, + "tmdb_id": { + "format": "int64", + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "media_type", + "tmdb_id" + ], + "type": "object" + }, + "RequestProgress": { + "additionalProperties": false, + "properties": { + "actor": { + "$ref": "#/components/schemas/Actor", + "description": "Who filed it, as recorded; absent when the request was filed outside the cockpit" + }, + "awaiting_approval": { + "description": "The request is filed in Seerr and nothing has been dispatched, imported or observed yet, so no acquisition has started. Absent otherwise", + "type": "boolean" + }, + "blocking_stage": { + "description": "First stage without done evidence. Absent only when every stage has it. Not a readiness claim", + "enum": [ + "request", + "acquisition", + "visibility", + "playback" + ], + "type": "string" + }, + "filed_by_you": { + "description": "The signed-in operator filed this command", + "type": "boolean" + }, + "id": { + "description": "request-N for a cockpit command, dispatch-N for a request Seerr approved outside the cockpit", + "type": "string" + }, + "media_type": { + "enum": [ + "movie", + "tv" + ], + "type": "string" + }, + "on_behalf": { + "description": "The actor's own requester differs from the attributed requester", + "type": "boolean" + }, + "requested_at": { + "type": "string" + }, + "requester": { + "$ref": "#/components/schemas/Person", + "description": "The household member the request is attributed to; this decides which feed the item belongs to" + }, + "seasons": { + "description": "Television seasons explicitly requested; empty for a movie or when not recorded", + "items": { + "format": "int64", + "type": "integer" + }, + "type": [ + "array", + "null" + ] + }, + "stages": { + "items": { + "$ref": "#/components/schemas/ReadinessStage" + }, + "type": [ + "array", + "null" + ] + }, + "title": { + "description": "Known from the dispatch log or library membership; empty when no local record names the title yet", + "type": "string" + }, + "tmdb_id": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "id", + "tmdb_id", + "media_type", + "requester", + "on_behalf", + "filed_by_you", + "seasons", + "stages" + ], + "type": "object" + }, + "RequestReceipt": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/RequestReceipt.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "actor": { + "$ref": "#/components/schemas/RequestReceiptActor", + "description": "Authenticated operator who issued the command; separate from the requester" + }, + "attempts": { + "format": "int64", + "type": "integer" + }, + "coverage": { + "description": "One outcome per selected season, never inferred from other seasons", + "items": { + "$ref": "#/components/schemas/RequestReceiptCoverage" + }, + "type": [ + "array", + "null" + ] + }, + "created_at": { + "type": "string" + }, + "detail": { + "description": "Why the command is pending or what was refused", + "type": "string" + }, + "id": { + "description": "Native request record; retrying the same command resumes this record", + "format": "int64", + "type": "integer" + }, + "media_type": { + "enum": [ + "movie", + "tv" + ], + "type": "string" + }, + "membership": { + "$ref": "#/components/schemas/RequestReceiptMembership" + }, + "on_behalf": { + "description": "The requester differs from the acting operator's own household requester", + "type": "boolean" + }, + "recommendation_profile": { + "type": "string" + }, + "requester": { + "description": "Household requester the request is attributed to", + "type": "string" + }, + "requester_username": { + "type": "string" + }, + "seasons": { + "description": "Explicit television seasons the operator asked for; empty for a movie", + "items": { + "format": "int64", + "type": "integer" + }, + "type": [ + "array", + "null" + ] + }, + "seerr": { + "$ref": "#/components/schemas/RequestReceiptSeerr" + }, + "source": { + "enum": [ + "title", + "recommendation" + ], + "type": "string" + }, + "state": { + "description": "Settled: every part has a verified outcome. Pending: a fact is unresolved. Retired: an administrator ended a stale-identity pending command without cancelling any Seerr request or reversing media effects", + "enum": [ + "settled", + "pending", + "retired" + ], + "type": "string" + }, + "tmdb_id": { + "format": "int64", + "type": "integer" + }, + "updated_at": { + "type": "string" + } + }, + "required": [ + "id", + "media_type", + "tmdb_id", + "state", + "requester", + "requester_username", + "actor", + "on_behalf", + "source", + "seasons", + "membership", + "seerr", + "coverage", + "attempts", + "created_at", + "updated_at" + ], + "type": "object" + }, + "RequestReceiptActor": { + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + }, + "requester": { + "description": "The acting operator's own household requester key", + "type": "string" + }, + "role": { + "enum": [ + "admin", + "household" + ], + "type": "string" + } + }, + "required": [ + "name", + "role", + "requester" + ], + "type": "object" + }, + "RequestReceiptCoverage": { + "additionalProperties": false, + "properties": { + "outcome": { + "enum": [ + "requested", + "already_requested", + "already_held", + "unresolved" + ], + "type": "string" + }, + "request_id": { + "format": "int64", + "type": "integer" + }, + "season": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "season", + "outcome" + ], + "type": "object" + }, + "RequestReceiptMembership": { + "additionalProperties": false, + "properties": { + "bucket": { + "description": "Library route the membership landed in", + "type": "string" + }, + "dispatch_log_id": { + "format": "int64", + "type": "integer" + }, + "status": { + "enum": [ + "added", + "already_owned", + "pending_dispatch", + "conflict", + "failed" + ], + "type": "string" + } + }, + "required": [ + "status" + ], + "type": "object" + }, + "RequestReceiptSeerr": { + "additionalProperties": false, + "properties": { + "outcome": { + "enum": [ + "requested", + "already_requested", + "not_needed", + "not_attempted", + "unresolved" + ], + "type": "string" + }, + "request_id": { + "description": "Seerr request attributed to the requester that covers this command", + "format": "int64", + "type": "integer" + } + }, + "required": [ + "outcome" + ], + "type": "object" + }, + "Season": { + "additionalProperties": false, + "properties": { + "air_date": { + "type": "string" + }, + "coverage": { + "enum": [ + "none", + "partial", + "complete", + "unknown" + ], + "type": "string" + }, + "episode_count": { + "format": "int64", + "type": "integer" + }, + "name": { + "type": "string" + }, + "number": { + "format": "int64", + "type": "integer" + }, + "overview": { + "type": "string" + }, + "recorded_episodes": { + "format": "int64", + "type": "integer" + }, + "request_state": { + "enum": [ + "none", + "requested", + "unknown" + ], + "type": "string" + }, + "seerr_state": { + "enum": [ + "untracked", + "unknown", + "pending", + "processing", + "partial", + "available", + "blocklisted", + "deleted" + ], + "type": "string" + } + }, + "required": [ + "number", + "name", + "overview", + "episode_count", + "recorded_episodes", + "coverage", + "request_state", + "seerr_state" + ], + "type": "object" + }, + "SeasonCoverage": { + "additionalProperties": false, + "properties": { + "coverage": { + "enum": [ + "complete", + "partial", + "none", + "unknown" + ], + "type": "string" + }, + "season": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "season", + "coverage" + ], + "type": "object" + }, + "SeasonDetail": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/SeasonDetail.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "episodes": { + "items": { + "$ref": "#/components/schemas/Episode" + }, + "type": [ + "array", + "null" + ] + }, + "media_type": { + "enum": [ + "tv" + ], + "type": "string" + }, + "metadata_state": { + "enum": [ + "observed", + "missing", + "unavailable" + ], + "type": "string" + }, + "observations": { + "items": { + "$ref": "#/components/schemas/LibraryObservation" + }, + "type": [ + "array", + "null" + ] + }, + "season": { + "$ref": "#/components/schemas/Season" + }, + "tmdb_id": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "tmdb_id", + "media_type", + "metadata_state", + "season", + "episodes", + "observations" + ], + "type": "object" + }, + "SeasonGroup": { + "additionalProperties": false, + "properties": { + "episodes": { + "items": { + "$ref": "#/components/schemas/EpisodeRow" + }, + "type": [ + "array", + "null" + ] + }, + "season": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "season", + "episodes" + ], + "type": "object" + }, + "SeedContribution": { + "additionalProperties": false, + "properties": { + "added": { + "description": "A successful attributed addition contributed nonzero seed weight", + "type": "boolean" + }, + "media_type": { + "enum": [ + "movie", + "tv" + ], + "type": "string" + }, + "title": { + "description": "Observed display name; absent when unknown", + "type": "string" + }, + "tmdb_id": { + "format": "int64", + "type": "integer" + }, + "watched": { + "description": "A played history event contributed nonzero seed weight", + "type": "boolean" + } + }, + "required": [ + "watched", + "added", + "media_type", + "tmdb_id" + ], + "type": "object" + }, + "SeerrObservation": { + "additionalProperties": false, + "properties": { + "detail": { + "type": "string" + }, + "requests": { + "items": { + "$ref": "#/components/schemas/SeerrRequest" + }, + "type": [ + "array", + "null" + ] + }, + "state": { + "enum": [ + "observed", + "unavailable" + ], + "type": "string" + } + }, + "required": [ + "state", + "requests" + ], + "type": "object" + }, + "SeerrRequest": { + "additionalProperties": false, + "properties": { + "id": { + "format": "int64", + "type": "integer" + }, + "status": { + "enum": [ + "pending", + "approved", + "declined", + "failed", + "completed", + "unknown" + ], + "type": "string" + } + }, + "required": [ + "id", + "status" + ], + "type": "object" + }, + "SourceDetail": { + "additionalProperties": false, + "properties": { + "arr": { + "type": "string" + }, + "imported": { + "description": "import timestamp (RFC3339)", + "type": "string" + }, + "indexer": { + "type": "string" + } + }, + "required": [ + "arr" + ], + "type": "object" + }, + "Title": { + "additionalProperties": false, + "properties": { + "backdrop_url": { + "description": "Landscape artwork on the TMDB image CDN; absent when none is observed or the upstream path is not a CDN-relative path", + "type": "string" + }, + "genre_ids": { + "description": "TMDB genre IDs in this title's media-type namespace; absent is unobserved, empty is observed without genres", + "items": { + "format": "int64", + "type": "integer" + }, + "type": "array" + }, + "held": { + "enum": [ + "held", + "unheld", + "unknown" + ], + "type": "string" + }, + "libraries": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "media_type": { + "enum": [ + "movie", + "tv" + ], + "type": "string" + }, + "overview": { + "type": "string" + }, + "poster_url": { + "type": "string" + }, + "release_date": { + "type": "string" + }, + "request_state": { + "enum": [ + "none", + "requested", + "unknown" + ], + "type": "string" + }, + "seerr_state": { + "enum": [ + "untracked", + "unknown", + "pending", + "processing", + "partial", + "available", + "blocklisted", + "deleted" + ], + "type": "string" + }, + "title": { + "type": "string" + }, + "tmdb_id": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "tmdb_id", + "media_type", + "title", + "overview", + "held", + "request_state", + "seerr_state", + "libraries" + ], + "type": "object" + }, + "TitleCast": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/TitleCast.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "credit_context": { + "description": "Seerr exposes only each TV performer's most frequent aggregate role, not every role or episode count", + "enum": [ + "movie-character", + "tv-leading-character" + ], + "type": "string" + }, + "items": { + "items": { + "$ref": "#/components/schemas/CastMember" + }, + "type": [ + "array", + "null" + ] + }, + "media_type": { + "enum": [ + "movie", + "tv" + ], + "type": "string" + }, + "metadata_state": { + "enum": [ + "observed", + "missing", + "unavailable" + ], + "type": "string" + }, + "paging": { + "description": "One complete returned Seerr cast list, not invented upstream pages", + "enum": [ + "unpaged" + ], + "type": "string" + }, + "source": { + "enum": [ + "seerr" + ], + "type": "string" + }, + "tmdb_id": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "tmdb_id", + "media_type", + "metadata_state", + "source", + "paging", + "credit_context", + "items" + ], + "type": "object" + }, + "TitleMetadata": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/TitleMetadata.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "checked_at": { + "format": "date-time", + "type": "string" + }, + "genre_state": { + "enum": [ + "unobserved", + "observed", + "missing", + "unavailable" + ], + "type": "string" + }, + "genres": { + "items": { + "$ref": "#/components/schemas/Genre" + }, + "type": [ + "array", + "null" + ] + }, + "genres_observed_at": { + "format": "date-time", + "type": "string" + }, + "media_type": { + "enum": [ + "movie", + "tv" + ], + "type": "string" + }, + "provider_state": { + "enum": [ + "unobserved", + "observed", + "missing", + "unavailable" + ], + "type": "string" + }, + "providers": { + "items": { + "$ref": "#/components/schemas/Provider" + }, + "type": [ + "array", + "null" + ] + }, + "providers_observed_at": { + "format": "date-time", + "type": "string" + }, + "region": { + "enum": [ + "NL" + ], + "type": "string" + }, + "tmdb_id": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "tmdb_id", + "media_type", + "region", + "genre_state", + "provider_state", + "genres", + "providers" + ], + "type": "object" + }, + "TitleRequestsBody": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/TitleRequestsBody.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "requests": { + "items": { + "$ref": "#/components/schemas/RequestReceipt" + }, + "type": [ + "array", + "null" + ] + } + }, + "required": [ + "requests" + ], + "type": "object" + }, + "TitleVersion": { + "additionalProperties": false, + "properties": { + "file": { + "$ref": "#/components/schemas/FileDetail" + }, + "role": { + "enum": [ + "primary", + "mirror" + ], + "type": "string" + }, + "seasons": { + "items": { + "$ref": "#/components/schemas/SeasonGroup" + }, + "type": [ + "array", + "null" + ] + }, + "source": { + "type": "string" + } + }, + "required": [ + "source", + "role" + ], + "type": "object" + }, + "ViewerReadiness": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/ViewerReadiness.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "blocking_stage": { + "enum": [ + "request", + "acquisition", + "visibility", + "playback" + ], + "type": "string" + }, + "coverage": { + "description": "Recorded-file coverage of the requested seasons (or every listed season when none were recorded); empty for a movie", + "items": { + "$ref": "#/components/schemas/SeasonCoverage" + }, + "type": [ + "array", + "null" + ] + }, + "media_type": { + "enum": [ + "movie", + "tv" + ], + "type": "string" + }, + "open_link": { + "$ref": "#/components/schemas/OpenLink" + }, + "seerr": { + "$ref": "#/components/schemas/SeerrObservation" + }, + "sources": { + "items": { + "$ref": "#/components/schemas/EvidenceSource" + }, + "type": [ + "array", + "null" + ] + }, + "stages": { + "items": { + "$ref": "#/components/schemas/ReadinessStage" + }, + "type": [ + "array", + "null" + ] + }, + "title": { + "type": "string" + }, + "tmdb_id": { + "format": "int64", + "type": "integer" + }, + "viewer": { + "$ref": "#/components/schemas/Person" + } + }, + "required": [ + "tmdb_id", + "media_type", + "viewer", + "stages", + "seerr", + "coverage", + "open_link", + "sources" + ], + "type": "object" + }, + "Display": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/Display.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "media_type": { + "enum": [ + "movie", + "tv" + ], + "type": "string" + }, + "metadata_state": { + "enum": [ + "observed", + "missing", + "unavailable" + ], + "type": "string" + }, + "poster_url": { + "type": "string" + }, + "title": { + "type": "string" + }, + "tmdb_id": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "tmdb_id", + "media_type", + "metadata_state" + ], + "type": "object" + }, + "TitleRatings": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/TitleRatings.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "ratings": { + "items": { + "$ref": "#/components/schemas/Rating" + }, + "type": [ + "array", + "null" + ] + } + }, + "required": [ + "ratings" + ], + "type": "object" + } +} diff --git a/data/tv-spec/spec.md b/data/tv-spec/spec.md new file mode 100644 index 00000000..4fb76581 --- /dev/null +++ b/data/tv-spec/spec.md @@ -0,0 +1,61 @@ +# Wolfskin TV specification — revision 5 + +2026-11-06. This feedback amendment applies to the approved revision-4 specification and frozen frames at `data/tv-v2/focus-2026-11-06/` (prototype SHA-256 `1bb7f4097a5303a58e45ec5e61cf867fa6cfdcbfe12fc7a196f00acacae5e48d`). Unchanged geometry, navigation, typography, colours and motion remain governed by those frames and the [written specification](https://pages.justinvisser.org/wolfskin/tv-spec). The amendment's prototype delta is `data/tv-v2/feedback-2026-11-06/`. + +## Who’s watching + +The Settings gear is horizontally centred beneath the complete people row, independent of person count or selection. Justin confirmed this placement. It uses the same 4 px accent straddle ring as the people, with the existing 1.08 scale and focus-only Settings caption. Down still reaches it only from the final person; Up returns to that person. + +## Focus visibility + +“Centered” means consistent visibility, not geometric centering. Keep the approved shelf rule: one-card lookahead, shifting after the second-last visible slot, with first/last positions clamped to the actual contents. Short rows do not manufacture empty scroll space. Poster, tile, wide, service, episode, cast and related shelves follow the same rule. + +The focused card and its ring remain fully inside the viewport, with at least the Level 2 gap (40 px) of horizontal clearance at each screen edge. Focused rows clear the nav band and the bottom edge. Preserve the approved 168 px heading pin and initial fully-visible-row exceptions; hero/header anchors remain zero. First entry, row changes, row ends and Back from a title must reconcile retained logical selection with the actual scroll position. A frame is recorded as positioned only after its scroll finishes. Discover uses the approved six poster slots, not a rounded-down five-slot approximation. + +Library and Person grids retain six columns and the existing Level 2 row spacing. The final Person column keeps the full poster width; horizontal padding must not shrink it. + +## Episode artwork and dimensions + +Episode cards use **400×280 px (200×140 dp)** art: 55 px / 24.4% taller than the previous 400×225 px tile. Other tile and wide-card sizes are unchanged. The episode shelf is `84 + 280 + 68 = 432 px`; its heading band and 68 px clearance before Cast remain Level 2. Cast moves down by the additional card height rather than overlapping it. + +Choose available art in this order: + +1. Episode `Primary` image — Jellyfin stores episode stills under this type. +2. Episode `Thumb` image. +3. Series `Backdrop`, centre-cropped to the episode slot. +4. The existing missing-art treatment when none is available. + +A series-page read supplies its already-read backdrop to the episode projection; it does not make one series request per episode. Saved pages rebuild the same projection from cached metadata. Thumbnail reads and prefetch use the 400×280 px episode request, not the unrelated poster dimensions. + +Read-only Jellyfin evidence: all ten The Bear season-three episodes have Primary tags and no episode Backdrop tags. Tomorrow’s Primary source is 2100×1182; the series Backdrop is 3840×2160. These are existing aired files, not missing/unaired episode placeholders. + +## Episode summaries and series header spacing + +In episode context, the summary belongs to the episode: focused episode first, explicitly opened episode next, otherwise the Up-next episode. Use the show overview only when no episode context is selected. An absent episode overview stays absent instead of substituting the show's description. The focused episode card can show two ellipsised lines of its own overview below its episode label. + +A held series header measures its actual copy, actions and action-caption band, then leaves **40 px** before the Seasons row. Remove the unconditional 900 px series-header spacer. Films, non-held titles and loading/error layouts retain their approved fixed header geometry. Measured series geometry also drives the frame offsets and focus-follow calculation, preventing season/episode rows from scrolling to stale offsets. Long titles or summaries may grow the header; controls must never overlap the seasons. + +## Hero image quality and memory + +| Surface | Requested/decode bound | Format policy | +|---|---|---| +| Home and Discover hero; title backdrop | 1920×1080 | ARGB, 7.91 MiB at the bound | +| Upright hero poster | 420×630 | RGB565 where supported, 0.50 MiB | +| Episode card | 400×280 | ARGB, 0.43 MiB | +| Library poster, ordinary tile/wide, cast | Existing slot dimensions | Existing RGB565/ARGB policy | + +Jellyfin image URLs discard conflicting width/height/max/fill caps and request the consuming slot while preserving image tags, quality and other query parameters. Jellyfin quality remains **96**. TMDB heroes use the original source rather than the previous 780 px source; Coil constrains decoding to the panel. Upright TMDB sheets use a suitable poster tier. Intentionally blurred poster-only washes remain bounded to the upright source, not an oversized portrait bitmap. Decode fitting prevents allocation of an oversized off-ratio source merely to fill a crop. + +The shared memory cache remains **64 MiB**, disk cache **128 MiB**, image concurrency **4**. Three 1080p hero bitmaps consume 23.73 MiB, leaving 40.27 MiB of the cache for other art. These are allocation bounds, not measured TV resident-memory claims; do not request or decode 4K hero bitmaps on the 2 GB armv7 box. + +Read-only source measurement: Desperate Housewives has a 2732×1536 Backdrop. With quality 96, Jellyfin returned a 1281×720 JPEG of 438,329 bytes for the old fill request and a 1921×1080 JPEG of 982,435 bytes for the new request; the extra source pixel is Jellyfin aspect-ratio rounding. The display decode stays bounded to 1920×1080. + +## Discover lane spacing + +Every Discover lane, including Movies for you, Series for you, service entries, service-specific lanes and Hidden picks, uses a **104 px heading band**: heading line plus **60 px heading-to-card gap**, up from 40 px. This is a consistent Level 2 extension, not a Series-only exception. Poster shelves become 652 px high and service-entry shelves 316 px. Captions and frame scroll offsets use the same heading band. Home headings remain unchanged. + +## Sources and verification + +Authoritative changed values live in `wolfskin-design/tokens.json`, `title-tokens.json` and `discover-tokens.json`; regenerate Kotlin, CSS and the drift manifest with `python3 wolfskin-design/generate_tokens.py`. + +The feedback prototype delta links the native 1920×1080 before/after frames. `HomeViewportTest` exercises short and long poster/tile/wide rows, first focus, both row directions and native Back from the shipped title surface. The real-Bear capture harness reads recorded Jellyfin metadata through `TitleReadFacade`; it is a throwaway proof and is not shipped. The final gates are `make test` and `make apk-debug`. No TV access or optimized-APK build is part of this worker's validation. diff --git a/data/tv-v2/feedback-2026-11-06/delta.json b/data/tv-v2/feedback-2026-11-06/delta.json new file mode 100644 index 00000000..5e732f73 --- /dev/null +++ b/data/tv-v2/feedback-2026-11-06/delta.json @@ -0,0 +1,50 @@ +{ + "specRevision": 5, + "base": "data/tv-v2/focus-2026-11-06/index.html", + "baseSha256": "1bb7f4097a5303a58e45ec5e61cf867fa6cfdcbfe12fc7a196f00acacae5e48d", + "stagePx": [1920, 1080], + "density": 2, + "picker": { + "gearAlignment": "center of the complete people row", + "gearRing": "same accent straddle ring as picker people", + "gearFocusScale": 1.08, + "navigation": "unchanged: Down from the final person only" + }, + "focus": { + "horizontalRule": "second-last-slot lookahead; clamp first/last to real contents", + "minimumScreenSideMarginPx": 40, + "headingPinPx": 168, + "geometricCentering": false, + "discoverPosterSlots": 6 + }, + "episode": { + "artPx": [400, 280], + "shelfHeightPx": 432, + "gapBeforeCastPx": 68, + "artFallback": ["episode Primary", "episode Thumb", "series Backdrop crop", "missing art"], + "summaryContext": ["focused episode", "explicitly opened episode", "up-next episode", "show when no episode context"], + "focusedCardSummaryLines": 2 + }, + "seriesHeader": { + "height": "measured copy, actions and caption band plus gap", + "gapBeforeSeasonsPx": 40, + "otherTitleHeaders": "unchanged" + }, + "discover": { + "allLaneHeadingBandPx": 104, + "allLaneHeadingToCardGapPx": 60, + "posterShelfHeightPx": 652, + "serviceShelfHeightPx": 316, + "homeHeadingBand": "unchanged" + }, + "images": { + "heroDecodeBoundPx": [1920, 1080], + "heroPosterDecodeBoundPx": [420, 630], + "jellyfinQuality": 96, + "memoryCacheMiB": 64, + "diskCacheMiB": 128, + "concurrentReads": 4 + }, + "nativeFrames": "https://pages.justinvisser.org/wolfskin/tv-fb1-visual/", + "specification": "../../tv-spec/spec.md" +} diff --git a/data/tv-v2/feedback-2026-11-06/index.html b/data/tv-v2/feedback-2026-11-06/index.html new file mode 100644 index 00000000..7b715570 --- /dev/null +++ b/data/tv-v2/feedback-2026-11-06/index.html @@ -0,0 +1,36 @@ +<!doctype html> +<html lang="en"> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>Wolfskin TV · feedback delta · revision 5 + +

Wolfskin TV · feedback delta

+

Specification revision 5. The approved frames remain the baseline; these native 1920×1080 frames show only the requested feedback changes. This is a frame prototype, not a simulated Jellyfin client.

+ +

Who’s watching

Gear centred beneath everyone, with the person-style focus ring.

+Native picker after: Settings centred below the complete people row +
BeforeNative picker before: gear below the final person
+

Home

Approved edge scrolling retained. Short/long poster, tile and wide rows, row changes and native title Back exercise the same visibility contract.

+Native Home after: focused card safely inside a long shelf +
BeforeNative Home baseline
+
Real-source hero request comparison

Same native renderer and source artwork: old 720p response, then panel-sized response. Intentionally blurred poster washes are unchanged.

Native hero using the old 720p source responseNative hero using the panel-resolution source response
+

Discover

60 px from lane title to cards; all lanes use the same 104 px heading band.

+Native Discover after: more space above every lane's cards +
BeforeNative Discover before: smaller heading gap
+

Series header

Episode-context summary and a measured header instead of the empty fixed-height spacer. Season chips and thumbnail artwork enter the initial view.

+Native Bear series header after: episode overview and visible thumbnail row +
BeforeNative Bear series header before: show overview and empty space before seasons
+

Episode shelf

Real episode Primary artwork, 400×280 px cards and the focused episode's own overview. Cast retains its Level 2 clearance.

+Native Bear episodes after: taller thumbnail cards, focused overview and Cast below +
BeforeNative Bear episodes before: monitor placeholders
+

Person

Six full-width poster columns; no shrinking of the final column.

+Native Person after +
BeforeNative Person before
+ diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 00000000..ce7d039f --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,24 @@ +FROM eclipse-temurin:21.0.11_10-jdk-jammy@sha256:dbfd085220ae632a0830166e443747d1ee89e9038d92e3b48c3e5e9d8292b9a7 + +RUN apt-get update && apt-get install -y --no-install-recommends git unzip \ + && rm -rf /var/lib/apt/lists/* +ENV ANDROID_HOME=/opt/android-sdk ANDROID_SDK_ROOT=/opt/android-sdk +ENV PATH="/opt/android-sdk/cmdline-tools/23.0/bin:/opt/android-sdk/build-tools/36.0.0:${PATH}" +RUN curl -fsSL https://dl.google.com/android/repository/commandlinetools-linux-16111833_latest.zip -o /tmp/tools.zip \ + && echo 'e025545c62a8e64c7559119566a569fb1dec5f60 /tmp/tools.zip' | sha1sum -c - \ + && mkdir -p "$ANDROID_HOME/cmdline-tools" \ + && unzip -q /tmp/tools.zip -d /tmp/android-tools \ + && mv /tmp/android-tools/cmdline-tools "$ANDROID_HOME/cmdline-tools/23.0" \ + && rm -rf /tmp/tools.zip /tmp/android-tools \ + && yes | sdkmanager --licenses >/dev/null \ + && sdkmanager 'platforms;android-37.0' 'build-tools;36.0.0' \ + && while IFS='=' read -r name value; do \ + if [ "$name" = Pkg.Revision ]; then test "$value" = 2 || exit 1; fi; \ + done < "$ANDROID_HOME/platforms/android-37.0/source.properties" \ + && curl -fsSL https://dl.google.com/android/repository/platform-tools_r37.0.1-linux.zip -o /tmp/platform-tools.zip \ + && echo '477254aa5f903c15cf51001717bdf347fb6b53e0 /tmp/platform-tools.zip' | sha1sum -c - \ + && unzip -q /tmp/platform-tools.zip -d "$ANDROID_HOME" \ + && rm /tmp/platform-tools.zip \ + && mkdir -m 1777 /gradle +ENV GRADLE_USER_HOME=/gradle HOME=/tmp +COPY signing.init.gradle /opt/wolfskin/signing.init.gradle diff --git a/docker/README.md b/docker/README.md new file mode 100644 index 00000000..fa36fcc0 --- /dev/null +++ b/docker/README.md @@ -0,0 +1,986 @@ +# 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, `realpath` (GNU coreutils), 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 # app default/debug suite plus wolfskin-design JVM/capture suite +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. + +## Optimized side-by-side comparison + +The `optimized` build type inherits `release`: R8 optimization/minification, +resource shrinking, non-debuggable code, dependency consumer keep rules and +dependency baseline profiles. It changes only signing (the Android debug key), +package (`org.justinvisser.wolfskin.tv.opt`) and launcher label (`Wolfskin (Opt)`). +The launcher icon/banner and release-compatible version name are unchanged: +the shared upgrade/updater parser rejects extra suffixes such as `-opt`. +`make apk` remains the clean, release-key-signed production build. Debug and +optimized containers share the persistent debug key described below; release +signing is unchanged. + +Build all four default-flavor outputs in one invocation through the container +wrapper, without a release keystore. On a host whose Gradle cache already holds +the pinned native extensions and their metadata: + +```sh +bash docker/gradle.sh optimized --offline \ + -PWholphinExtensionsUsername=JustinZeus \ + -PWholphinExtensionsPassword=unused \ + :app:assembleDefaultOptimized +``` + +The unused password is not a credential: offline resolution never contacts +GitHub Packages. A cold cache instead needs a read-packages credential supplied +through the existing `ORG_GRADLE_PROJECT_WholphinExtensionsUsername` and +`ORG_GRADLE_PROJECT_WholphinExtensionsPassword` hooks inside the build container, +or securely seeded cached artifacts. No release signing inputs are needed. +Without native extension configuration, the existing dependency selection uses +the MPV stub and omits ffmpeg/AV1, just like `make apk-debug`; that is not a +playback-complete comparison. Inspect native libraries before installing. + +Outputs are in `app/build/outputs/apk/default/optimized/`: universal, arm64-v8a, +armeabi-v7a and x86_64. Keep +`app/build/outputs/mapping/defaultOptimized/mapping.txt` with those APKs to +deobfuscate crashes. This variant shares release library variants and the release +`debugHomeSnapshot` implementation, not `src/debug` or its preview JSON/artwork. +`BuildConfig.DEBUG` remains false: no preview-fixture setting, debug StrictMode +or Compose diagnostic stack traces. + +This is a fresh app sandbox: sign in with Jellyfin Quick Connect or the normal +server/user flow again; it does not inherit the debug/release app's logins or +preferences. **Wolfskin screens** defaults on, and the same Advanced switch can +restore upstream screens. The same Conductarr door +(`https://wolfskin-tv.justinvisser.org`) and URL override apply. The selected +Jellyfin user's token is borrowed for Conductarr; no app-specific TV credential +or embedded secret is added. All other settings retain release behavior, +including update checks; a production release APK is not an update for this +separate package/debug signer. + +`androidx.profileinstaller` and library-provided profiles already exist. There +is no recorded app baseline-profile source or Macrobenchmark module. +`src/optimized/baselineProfiles/baseline-prof.txt` adds a hand-maintained seed: application and +activity classes plus their `onCreate` methods, `WolfskinAppKt`, +`WolfskinScreenPreference` and `ConductarrPreference`. AGP merges it with library +profiles, rewrites it through R8 and packages `assets/dexopt/baseline.prof` and +`baseline.profm`. It is not a measured navigation profile or evidence of a speedup. +To replace the seed, add an AndroidX Baseline Profile producer/Macrobenchmark +module, record cold launch, Quick Connect/sign-in, person selection, Home, +Libraries, Discover, title pages and playback on a suitable device, then rebuild +and compare startup/frame timings. Device-driven recording and runtime checks +remain separate from an APK build. +The optimized source set explicitly registers that profile directory; a bare +`src/optimized/baseline-prof.txt` is not consumed by this AGP configuration. + +Static verification: use `aapt2 dump badging` for package/label/SDKs and absence +of `application-debuggable`; `apksigner verify --print-certs` for the debug +signer; `unzip -l` for both dexopt assets, native ABI contents and absence of +`assets/wolfskin-*`/`assets/library/` fixtures. Review R8 warnings and effective +consumer rules rather than adding blanket keeps. JVM tests do not prove +minified Hilt/Room/serialization/JNI runtime safety; exercise sign-in, real +Conductarr reads, artwork, database reopen and each player on the comparison box. +`help --task :app:testDefaultOptimizedUnitTest` confirms that this non-debug +variant has no JVM unit-test task, just like release. The existing debug JVM +`VersionCompareTests` receives the actual optimized version name from the variant +API and checks it with the same `Version.fromString` used by upgrade/update handling. +Build logic is otherwise checked through actual APK assembly and static inspection. + + +## 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. + +## Wolfskin shell + +Wolfskin UI lives in `app/src/wolfskin/java`, beside upstream source. The signed-in +entry in `MainContent.kt` calls `WolfskinApp`; Wolfskin-only routes use the single +`Destination.Wolfskin` subtype in the existing navigation stack. Other destinations +continue through upstream `DestinationContent`. Full-screen player/settings pages +cover the Wolfskin nav. + +The independent `:wolfskin-design` module owns the shell components, fixed person +colours, focus policy, per-person root-view memory, palette and motion. Its +`tokens.json` is the copied approved TV-spec source, retaining the locked prototype +SHA and provenance. `shell-tokens.json` adds shared shell/grid/sheet measurements; +`title-tokens.json`, `home-tokens.json` and `discover-tokens.json` add their screen +measurements from the same locked prototype. Generate Kotlin, CSS and the checksum manifest together: + +```sh +python3 wolfskin-design/generate_tokens.py +python3 wolfskin-design/generate_tokens.py --check +``` + +`preBuild` checks the manifest against the source JSON, generator and generated +outputs. Editing any of them without regenerating fails the build. The shell uses +the generated palette rather than the retired `WOLFSKIN` theme-enum value; the old +numeric value is reserved. + +Compose output maps source weights 600/650 to the platform Bold (700) face, which +resolves consistently in native captures; the approved JSON weights stay unchanged. +Nav, picker names, labels and pill weights use that same mapping. Nav label colour +fades for 200 ms ease-out; the blob retains its critically damped spring. + +Shell view accessors are read-only, including the first read from Discover's +`snapshotFlow`. Construction and explicit person/navigation events retain root +and destination memory; reading an unknown destination returns an unsaved default. +Per-person focus, cards and scrollers still survive route changes, while reselecting +the current root resets it. JVM regressions exercise both read-only snapshots and +the signed-in asynchronous Discover host without a screen prewarming its memory. + +Settings pills have a separate 1 dp stroke (2 px on the stage), not a focus ring. +The stroke is inset within the fixed 20 dp / 40 px height; horizontal border-box +padding includes it. The 6 px dash / 4 px gap follows the browser's 3:2 stroke-width +ratios. Resting borders are white at 22% opacity; focused borders use ink at 40%. + +The small gear centred beneath the complete people row opens Settings with Requests focused. +Settings groups hand off to existing upstream pages; Requests opens the household +Coming-in sheet described below. **Advanced settings → Wolfskin screens** is the persistent +rollout switch: turning it off renders upstream Home and library destinations with +the upstream Backdrop and top scrim, while retaining Wolfskin's nav, picker and +Settings list. Native Up traversal stays in content until the first focusable row, +then enters the nav. Turning the switch on renders Wolfskin Home and Libraries. +Discover and Requests are integrated Wolfskin surfaces; title hand-offs remain labelled. + +Saved Jellyfin identities use the existing upstream session. Conductarr supplies +household labels, requester capabilities and library audiences after sign-in. +The locked shell projection in `app/src/debug/assets/wolfskin-shell.json` is used +only when the Advanced debug fixture switch is enabled; release excludes it. + +Person selection immediately clears the old Conductarr identity and Jellyfin +read gate before saved-login I/O. Detached clients are disposed exactly once on +the application's IO scope, after the replacement (or signed-out) identity is +published; a slow or failing socket close cannot block the picker or abort a +switch. Cleanup outlives the replaced screen. Retry and session replacement use +the same disposal owner. Superseded sign-ins are cancelled and serialized so a +late reply cannot navigate back to an earlier person. + +The fixture-mode marker belongs to the singleton `WolfskinSession`, alongside its +server and shell state, not to the user-scoped ViewModel. Switching people replaces +the upstream app entry and ViewModel but retains the same-server, same-mode shell: +the picker stays closed on Home after the first OK. A cold start still opens it, +and the nav avatar explicitly reopens it. `PersonPickerSessionTest` drives the real +shell and user-entry replacement for launch/avatar Left and Right, the initial +person, gear and back, and fast Left-Left-OK; each selection emits one person action. + +Preference and people-fixture reads, Conductarr client construction (including +Home/title retries), and private-cache directory initialization run on IO. +`PersonSwitchThreadingTest` replays the real picker across Justin → Mindel → +Shared → Justin while all closes are held pending, verifies retired clients +cannot start reads, contains a failing close, and checks a burst of choices +against a late sign-in reply. The thread-recording close fake rejects the main +Looper; actual TLS/StrictMode verification remains a real-device check. + +Discover adds the picks hero, type chips, catalogue shelves, service pages, continuous +See all grids, Hidden picks, request chooser and receipts. Its `discover-title/...` +destination is an explicit title-family integration stub exposing only Discover's +request and pick actions. Unheld titles have no trailer control until the pinned +Conductarr title contract supplies a trailer. + +Named “Because you watched” shelves require watched contribution evidence. +Seeds are grouped by media type and TMDB identity, never by caption; repeated +contributions retain both watched/added flags. The current feed has no per-seed +watch timestamp, so eligible shelves keep wire order. Added-only contributions +remain available in the general For-you collections, but make no named shelf. +Titleless contributions do not make a shelf. Recommendations remain acquisition +suggestions, not a filter to the viewer's current library. + +The debug-only fixture and local art live in +`app/src/debug/assets/wolfskin-discover/`. The Advanced preview switch selects a +network-free fixture host; real providers are the default in debug and release. +The mutation adapter accepts the selected person's `ConductarrClient` and uses +only typed routes. Request submission first reads title receipts. Unknown outcomes +are checked, never blindly re-sent, including after closing a sheet or choosing +the same uncertain recipient again. Whole-series requests require season metadata. + +Discover hero and Restore text use explicit per-line CSS allocations so native +paragraph overflow cannot enlarge the bottom-anchored stack. Restore subtitles +retain the sheet's negative top margin and bottom gap. Recovery panels may +overflow their fixed-height scroll row so sign-in copy never shrinks the retry +button. Native regressions cover those three layout boundaries. + +Discover's native capture suite covers its 28 named acceptance states and emits +text-layout metrics beside the PNGs. Data-driven Android key replays cover opening +Discover in 4 presses, requesting for yourself in 7, requesting for Mindel in 9 and +Not interested in 8. Additional regressions exercise held-OK safety, uncertain +receipts, per-recipient outcomes, collection recovery and Undo, unknown seasons, +Dutch decimal formatting and short-row/tail clamping. Discover reuses the Libraries +grid, poster, ratings plate, See all and recovery cards, bounded image loader, caption +separator token and the shell's single hold guard. Both frames share the 180 ms scroll tween. + +The TV keeps platform Roboto with the approved type sizes and weights. Browser +system-font wrapping is not a per-title contract: no bundled font, forced title +breaks or width compensation is applied. Discover's own stacked text preserves +untrimmed CSS line boxes without changing merged shell/library typography. Hero, +sheet and shared focused-content shadows use tokenized drop shadows, not Material +elevation; their alpha animates in the draw phase. Native text-layout measurements +accompany each acceptance capture; remaining font-driven wrapping and text-block +positions must be listed explicitly in its comparison report. + +The asynchronous debug fixture seam hands loading focus back to the first loaded +action when Discover is still the selected destination. A JVM regression releases +the actual host's suspended fixture load and presses Android OK: it must open the +hero rather than activate the nav. Fixture completion does not steal a different +destination or nav selection. + +Native Roborazzi captures use Robolectric SDK 34 and +`w960dp-h540dp-xhdpi` (1920×1080 at density 2.0). `make test` writes capture PNGs and +the sampled Compose nav-spring curve to `build/wolfskin-captures/`. +Home, Library and Discover captures use `home/`, `library/` and `discover/` +subdirectories so shared spec state numbers cannot overwrite another renderer's image. +Key replays exercise the Android event adapter, including the 6/8/10/11-press shell paths. +The profile hand-off capture targets the real `HomeSettingsPage`; Playback is a +separate press-count path. + +Robolectric SDK 28/30/34 runtimes resolve through Gradle's existing persistent +dependency cache, then copy to `wolfskin-design/build/robolectric-sdk/`. Test workers +use that local directory in offline mode instead of downloading Android JARs into +the container's ephemeral home. A cache miss still needs Maven Central; rate-limited +hosts can seed the existing cache from SHA512-verified local Maven artifacts without +changing the wrapper or adding volumes. + +Default-flavour launcher artwork and English, Hungarian and Polish string overrides +live in `app/src/default/res/`. Legacy Wolfskin strings in 25 translated +`main` resource folders have not yet been moved; non-default `appstore`/`firetv` +flavours therefore combine upstream icons with some legacy Wolfskin text. Those +flavours and a full locale migration are not certified by this slice. Debug +assembly runs `checkWolfskinBranding` against every packaged APK through AGP's public +artifact provider and AAPT2. It checks all string, plural and array configurations, +rejecting user-visible upstream branding while preserving the upstream legal +attribution and project URL. The check does not depend on incremental merge files. + +## Wolfskin Home + +Home is one lazy frame: a non-rotating resume → Next up → newest → pick hero, +Jellyfin shelves, per-destination "Your …" shelves and the Discover continuation. +The final action opens the real merged Discover screen at its top through the +shell's root re-selection transition. Home's See all cards open `WolfskinSeeAllLists`, carrying +the complete underlying list rather than the visible shelf window. Title content +remains the later-slice target. + +All Home components and immutable screen models live in `:wolfskin-design`. +The shell's remembered view owns vertical position, each row's selected card and +actual horizontal `LazyListState`, and the bounded six-hour ratings session cache. +Composing destinations bind `rememberedView(route)` before navigation effects run, so a +new See all grid cannot borrow or clamp Home's scroller. Re-entry restores the +remembered track before the page with `scrollToItem`, preserving exact indices and +pixel offsets rather than animating back. Android focus stays with the shell; +cards use its logical focus, without requests against uncomposed lazy items. +Back preserves the viewport; returning to Home does not re-run focus-follow over +a manually scrolled saved view. The next arrow re-enters visible content when +scrolling moved focus off-screen. +Pointing changes focus without activation, cards activate on key-up, and held +OK events cannot activate twice. +Between shelves, an unvisited row selects the nearest visible card center even +when card widths differ. A visited row restores its column. Horizontal movement +keeps the track still while focus remains in its visible middle. +The initial skeleton parks focus on the nav. When the first content arrives, +an untouched fresh entry focuses hero button zero; intervening nav input prevents +that automatic transfer. Home, Libraries and Discover use the same `ShellContentFocus` +release/repeat guard and Back ladder. + +`HomeReadFacade` confines Home SDK reads to the selected user. Resume and Next up +publish independently of slow or failed library shelves; saved metadata still +paints immediately, but a saved hero offers Details until watch data revalidates. +Home and title Play actions resolve the current Jellyfin item and its current +checkpoint at the click, preserving millisecond positions. Series actions use +live NextUp with resumable episodes enabled; an explicitly opened episode stays +that episode, and Restart deliberately starts that displayed episode at zero. +Played flags do not discard a positive checkpoint for an intentional rewatch. +No season, recommendation stub or different person's session becomes playable. +An unresolved person switch guards Home and both owned/upstream library content +before mounting any source read with the prior person's Jellyfin session. + +Home and title watch reads refresh on entry and foreground. A successful +PlaybackStopped acknowledgement publishes a revision for the captured server/user, +so a return read that beat the stop write is refreshed again after that write. +The acknowledgement also removes that user's persisted library grid snapshots +instead of waiting for their ten-minute TTL; an in-memory revision bypasses any +older snapshot that raced invalidation. Stop-report failures remain logged and +do not publish a successful acknowledgement. External players refresh after a +supported stop result; an external app that returns no position cannot supply a +missing checkpoint. Another client's changes after the final read are outside +that click's snapshot. + +Debug variants additionally package the locked fixture and local artwork under +`wolfskin-design/src/debug/assets/wolfskin-home/`; release variants exclude them. +The explicit fixture provider supplies approved household data and Home states +without network reads in tests. Real signed-out Home hides every rating and shows +"Discover needs sign-in"; signed-in Home reads the Conductarr continuation and +ratings-only endpoint. No Seerr fallback is used. + +Home, Libraries and Discover reuse `TitleCard`, `Caption` and `LibrarySeeAllCard`; +those screens and Requests share one `WolfskinImages` loader. Exact-slot requests use RGB565 for posters, +disable crossfades, share a 64 MiB memory/128 MiB disk cache, limit image work to +four reads and prefetch the next shelf. Rating plates start at 14 sp, shrink to 12 sp +and drop trailing entries rather than wrap. Their uncached read gate is 350 ms; +cached focus returns without that delay. + +Focus rings and native lift draw outside fixed slots. The Saved nav chip uses its +own geometry; the Discover section uses a warning pill. Caption separators multiply +the existing muted colour alpha by the shared 40% token. Home-only untrimmed styles +and `exactLineBox` honor the declared CSS line boxes despite Android's intrinsic +paragraph minimums; shell and library typography retain their existing defaults. +The shared See all component uses its canonical locked 8px gap, not the generic +10px tail spacing. Focus lifts use the merged draw-only `FocusLift` implementation; +the obsolete Home shadow helper and duplicate gap/lift measurements are removed. +Vertical moves clamp to short target rows, including a final See all card. + +`make test` includes `HomeCaptureTest` (23 native acceptance states) and the +data-driven paths in `wolfskin-home-presses.json`: play next 2, resume 3, Discover +4, deep-row Back/Down 9, Trending 13 and Trending's See all card 33 presses. +Home captures are written to `build/wolfskin-captures/home/`. Compare those images +side by side with the frozen spec captures; Compose Roboto/JPEG rasterisation, +the approved button-scale consolidation and removal of prototype sample pills +are recorded differences, not a claim of browser pixel equality. + +## Requests under Settings + +The Requests row and Coming-in sheet share one immutable household history snapshot. +Settings counts returned requests, including Ready, rather than calling every +non-Ready row “open”. An unavailable empty snapshot has no count. Deleted rows +read Removed; absent holdings read Not in any library; unreleased titles read +Not out yet; holdings outside the recipient's libraries read In another library. +Waiting for an import is not evidence of an active download. Downloading and +Searching require explicit acquisition signals. Visibility-proof faults read +Check needed; playback faults read File health needs attention, even when a +library holding is present. Right expands the actual stage evidence and the +relevant summary, including episode/source names when supplied by that summary. +The activity feed currently supplies only a title-level summary for Avatar. + +Attention-needed rows sort first, then active acquisition/checks, waiting and +settled history. The bounded window shows five rows, or four with readiness +expanded. Left collapses; Up/Down collapse while moving. Opening a title preserves +the request for Back, and closing the sheet restores its opener. Selecting a root +or changing person clears the title-return link. Shared's authenticated actor +always reads “by Shared”, never “by you”; absent actors have no “by” clause. + +The debug fixture switch loads `app/src/debug/assets/wolfskin-requests.json` and +its eight local posters. Shared and focused-rating records also feed the native +acceptance states; the identical projection is in design-test resources. +No “sample” or “example stages” pill is drawn. The default provider instead maps +Conductarr household activity, authenticated actors and display artwork into +the same sheet. Release excludes all fixture assets. There is no Seerr fallback. +Foreground refreshes once; an open sheet or unsettled requests refresh every +30 seconds while started. The immutable debug projection is loaded once, not +decoded again on every poll. +Thumbnails reuse `WolfskinImages` with a 96×144-pixel request, RGB565, no crossfade, +the shared 64 MB memory/128 MB disk cache and four-read limit. Actor separator +dots use the surrounding text colour; the 40% rule applies only to poster captions. + +Uncached focused ratings wait 350 ms; moving away cancels the read. Read ratings +show immediately on return; sign-out clears visibility. This follows the frozen +page and spec 6.12, resolving the contradictory no-dwell sentence in spec 7.8. +Deterministic coroutine-clock tests cover cancellation, reentry, cached return, +sign-out and refreshed locale scores; a thin Compose test verifies actual Dutch +rendering and sign-out suppression. The approved nine-key Requests path is JSON +data replayed through the real shell. The Requests-only harness starts at the +Home-to-nav boundary; Home's actual hero and asynchronous entry have separate key replays. + +Sheet dimensions, padding, glyphs, scales, colours and durations are generated +tokens. Scoped sheet line boxes retain CSS heights and baselines independently +of native leading rounding; shared shell/library typography is unchanged. Platform +Roboto remains required: the host browser used DejaVu Sans, so its width, +wrapping, ellipsis and text-block positions are not a wrapping-parity target. +For example, the 44-pixel “Coming in” ink is 239 pixels wide in the reference +and 194 pixels in native captures. No per-title compensation or font bundle is +used. Native comparisons must report those structural differences explicitly. +Readiness glyphs sit inline on the text baseline with no flex gap, matching the +frozen block-span cascade. Items wrap intrinsically: Roboto fits the expanded +readiness into two lines where the reference uses three; no grouping is forced. + +The six rating marks retain the exact locked SVG source in +`wolfskin-design/request-marks/`; native-size PNGs and their hashes are generated +from the JSON dimensions with the locked contain fit (`.rr .rb` outranks `.crow img`). +Inside Coming-in rows they retain the prototype's dark `surface3` chips with 12-pixel rounded corners. +Loaded poster thumbnails have no placeholder fill beneath their clipped edge. + +```sh +uv run --with cairosvg==2.9.1 python wolfskin-design/generate_request_marks.py +``` + +`RequestsCaptureTest` writes `st-081`…`st-086`, `st-088` and `st-111` at 1920×1080, +density 2.0. Title selection opens the shared native title destination by media +type and TMDB identity. Back restores the exact Coming-in row and expansion; +the temporary `request-title/...` placeholder is removed. TV sign-in, release +and device deployment remain separate. + +`ActivityTruthProjectionTest` feeds recorded activity JSON through the TV adapter +for Steins;Gate, Yu Yu Hakusho, Love is Blind, Spider-Man: Homecoming, deleted +One Hour Photo, unreleased The Body, Avatar health and Partition waiting. +`ConductarrTruthCaptureTest` renders those provider snapshots and Mindel's recorded +recommendation feed on the native Compose surfaces at 1920×1080, writing +`build/wolfskin-captures/truth/`. Artwork is deliberately omitted to keep these +captures offline; missing-art rendering, tokens, focus, sheet geometry and shelf +spacing are the shipped components. + +## Libraries and See all + +Movies, Shows and Anime use the shell's retained view and a continuous six-column +lazy grid, reduced to five columns while the letter rail is visible so scaled +posters cannot overlap it. Up from the first row reaches Sort/Filter, then the nav; +Back preserves the card and scroll. Sort cycles A–Z, Recently added and Rating. +The letter rail exists only at 200 titles with A–Z selected. Card OK activates on key-up through +the same shell hold guard, without a long-press menu. +Down clamps a short final title row before entering the separate Try again / Show +more row; an empty slot cannot activate recovery. An unavailable library hides +its unknown item count. + +Caption separators use 40% of the existing muted colour's alpha. The rail follows +the locked prototype CSS: bold 24 px letters, with the focused letter bold 28 px, +overriding the spec table's generic caption style for this component. +Library and Settings header initials use the muted colour over the person's fill; +navigation and picker avatars retain their dark initials. + +The debug-only `wolfskin-library.json` and `library/assets/` contain the locked +prototype's titles and local poster art. Release libraries read through +`LibraryReadFacade`, with 200-item Jellyfin chunks and ten-minute per-server/person +snapshots using the existing atomic disk cache. Saved data is painted before a +quiet refresh; a failed refresh keeps it. Production ratings remain hidden before +Conductarr sign-in and use the shared ratings-only provider afterwards. + +Home/Discover shelves open a full list through +`WolfskinSeeAllLists.open(person, key, title, fullList, navigation, loadNextPage)`. +The optional suspend page provider returns `LibraryPage(items, hasMore)` using the +generated Conductarr client. The grid reads within ten cards of its end, deduplicates +title identities, and pauses on Try again or Show more rather than looping. +Title activation opens `title/{id}` through `Destination.Wolfskin` for prototype +identities, or the existing typed media destination for real Jellyfin identities. +Movies, series, episodes and people use the native title family; collections and +music keep their upstream destinations. + +`LibraryCaptureTest` renders the sixteen approved library/See all states offline. +`LibraryKeysTest` reads the approved JSON path and reaches Spirited Away in exactly +20 Android key presses. Paging and snapshot tests cover duplicate-only reads, +failure/retry, stale copies, and person/server isolation. Library captures are written +to `build/wolfskin-captures/library/`; Discover's independent captures use `discover/`. + +### Roomy card spacing + +The generated design tokens use 40 px card gaps (44 px for cast), 84 px shelf +heading bands, 68 px shelf bottom gaps and 524 px grid rows at 1920×1080. +Posters remain 256×384 px; tiles are 400×225 px and wide cards 544×306 px, +preserving six/four/three shelf slots. Library and See all toolbars occupy 136 px: +chips stay at y 252–320 and the first poster row starts at y 372 (52 px clear). +Discover and season-chip rows retain their 104 px height. + +Cards and cast circles draw a single 4 px accent stroke centred on the art edge, +above the content, with no background gap. Card lift parameters are unchanged; +cast portraits and episode tiles now receive that lift too. The stroke and art +scale together at 1.08. +Title shelves reserve vertical drawing room for the scaled ring. +Before scaling the stroke, ring-to-neighbour clearances round to 28/22/16 px +for poster/tile/wide cards; including stroke scale they are 27.6/21.8/16.1 px. + +Poster progress uses the independent 18 px title-card inset, not caption spacing: +the track remains 220 px wide on a 256 px poster. Library focus-follow restarts +when the effective column count, focused row or header geometry changes, retaining +deep focus across live snapshots that add or remove the letter rail. +`LibrarySpacingRegressionTest` renders both progress geometry and the unchanged +focused index across the 199↔200-title boundary. + +`LibraryCaptureTest.spacingShowsFirstFocused`, +`HomeCaptureTest.spacingNextUpFirstFocused` and +`TitleCaptureTest.spacingCastFirstFocused` render the amended reference states +at native resolution. Their files are `library/spacing-shows.png`, +`home/spacing-next-up.png` and `st-114.png` under `build/wolfskin-captures/`. +These captures are recordings, not automated reference-pixel assertions; pair +measurements are a separate check. Platform Roboto metrics and fixture ordering +are not browser pixel parity. + +### Shared focus rings + +The same `focusRing` stroke now covers pill/icon buttons (hero, title, state, +recovery and sheet actions), the nav avatar, picker people and gear. It is always +accent, 4 px / 2 dp wide, centred on the surface edge with no background gap. +The former outside-gap renderer is removed. The stroke draws after opaque +content, so neither half can be covered by the background or portrait. + +Control tokens are `Focus.ringControlGap = 0` and `ringControlRing = 2.dp`; +avatar and picker tokens use the same geometry. Existing scales remain 1.05 for +buttons and 1.08 for picker people/gear; the nav avatar remains unscaled. Card +and button lifts remain; avatars, people and gear gain no shadow. Existing +clearance accommodates the smaller outside reach without layout movement. + +Filled-only chips, nav pills, Settings/sheet/Coming-in rows and the A–Z rail +are unchanged. Selected-only chip/icon/radio borders and decorative borders +are not focus rings. These distinctions are explicit in the inventory and +reference states rather than silently adding rings to filled controls. + +`FocusRingTest.ringStraddlesOpaqueSurfaceAndDisappearsWithoutFocus` checks the +two outside and two inside pixels against opaque content for pill, circle and +card radii, including clearing the stroke on blur. The focused hero Details, +nav avatar, picker, Discover chip, Library toolbar chip and title Request +captures exercise the actual screens at 1920×1080. Browser and native glyph +metrics differ; their ring offsets are measured relative to each surface edge. + +### TV feedback corrections (spec revision 5) + +The [revision-5 amendment](../data/tv-spec/spec.md) and +[native frame prototype delta](../data/tv-v2/feedback-2026-11-06/index.html) +retain the approved frames except for the requested feedback changes: + +- The picker gear is centred under everyone and uses the picker-person ring. +- Shelf tracks share second-last-slot lookahead, count clamping and settled + offsets. Home records completed frame positioning rather than an animation + that may still be cancelled; first mount restores the intended shelf item. + Discover uses all six approved poster slots. Person keeps the final column + at full poster width. +- Episode images choose episode Primary, then Thumb, then the already-read + series Backdrop. Episode cards are 400×280 px; cast retains its 68 px clearance. + Episode context uses the episode overview, with two summary lines on the + focused card. Held-series headers measure their copy/actions and leave 40 px + before seasons instead of reserving a fixed empty 900 px header. +- Every Discover heading uses a 104 px band, including a 60 px title-to-card + gap. Home heading geometry is unchanged. +- Hero/backdrop decoding is bounded to 1920×1080; upright hero sheets request + 420×630. The shared request builder removes conflicting Jellyfin size caps, + preserves tags and quality 96, and selects a suitable TMDB tier. The 64 MiB + memory cache, 128 MiB disk cache and four image-read limit remain unchanged. + +`HomeViewportTest` covers short/long poster, tile and wide rows, both row +directions, first focus and native Back from the actual title surface. Native +proof records real Bear season-three metadata through the title read facade; +the proof-only harness is not part of the shipped tests or APK. + + +## Title pages and people + +`TitleSurface` owns the film/series/episode header, season chips, episode tiles, +cast and related shelves. `PersonSurface` owns the real biography and continuous +six-column filmography. Both reuse the shell/nav/back ladder, `LibraryPoster`, +the adaptive `LibraryRatingPlate`, shared captions and the single bounded +`WolfskinImages` loader. Image requests use exact slot sizes, RGB565 for opaque +posters, no crossfade, four concurrent reads and next-row prefetch. The fixed +title backdrop remains visible behind deep rows; only off-screen row images +leave composition. + +The options row preserves complete labels beyond the copy-column measure. +Version choices close after selection; Audio and subtitles stays open, including +an explicit Off choice. Real source IDs use the Jellyfin SDK UUID parser, and +stream indexes remain the server's indexes rather than positions in a list. +Selections persist through the same playback repository as the upstream player. +Marking a whole series asks first; an explicit episode remains the exact play and +watched target even while another season is selected. Nested person/title visits +retain their entry's focus and selection state. +Choosing another episode from an episode page replaces only the top navigation +entry, preserving its series/Home parent. Choosing the first episode from a +series, related titles, cast and performer credits still push normal drill-ins. +Down into season chips initially focuses the selected playable season, including +offscreen later seasons; the row scrolls that chip into view and remembers +subsequent deliberate browsing. Saved title watch data cannot enable Play/Resume +or Restart before source revalidation. +Hardware and remote Back share the sheet/picker/request-overlay precedence, then +pop a pushed title/person from content **or** nav. Initial loading has no content +target: Down stays in nav, while Back can still leave the title. A missing rating +set emits no layout node, so it cannot reserve an invisible extra facts gap. + +`TitleReadFacade` confines SDK reads, server/user-scoped last-good disk copies, +person credits and playback mutations to one boundary. `ConductarrTitlePages` +adds real detail, cast, related titles, performer/filmography, readiness/receipts +and source ratings. A catalogue title gains held/play controls only after an +exact TMDB lookup returns this Jellyfin user's actual item identity. Episode +votes enrich those real episode identities; no catalogue stub becomes playable. +Debug-only `wolfskin-titles.json` and local artwork remain behind the explicit +Advanced fixture switch; release never packages or selects them. + +Held trailers use Jellyfin remote/local trailer data. YouTube URLs target an +installed YouTube handler such as SmartTube, never a browser. No handler leaves +the title visible with a calm message. Titles nobody holds have no Trailer +button until Conductarr's trailer field exists. Title requests use the same +actor-safe chooser and identity-lifetime uncertain-submission ledger as Discover. +Signed-out and offline pages cannot submit; no Seerr fallback is used. + +`TitleCaptureTest` writes the 34 approved title/person/options frames plus native +text weights, colours, line boxes and bounds at 1920×1080, density 2.0. The approved +typeface ruling keeps platform Roboto, not the browser's DejaVu Sans: no per-title +compensation and no wrapping-parity claim. Explicit CSS line boxes constrain title +headers, sheets, cast and poster captions without changing shared type tokens. +Fractional stack placement rounds only the accumulated position, preventing a +one-pixel-per-row drift. Focused controls share the canonical lift shadow. +`TitleKeysTest` replays real Android keys, including modal Back/hold, nearest-row +entry, remembered columns, long-sheet visibility and full media-table geometry. +`TitleAsyncFocusTest` delays title and person replies through the real destination +and view-model, then exercises content focus and hardware Back. Subtitles Off +maps to the player's explicit disabled sentinel, not its unspecified default. + +`TitleLaunchKeysTest` starts at Who's watching and replays the full 4/8/15/11 +sequences through the real Home hero/shelves and title/person surfaces. +`title-press-paths.json` supplies every event; no prefix is skipped or padded. +The shorter 1/5/6/2 title-entry replays remain as focused regressions. SDK cache, +exact-episode, track-index, identity, locale-layout and trailer-handler tests +run offline with in-process service replies and local art. + +## Offline states and recovery + +Owned screens share `RecoveryToast`: the warning glyph, 760 px maximum width, +22/28 px padding, 14 px icon gap, 22 px corners and CSS-sized body line boxes. +Network failures never expose transport exception text. Playback failure reads +“Can’t reach Jellyfin to play. Check the network and try again.” and leaves the +originating card focused; no simulated player hand-off panel is shipped. Retry +failure reads “Still can’t reach it. The last saved view stays on screen.” + +Library headers and the nav publish the same saved-copy timestamp. Failed library +refreshes retain cards and expose a reachable Try again tail. Discover retains its +current catalogue after a failed refresh, adds a recovery tail and disables request +and dismissal writes until recovery; a revoked sign-in discards that saved projection. +Previously observed plates can remain visible offline, but moving to an uncached +card cannot start a ratings read. `SavedPill` shares the compact cached-header +treatment across Home, Libraries and Requests; the nav retains its larger wifi chip. + +Requests focus, observed ratings and retained rows are partitioned by selected person. +A failed authenticated read preserves the last rows and their timestamp; Down from +the final row reaches Try again and Up returns to the row. Signed-out data is not +retained as an authenticated copy. Returning to a foreground person triggers that +person's refresh rather than waiting for the prior person's polling timer. + +Home disk snapshots remain server/user scoped. A new identity starts with its own +skeleton or saved copy, never the previous person's projection; late responses from +an older read cannot overwrite the current identity. A disk-write failure does not +turn a successful Jellyfin read into an outage. Skeleton-only Home, Library and +Discover screens keep focus in the nav even when Down is pressed repeatedly. + +Library/Discover posters use the same title/glyph/160-degree gradient fallback as +Home, including an actual Coil read failure. Discover hero image failures use the +shared ambient background; missing artwork is not a successful data refresh. + +`RecoveryStateTest`, `RecoveryCaptureTest`, `HomeRecoveryTest` and `PlayerHandoffTest` +exercise state transitions, person isolation, native recovery focus/geometry and the +real upstream playback destination with offline fixtures. New native captures and +rendered text/font metrics go to `build/wolfskin-captures/recovery/`; Dutch score +captures configure the Android resource locale as well as the JVM locale. + +Conductarr transport and identity live outside the state components. A revoked +sign-in discards the current person's authenticated projections; an outage keeps +last-known-good (LKG) data and its Saved timestamp. No Seerr fallback or offline +request queue is used. Title and performer pages use the same authenticated cache. + +## Conductarr production wiring + +The default endpoint is `BuildConfig.CONDUCTARR_BASE_URL`: +`https://wolfskin-tv.justinvisser.org`. Override **Conductarr server URL** beside +**Wolfskin screens** in Advanced settings; blank restores the build default. +HTTPS is required except for loopback fake servers. **Wolfskin preview fixtures** +is a debug-only, off-by-default switch; it never sends HTTP mutations. + +`ConductarrSession` borrows the currently selected user's existing Jellyfin access +token. It sends only `Authorization: MediaBrowser Token=""`, with no cookie, +API key or second credential store. Person/server/token/endpoint changes discard +the old client and in-memory providers. LKG files are isolated by person and client +identity in app-private, backup-excluded storage. A 401 clears that identity's +cache and returns to sign-in states; Try again reconstructs and verifies the client. + +Home publishes Jellyfin/LKG rows independently of Conductarr, including retry +authentication, and paints new Jellyfin data before writing its disk copy. +Production calls use 3-second connect, 5-second read and 8-second total limits. +Request recipients include authorized Conductarr profiles without saved Jellyfin +logins; that does not create selectable TV logins. Late name reads preserve the +open chooser's selected recipient and footer focus. + +The launch picker publishes the current Jellyfin user and working navigation before +the saved-login database read. Saved people and Conductarr household metadata fill +that same shell later; slow activity or catalogue reads cannot hold its first usable +frame. Discover details navigate to the shared title page, including cast, performer +navigation and related titles, rather than the header-only design preview. Held +items whose detail omits ProviderIds or People get an explicit Jellyfin field read +before catalogue enrichment, without changing their playback identity. Scrolled +navigation draws an opaque band; the unscrolled hero treatment is unchanged. +Cached `NavDisplay` entries read the current shell and Discover composition locals, +not the temporary launch shell captured when the entry was first created. Otherwise +the screen can retain skeleton people, disconnected tab actions and an open picker +even after the real session is ready. +Only the current navigation entry may set the shared route or enable the outer +host's Back handler; outgoing entries can remain composed during a transition. +Nested title handlers retain their existing lifecycle-based behaviour. + +Artwork uses a fresh bounded Wolfskin loader, not the upstream loader's +authenticated fetcher. The app's network interceptor removes Authorization from +every non-Jellyfin artwork origin (scheme, host and port), including redirects; +current Jellyfin artwork still authenticates. A failed or empty library-audience response keeps +the existing mapping or Jellyfin name rule. Returning to the foreground retries +household metadata without rebuilding the selected identity. +An Anime audience grants a library grouping, not proof of owned titles. Household +refresh enables Anime only after a one-title Jellyfin read confirms visible series +in an accessible Anime library for the signed-in user. This runs after the live +picker is published; failures retain that user's last confirmed availability. +Empty Anime Home shelves remain omitted. +When availability changes, focus follows the avatar or a surviving destination; +removing Anime never leaves an out-of-range nav selection. If Anime was the root, +both the shell and navigation stack replace it with Home while retaining pushed +title/person pages. `AnimeRootRemovalTest` covers active-root Back, avatar focus, +and the real navigation stack with one or two pushed pages. + +| Screen seam | Production supplier | +| --- | --- | +| People and nav | Operator, profiles, library audiences; saved Jellyfin users remain the only selectable logins | +| Home continuation | Recommendation feed, catalogue options/discovery; Coming soon stays Discover-only | +| Discover | Feed/contributions, services, New on, Trending, genres, Coming soon, parameterized service shelves, dismissal reads/writes | +| Catalogue paging | Two-shelf read-ahead, distinct title identities, per-parameter disk pages, retry/duplicate-only tails; no page counters | +| Request sheet | Capability-limited requesters, season metadata, POST receipt, receipt lookup before any resubmit; actor comes only from the token | +| Requests | Household activity, stage precedence, actor/requester evidence and display posters; foreground/30-second refresh | +| Ratings | Ratings-only read after 350 ms card dwell; immediate hero/title/Coming-in reads; per-type six-hour/300-entry cache; episode season votes | +| Titles and people | Detail/enrichment/display, cast, related, real Jellyfin season episodes plus votes, performer/filmography, title activity and uncached receipts; shared request/hide workflows | + +The pinned client exposes exactly the 22 TV operations, including ratings and +display. Conditional reads send ETags and reuse the saved body on 304. The fake +server tests use representative wire JSON, never a live Conductarr or Jellyfin. +`ConductarrSessionTest`, `ConductarrProjectionTest`, `ConductarrTitlePagesTest`, +`ConductarrSurfaceTest`, `ConductarrHomeSurfaceTest` and `ConductarrTitleSurfaceTest` cover identity/cache +isolation, 401/retry, saved offline data, actor-safe requests, ratings timing, +title playback permissions/episode votes and native real-provider surfaces. +The existing fixture screen tests remain in the normal `make test` gate. +`WolfskinArtworkTest` exercises the actual Coil loader against two HTTP hosts +and a same-host/different-port origin with an authenticated upstream parent, +and verifies successful decoding with credentials only on Jellyfin. `ConductarrAudienceRecoveryTest` covers +operator success plus failed/empty audiences, name-rule retention and foreground healing. + +`ShellStartupTimingTest` holds saved-login and real-wire activity reads pending +while exercising the picker, early tabs and a person switch. `TitleReadFacadeTest` +covers omitted held-film provider ids with authenticated cast/related enrichment. +`DiscoverTitleTimingTest` delays the cast reply, exercises real remote Down/Back +and writes native cast/related captures to `walk-fixes/`. +`NavBandOcclusionTest` checks pixels across the entire scrolled navigation band. +`FirstSignInNavigationTest` starts the full app host with no existing shell and +immediate signed-in replies, then continues as Mindel and renders her six-film +Movies grid and 29-series Shows grid from owner-prefixed Jellyfin views. It also +checks that Continue closes the real session picker and selecting Home does not +reopen it. +`AnimeAvailabilityTest` reads the signed-in audience wire shape and checks Shared +and Mindel with empty and nonempty Jellyfin libraries: no empty Anime nav item or +Home shelf, while a real visible series enables both. + +Not done here: deploying the dedicated hostname or widening server authority; +hardware installation/playback verification; releases, signing or version changes. +Unheld trailers remain absent because the server contract has no trailer field. +Saved Jellyfin logins are not fabricated from Conductarr profiles: a person +without an upstream token remains signed out. Fixtures require explicit debug opt-in. + +### Manual smoke after endpoint deployment (no build command) + +Use an already authorized test build and a sandbox that accepts its selected +Jellyfin login. No token needs to be copied, displayed or entered into Wolfskin. + +1. In Advanced settings, set the sandbox URL with fixtures **off**. Select Shared, + then another saved person; verify their operator/requester and independent views. + Its hostname must be in Conductarr's TV allowed-host list; arbitrary hosts or + IP addresses are refused even with the correct token. +2. Open Home, Discover, a service, Hidden picks and Requests. Open a title, cast + member and filmography credit; check real facts, related titles, episode votes, + stage/actor labels, card dwell ratings and continuous See all loading. +3. Make the sandbox return 503 after a successful read. Reopen: saved content and + Saved time remain, requests cannot submit, and Try again performs another read. +4. Return 401, then restore authentication. Verify ratings/request actions disappear + and this person's saved Conductarr data is cleared. Try again restores data + without changing Jellyfin login or copying a token. +5. Have the sandbox accept a Shared-on-behalf request but lose its POST response. + Use Check again: the receipt says Shared, and the sandbox records one POST. +6. Restore the blank/default URL when the dedicated TV hostname and server's + 22-operation authority are deployed. This checklist is manual, not a live gate. + +Title and person surfaces publish saved timestamps through the same per-person +`RememberedView` partition and nav Saved indicator. Their timed notices render +the shared `RecoveryToast`; failed Try again and playback use `RecoveryMessage` +without transport details. A selected-person mismatch never renders the former +person's title/person projection. Title and performer disk copies remain isolated +by both server and authenticated user; native retry and cache-isolation regressions +exercise these boundaries without a live service. + +## TV fidelity and source gates + +Library/Discover page headings use the existing exact CSS line-box layout. +Request sheets reserve the inherited inline text descent around their ratings; +person rows and receipt panels therefore share the locked frame's vertical +coordinates. Settings row title/facts also retain their CSS line boxes. These +are local component fixes: shared typography tokens, Home and caption styling +do not shift. + +Home ratings observations use independent six-hour, 300-entry title and +series/season caches. A season retains each observed episode's own score and +timestamp; changing a Jellyfin episode UUID does not lose the observation, and +another episode or season never borrows that score. Episodes without a complete +catalogue identity remain isolated by their Jellyfin identity. + +Cold library grids publish each cumulative 200-item SDK chunk before the next +read completes. An existing complete saved copy stays visible throughout a +refresh, and only a completed read replaces it on disk. A cold later-chunk +failure keeps the already-readable titles with a retry tail, without saving an +incomplete copy. Appended titles preserve the focused title even when the +destination's sort inserts them before it. Catalogue row keys use the same +media-type/TMDB identity as their cards. + +Undo restores a dismissed pick silently; Show again announces “Back in your +picks.” Title pages own their own toast so the request adapter never duplicates +it. The saved Discover retry tail is labelled More picks, not Try again twice. + +People retry retains an already signed-in Conductarr client and its uncertain +submission ledger; only a signed-out identity is rebuilt. Home authorizes its +Jellyfin read by the selected person's saved Jellyfin user ID, so a lost +Conductarr operator or a renamed household profile does not hide that user's +own library. A different saved user ID still fails closed. + +`make test` runs `OwnedSourceAuditTest` through the design module's JVM suite. +It scans the owned Kotlin sources in `wolfskin-design/src/main/java` and +`app/src/wolfskin`, rejecting untokened `.dp`/`.sp` conversions and numeric Color +constructors outside generated tokens, token font sizes below 12 sp, raw +household library labels, and page-counter/navigation wording. Its negative +cases plant each violation independently. Existing native capture checks also +verify rendered font sizes and wording. `LibraryProgressiveSurfaceTest` captures +the usable first chunk while the final read is still suspended. + +## 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. + +## Persistent debug signing + +`make apk-debug` and non-release `docker/gradle.sh` invocations (including +`:app:assembleDefaultOptimized`) use one host keystore, outside the checkout: + +```text +/root/.treehouse/firstmate-7bab20/2/firstmate/data/signing/debug.keystore +``` + +Set `WOLFSKIN_DEBUG_KEYSTORE` to another file in a dedicated directory outside +the checkout when building on another host, for example: + +```sh +WOLFSKIN_DEBUG_KEYSTORE="$HOME/.local/share/wolfskin-signing/debug.keystore" make apk-debug +``` + +The wrapper creates a missing key once using the build image's `keytool`: RSA +2048, 10,000-day validity, alias `androiddebugkey`, store/key passwords `android`, +and subject `CN=Android Debug`. The containing directory is mode `0700`, the +file mode `0600`. Existing keys are reused, never regenerated; generation errors +abort the build rather than falling back to an ephemeral container key. +The file is bind-mounted read-only at `/tmp/.android/debug.keystore`, with +`ANDROID_USER_HOME=/tmp/.android`, for every non-release Gradle container. +Keep and back up this file across clean builds, disposable worktrees, container +rebuilds and Gradle cache resets. Never commit it. The standard debug passwords +are public; this key is for local debug/optimized builds, not release signing. + +**One-time reinstall:** the first build with the stable key still needs one +uninstall+reinstall on the box because the signer changes once; later ones +install in place with `adb install -r`, retaining sign-ins and settings. +The initial uninstall removes app data, so sign in again once after that switch. +Deleting/replacing the persistent key or choosing a different override changes +the signer again and requires another reinstall. + + +## Release credentials + +The default escrow is +`/opt/stacks/ventoy/machine-parity/manifest-tank/secrets/wolfskin/`. Check +`mountpoint -q /mnt/ventoy` before using that path. Set `WOLFSKIN_SECRETS_DIR` +to use another existing directory. Its required files are: + +| File | Contents | +| --- | --- | +| `release.jks` | Separate Wolfskin PKCS12 keystore, RSA 4096, alias `wolfskin` | +| `release.properties` | Java properties `storePassword` and `keyPassword` | +| `github-packages-token` | GitHub token with `read:packages`, username `JustinZeus` | + +The directory must be mode `0700`, each file mode `0600`, owned by the building +user. Never commit them or put their contents in logs. Back up the keystore and +passwords together: losing the release key forces a reinstall. Do not reuse +wolfskin-android's key or the gh CLI login token. + +Release builds mount escrow read-only. The token is loaded inside the container +into upstream's `ORG_GRADLE_PROJECT_WholphinExtensionsUsername` and +`ORG_GRADLE_PROJECT_WholphinExtensionsPassword` environment hooks, never a Docker +command argument. The signing init script registers `wolfskin`; the nonsecret +`release-signing.properties` is mounted as `local.properties` so upstream's +`release.signing.config` hook selects it. Any host `local.properties` is hidden +only for the duration of this container. Keystore and passwords stay outside the +checkout and image. Missing credentials fail the release rather than silently +shipping the MPV stub. + +## Versioning and local release markers + +Upstream computes `versionName` from +`git describe --tags --long --match='v*'`, dropping the leading `v`, and computes +`versionCode` from the count of all local `v*` and `p*` tags. Preserve and fetch +those tags; a shallow or incomplete checkout can give an incorrect version. +Use the full `git describe` output as the human release name. + +After committing the intended release, with a clean checkout and fetched tags: + +```sh +make release-tag +make apk +``` + +The helper creates one lightweight `pwolfskin-YYYY_MM_DD` tag in UTC, appending +`_1`, `_2`, etc. on collision. Each marker adds one to `versionCode` without +changing upstream's `v*`-based version name. It never pushes tags, publishes a +release, or installs an APK. Do not delete published release markers. Tag +creation and publication must be serialized between release operators. + +## Inspecting release artifacts + +Use `apksigner verify --verbose --print-certs APK` and +`aapt2 dump badging APK` inside the build image. Compare the signer SHA-256 with +`keytool -list -v -alias wolfskin -keystore /path/to/release.jks` (enter its +password privately). Inspect APK ZIP entries for real native MPV and ffmpeg +libraries for each shipped ABI; a successful Java compilation alone does not +prove that codec extensions are included. diff --git a/docker/gradle.sh b/docker/gradle.sh new file mode 100755 index 00000000..461018bc --- /dev/null +++ b/docker/gradle.sh @@ -0,0 +1,70 @@ +#!/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 + debug_keystore=$(realpath -m "${WOLFSKIN_DEBUG_KEYSTORE:-/root/.treehouse/firstmate-7bab20/2/firstmate/data/signing/debug.keystore}") + case "$debug_keystore" in + "$root"|"$root"/*) + printf 'Debug keystore must be outside the checkout: %s\n' "$debug_keystore" >&2 + exit 1 + ;; + esac + signing_directory=$(dirname "$debug_keystore") + mkdir -p "$signing_directory" + chmod 700 "$signing_directory" + if [[ ! -e $debug_keystore ]]; then + temporary_directory=$(mktemp -d "$signing_directory/.debug-keystore.XXXXXX") + trap 'rm -rf -- "$temporary_directory"' EXIT + if ! docker run --rm --platform linux/amd64 --user "$(id -u):$(id -g)" \ + --mount "type=bind,src=$temporary_directory,dst=/run/signing" \ + "$image" keytool -genkeypair -noprompt -keystore /run/signing/debug.keystore \ + -storetype JKS -storepass android -keypass android -alias androiddebugkey \ + -keyalg RSA -keysize 2048 -validity 10000 -dname "CN=Android Debug"; then + printf 'Failed to generate persistent debug keystore: %s\n' "$debug_keystore" >&2 + exit 1 + fi + chmod 600 "$temporary_directory/debug.keystore" + mv "$temporary_directory/debug.keystore" "$debug_keystore" + fi + if [[ ! -s $debug_keystore ]]; then + printf 'Missing or empty persistent debug keystore: %s\n' "$debug_keystore" >&2 + exit 1 + fi + chmod 600 "$debug_keystore" + arguments+=(--env ANDROID_USER_HOME=/tmp/.android + --mount "type=bind,src=$debug_keystore,dst=/tmp/.android/debug.keystore,readonly") + docker run "${arguments[@]}" "$image" ./gradlew "${gradle_arguments[@]}" "$@" +fi diff --git a/docker/release-signing.properties b/docker/release-signing.properties new file mode 100644 index 00000000..7e15afe4 --- /dev/null +++ b/docker/release-signing.properties @@ -0,0 +1 @@ +release.signing.config=wolfskin diff --git a/docker/release-tag.sh b/docker/release-tag.sh new file mode 100755 index 00000000..14b2637c --- /dev/null +++ b/docker/release-tag.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# Add one lightweight release marker without publishing it. +set -euo pipefail +if [[ -n $(git status --porcelain) ]]; then + printf 'Commit or discard checkout changes before tagging a release.\n' >&2 + exit 1 +fi +base="pwolfskin-$(date -u +%Y_%m_%d)" +tag=$base +sequence=1 +while git show-ref --verify --quiet "refs/tags/$tag"; do + tag="${base}_${sequence}" + sequence=$((sequence + 1)) +done +git tag "$tag" +printf 'Created %s locally; no tag was pushed.\n' "$tag" +printf 'versionName: %s\n' "$(git describe --tags --long --match='v*' | cut -c 2-)" +printf 'versionCode: %s\n' "$(git tag --list 'v*' 'p*' | wc -l)" diff --git a/docker/signing.init.gradle b/docker/signing.init.gradle new file mode 100644 index 00000000..b07a4770 --- /dev/null +++ b/docker/signing.init.gradle @@ -0,0 +1,17 @@ +// Keep signing material outside the checkout; upstream selects this configuration via local.properties. +allprojects { + pluginManager.withPlugin('com.android.application') { + def passwords = new Properties() + file('/run/wolfskin/release.properties').withInputStream { passwords.load(it) } + android.signingConfigs.create('wolfskin') { + storeFile = file('/run/wolfskin/release.jks') + storePassword = passwords.getProperty('storePassword') + keyPassword = passwords.getProperty('keyPassword') + keyAlias = 'wolfskin' + enableV1Signing = true + enableV2Signing = true + enableV3Signing = true + enableV4Signing = true + } + } +} diff --git a/gradle.properties b/gradle.properties index 26e5ff1a..6c29e7b3 100644 --- a/gradle.properties +++ b/gradle.properties @@ -27,6 +27,7 @@ android.enableAppCompileTimeRClass=true android.usesSdkInManifest.disallowed=true android.uniquePackageNames=true android.dependency.useConstraints=false +android.enableR8.fullMode=true android.r8.strictFullModeForKeepRules=true android.r8.optimizedResourceShrinking=true android.builtInKotlin=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index eab5a055..9d1d45b2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,51 +1,62 @@ [versions] -aboutLibraries = "14.2.0" -acra = "5.13.1" -agp = "9.1.1" +aboutLibraries = "15.3.0" +acra = "5.14.2" +agp = "9.5.0" auto-service = "1.1.1" autoServiceKsp = "1.2.0" desugar_jdk_libs = "2.1.5" -hiltNavigationCompose = "1.3.0" -hiltWork = "1.3.0" +hiltNavigationCompose = "1.4.0" +hiltCompiler = "1.4.0" kache = "2.1.1" -kotlin = "2.3.21" -ksp = "2.3.7" -coreKtx = "1.18.0" -appcompat = "1.7.1" -composeBom = "2026.05.00" -mockk = "1.14.9" -robolectric = "4.16.1" -multiplatformMarkdownRenderer = "0.40.2" -okhttpBom = "5.3.2" +kotlin = "2.4.21" +ksp = "2.3.9" +coreKtx = "1.19.1" +appcompat = "1.8.0" +composeBom = "2026.09.00" +mockk = "1.14.11" +profileinstaller = "1.4.1" +robolectric = "4.17" +multiplatformMarkdownRenderer = "0.46.0" +okhttpBom = "5.5.1" programguide = "1.6.0" slf4j2Timber = "1.2" timber = "5.0.1" tvFoundation = "1.0.0" tvMaterial = "1.1.0-alpha01" -lifecycleRuntimeKtx = "2.10.0" +lifecycleRuntimeKtx = "2.11.0" activityCompose = "1.13.0" -androidx-media3 = "1.10.0" -coil = "3.4.0" +androidx-media3 = "1.11.1" +# Dolby Vision profile 7 -> 8.1 RPU rewrite (libdovi behind JNI), MIT +exoplayerhdrutils = "0.4.1" +coil = "3.6.3" jellyfin-sdk = "1.7.1" -nav3Core = "1.1.1" -lifecycleViewmodelNav3 = "2.10.0" -material3AdaptiveNav3 = "1.0.0-alpha03" +nav3Core = "1.1.7" +lifecycleViewmodelNav3 = "2.11.0" +material3AdaptiveNav3 = "1.3.0" protobuf = "0.10.0" datastore = "1.2.1" -kotlinx-serialization = "1.11.0" -protobuf-javalite = "4.34.1" -hilt = "2.59.2" -room = "2.8.4" +kotlinx-serialization = "1.12.0" +protobuf-javalite = "4.36.2" +hilt = "2.60.1" +room = "2.8.5" preferenceKtx = "1.2.1" tvprovider = "1.1.0" workRuntimeKtx = "2.11.2" paletteKtx = "1.0.0" -assMedia = "0.4.0" +libass-android = "0.5.1" kotlinxCoroutinesTest = "1.11.0" coreTesting = "2.2.0" -openapi-generator = "7.22.0" +openapi-generator = "7.26.0" runner = "1.7.0" -wholphin-extensions = "0.1.2" +wholphin-extensions = "0.2.4" +junit = "4.13.2" +junitVersion = "1.3.0" +espressoCore = "3.7.0" +material = "1.14.0" + +compileSdk = "37" +targetSdk = "37" +minSdk = "23" [libraries] wholphin-extensions-mpv = { module = "com.github.damontecres.wholphin.mpv:wholphin-mpv", version.ref = "wholphin-extensions" } @@ -67,21 +78,20 @@ androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui- androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } androidx-compose-runtime = { group = "androidx.compose.runtime", name = "runtime-android" } -androidx-compose-runtime-livedata = { group = "androidx.compose.runtime", name = "runtime-livedata" } -androidx-hilt-compiler = { module = "androidx.hilt:hilt-compiler", version.ref = "hiltWork" } +androidx-hilt-compiler = { module = "androidx.hilt:hilt-compiler", version.ref = "hiltCompiler" } androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "hiltNavigationCompose" } androidx-tv-foundation = { group = "androidx.tv", name = "tv-foundation", version.ref = "tvFoundation" } androidx-tv-material = { group = "androidx.tv", name = "tv-material", version.ref = "tvMaterial" } androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } -androidx-lifecycle-livedata-ktx = { group = "androidx.lifecycle", name = "lifecycle-livedata-ktx", version.ref = "lifecycleRuntimeKtx" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" } androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" } androidx-tvprovider = { module = "androidx.tvprovider:tvprovider", version.ref = "tvprovider" } -androidx-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" } androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "workRuntimeKtx" } -androidx-hilt-work = { module = "androidx.hilt:hilt-work", version.ref = "hiltWork" } +androidx-hilt-work = { module = "androidx.hilt:hilt-work", version.ref = "hiltCompiler" } +androidx-profileinstaller = { module = "androidx.profileinstaller:profileinstaller", version.ref = "profileinstaller" } + auto-service-annotations = { module = "com.google.auto.service:auto-service-annotations", version.ref = "auto-service" } auto-service-ksp = { module = "dev.zacsweers.autoservice:auto-service-ksp", version.ref = "autoServiceKsp" } desugar_jdk_libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar_jdk_libs" } @@ -110,6 +120,7 @@ androidx-media3-exoplayer-dash = { module = "androidx.media3:media3-exoplayer-da androidx-media3-ui = { module = "androidx.media3:media3-ui", version.ref = "androidx-media3" } androidx-media3-ui-compose = { module = "androidx.media3:media3-ui-compose", version.ref = "androidx-media3" } androidx-media3-effect = { group = "androidx.media3", name = "media3-effect", version.ref = "androidx-media3" } +exoplayerhdrutils = { module = "com.suyashbelekar:exoplayerhdrutils", version.ref = "exoplayerhdrutils" } coil-core = { module = "io.coil-kt.coil3:coil-core", version.ref = "coil" } coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" } @@ -140,7 +151,11 @@ kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-t androidx-core-testing = { module = "androidx.arch.core:core-testing", version.ref = "coreTesting" } androidx-runner = { group = "androidx.test", name = "runner", version.ref = "runner" } -ass-media = { group = "io.github.peerless2012", name = "ass-media", version.ref = "assMedia" } +ass-media = { group = "io.github.peerless2012", name = "ass-media", version.ref = "libass-android" } +junit = { group = "junit", name = "junit", version.ref = "junit" } +androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } +androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } +material = { group = "com.google.android.material", name = "material", version.ref = "material" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } @@ -153,3 +168,4 @@ hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } room = { id = "androidx.room", version.ref = "room" } aboutLibraries = { id = "com.mikepenz.aboutlibraries.plugin.android", version.ref = "aboutLibraries" } openapi-generator = { id = "org.openapi.generator", version.ref = "openapi-generator" } +android-library = { id = "com.android.library", version.ref = "agp" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index b1b8ef56..9fa7f288 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index b52fb7e7..91c4fc64 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.2-bin.zip networkTimeout=10000 retries=0 retryBackOffMs=500 diff --git a/gradlew b/gradlew index b9bb139f..249efbb0 100755 --- a/gradlew +++ b/gradlew @@ -20,7 +20,7 @@ ############################################################################## # -# Gradle start up script for POSIX generated by Gradle. +# gradlew start up script for POSIX generated by Gradle. # # Important for running: # @@ -29,7 +29,7 @@ # bash, then to run this script, type that shell name before the whole # command line, like: # -# ksh Gradle +# ksh gradlew # # Busybox and similar reduced shells will NOT work, because this script # requires all of these POSIX shell features: diff --git a/gradlew.bat b/gradlew.bat index aa5f10b0..03754c4c 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -19,13 +19,40 @@ @if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem -@rem Gradle startup script for Windows +@rem gradlew startup script for Windows @rem @rem ########################################################################## @rem Set local scope for the variables, and ensure extensions are enabled setlocal EnableExtensions +@rem Catch executions from older scripts and ensure they exit cleanly. +@rem This can be removed once we can be reasonably confident that few people +@rem will be migrating directly to this new wrapper. +goto afterSafetyNet +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +goto exitWithErrorLevel +:afterSafetyNet + set DIRNAME=%~dp0 if "%DIRNAME%"=="" set DIRNAME=. @rem This is normally unused @@ -45,13 +72,14 @@ set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 if %ERRORLEVEL% equ 0 goto execute -echo. 1>&2 -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 +1>&2 echo. +1>&2 echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +1>&2 echo. +1>&2 echo Please set the JAVA_HOME variable in your environment to match the +1>&2 echo location of your Java installation. "%COMSPEC%" /c exit 1 +goto exitWithErrorLevel :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% @@ -59,24 +87,26 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute -echo. 1>&2 -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 +1>&2 echo. +1>&2 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +1>&2 echo. +1>&2 echo Please set the JAVA_HOME variable in your environment to match the +1>&2 echo location of your Java installation. "%COMSPEC%" /c exit 1 +goto exitWithErrorLevel :execute @rem Setup the command line -@rem Execute Gradle +@rem Execute gradlew @rem endlocal doesn't take effect until after the line is parsed and variables are expanded @rem which allows us to clear the local environment before executing the java command -endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel & goto exitWithErrorLevel +@rem This label must not be changed. We rely on old scripts being able to jump to this point. :exitWithErrorLevel @rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts "%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/lint.xml b/lint.xml new file mode 100644 index 00000000..49e825b2 --- /dev/null +++ b/lint.xml @@ -0,0 +1,4 @@ + + + + diff --git a/renovate.json b/renovate.json index bba12817..5f9021d9 100644 --- a/renovate.json +++ b/renovate.json @@ -11,7 +11,14 @@ }, { "groupName": "Github Actions", - "matchManagers": ["github-actions"] + "matchManagers": ["github-actions"], + "pinDigests": true + }, + { + "groupName": "Android TV Dependencies", + "matchPackageNames": [ + "androidx.tv:**" + ] }, { "groupName": "Androidx Media3", diff --git a/settings.gradle.kts b/settings.gradle.kts index a9195399..76b787aa 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -29,3 +29,6 @@ dependencyResolutionManagement { rootProject.name = "Wholphin" include(":app") +include(":wholphin-mpv-stub") +include(":wolfskin-design") +include(":conductarr-client") diff --git a/wholphin-mpv-stub/.gitignore b/wholphin-mpv-stub/.gitignore new file mode 100644 index 00000000..796b96d1 --- /dev/null +++ b/wholphin-mpv-stub/.gitignore @@ -0,0 +1 @@ +/build diff --git a/wholphin-mpv-stub/README.md b/wholphin-mpv-stub/README.md new file mode 100644 index 00000000..67956101 --- /dev/null +++ b/wholphin-mpv-stub/README.md @@ -0,0 +1,5 @@ +# wholphin-mpv-stub + +This is a non-functional stub implementation for the `wholphin-mpv` library in https://github.com/damontecres/wholphin-extensions. + +This provides the minimal API so that Wholphin can compile without `wholphin-mpv`, but any runtime usage will error out. diff --git a/wholphin-mpv-stub/build.gradle.kts b/wholphin-mpv-stub/build.gradle.kts new file mode 100644 index 00000000..9017f416 --- /dev/null +++ b/wholphin-mpv-stub/build.gradle.kts @@ -0,0 +1,40 @@ +plugins { + alias(libs.plugins.android.library) +} + +private fun Provider.getInt() = get().toInt() + +android { + namespace = "com.github.damontecres.wholphin.mpv" + compileSdk { + version = release(libs.versions.compileSdk.getInt()) + } + + defaultConfig { + minSdk = libs.versions.minSdk.getInt() + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } +} + +dependencies { + implementation(libs.androidx.media3.exoplayer) + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) +} diff --git a/wholphin-mpv-stub/consumer-rules.pro b/wholphin-mpv-stub/consumer-rules.pro new file mode 100644 index 00000000..e69de29b diff --git a/wholphin-mpv-stub/proguard-rules.pro b/wholphin-mpv-stub/proguard-rules.pro new file mode 100644 index 00000000..f1b42451 --- /dev/null +++ b/wholphin-mpv-stub/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/wholphin-mpv-stub/src/main/AndroidManifest.xml b/wholphin-mpv-stub/src/main/AndroidManifest.xml new file mode 100644 index 00000000..8bdb7e14 --- /dev/null +++ b/wholphin-mpv-stub/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + diff --git a/wholphin-mpv-stub/src/main/java/com/github/damontecres/wholphin/mpv/MPVLib.kt b/wholphin-mpv-stub/src/main/java/com/github/damontecres/wholphin/mpv/MPVLib.kt new file mode 100644 index 00000000..d7c993f1 --- /dev/null +++ b/wholphin-mpv-stub/src/main/java/com/github/damontecres/wholphin/mpv/MPVLib.kt @@ -0,0 +1,26 @@ +package com.github.damontecres.wholphin.mpv + +/** + * Stubs out MPVLib in wholphin-mpv + */ +object MPVLib { + fun setPropertyString( + key: String, + value: String, + ): Unit = throw MpvStubException() + + fun setPropertyInt( + key: String, + value: Int, + ): Unit = throw MpvStubException() + + fun setPropertyDouble( + key: String, + value: Double, + ): Unit = throw MpvStubException() + + fun setPropertyBoolean( + key: String, + value: Boolean, + ): Unit = throw MpvStubException() +} diff --git a/wholphin-mpv-stub/src/main/java/com/github/damontecres/wholphin/mpv/MpvPlayer.kt b/wholphin-mpv-stub/src/main/java/com/github/damontecres/wholphin/mpv/MpvPlayer.kt new file mode 100644 index 00000000..fd4e0e8d --- /dev/null +++ b/wholphin-mpv-stub/src/main/java/com/github/damontecres/wholphin/mpv/MpvPlayer.kt @@ -0,0 +1,22 @@ +package com.github.damontecres.wholphin.mpv + +import android.content.Context +import android.os.Looper +import androidx.annotation.OptIn +import androidx.media3.common.SimpleBasePlayer +import androidx.media3.common.util.UnstableApi +import kotlin.time.Duration + +/** + * Stubs out MpvPlayer in wholphin-mpv + */ +@OptIn(UnstableApi::class) +class MpvPlayer( + private val context: Context, + private val enableHardwareDecoding: Boolean, + private val useGpuNext: Boolean, +) : SimpleBasePlayer(Looper.getMainLooper()) { + override fun getState(): State = throw MpvStubException() + + var subtitleDelay: Duration = throw MpvStubException() +} diff --git a/wholphin-mpv-stub/src/main/java/com/github/damontecres/wholphin/mpv/MpvStubException.kt b/wholphin-mpv-stub/src/main/java/com/github/damontecres/wholphin/mpv/MpvStubException.kt new file mode 100644 index 00000000..713d40c3 --- /dev/null +++ b/wholphin-mpv-stub/src/main/java/com/github/damontecres/wholphin/mpv/MpvStubException.kt @@ -0,0 +1,3 @@ +package com.github.damontecres.wholphin.mpv + +class MpvStubException : Exception("This build is using wholphin-mpv-stub which does not implement actual functionality") diff --git a/wolfskin-design/build.gradle.kts b/wolfskin-design/build.gradle.kts new file mode 100644 index 00000000..d1275edb --- /dev/null +++ b/wolfskin-design/build.gradle.kts @@ -0,0 +1,68 @@ +import java.security.MessageDigest +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.compose) +} + +android { + namespace = "org.justinvisser.wolfskin.design" + compileSdk = libs.versions.compileSdk.get().toInt() + defaultConfig { minSdk = libs.versions.minSdk.get().toInt() } + buildFeatures { compose = true } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + testOptions.unitTests.isIncludeAndroidResources = true +} +kotlin.compilerOptions.jvmTarget = JvmTarget.JVM_11 + +val robolectricSdk by configurations.creating { + isCanBeResolved = true + isCanBeConsumed = false + isTransitive = false +} +val cacheRobolectricSdk by tasks.registering(Copy::class) { + from(robolectricSdk) + into(layout.buildDirectory.dir("robolectric-sdk")) +} + +val checkTokenDrift by tasks.registering { + inputs.files(fileTree(projectDir) { include("tokens.json", "shell-tokens.json", "title-tokens.json", "home-tokens.json", "discover-tokens.json", "generate_tokens.py", "tokens.css", "tokens.sha256", "src/main/java/**/WolfskinTokens.kt") }) + doLast { + file("tokens.sha256").readLines().forEach { line -> + val (expected, name) = line.split(" ", limit = 2) + val actual = MessageDigest.getInstance("SHA-256").digest(file(name).readBytes()).joinToString("") { "%02x".format(it) } + check(actual == expected) { "Token drift in $name: run python3 wolfskin-design/generate_tokens.py" } + } + } +} +tasks.named("preBuild") { dependsOn(checkTokenDrift) } +tasks.withType().configureEach { + dependsOn(cacheRobolectricSdk) + systemProperty("robolectric.dependency.dir", layout.buildDirectory.dir("robolectric-sdk").get().asFile.absolutePath) + systemProperty("robolectric.offline", "true") + systemProperty("robolectric.graphicsMode", "NATIVE") + systemProperty("roborazzi.test.record", "true") + systemProperty("wolfskin.captureDirectory", rootProject.layout.buildDirectory.dir("wolfskin-captures").get().asFile.absolutePath) +} + +dependencies { + robolectricSdk("org.robolectric:android-all-instrumented:9-robolectric-4913185-2-i7") + robolectricSdk("org.robolectric:android-all-instrumented:11-robolectric-6757853-i7") + robolectricSdk("org.robolectric:android-all-instrumented:14-robolectric-10818077-i7") + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.tv.material) + implementation(libs.coil.compose) + debugImplementation(libs.androidx.compose.ui.test.manifest) + testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.robolectric) + implementation(libs.androidx.compose.material3) + testImplementation(libs.androidx.compose.ui.test.junit4) + testImplementation("io.github.takahirom.roborazzi:roborazzi:1.77.0") + testImplementation("io.github.takahirom.roborazzi:roborazzi-compose:1.77.0") +} diff --git a/wolfskin-design/discover-tokens.json b/wolfskin-design/discover-tokens.json new file mode 100644 index 00000000..bf45e265 --- /dev/null +++ b/wolfskin-design/discover-tokens.json @@ -0,0 +1,280 @@ +{ + "discover": { + "shelfHeadingBand": {"dp": 52, "px": 104, "source": "feedback-2026-11-06: heading-to-card gap 60 px on every Discover lane"}, + "sheetPaddingX": { + "$type": "dimension", + "px": 64, + "dp": 32, + "source": "locked index.html:227" + }, + "sheetPaddingY": { + "$type": "dimension", + "px": 80, + "dp": 40, + "source": "locked index.html:227" + }, + "sheetGap": { + "$type": "dimension", + "px": 18, + "dp": 9, + "source": "locked index.html:227" + }, + "sheetSubGap": { + "$type": "dimension", + "px": 14, + "dp": 7, + "source": "locked index.html:229" + }, + "sheetRowPadding": { + "$type": "dimension", + "px": 26, + "dp": 13, + "source": "locked index.html:231" + }, + "sheetAvatar": { + "$type": "dimension", + "px": 60, + "dp": 30, + "source": "locked index.html:232" + }, + "sheetRadio": { + "$type": "dimension", + "px": 40, + "dp": 20, + "source": "locked index.html:234" + }, + "sheetRadioDot": { + "$type": "dimension", + "px": 16, + "dp": 8, + "source": "locked index.html:236" + }, + "sheetPersonGap": { + "$type": "dimension", + "px": 24, + "dp": 12, + "source": "locked index.html:231" + }, + "sheetButtonTop": { + "$type": "dimension", + "px": 12, + "dp": 6, + "source": "locked index.html:241" + }, + "chipTop": { + "$type": "dimension", + "px": 16, + "dp": 8, + "source": "locked index.html:184" + }, + "chipHeight": { + "$type": "dimension", + "px": 68, + "dp": 34, + "source": "locked index.html:185" + }, + "chipPadding": { + "$type": "dimension", + "px": 28, + "dp": 14, + "source": "locked index.html:185" + }, + "chipGap": { + "$type": "dimension", + "px": 16, + "dp": 8, + "source": "locked index.html:184" + }, + "heroTitleGap": { + "$type": "dimension", + "px": 16, + "dp": 8, + "source": "locked index.html:103" + }, + "heroKickerGap": { + "$type": "dimension", + "px": 10, + "dp": 5, + "source": "locked index.html:101" + }, + "heroMetaGap": { + "$type": "dimension", + "px": 14, + "dp": 7, + "source": "locked index.html:104" + }, + "heroOverviewGap": { + "$type": "dimension", + "px": 30, + "dp": 15, + "source": "locked index.html:105" + }, + "heroFactsGap": { + "$type": "dimension", + "px": 22, + "dp": 11, + "source": "locked index.html:104" + }, + "heroFactsHeight": { + "$type": "dimension", + "px": 38, + "dp": 19, + "source": "locked index.html:104" + }, + "heroSheetRight": { + "$type": "dimension", + "px": 180, + "dp": 90, + "source": "locked index.html:97" + }, + "heroSheetTop": { + "$type": "dimension", + "px": 140, + "dp": 70, + "source": "locked index.html:98" + }, + "heroSheetWidth": { + "$type": "dimension", + "px": 360, + "dp": 180, + "source": "locked index.html:98" + }, + "heroSheetHeight": { + "$type": "dimension", + "px": 540, + "dp": 270, + "source": "locked index.html:98" + }, + "dotSize": { + "$type": "dimension", + "px": 14, + "dp": 7, + "source": "locked index.html:114" + }, + "dotActive": { + "$type": "dimension", + "px": 44, + "dp": 22, + "source": "locked index.html:115" + }, + "dotGap": { + "$type": "dimension", + "px": 14, + "dp": 7, + "source": "locked index.html:113" + }, + "dotMargin": { + "$type": "dimension", + "px": 18, + "dp": 9, + "source": "locked index.html:113" + }, + "dotChevron": { + "$type": "dimension", + "px": 40, + "dp": 20, + "source": "locked index.html:116" + }, + "stateTop": { + "$type": "dimension", + "px": 20, + "dp": 10, + "source": "locked index.html:194" + }, + "statePaddingX": { + "$type": "dimension", + "px": 40, + "dp": 20, + "source": "locked index.html:194" + }, + "statePaddingY": { + "$type": "dimension", + "px": 34, + "dp": 17, + "source": "locked index.html:194" + }, + "stateTextGap": { + "$type": "dimension", + "px": 24, + "dp": 12, + "source": "locked index.html:198" + }, + "noteTop": { + "$type": "dimension", + "px": 14, + "dp": 7, + "source": "locked index.html:191" + }, + "headerTop": { + "$type": "dimension", + "px": 150, + "dp": 75, + "source": "locked index.html:199" + }, + "headerGap": { + "$type": "dimension", + "px": 24, + "dp": 12, + "source": "locked index.html:202" + }, + "entryPaddingX": { + "$type": "dimension", + "px": 22, + "dp": 11, + "source": "locked index.html:149" + }, + "entryPaddingY": { + "$type": "dimension", + "px": 20, + "dp": 10, + "source": "locked index.html:149" + }, + "entryText": { + "$type": "typography", + "px": 30, + "sp": 15, + "weight": 800, + "letterSpacingEm": -0.01, + "$value": { + "fontSize": "15sp", + "fontWeight": 800 + }, + "source": "locked index.html:149" + }, + "sheetRadioRing": { + "$type": "dimension", + "px": 3, + "dp": 1.5, + "source": "locked index.html:234" + }, + "skeletonNameWidth": { + "$type": "dimension", + "px": 260, + "dp": 130, + "source": "locked index.html:1851" + }, + "skeletonNameHeight": { + "$type": "dimension", + "px": 34, + "dp": 17, + "source": "locked index.html:1851" + }, + "sheetSubTop": {"$type": "dimension", "px": -8, "dp": -4, "source": "locked index.html:229"}, + "sheetRatingTop": {"$type": "dimension", "px": -10, "dp": -5, "source": "locked index.html:168"}, + "sheetRatingBottom": {"$type": "dimension", "px": 6, "dp": 3, "source": "locked index.html:168"}, + "sheetLabelTop": {"$type": "dimension", "px": 8, "dp": 4, "source": "locked index.html:230"}, + "noteIcon": {"$type": "dimension", "px": 30, "dp": 15, "source": "locked index.html:192"}, + "noteGap": {"$type": "dimension", "px": 16, "dp": 8, "source": "locked index.html:191"}, + "frameBottomMin": {"$type": "dimension", "px": 80, "dp": 40, "source": "locked index.html:1042"}, + "anchorFitTop": {"$type": "dimension", "px": 700, "dp": 350, "source": "locked index.html:1062"}, + "anchorBottomClearance": {"$type": "dimension", "px": 40, "dp": 20, "source": "locked index.html:1062"}, + "reasonOpacity": {"$type": "number", "$value": 0.8, "source": "locked index.html:240"}, + "stageGap": {"$type": "dimension", "px": 10, "dp": 5, "source": "locked index.html:220"}, + "entryBorderOpacity": {"$type": "number", "$value": 0.08, "source": "locked index.html:149"}, + "ambientInset": {"$type": "dimension", "px": 80, "dp": 40, "source": "locked index.html:96"}, + "ambientBlur": {"$type": "dimension", "px": 60, "dp": 30, "source": "locked index.html:96"}, + "ambientScale": {"$type": "number", "$value": 1.1, "source": "locked index.html:96"}, + "ambientSaturation": {"$type": "number", "$value": 1.2, "source": "locked index.html:96"}, + "ambientBrightness": {"$type": "number", "$value": 0.55, "source": "locked index.html:96"} + } +} diff --git a/wolfskin-design/generate_request_marks.py b/wolfskin-design/generate_request_marks.py new file mode 100755 index 00000000..bba6a960 --- /dev/null +++ b/wolfskin-design/generate_request_marks.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 +"""Rasterize locked prototype rating marks at the Coming-in row's native pixel size.""" +from pathlib import Path +import hashlib +import json +import cairosvg + +ROOT = Path(__file__).resolve().parent +SOURCE = ROOT / "request-marks" +OUTPUT = ROOT / "src/main/res/drawable-nodpi" + + +def main() -> None: + """Keep the original SVGs and record hashes alongside generated television-size PNGs.""" + OUTPUT.mkdir(parents=True, exist_ok=True) + tokens = json.loads((ROOT / "tokens.json").read_text()) + supplement = json.loads((ROOT / "shell-tokens.json").read_text()) + height = int(tokens["type"]["label"]["px"]) + wordmark_ratio = supplement["requests"]["ratingsWordmarkRatio"]["$value"] + manifest = [] + for source in sorted(SOURCE.glob("*.svg")): + target = OUTPUT / f"coming_in_rating_{source.stem}.png" + width = round(height * wordmark_ratio) if source.stem in ("imdb", "tmdb") else height + cairosvg.svg2png(url=str(source), write_to=str(target), output_width=width, output_height=height) + for path in (source, target): + manifest.append(f"{hashlib.sha256(path.read_bytes()).hexdigest()} {path.relative_to(ROOT)}") + (ROOT / "request-marks.sha256").write_text("\n".join(manifest) + "\n") + + +if __name__ == "__main__": + main() diff --git a/wolfskin-design/generate_tokens.py b/wolfskin-design/generate_tokens.py new file mode 100755 index 00000000..51a49b20 --- /dev/null +++ b/wolfskin-design/generate_tokens.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +"""Generate Compose and CSS tokens and a build-verifiable drift manifest.""" +from __future__ import annotations + +import argparse +import hashlib +import json +from pathlib import Path + +ROOT = Path(__file__).resolve().parent +KOTLIN = Path("src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt") + + +def number(value: float) -> str: + """Render stable Kotlin decimal literals.""" + return str(float(value)) + + +def property_name(name: str) -> str: + """Keep source names valid as Kotlin identifiers.""" + return name.replace("-", "_") + + +def compose_weight(weight: int) -> str: + """Use the platform bold face for intermediate weights that resolve to Regular in native captures.""" + return "FontWeight.Bold" if 600 <= weight <= 700 else f"FontWeight({weight})" + + +def render_tokens(tokens: dict) -> tuple[str, str]: + """Translate locked values into platform-resolvable Compose and CSS tokens.""" + lines = [ + "// Generated by generate_tokens.py; edit the JSON sources instead.", + "package org.justinvisser.wolfskin.design", + "", + "import androidx.compose.ui.graphics.Color as ComposeColor", + "import androidx.compose.ui.text.TextStyle", + "import androidx.compose.ui.text.font.FontWeight", + "import androidx.compose.ui.unit.dp", + "import androidx.compose.ui.unit.em", + "import androidx.compose.ui.unit.sp", + "", + "/** The locked TV design, converted at the source density. */", + "object WolfskinTokens {", + ] + css = ["/* Generated from the locked TV token sources. */", ":root {"] + for group_name, group in tokens.items(): + if group_name.startswith("$") or group_name in ("meta", "font"): + continue + lines.append(f" object {group_name[0].upper() + group_name[1:]} {{") + for name, token in group.items(): + if not isinstance(token, dict): + continue + name = property_name(name) + kind = token.get("$type") + value = token.get("$value") + if kind == "color": + rgba = value.lstrip("#") + argb = (rgba[6:8] if len(rgba) == 8 else "ff") + rgba[:6] + lines.append(f" val {name} = ComposeColor(0x{argb.upper()})") + css.append(f" --{group_name}-{name}: {value};") + elif kind == "typography" and "sp" in token: + size = token["sp"] + weight = token.get("weight", token["$value"]["fontWeight"]) + height = size * token.get("lineHeight", 1.3) + tracking = token.get("letterSpacingEm", 0) + lines.append(f" val {name} = TextStyle(fontSize = {number(size)}.sp, fontWeight = {compose_weight(weight)}, lineHeight = {number(height)}.sp, letterSpacing = {number(tracking)}.em)") + css.append(f" --{group_name}-{name}: {number(token['px'])}px;") + elif kind == "typography" and "sizes" in token: + for context, pixels in token["sizes"].items(): + lines.append(f" val {name}{context[0].upper() + context[1:]} = TextStyle(fontSize = {number(pixels / 2)}.sp, fontWeight = {compose_weight(token['weight'])})") + elif kind == "duration": + lines.append(f" const val {name} = {int(value.removesuffix('ms'))}") + css.append(f" --{group_name}-{name}: {value};") + elif kind == "transition" and value.get("type") == "spring": + lines.append(f" const val {name}DampingRatio = {number(value['dampingRatio'])}f") + lines.append(f" const val {name}Stiffness = {number(value['stiffness'])}f") + elif kind == "shadow": + for suffix in ("gapPx", "ringPx"): + lines.append(f" val {name}{suffix[0].upper() + suffix[1:-2]} = {number(token[suffix] / 2)}.dp") + elif isinstance(token.get("dp"), (float, int)): + lines.append(f" val {name} = {number(token['dp'])}.dp") + css.append(f" --{group_name}-{name}: {number(token['px'])}px;") + elif isinstance(token.get("dp"), list): + for suffix, dimension in zip(("Width", "Height"), token["dp"]): + lines.append(f" val {name}{suffix} = {number(dimension)}.dp") + elif isinstance(token.get("dp"), dict): + for suffix, dimension in token["dp"].items(): + lines.append(f" val {name}{suffix[0].upper() + suffix[1:]} = {number(dimension)}.dp") + if kind == "card": + lines.append(f" const val {name}Slots = {token['fullSlotsPerRow']}") + elif group_name == "image" and isinstance(token.get("px"), list): + for suffix, dimension in zip(("Width", "Height"), token["px"]): + lines.append(f" const val {name}{suffix} = {int(dimension)}") + elif group_name == "image" and isinstance(value, dict): + for suffix, count in value.items(): + lines.append(f" const val {name}{suffix[0].upper() + suffix[1:]} = {int(count)}") + elif isinstance(value, (float, int)) and not isinstance(value, bool): + lines.append(f" const val {name} = {number(value)}f") + elif kind in ("gradient", "filter", "rule") and isinstance(value, str): + lines.append(f" const val {name} = {json.dumps(value)}") + if kind == "gradient": + css.append(f" --{group_name}-{name}: {value};") + lines.append(" }") + lines.append("}") + css.append("}") + return "\n".join(lines) + "\n", "\n".join(css) + "\n" + + +def main() -> None: + """Write generated artifacts, or fail when committed artifacts drift.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--check", action="store_true") + arguments = parser.parse_args() + tokens = json.loads((ROOT / "tokens.json").read_text()) + tokens.update(json.loads((ROOT / "shell-tokens.json").read_text())) + tokens.update(json.loads((ROOT / "title-tokens.json").read_text())) + tokens.update(json.loads((ROOT / "home-tokens.json").read_text())) + tokens.update(json.loads((ROOT / "discover-tokens.json").read_text())) + kotlin, css = render_tokens(tokens) + outputs = {str(KOTLIN): kotlin, "tokens.css": css} + inputs = ["tokens.json", "shell-tokens.json", "title-tokens.json", "home-tokens.json", "discover-tokens.json", "generate_tokens.py"] + checksums = {name: hashlib.sha256((ROOT / name).read_bytes()).hexdigest() for name in inputs} + checksums.update({name: hashlib.sha256(content.encode()).hexdigest() for name, content in outputs.items()}) + outputs["tokens.sha256"] = "".join(f"{checksum} {name}\n" for name, checksum in sorted(checksums.items())) + for name, content in outputs.items(): + path = ROOT / name + if arguments.check: + if not path.exists() or path.read_text() != content: + raise SystemExit(f"Token drift: run python3 wolfskin-design/generate_tokens.py ({name})") + else: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content) + print("Token drift check passed" if arguments.check else "Generated Kotlin, CSS and drift manifest") + + +if __name__ == "__main__": + main() diff --git a/wolfskin-design/home-tokens.json b/wolfskin-design/home-tokens.json new file mode 100644 index 00000000..a574a31d --- /dev/null +++ b/wolfskin-design/home-tokens.json @@ -0,0 +1,322 @@ +{ + "home": { + "kickerGap": { + "$type": "dimension", + "px": 12, + "dp": 6.0, + "source": "locked index.html:94-165,180-198,1014-1029,1276-1289" + }, + "kickerBottom": { + "$type": "dimension", + "px": 10, + "dp": 5.0, + "source": "locked index.html:101" + }, + "titleBottom": { + "$type": "dimension", + "px": 16, + "dp": 8.0, + "source": "locked index.html:103" + }, + "factsHeight": { + "$type": "dimension", + "px": 38, + "dp": 19.0, + "source": "locked index.html:94-165,180-198,1014-1029,1276-1289" + }, + "factsBottom": { + "$type": "dimension", + "px": 14, + "dp": 7.0, + "source": "locked index.html:94-165,180-198,1014-1029,1276-1289" + }, + "factsGap": { + "$type": "dimension", + "px": 22, + "dp": 11.0, + "source": "locked index.html:94-165,180-198,1014-1029,1276-1289" + }, + "overviewBottom": { + "$type": "dimension", + "px": 30, + "dp": 15.0, + "source": "locked index.html:94-165,180-198,1014-1029,1276-1289" + }, + "cardPadding": { + "$type": "dimension", + "px": 22, + "dp": 11.0, + "source": "locked index.html:94-165,180-198,1014-1029,1276-1289" + }, + "cardOverlayTop": { + "$type": "dimension", + "px": 54, + "dp": 27.0, + "source": "locked index.html:94-165,180-198,1014-1029,1276-1289" + }, + "cardOverlayBottom": { + "$type": "dimension", + "px": 18, + "dp": 9.0, + "source": "locked index.html:94-165,180-198,1014-1029,1276-1289" + }, + "sectionTop": { + "$type": "dimension", + "px": 40, + "dp": 20.0, + "source": "locked index.html:94-165,180-198,1014-1029,1276-1289" + }, + "sectionAvatar": { + "$type": "dimension", + "px": 56, + "dp": 28.0, + "source": "locked index.html:94-165,180-198,1014-1029,1276-1289" + }, + "sectionGap": { + "$type": "dimension", + "px": 22, + "dp": 11.0, + "source": "locked index.html:180" + }, + "stateTextBottom": { + "$type": "dimension", + "px": 24, + "dp": 12.0, + "source": "locked index.html:198" + }, + "stateTop": { + "$type": "dimension", + "px": 20, + "dp": 10.0, + "source": "locked index.html:194" + }, + "statePaddingX": { + "$type": "dimension", + "px": 40, + "dp": 20.0, + "source": "locked index.html:94-165,180-198,1014-1029,1276-1289" + }, + "statePaddingY": { + "$type": "dimension", + "px": 34, + "dp": 17.0, + "source": "locked index.html:94-165,180-198,1014-1029,1276-1289" + }, + "stateGap": { + "$type": "dimension", + "px": 8, + "dp": 4.0, + "source": "locked index.html:195" + }, + "stateIcon": { + "$type": "dimension", + "px": 36, + "dp": 18.0, + "source": "locked index.html:94-165,180-198,1014-1029,1276-1289" + }, + "captionWidth": { + "$type": "dimension", + "px": 640, + "dp": 320.0, + "source": "locked index.html:94-165,180-198,1014-1029,1276-1289" + }, + "plateTop": { + "$type": "dimension", + "px": 14, + "dp": 7.0, + "source": "locked index.html:94-165,180-198,1014-1029,1276-1289" + }, + "plateSide": { + "$type": "dimension", + "px": 16, + "dp": 8.0, + "source": "locked index.html:94-165,180-198,1014-1029,1276-1289" + }, + "plateFade": { + "$type": "dimension", + "px": 46, + "dp": 23.0, + "source": "locked index.html:94-165,180-198,1014-1029,1276-1289" + }, + "ratingGap": { + "$type": "dimension", + "px": 20, + "dp": 10.0, + "source": "locked index.html:94-165,180-198,1014-1029,1276-1289" + }, + "ratingMarkGap": { + "$type": "dimension", + "px": 8, + "dp": 4.0, + "source": "locked index.html:94-165,180-198,1014-1029,1276-1289" + }, + "heroSkeletonWidth": { + "$type": "dimension", + "px": 640, + "dp": 320.0, + "source": "locked index.html:94-165,180-198,1014-1029,1276-1289" + }, + "heroSkeletonHeight": { + "$type": "dimension", + "px": 84, + "dp": 42.0, + "source": "locked index.html:94-165,180-198,1014-1029,1276-1289" + }, + "heroSkeletonFactsWidth": { + "$type": "dimension", + "px": 460, + "dp": 230.0, + "source": "locked index.html:94-165,180-198,1014-1029,1276-1289" + }, + "heroSkeletonFactsHeight": { + "$type": "dimension", + "px": 34, + "dp": 17.0, + "source": "locked index.html:94-165,180-198,1014-1029,1276-1289" + }, + "heroSkeletonButtonWidth": { + "$type": "dimension", + "px": 260, + "dp": 130.0, + "source": "locked index.html:94-165,180-198,1014-1029,1276-1289" + }, + "heroSkeletonGap": { + "$type": "dimension", + "px": 22, + "dp": 11.0, + "source": "locked index.html:94-165,180-198,1014-1029,1276-1289" + }, + "heroSkeletonButtonGap": { + "$type": "dimension", + "px": 40, + "dp": 20.0, + "source": "locked index.html:94-165,180-198,1014-1029,1276-1289" + }, + "artGlyph": { + "$type": "dimension", + "px": 44, + "dp": 22.0, + "source": "locked index.html:94-165,180-198,1014-1029,1276-1289" + }, + "cardSheetLeft": { + "$type": "dimension", + "px": 22, + "dp": 11.0, + "source": "locked index.html:94-165,180-198,1014-1029,1276-1289" + }, + "cardSheetTop": { + "$type": "dimension", + "px": 18, + "dp": 9.0, + "source": "locked index.html:94-165,180-198,1014-1029,1276-1289" + }, + "cardSheetInset": { + "$type": "dimension", + "px": 36, + "dp": 18.0, + "source": "locked index.html:94-165,180-198,1014-1029,1276-1289" + }, + "heroSheetRight": { + "$type": "dimension", + "px": 180, + "dp": 90.0, + "source": "locked index.html:94-165,180-198,1014-1029,1276-1289" + }, + "heroSheetTop": { + "$type": "dimension", + "px": 150, + "dp": 75.0, + "source": "locked index.html:94-165,180-198,1014-1029,1276-1289" + }, + "cardBlur": { + "$type": "dimension", + "px": 26, + "dp": 13.0, + "source": "locked index.html:94-165,180-198,1014-1029,1276-1289" + }, + "heroBlur": { + "$type": "dimension", + "px": 60, + "dp": 30.0, + "source": "locked index.html:94-165,180-198,1014-1029,1276-1289" + }, + "progressInset": { + "$type": "dimension", + "px": 18, + "dp": 9.0, + "source": "locked index.html:94-165,180-198,1014-1029,1276-1289" + }, + "progressBottom": { + "$type": "dimension", + "px": 10, + "dp": 5.0, + "source": "locked index.html:94-165,180-198,1014-1029,1276-1289" + }, + "endTop": { + "$type": "dimension", + "px": 40, + "dp": 20.0, + "source": "locked index.html:94-165,180-198,1014-1029,1276-1289" + }, + "frameBottom": { + "$type": "dimension", + "px": 80, + "dp": 40.0, + "source": "locked index.html:1042" + }, + "toastPadding": { + "$type": "dimension", + "px": 28, + "dp": 14.0, + "source": "locked index.html:94-165,180-198,1014-1029,1276-1289" + }, + "toastTop": { + "$type": "dimension", + "px": 150, + "dp": 75.0, + "source": "locked index.html:94-165,180-198,1014-1029,1276-1289" + }, + "toastWidth": { + "$type": "dimension", + "px": 760, + "dp": 380.0, + "source": "locked index.html:94-165,180-198,1014-1029,1276-1289" + }, + "toastPaddingY": { "$type": "dimension", "px": 22, "dp": 11, "source": "locked index.html:270" }, + "toastGap": { "$type": "dimension", "px": 14, "dp": 7, "source": "locked index.html:270" }, + "toastShadowBlur": { "$type": "dimension", "px": 60, "dp": 30, "source": "locked index.html:270" }, + "toastShadowY": { "$type": "dimension", "px": 20, "dp": 10, "source": "locked index.html:270" }, + "toastShadowOpacity": { "$type": "number", "$value": 0.5, "source": "locked index.html:270" }, + "cardWashInset": { + "$type": "dimension", + "px": 30, + "dp": 15.0, + "source": "locked index.html:129" + }, + "heroWashInset": { + "$type": "dimension", + "px": 80, + "dp": 40.0, + "source": "locked index.html:96" + }, + "noteTop": { + "$type": "dimension", + "px": 14, + "dp": 7.0, + "source": "locked index.html:191" + }, + "noteGap": { + "$type": "dimension", + "px": 16, + "dp": 8.0, + "source": "locked index.html:191" + }, + "hairline": { "$type": "dimension", "px": 2, "dp": 1.0, "source": "locked index.html:87,146,194" }, + "savedHeight": { "$type": "dimension", "px": 52, "dp": 26.0, "source": "locked index.html:87" }, + "savedPadding": { "$type": "dimension", "px": 20, "dp": 10.0, "source": "locked index.html:87" }, + "savedGap": { "$type": "dimension", "px": 10, "dp": 5.0, "source": "locked index.html:87" }, + "savedIcon": { "$type": "dimension", "px": 28, "dp": 14.0, "source": "locked index.html:88" }, + "savedRadius": { "$type": "dimension", "px": 28, "dp": 14.0, "source": "locked index.html:87" }, + "posterProgressBottom": { "$type": "dimension", "px": 12, "dp": 6.0, "source": "locked index.html:138-140" } + } +} diff --git a/wolfskin-design/request-marks.sha256 b/wolfskin-design/request-marks.sha256 new file mode 100644 index 00000000..c29ddefc --- /dev/null +++ b/wolfskin-design/request-marks.sha256 @@ -0,0 +1,12 @@ +01aa8a3c3e9db48f0e1c6af25056940986784315bf66f48a8120a7317bf6d75a request-marks/fresh.svg +743b6139dc12019d0940568869065722593f44ce416a7e9c7b8d9ef92942bb3f src/main/res/drawable-nodpi/coming_in_rating_fresh.png +27f93b15a075b015e2448eca7949f7a3da03c37fbf26cecf6df3bdab45fda993 request-marks/imdb.svg +a738afd57519b1b4a38192927b48bd45755ce75e4673c6b12b9b3e0c2e388372 src/main/res/drawable-nodpi/coming_in_rating_imdb.png +510d0f9fb77d72190ebf03208f04215a4e86f71e165488518ac75aa49cf4a79d request-marks/rotten.svg +0aade711ba57b6b201c7395ece97108df928b6076d8120404cdf4155a4242632 src/main/res/drawable-nodpi/coming_in_rating_rotten.png +b8dcf4d27eba2446380980c96d82b1f64aa6859fa2ea1cdab867f849f9fec4fd request-marks/spilled.svg +fa4f50258009cd1d16f0d0f5a3c216dc88a57409b1bccc379512ae914cc9d714 src/main/res/drawable-nodpi/coming_in_rating_spilled.png +d537fb228cf3ded904ef09b136fe3fec72548ebc1fea3fbbd1ad9e36364db38b request-marks/tmdb.svg +8e1cb507f876f324bb5c53bf4e60775712f1fdddbffe94fd249e6e74099e045e src/main/res/drawable-nodpi/coming_in_rating_tmdb.png +2ad511c7a101a7f2f8810434911df8c13cc126fa691272242037237172600bc3 request-marks/upright.svg +6ff8a05501bc1c15b96c0130271c41baaa2ddb74f64916f58b9e7f918931d789 src/main/res/drawable-nodpi/coming_in_rating_upright.png diff --git a/wolfskin-design/request-marks/fresh.svg b/wolfskin-design/request-marks/fresh.svg new file mode 100644 index 00000000..977253a9 --- /dev/null +++ b/wolfskin-design/request-marks/fresh.svg @@ -0,0 +1,20 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + diff --git a/wolfskin-design/request-marks/imdb.svg b/wolfskin-design/request-marks/imdb.svg new file mode 100644 index 00000000..b2a908bc --- /dev/null +++ b/wolfskin-design/request-marks/imdb.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/wolfskin-design/request-marks/rotten.svg b/wolfskin-design/request-marks/rotten.svg new file mode 100644 index 00000000..04bef05b --- /dev/null +++ b/wolfskin-design/request-marks/rotten.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/wolfskin-design/request-marks/spilled.svg b/wolfskin-design/request-marks/spilled.svg new file mode 100644 index 00000000..af36063b --- /dev/null +++ b/wolfskin-design/request-marks/spilled.svg @@ -0,0 +1,46 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/wolfskin-design/request-marks/tmdb.svg b/wolfskin-design/request-marks/tmdb.svg new file mode 100644 index 00000000..62a66055 --- /dev/null +++ b/wolfskin-design/request-marks/tmdb.svg @@ -0,0 +1 @@ +Asset 4 \ No newline at end of file diff --git a/wolfskin-design/request-marks/upright.svg b/wolfskin-design/request-marks/upright.svg new file mode 100644 index 00000000..bf1cc85a --- /dev/null +++ b/wolfskin-design/request-marks/upright.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/wolfskin-design/shell-tokens.json b/wolfskin-design/shell-tokens.json new file mode 100644 index 00000000..e66ac6ec --- /dev/null +++ b/wolfskin-design/shell-tokens.json @@ -0,0 +1,433 @@ +{ + "requests": { + "sheetPaddingY": { "$type": "dimension", "px": 80, "dp": 40, "source": "index.html:227" }, + "sheetPaddingX": { "$type": "dimension", "px": 64, "dp": 32, "source": "index.html:227" }, + "sheetGap": { "$type": "dimension", "px": 18, "dp": 9, "source": "index.html:227" }, + "sheetTitleLineHeight": { "$type": "dimension", "px": 56, "dp": 28, "source": "index.html:228; locked st-083/st-086: content begins at 154px after 80px padding and 18px gap" }, + "sheetTitleBaseline": { "$type": "dimension", "px": 44, "dp": 22, "source": "locked st-083: modal ink-bottom 123px, native baseline boundary 124px minus 80px padding" }, + "titleLineHeight": { "$type": "dimension", "px": 39, "dp": 19.5, "source": "index.html:250; 30px font with 1.3 line box" }, + "titleBaseline": { "$type": "dimension", "px": 31, "dp": 15.5, "source": "locked st-082/st-086: ink-bottom 45px, 15px text-block inset, baseline boundary 1px below ink" }, + "factsLineHeight": { "$type": "dimension", "px": 31.2, "dp": 15.6, "source": "index.html:251; 24px font with 1.3 line box" }, + "factsBaseline": { "$type": "dimension", "px": 24, "dp": 12, "source": "locked st-082/st-086: ink-bottom 77px, 15px block inset, 39px title box and 1px baseline boundary" }, + "stageLineHeight": { "$type": "dimension", "px": 39.6, "dp": 19.8, "source": "index.html:251-252; inline-flex stage within the 28px/1.3 parent strut" }, + "stageLabelLineHeight": { "$type": "dimension", "px": 33.8, "dp": 16.9, "source": "index.html:220; 26px font with 1.3 leading" }, + "stageBaseline": { "$type": "dimension", "px": 24, "dp": 12, "source": "locked st-082/st-086: ink-bottom 112px, 15px block inset, 39px title, 31.2px facts, 4px margin and 1px baseline boundary" }, + "emptyMargin": { "$type": "dimension", "px": 20, "dp": 10, "source": "index.html:1905" }, + "rowPaddingY": { "$type": "dimension", "px": 14, "dp": 7, "source": "index.html:247" }, + "rowPaddingX": { "$type": "dimension", "px": 18, "dp": 9, "source": "index.html:247" }, + "rowGap": { "$type": "dimension", "px": 24, "dp": 12, "source": "index.html:247" }, + "listGap": { "$type": "dimension", "px": 6, "dp": 3, "source": "index.html:255" }, + "stageMargin": { "$type": "dimension", "px": 4, "dp": 2, "source": "index.html:252" }, + "stageGlyph": { "$type": "dimension", "px": 28, "dp": 14, "source": "index.html:221" }, + "stageGap": { "$type": "dimension", "px": 10, "dp": 5, "source": "index.html:220" }, + "readinessGapX": { "$type": "dimension", "px": 22, "dp": 11, "source": "index.html:312" }, + "readinessGapY": { "$type": "dimension", "px": 10, "dp": 5, "source": "index.html:312" }, + "readinessMargin": { "$type": "dimension", "px": 8, "dp": 4, "source": "index.html:312" }, + "readinessGlyph": { "$type": "dimension", "px": 26, "dp": 13, "source": "index.html:314" }, + "chevron": { "$type": "dimension", "px": 30, "dp": 15, "source": "index.html:317" }, + "chevronAlpha": { "$type": "number", "$value": 0.6, "source": "index.html:317" }, + "receiptPaddingY": { "$type": "dimension", "px": 28, "dp": 14, "source": "index.html:243" }, + "receiptPaddingX": { "$type": "dimension", "px": 30, "dp": 15, "source": "index.html:243" }, + "receiptGap": { "$type": "dimension", "px": 10, "dp": 5, "source": "index.html:243" }, + "receiptGlyph": { "$type": "dimension", "px": 36, "dp": 18, "source": "index.html:245" }, + "buttonMargin": { "$type": "dimension", "px": 12, "dp": 6, "source": "index.html:241" }, + "skeletonTitleWidth": { "$type": "dimension", "px": 360, "dp": 180, "source": "index.html:1902" }, + "skeletonTitleHeight": { "$type": "dimension", "px": 34, "dp": 17, "source": "index.html:1902" }, + "skeletonSubtitleWidth": { "$type": "dimension", "px": 240, "dp": 120, "source": "index.html:1902" }, + "skeletonSubtitleHeight": { "$type": "dimension", "px": 26, "dp": 13, "source": "index.html:1902" }, + "skeletonGap": { "$type": "dimension", "px": 12, "dp": 6, "source": "index.html:1902" }, + "ratingsGap": { "$type": "dimension", "px": 20, "dp": 10, "source": "index.html:156" }, + "ratingsEntryGap": { "$type": "dimension", "px": 8, "dp": 4, "source": "index.html:157" }, + "shadowBlur": { "$type": "dimension", "px": 80, "dp": 40, "source": "index.html:227" }, + "shadowOffset": { "$type": "dimension", "px": -30, "dp": -15, "source": "index.html:227" }, + "shadowColor": { "$type": "color", "$value": "#00000080", "source": "index.html:227" }, + "ratingsWordmarkRatio": { "$type": "number", "$value": 1.8, "source": "index.html:160" } + }, + "shell": { + "navLabelFade": { + "$type": "duration", + "$value": "200ms", + "source": "index.html:81; nav label color transition uses 200 ms ease-out" + }, + "zero": { + "$type": "dimension", + "px": 0, + "dp": 0.0, + "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" + }, + "stageWidth": { + "$type": "dimension", + "px": 1920, + "dp": 960.0, + "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" + }, + "stageHeight": { + "$type": "dimension", + "px": 1080, + "dp": 540.0, + "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" + }, + "wordmarkWidth": { + "$type": "dimension", + "px": 169, + "dp": 84.5, + "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" + }, + "wordmarkTop": { + "$type": "dimension", + "px": 73.6, + "dp": 36.8, + "source": "index.html:74,79: centre a 46.8 px line in the 64 px nav item" + }, + "navHomeWidth": { + "$type": "dimension", + "px": 149, + "dp": 74.5, + "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" + }, + "navDiscoverWidth": { + "$type": "dimension", + "px": 198, + "dp": 99.0, + "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" + }, + "navMoviesWidth": { + "$type": "dimension", + "px": 171, + "dp": 85.5, + "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" + }, + "navShowsWidth": { + "$type": "dimension", + "px": 161, + "dp": 80.5, + "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" + }, + "navAnimeWidth": { + "$type": "dimension", + "px": 158, + "dp": 79.0, + "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" + }, + "navItemTop": { + "$type": "dimension", + "px": 65, + "dp": 32.5, + "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" + }, + "pickerTitleTop": { + "$type": "dimension", + "px": 272.3, + "dp": 136.15, + "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" + }, + "pickerPeopleTop": { + "$type": "dimension", + "px": 425.5, + "dp": 212.75, + "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" + }, + "pickerNameGap": { + "$type": "dimension", + "px": 26, + "dp": 13.0, + "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" + }, + "pickerGearTop": { + "$type": "dimension", + "px": 751.7, + "dp": 375.85, + "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" + }, + "pickerGearCaptionTop": { + "$type": "dimension", + "px": 72, + "dp": 36.0, + "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" + }, + "pickerLetter": { + "$type": "dimension", + "px": 100, + "dp": 50.0, + "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" + }, + "pickerSkeletonNameWidth": { + "$type": "dimension", + "px": 140, + "dp": 70.0, + "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" + }, + "pickerSkeletonNameHeight": { + "$type": "dimension", + "px": 40, + "dp": 20.0, + "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" + }, + "pickerLoadingTitleTop": { + "$type": "dimension", + "px": 247.4, + "dp": 123.7, + "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" + }, + "pickerLoadingPeopleTop": { + "$type": "dimension", + "px": 400.6, + "dp": 200.3, + "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" + }, + "pickerLoadingButtonTop": { + "$type": "dimension", + "px": 756.6, + "dp": 378.3, + "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" + }, + "pickerErrorTitleTop": { + "$type": "dimension", + "px": 297.6, + "dp": 148.8, + "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" + }, + "pickerErrorPanelTop": { + "$type": "dimension", + "px": 450.8, + "dp": 225.4, + "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" + }, + "pickerErrorPanelWidth": { + "$type": "dimension", + "px": 980, + "dp": 490.0, + "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" + }, + "pickerErrorPanelHeight": { + "$type": "dimension", + "px": 185.6, + "dp": 92.8, + "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" + }, + "pickerErrorButtonTop": { + "$type": "dimension", + "px": 706.4, + "dp": 353.2, + "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" + }, + "settingsHeaderTop": { + "$type": "dimension", + "px": 150, + "dp": 75.0, + "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" + }, + "settingsHeaderAvatar": { + "$type": "dimension", + "px": 52, + "dp": 26.0, + "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" + }, + "settingsHeaderGap": { + "$type": "dimension", + "px": 24, + "dp": 12.0, + "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" + }, + "settingsRowTop": { + "$type": "dimension", + "px": 10, + "dp": 5.0, + "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" + }, + "settingsRowPadding": { + "$type": "dimension", + "px": 30, + "dp": 15.0, + "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" + }, + "settingsRowGap": { + "$type": "dimension", + "px": 24, + "dp": 12.0, + "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" + }, + "settingsIcon": { + "$type": "dimension", + "px": 36, + "dp": 18.0, + "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" + }, + "settingsPillHeight": { + "$type": "dimension", + "px": 40, + "dp": 20.0, + "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" + }, + "settingsPillStroke": { + "$type": "dimension", + "px": 2, + "dp": 1.0, + "source": "index.html:122; pill border 2 px inside its 40 px border-box" + }, + "settingsPillDashRatio": { + "$type": "number", + "$value": 3, + "source": "st-091 frozen browser top border: 6 px dashes at 2 px stroke width" + }, + "settingsPillDashGapRatio": { + "$type": "number", + "$value": 2, + "source": "st-091 frozen browser top border: 4 px gaps at 2 px stroke width" + }, + "settingsPillBorder": { + "$type": "color", + "$value": "#ffffff38", + "source": "index.html:122; white at 22 percent opacity, rounded to 8-bit alpha" + }, + "settingsPillFocusedBorder": { + "$type": "color", + "$value": "#0b0b1066", + "source": "index.html:328; focused pill ink at 40 percent opacity" + }, + "settingsPillPadding": { + "$type": "dimension", + "px": 16, + "dp": 8.0, + "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" + }, + "settingsPillGap": { + "$type": "dimension", + "px": 12, + "dp": 6.0, + "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" + }, + "settingsChevron": { + "$type": "dimension", + "px": 30, + "dp": 15.0, + "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" + }, + "settingsEndPadding": { + "$type": "dimension", + "px": 620, + "dp": 310.0, + "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" + }, + "panelPaddingX": { + "$type": "dimension", + "px": 30, + "dp": 15.0, + "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" + }, + "panelPaddingY": { + "$type": "dimension", + "px": 28, + "dp": 14.0, + "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" + }, + "panelGap": { + "$type": "dimension", + "px": 10, + "dp": 5.0, + "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" + }, + "panelHeadingGap": { + "$type": "dimension", + "px": 14, + "dp": 7.0, + "source": "index.html:244: receipt heading icon-to-title gap" + }, + "panelIcon": { + "$type": "dimension", + "px": 36, + "dp": 18.0, + "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" + }, + "skeletonRadius": { + "$type": "dimension", + "px": 10, + "dp": 5.0, + "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" + }, + "gearOpacity": { + "$type": "number", + "$value": 0.7, + "source": "locked index.html CSS / measured st-* bounds" + }, + "navBandOpacity": { + "$type": "number", + "$value": 0.72, + "source": "locked index.html CSS / measured st-* bounds" + }, + "navSolidStop": { + "$type": "number", + "$value": 0.84, + "source": "locked index.html CSS / measured st-* bounds" + } + }, + "library": { + "chipHeight": {"$type":"dimension","px":68,"dp":34,"source":"index.html:185"}, + "chipPadding": {"$type":"dimension","px":28,"dp":14,"source":"index.html:185"}, + "chipGap": {"$type":"dimension","px":16,"dp":8,"source":"index.html:184-185"}, + "chipTop": {"$type":"dimension","px":16,"dp":8,"source":"index.html:184"}, + "chipIcon": {"$type":"dimension","px":26,"dp":13,"source":"index.html:185"}, + "captionWidth": {"$type":"dimension","px":640,"dp":320,"source":"index.html:151"}, + "captionSeparatorOpacity": {"$type":"number","$value":0.4,"source":"index.html:1207; spec 4.4"}, + "captionFactsGap": {"$type":"dimension","px":18,"dp":9,"source":"index.html:154"}, + "badgeIcon": {"$type":"dimension","px":28,"dp":14,"source":"index.html:142"}, + "plateTop": {"$type":"dimension","px":14,"dp":7,"source":"index.html:162"}, + "plateSide": {"$type":"dimension","px":16,"dp":8,"source":"index.html:162"}, + "plateBottom": {"$type":"dimension","px":46,"dp":23,"source":"index.html:162"}, + "plateInlineDescent": {"$type":"dimension","px":6.6,"dp":3.3,"source":"index.html:156,162 inline-flex baseline strut; proto-104 rplate 100.2px minus 14+33.6+46"}, + "plateFloor": {"$type":"typography","px":24,"sp":12,"weight":700,"$value":{"fontWeight":700},"source":"index.html:802"}, + "plateStep": {"$type":"number","$value":0.25,"source":"spec 4.8"}, + "plateGapEm": {"$type":"number","$value":0.64,"source":"index.html:164"}, + "markGapEm": {"$type":"number","$value":0.29,"source":"index.html:165"}, + "wordmarkRatio": {"$type":"number","$value":1.8,"source":"index.html:160"}, + "plateOpacity": {"$type":"number","$value":0.9,"source":"index.html:162"}, + "plateMidOpacity": {"$type":"number","$value":0.72,"source":"index.html:162"}, + "plateMidStop": {"$type":"number","$value":0.52,"source":"index.html:162"}, + "railWidth": {"$type":"dimension","px":52,"dp":26,"source":"index.html:204"}, + "railLetterHeight": {"$type":"dimension","px":30,"dp":15,"source":"index.html:205"}, + "railFocusHeight": {"$type":"dimension","px":44,"dp":22,"source":"index.html:208"}, + "railLetter": {"$type":"typography","px":24,"sp":12,"weight":700,"$value":{"fontWeight":700},"source":"index.html:205; prototype wins over spec 4.3 caption weight"}, + "railFocusedLetter": {"$type":"typography","px":28,"sp":14,"weight":700,"$value":{"fontWeight":700},"source":"index.html:208; prototype wins over spec 4.3 caption size"}, + "panelPaddingX": {"$type":"dimension","px":40,"dp":20,"source":"index.html:194"}, + "panelPaddingY": {"$type":"dimension","px":34,"dp":17,"source":"index.html:194"}, + "panelTextGap": {"$type":"dimension","px":24,"dp":12,"source":"index.html:198"}, + "panelTitleGap": {"$type":"dimension","px":8,"dp":4,"source":"index.html:195"}, + "panelIconGap": {"$type":"dimension","px":14,"dp":7,"source":"index.html:195"}, + "panelTop": {"$type":"dimension","px":20,"dp":10,"source":"index.html:194"}, + "borderWidth": {"$type":"dimension","px":2,"dp":1,"source":"index.html:188,194,299"}, + "chipContentGap": {"$type":"dimension","px":12,"dp":6,"source":"index.html:185"}, + "chipLabelOpacity": {"$type":"number","$value":0.7,"source":"index.html:190"}, + "tailIcon": {"$type":"dimension","px":44,"dp":22,"source":"index.html:300"}, + "tailPadding": {"$type":"dimension","px":20,"dp":10,"source":"index.html:299"}, + "tailGap": {"$type":"dimension","px":10,"dp":5,"source":"index.html:299"}, + "badgeRing": {"$type":"dimension","px":3,"dp":1.5,"source":"index.html:144"}, + "badgeIcon": {"$type":"dimension","px":28,"dp":14,"source":"index.html:142"}, + "seeAllGap": {"$type":"dimension","px":8,"dp":4,"source":"index.html:146"}, + "tailDescription": {"$type":"typography","px":24,"sp":12,"weight":500,"$value":{"fontWeight":500},"source":"index.html:301"}, + "headerSubtitle": {"$type":"typography","px":30,"sp":15,"weight":400,"$value":{"fontWeight":400},"source":"index.html:201"}, + "imageReads": {"$type":"number","$value":4,"source":"spec 4.9, tokens.json image.concurrentReads"}, + "unknownCriticOpacity": {"$type":"number","$value":0.55,"source":"spec 4.7, index.html:161"}, + "fallbackPadding": {"$type":"dimension","px":22,"dp":11,"source":"index.html:130"}, + "fallbackIcon": {"$type":"dimension","px":44,"dp":22,"source":"index.html:130"}, + "fallbackHueMultiplier": {"$type":"number","$value":31,"source":"index.html:705"}, + "fallbackSaturationTop": {"$type":"number","$value":0.22,"source":"index.html:130"}, + "fallbackLightnessTop": {"$type":"number","$value":0.22,"source":"index.html:130"}, + "fallbackSaturationBottom": {"$type":"number","$value":0.26,"source":"index.html:130"}, + "fallbackLightnessBottom": {"$type":"number","$value":0.1,"source":"index.html:130"}, + "fallbackIconOpacity": {"$type":"number","$value":0.45,"source":"index.html:132"} + }, + "lift": { + "cardBlur": {"$type":"dimension","px":50,"dp":25,"source":"index.html:134; spec 4.5"}, + "cardY": {"$type":"dimension","px":18,"dp":9,"source":"index.html:134"}, + "cardOpacity": {"$type":"number","$value":0.65,"source":"index.html:134"}, + "buttonBlur": {"$type":"dimension","px":40,"dp":20,"source":"index.html:110; spec 4.5"}, + "buttonY": {"$type":"dimension","px":12,"dp":6,"source":"spec 4.5"}, + "buttonOpacity": {"$type":"number","$value":0.5,"source":"spec 4.5"}, + "heroBlur": {"$type":"dimension","px":80,"dp":40,"source":"index.html:97"}, + "heroY": {"$type":"dimension","px":30,"dp":15,"source":"index.html:97"}, + "heroOpacity": {"$type":"number","$value":0.6,"source":"index.html:97"}, + "sheetBlur": {"$type":"dimension","px":80,"dp":40,"source":"index.html:227"}, + "sheetX": {"$type":"dimension","px":-30,"dp":-15,"source":"index.html:227"}, + "sheetOpacity": {"$type":"number","$value":0.5,"source":"index.html:227"} + } +} diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-all-hands-on-deck-class-1-a-2021-s5e1-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-all-hands-on-deck-class-1-a-2021-s5e1-backdrop.jpg new file mode 100644 index 00000000..f5b2f94c Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-all-hands-on-deck-class-1-a-2021-s5e1-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-all-hands-on-deck-class-1-a-2021-s5e1-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-all-hands-on-deck-class-1-a-2021-s5e1-poster.jpg new file mode 100644 index 00000000..cf571b93 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-all-hands-on-deck-class-1-a-2021-s5e1-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-announcement-2022-s1e7-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-announcement-2022-s1e7-backdrop.jpg new file mode 100644 index 00000000..a874a0c0 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-announcement-2022-s1e7-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-announcement-2022-s1e7-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-announcement-2022-s1e7-poster.jpg new file mode 100644 index 00000000..df5dc498 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-announcement-2022-s1e7-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-episode-1-2025-s1e1-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-episode-1-2025-s1e1-backdrop.jpg new file mode 100644 index 00000000..f2519b04 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-episode-1-2025-s1e1-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-episode-1-2025-s1e1-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-episode-1-2025-s1e1-poster.jpg new file mode 100644 index 00000000..77febd79 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-episode-1-2025-s1e1-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-episode-2-2016-s1e2-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-episode-2-2016-s1e2-backdrop.jpg new file mode 100644 index 00000000..3f8bdeec Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-episode-2-2016-s1e2-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-episode-2-2016-s1e2-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-episode-2-2016-s1e2-poster.jpg new file mode 100644 index 00000000..d29448bb Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-episode-2-2016-s1e2-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-erickerhead-2026-s9e6-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-erickerhead-2026-s9e6-backdrop.jpg new file mode 100644 index 00000000..1c4c0ffc Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-erickerhead-2026-s9e6-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-erickerhead-2026-s9e6-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-erickerhead-2026-s9e6-poster.jpg new file mode 100644 index 00000000..b95c4890 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-erickerhead-2026-s9e6-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-girls-interrupted-2007-s1e7-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-girls-interrupted-2007-s1e7-backdrop.jpg new file mode 100644 index 00000000..ffff979b Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-girls-interrupted-2007-s1e7-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-girls-interrupted-2007-s1e7-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-girls-interrupted-2007-s1e7-poster.jpg new file mode 100644 index 00000000..38428e76 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-girls-interrupted-2007-s1e7-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-i-heart-ny-2002-s4e18-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-i-heart-ny-2002-s4e18-backdrop.jpg new file mode 100644 index 00000000..57beacc1 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-i-heart-ny-2002-s4e18-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-i-heart-ny-2002-s4e18-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-i-heart-ny-2002-s4e18-poster.jpg new file mode 100644 index 00000000..e546cc6d Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-i-heart-ny-2002-s4e18-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-new-day-2021-s1e2-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-new-day-2021-s1e2-backdrop.jpg new file mode 100644 index 00000000..1f747323 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-new-day-2021-s1e2-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-new-day-2021-s1e2-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-new-day-2021-s1e2-poster.jpg new file mode 100644 index 00000000..2f23d1fa Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-new-day-2021-s1e2-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-no-honeymoon-2020-s1e8-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-no-honeymoon-2020-s1e8-backdrop.jpg new file mode 100644 index 00000000..9d95ba77 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-no-honeymoon-2020-s1e8-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-no-honeymoon-2020-s1e8-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-no-honeymoon-2020-s1e8-poster.jpg new file mode 100644 index 00000000..49c61208 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-no-honeymoon-2020-s1e8-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-notapusy-2005-s3e4-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-notapusy-2005-s3e4-backdrop.jpg new file mode 100644 index 00000000..ecb7e8cd Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-notapusy-2005-s3e4-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-notapusy-2005-s3e4-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-notapusy-2005-s3e4-poster.jpg new file mode 100644 index 00000000..02b0dd43 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-notapusy-2005-s3e4-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-one-of-us-2024-s1e7-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-one-of-us-2024-s1e7-backdrop.jpg new file mode 100644 index 00000000..66bb4d00 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-one-of-us-2024-s1e7-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-one-of-us-2024-s1e7-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-one-of-us-2024-s1e7-poster.jpg new file mode 100644 index 00000000..00a07ca7 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-one-of-us-2024-s1e7-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-remembrance-day-2026-s2e1-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-remembrance-day-2026-s2e1-backdrop.jpg new file mode 100644 index 00000000..23a242da Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-remembrance-day-2026-s2e1-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-remembrance-day-2026-s2e1-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-remembrance-day-2026-s2e1-poster.jpg new file mode 100644 index 00000000..930943b0 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-remembrance-day-2026-s2e1-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-sex-and-the-country-2001-s4e9-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-sex-and-the-country-2001-s4e9-backdrop.jpg new file mode 100644 index 00000000..57beacc1 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-sex-and-the-country-2001-s4e9-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-sex-and-the-country-2001-s4e9-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-sex-and-the-country-2001-s4e9-poster.jpg new file mode 100644 index 00000000..e546cc6d Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-sex-and-the-country-2001-s4e9-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-south-american-biker-gangs-2026-s29e1-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-south-american-biker-gangs-2026-s29e1-backdrop.jpg new file mode 100644 index 00000000..cd0aec39 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-south-american-biker-gangs-2026-s29e1-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-south-american-biker-gangs-2026-s29e1-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-south-american-biker-gangs-2026-s29e1-poster.jpg new file mode 100644 index 00000000..d8568d54 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-south-american-biker-gangs-2026-s29e1-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-the-breakup-part-2-2001-s1e17-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-the-breakup-part-2-2001-s1e17-backdrop.jpg new file mode 100644 index 00000000..b42ba953 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-the-breakup-part-2-2001-s1e17-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-the-breakup-part-2-2001-s1e17-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-the-breakup-part-2-2001-s1e17-poster.jpg new file mode 100644 index 00000000..23b668cb Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-the-breakup-part-2-2001-s1e17-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-the-man-who-would-be-vogue-2018-s2e1-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-the-man-who-would-be-vogue-2018-s2e1-backdrop.jpg new file mode 100644 index 00000000..84944d39 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-the-man-who-would-be-vogue-2018-s2e1-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-the-man-who-would-be-vogue-2018-s2e1-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-the-man-who-would-be-vogue-2018-s2e1-poster.jpg new file mode 100644 index 00000000..4086bf17 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-the-man-who-would-be-vogue-2018-s2e1-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-the-uncomfortability-2010-s1e2-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-the-uncomfortability-2010-s1e2-backdrop.jpg new file mode 100644 index 00000000..bc22e11a Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-the-uncomfortability-2010-s1e2-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-the-uncomfortability-2010-s1e2-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-the-uncomfortability-2010-s1e2-poster.jpg new file mode 100644 index 00000000..ae305043 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-the-uncomfortability-2010-s1e2-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-three-monsters-1992-s1e6-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-three-monsters-1992-s1e6-backdrop.jpg new file mode 100644 index 00000000..ba65ca2d Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-three-monsters-1992-s1e6-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-three-monsters-1992-s1e6-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-three-monsters-1992-s1e6-poster.jpg new file mode 100644 index 00000000..f5843766 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-three-monsters-1992-s1e6-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-tomorrow-2024-s3e1-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-tomorrow-2024-s3e1-backdrop.jpg new file mode 100644 index 00000000..9c9cca57 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-tomorrow-2024-s3e1-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-tomorrow-2024-s3e1-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-tomorrow-2024-s3e1-poster.jpg new file mode 100644 index 00000000..bbf0ab28 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-tomorrow-2024-s3e1-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-what-more-do-i-need-2008-s5e7-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-what-more-do-i-need-2008-s5e7-backdrop.jpg new file mode 100644 index 00000000..40ec3001 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-what-more-do-i-need-2008-s5e7-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-what-more-do-i-need-2008-s5e7-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-what-more-do-i-need-2008-s5e7-poster.jpg new file mode 100644 index 00000000..13f0f7b3 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-what-more-do-i-need-2008-s5e7-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-101-dalmatians-ii-patch-s-london-adventure-2003-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-101-dalmatians-ii-patch-s-london-adventure-2003-backdrop.jpg new file mode 100644 index 00000000..f0d6f752 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-101-dalmatians-ii-patch-s-london-adventure-2003-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-101-dalmatians-ii-patch-s-london-adventure-2003-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-101-dalmatians-ii-patch-s-london-adventure-2003-poster.jpg new file mode 100644 index 00000000..b9b72894 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-101-dalmatians-ii-patch-s-london-adventure-2003-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-12-years-a-slave-2013-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-12-years-a-slave-2013-backdrop.jpg new file mode 100644 index 00000000..d93357e5 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-12-years-a-slave-2013-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-12-years-a-slave-2013-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-12-years-a-slave-2013-poster.jpg new file mode 100644 index 00000000..ff1f55e6 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-12-years-a-slave-2013-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-a-bronx-tale-1993-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-a-bronx-tale-1993-backdrop.jpg new file mode 100644 index 00000000..15c11ff7 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-a-bronx-tale-1993-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-a-bronx-tale-1993-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-a-bronx-tale-1993-poster.jpg new file mode 100644 index 00000000..f7444271 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-a-bronx-tale-1993-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-a-silent-voice-the-movie-2016-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-a-silent-voice-the-movie-2016-backdrop.jpg new file mode 100644 index 00000000..b386b136 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-a-silent-voice-the-movie-2016-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-a-silent-voice-the-movie-2016-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-a-silent-voice-the-movie-2016-poster.jpg new file mode 100644 index 00000000..580b22f4 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-a-silent-voice-the-movie-2016-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-absolution-2024-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-absolution-2024-backdrop.jpg new file mode 100644 index 00000000..eb28037c Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-absolution-2024-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-absolution-2024-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-absolution-2024-poster.jpg new file mode 100644 index 00000000..3ce49112 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-absolution-2024-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-across-the-hall-2009-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-across-the-hall-2009-backdrop.jpg new file mode 100644 index 00000000..6c1ee8ac Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-across-the-hall-2009-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-across-the-hall-2009-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-across-the-hall-2009-poster.jpg new file mode 100644 index 00000000..52362b4e Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-across-the-hall-2009-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-addicted-2014-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-addicted-2014-backdrop.jpg new file mode 100644 index 00000000..5c016976 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-addicted-2014-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-addicted-2014-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-addicted-2014-poster.jpg new file mode 100644 index 00000000..e9a03c14 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-addicted-2014-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-adulterers-2016-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-adulterers-2016-backdrop.jpg new file mode 100644 index 00000000..331391ed Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-adulterers-2016-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-adulterers-2016-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-adulterers-2016-poster.jpg new file mode 100644 index 00000000..8b65745b Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-adulterers-2016-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-all-the-money-in-the-world-2017-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-all-the-money-in-the-world-2017-backdrop.jpg new file mode 100644 index 00000000..ac4b828a Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-all-the-money-in-the-world-2017-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-all-the-money-in-the-world-2017-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-all-the-money-in-the-world-2017-poster.jpg new file mode 100644 index 00000000..8beb2f7f Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-all-the-money-in-the-world-2017-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-alone-with-her-2006-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-alone-with-her-2006-backdrop.jpg new file mode 100644 index 00000000..ddf8f1dc Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-alone-with-her-2006-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-alone-with-her-2006-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-alone-with-her-2006-poster.jpg new file mode 100644 index 00000000..7ce6fa96 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-alone-with-her-2006-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-american-gangster-2007-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-american-gangster-2007-backdrop.jpg new file mode 100644 index 00000000..cb5c5de7 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-american-gangster-2007-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-american-gangster-2007-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-american-gangster-2007-poster.jpg new file mode 100644 index 00000000..0f41d299 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-american-gangster-2007-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-annie-hall-1977-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-annie-hall-1977-backdrop.jpg new file mode 100644 index 00000000..16d0b27d Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-annie-hall-1977-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-annie-hall-1977-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-annie-hall-1977-poster.jpg new file mode 100644 index 00000000..9023a691 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-annie-hall-1977-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-ant-man-and-the-wasp-quantumania-2023-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-ant-man-and-the-wasp-quantumania-2023-backdrop.jpg new file mode 100644 index 00000000..2a99846d Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-ant-man-and-the-wasp-quantumania-2023-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-ant-man-and-the-wasp-quantumania-2023-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-ant-man-and-the-wasp-quantumania-2023-poster.jpg new file mode 100644 index 00000000..030addf0 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-ant-man-and-the-wasp-quantumania-2023-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-apocalypse-now-1979-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-apocalypse-now-1979-backdrop.jpg new file mode 100644 index 00000000..93292da3 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-apocalypse-now-1979-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-apocalypse-now-1979-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-apocalypse-now-1979-poster.jpg new file mode 100644 index 00000000..09cd6aa8 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-apocalypse-now-1979-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-appleseed-alpha-2014-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-appleseed-alpha-2014-backdrop.jpg new file mode 100644 index 00000000..82579b86 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-appleseed-alpha-2014-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-appleseed-alpha-2014-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-appleseed-alpha-2014-poster.jpg new file mode 100644 index 00000000..62bd0afa Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-appleseed-alpha-2014-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-aquaman-and-the-lost-kingdom-2023-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-aquaman-and-the-lost-kingdom-2023-backdrop.jpg new file mode 100644 index 00000000..6693cb6d Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-aquaman-and-the-lost-kingdom-2023-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-aquaman-and-the-lost-kingdom-2023-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-aquaman-and-the-lost-kingdom-2023-poster.jpg new file mode 100644 index 00000000..4550bdaa Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-aquaman-and-the-lost-kingdom-2023-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-arctic-dogs-2019-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-arctic-dogs-2019-backdrop.jpg new file mode 100644 index 00000000..1794c99d Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-arctic-dogs-2019-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-arctic-dogs-2019-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-arctic-dogs-2019-poster.jpg new file mode 100644 index 00000000..07552443 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-arctic-dogs-2019-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-arrival-2016-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-arrival-2016-backdrop.jpg new file mode 100644 index 00000000..605724e7 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-arrival-2016-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-arrival-2016-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-arrival-2016-poster.jpg new file mode 100644 index 00000000..fe224a15 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-arrival-2016-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-avatar-aang-the-last-airbender-2026-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-avatar-aang-the-last-airbender-2026-backdrop.jpg new file mode 100644 index 00000000..d3b12fe0 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-avatar-aang-the-last-airbender-2026-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-avatar-aang-the-last-airbender-2026-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-avatar-aang-the-last-airbender-2026-poster.jpg new file mode 100644 index 00000000..f57f1052 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-avatar-aang-the-last-airbender-2026-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-avengers-age-of-ultron-2015-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-avengers-age-of-ultron-2015-backdrop.jpg new file mode 100644 index 00000000..f739d620 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-avengers-age-of-ultron-2015-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-avengers-age-of-ultron-2015-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-avengers-age-of-ultron-2015-poster.jpg new file mode 100644 index 00000000..300840ff Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-avengers-age-of-ultron-2015-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-avengers-confidential-black-widow-punisher-2014-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-avengers-confidential-black-widow-punisher-2014-backdrop.jpg new file mode 100644 index 00000000..8163931a Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-avengers-confidential-black-widow-punisher-2014-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-avengers-confidential-black-widow-punisher-2014-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-avengers-confidential-black-widow-punisher-2014-poster.jpg new file mode 100644 index 00000000..b096fff2 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-avengers-confidential-black-widow-punisher-2014-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-avengers-infinity-war-2018-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-avengers-infinity-war-2018-backdrop.jpg new file mode 100644 index 00000000..90dc4672 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-avengers-infinity-war-2018-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-avengers-infinity-war-2018-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-avengers-infinity-war-2018-poster.jpg new file mode 100644 index 00000000..57df9298 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-avengers-infinity-war-2018-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-awake-2007-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-awake-2007-backdrop.jpg new file mode 100644 index 00000000..6ddd7f59 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-awake-2007-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-awake-2007-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-awake-2007-poster.jpg new file mode 100644 index 00000000..32595897 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-awake-2007-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-batman-ninja-2018-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-batman-ninja-2018-backdrop.jpg new file mode 100644 index 00000000..9b538d9f Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-batman-ninja-2018-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-batman-ninja-2018-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-batman-ninja-2018-poster.jpg new file mode 100644 index 00000000..7f59127b Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-batman-ninja-2018-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-batman-the-dark-knight-returns-part-1-2012-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-batman-the-dark-knight-returns-part-1-2012-backdrop.jpg new file mode 100644 index 00000000..9d385801 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-batman-the-dark-knight-returns-part-1-2012-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-batman-the-dark-knight-returns-part-1-2012-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-batman-the-dark-knight-returns-part-1-2012-poster.jpg new file mode 100644 index 00000000..ce5623fe Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-batman-the-dark-knight-returns-part-1-2012-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-batman-v-superman-dawn-of-justice-2016-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-batman-v-superman-dawn-of-justice-2016-backdrop.jpg new file mode 100644 index 00000000..b4fa63b9 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-batman-v-superman-dawn-of-justice-2016-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-batman-v-superman-dawn-of-justice-2016-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-batman-v-superman-dawn-of-justice-2016-poster.jpg new file mode 100644 index 00000000..f8115170 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-batman-v-superman-dawn-of-justice-2016-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-black-adam-2022-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-black-adam-2022-backdrop.jpg new file mode 100644 index 00000000..3232fb22 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-black-adam-2022-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-black-adam-2022-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-black-adam-2022-poster.jpg new file mode 100644 index 00000000..dde008b5 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-black-adam-2022-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-black-bear-2020-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-black-bear-2020-backdrop.jpg new file mode 100644 index 00000000..4b0b262f Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-black-bear-2020-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-black-bear-2020-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-black-bear-2020-poster.jpg new file mode 100644 index 00000000..61c8860f Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-black-bear-2020-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-black-panther-2018-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-black-panther-2018-backdrop.jpg new file mode 100644 index 00000000..f0b8de8a Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-black-panther-2018-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-black-panther-2018-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-black-panther-2018-poster.jpg new file mode 100644 index 00000000..6fdaa3d2 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-black-panther-2018-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-black-panther-wakanda-forever-2022-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-black-panther-wakanda-forever-2022-backdrop.jpg new file mode 100644 index 00000000..ac7ad532 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-black-panther-wakanda-forever-2022-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-black-panther-wakanda-forever-2022-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-black-panther-wakanda-forever-2022-poster.jpg new file mode 100644 index 00000000..d0219a75 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-black-panther-wakanda-forever-2022-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-blame-2017-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-blame-2017-backdrop.jpg new file mode 100644 index 00000000..f39cf5ce Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-blame-2017-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-blame-2017-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-blame-2017-poster.jpg new file mode 100644 index 00000000..8d50255f Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-blame-2017-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-bleach-the-movie-fade-to-black-2008-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-bleach-the-movie-fade-to-black-2008-backdrop.jpg new file mode 100644 index 00000000..f426d0ce Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-bleach-the-movie-fade-to-black-2008-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-bleach-the-movie-fade-to-black-2008-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-bleach-the-movie-fade-to-black-2008-poster.jpg new file mode 100644 index 00000000..d3619fbd Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-bleach-the-movie-fade-to-black-2008-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-bohemian-rhapsody-2018-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-bohemian-rhapsody-2018-backdrop.jpg new file mode 100644 index 00000000..b6b5a7cd Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-bohemian-rhapsody-2018-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-bohemian-rhapsody-2018-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-bohemian-rhapsody-2018-poster.jpg new file mode 100644 index 00000000..20d2f9ee Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-bohemian-rhapsody-2018-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-braveheart-1995-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-braveheart-1995-backdrop.jpg new file mode 100644 index 00000000..c1c84c59 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-braveheart-1995-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-braveheart-1995-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-braveheart-1995-poster.jpg new file mode 100644 index 00000000..32fd8d4d Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-braveheart-1995-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-butterfly-on-a-wheel-2007-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-butterfly-on-a-wheel-2007-backdrop.jpg new file mode 100644 index 00000000..e49fe1d5 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-butterfly-on-a-wheel-2007-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-butterfly-on-a-wheel-2007-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-butterfly-on-a-wheel-2007-poster.jpg new file mode 100644 index 00000000..b54cc5c7 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-butterfly-on-a-wheel-2007-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-by-any-means-2026-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-by-any-means-2026-backdrop.jpg new file mode 100644 index 00000000..e536359f Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-by-any-means-2026-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-by-any-means-2026-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-by-any-means-2026-poster.jpg new file mode 100644 index 00000000..92fd601f Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-by-any-means-2026-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-captain-america-civil-war-2016-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-captain-america-civil-war-2016-backdrop.jpg new file mode 100644 index 00000000..5700c4e7 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-captain-america-civil-war-2016-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-captain-america-civil-war-2016-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-captain-america-civil-war-2016-poster.jpg new file mode 100644 index 00000000..426142ef Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-captain-america-civil-war-2016-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-captain-america-the-first-avenger-2011-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-captain-america-the-first-avenger-2011-backdrop.jpg new file mode 100644 index 00000000..411ceebe Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-captain-america-the-first-avenger-2011-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-captain-america-the-first-avenger-2011-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-captain-america-the-first-avenger-2011-poster.jpg new file mode 100644 index 00000000..bba96574 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-captain-america-the-first-avenger-2011-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-captain-america-the-winter-soldier-2014-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-captain-america-the-winter-soldier-2014-backdrop.jpg new file mode 100644 index 00000000..5661fcff Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-captain-america-the-winter-soldier-2014-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-captain-america-the-winter-soldier-2014-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-captain-america-the-winter-soldier-2014-poster.jpg new file mode 100644 index 00000000..c2dc5612 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-captain-america-the-winter-soldier-2014-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-careful-what-you-wish-for-2015-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-careful-what-you-wish-for-2015-backdrop.jpg new file mode 100644 index 00000000..988e20d5 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-careful-what-you-wish-for-2015-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-careful-what-you-wish-for-2015-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-careful-what-you-wish-for-2015-poster.jpg new file mode 100644 index 00000000..b58ce600 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-careful-what-you-wish-for-2015-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-castle-in-the-sky-1986-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-castle-in-the-sky-1986-backdrop.jpg new file mode 100644 index 00000000..f785c549 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-castle-in-the-sky-1986-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-castle-in-the-sky-1986-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-castle-in-the-sky-1986-poster.jpg new file mode 100644 index 00000000..a6a99fee Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-castle-in-the-sky-1986-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-chicago-2002-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-chicago-2002-backdrop.jpg new file mode 100644 index 00000000..6b3d02a3 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-chicago-2002-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-chicago-2002-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-chicago-2002-poster.jpg new file mode 100644 index 00000000..1def9414 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-chicago-2002-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-citizen-kane-1941-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-citizen-kane-1941-backdrop.jpg new file mode 100644 index 00000000..d41a10ef Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-citizen-kane-1941-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-citizen-kane-1941-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-citizen-kane-1941-poster.jpg new file mode 100644 index 00000000..f26414a7 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-citizen-kane-1941-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-coherence-2014-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-coherence-2014-backdrop.jpg new file mode 100644 index 00000000..37da64f9 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-coherence-2014-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-coherence-2014-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-coherence-2014-poster.jpg new file mode 100644 index 00000000..f3601b9b Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-coherence-2014-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-conclave-2024-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-conclave-2024-backdrop.jpg new file mode 100644 index 00000000..db0a7fcd Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-conclave-2024-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-conclave-2024-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-conclave-2024-poster.jpg new file mode 100644 index 00000000..c6aecefa Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-conclave-2024-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-crime-of-passion-1956-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-crime-of-passion-1956-backdrop.jpg new file mode 100644 index 00000000..6c70a452 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-crime-of-passion-1956-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-crime-of-passion-1956-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-crime-of-passion-1956-poster.jpg new file mode 100644 index 00000000..4acfa871 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-crime-of-passion-1956-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-deep-water-2022-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-deep-water-2022-backdrop.jpg new file mode 100644 index 00000000..bec98992 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-deep-water-2022-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-deep-water-2022-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-deep-water-2022-poster.jpg new file mode 100644 index 00000000..018d4f4b Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-deep-water-2022-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-derailed-2005-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-derailed-2005-backdrop.jpg new file mode 100644 index 00000000..e958449b Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-derailed-2005-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-derailed-2005-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-derailed-2005-poster.jpg new file mode 100644 index 00000000..d93f3124 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-derailed-2005-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-die-my-love-2025-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-die-my-love-2025-backdrop.jpg new file mode 100644 index 00000000..754e713f Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-die-my-love-2025-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-die-my-love-2025-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-die-my-love-2025-poster.jpg new file mode 100644 index 00000000..f6eca818 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-die-my-love-2025-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-dream-house-2011-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-dream-house-2011-backdrop.jpg new file mode 100644 index 00000000..29ecc103 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-dream-house-2011-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-dream-house-2011-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-dream-house-2011-poster.jpg new file mode 100644 index 00000000..d3444637 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-dream-house-2011-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-dream-lover-1994-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-dream-lover-1994-backdrop.jpg new file mode 100644 index 00000000..7f5a31ac Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-dream-lover-1994-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-dream-lover-1994-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-dream-lover-1994-poster.jpg new file mode 100644 index 00000000..3b563cf9 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-dream-lover-1994-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-drop-2025-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-drop-2025-backdrop.jpg new file mode 100644 index 00000000..ee642530 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-drop-2025-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-drop-2025-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-drop-2025-poster.jpg new file mode 100644 index 00000000..4cf7d66b Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-drop-2025-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-eastern-promises-2007-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-eastern-promises-2007-backdrop.jpg new file mode 100644 index 00000000..9096a0fa Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-eastern-promises-2007-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-eastern-promises-2007-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-eastern-promises-2007-poster.jpg new file mode 100644 index 00000000..01a26f3b Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-eastern-promises-2007-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-easy-a-2010-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-easy-a-2010-backdrop.jpg new file mode 100644 index 00000000..c27fa57b Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-easy-a-2010-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-easy-a-2010-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-easy-a-2010-poster.jpg new file mode 100644 index 00000000..45e13a32 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-easy-a-2010-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-el-camino-a-breaking-bad-movie-2019-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-el-camino-a-breaking-bad-movie-2019-backdrop.jpg new file mode 100644 index 00000000..8edc02a6 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-el-camino-a-breaking-bad-movie-2019-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-el-camino-a-breaking-bad-movie-2019-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-el-camino-a-breaking-bad-movie-2019-poster.jpg new file mode 100644 index 00000000..7a62df45 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-el-camino-a-breaking-bad-movie-2019-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-emancipation-2022-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-emancipation-2022-backdrop.jpg new file mode 100644 index 00000000..ac0777c7 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-emancipation-2022-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-emancipation-2022-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-emancipation-2022-poster.jpg new file mode 100644 index 00000000..c1bee971 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-emancipation-2022-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-faces-1968-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-faces-1968-backdrop.jpg new file mode 100644 index 00000000..60273dd9 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-faces-1968-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-faces-1968-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-faces-1968-poster.jpg new file mode 100644 index 00000000..43f996bd Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-faces-1968-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-fatale-2020-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-fatale-2020-backdrop.jpg new file mode 100644 index 00000000..b13c33c6 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-fatale-2020-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-fatale-2020-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-fatale-2020-poster.jpg new file mode 100644 index 00000000..dda9d622 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-fatale-2020-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-fighting-with-my-family-2019-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-fighting-with-my-family-2019-backdrop.jpg new file mode 100644 index 00000000..46835996 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-fighting-with-my-family-2019-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-fighting-with-my-family-2019-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-fighting-with-my-family-2019-poster.jpg new file mode 100644 index 00000000..169c466d Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-fighting-with-my-family-2019-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-force-of-nature-the-dry-2-2024-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-force-of-nature-the-dry-2-2024-backdrop.jpg new file mode 100644 index 00000000..3735ee63 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-force-of-nature-the-dry-2-2024-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-force-of-nature-the-dry-2-2024-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-force-of-nature-the-dry-2-2024-poster.jpg new file mode 100644 index 00000000..93ba0d71 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-force-of-nature-the-dry-2-2024-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-free-state-of-jones-2016-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-free-state-of-jones-2016-backdrop.jpg new file mode 100644 index 00000000..a7e5ddff Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-free-state-of-jones-2016-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-free-state-of-jones-2016-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-free-state-of-jones-2016-poster.jpg new file mode 100644 index 00000000..e69af7c0 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-free-state-of-jones-2016-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-from-up-on-poppy-hill-2011-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-from-up-on-poppy-hill-2011-backdrop.jpg new file mode 100644 index 00000000..32a28bd7 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-from-up-on-poppy-hill-2011-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-from-up-on-poppy-hill-2011-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-from-up-on-poppy-hill-2011-poster.jpg new file mode 100644 index 00000000..264076e2 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-from-up-on-poppy-hill-2011-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-ghost-in-the-shell-1995-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-ghost-in-the-shell-1995-backdrop.jpg new file mode 100644 index 00000000..716b8a87 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-ghost-in-the-shell-1995-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-ghost-in-the-shell-1995-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-ghost-in-the-shell-1995-poster.jpg new file mode 100644 index 00000000..58d221ba Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-ghost-in-the-shell-1995-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-ghost-in-the-shell-2-innocence-2004-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-ghost-in-the-shell-2-innocence-2004-backdrop.jpg new file mode 100644 index 00000000..4a9931ed Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-ghost-in-the-shell-2-innocence-2004-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-ghost-in-the-shell-2-innocence-2004-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-ghost-in-the-shell-2-innocence-2004-poster.jpg new file mode 100644 index 00000000..ae262147 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-ghost-in-the-shell-2-innocence-2004-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-gosford-park-2001-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-gosford-park-2001-backdrop.jpg new file mode 100644 index 00000000..42c4cea6 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-gosford-park-2001-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-gosford-park-2001-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-gosford-park-2001-poster.jpg new file mode 100644 index 00000000..6a6aa651 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-gosford-park-2001-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-grave-of-the-fireflies-1988-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-grave-of-the-fireflies-1988-backdrop.jpg new file mode 100644 index 00000000..0e1add06 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-grave-of-the-fireflies-1988-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-grave-of-the-fireflies-1988-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-grave-of-the-fireflies-1988-poster.jpg new file mode 100644 index 00000000..1b71996a Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-grave-of-the-fireflies-1988-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-howl-s-moving-castle-2004-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-howl-s-moving-castle-2004-backdrop.jpg new file mode 100644 index 00000000..0aeef2ba Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-howl-s-moving-castle-2004-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-howl-s-moving-castle-2004-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-howl-s-moving-castle-2004-poster.jpg new file mode 100644 index 00000000..042a6c80 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-howl-s-moving-castle-2004-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-in-the-heights-2021-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-in-the-heights-2021-backdrop.jpg new file mode 100644 index 00000000..7cd4ee24 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-in-the-heights-2021-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-in-the-heights-2021-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-in-the-heights-2021-poster.jpg new file mode 100644 index 00000000..e05bfdb0 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-in-the-heights-2021-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-jennifer-s-body-2009-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-jennifer-s-body-2009-backdrop.jpg new file mode 100644 index 00000000..032771a0 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-jennifer-s-body-2009-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-jennifer-s-body-2009-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-jennifer-s-body-2009-poster.jpg new file mode 100644 index 00000000..41cc1eb5 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-jennifer-s-body-2009-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-kiki-s-delivery-service-1989-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-kiki-s-delivery-service-1989-backdrop.jpg new file mode 100644 index 00000000..2a108f0c Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-kiki-s-delivery-service-1989-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-kiki-s-delivery-service-1989-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-kiki-s-delivery-service-1989-poster.jpg new file mode 100644 index 00000000..80a59b1e Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-kiki-s-delivery-service-1989-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-lemony-snicket-s-a-series-of-unfortunate-events-2004-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-lemony-snicket-s-a-series-of-unfortunate-events-2004-backdrop.jpg new file mode 100644 index 00000000..38203b77 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-lemony-snicket-s-a-series-of-unfortunate-events-2004-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-lemony-snicket-s-a-series-of-unfortunate-events-2004-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-lemony-snicket-s-a-series-of-unfortunate-events-2004-poster.jpg new file mode 100644 index 00000000..4c78fee9 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-lemony-snicket-s-a-series-of-unfortunate-events-2004-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-locke-2014-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-locke-2014-backdrop.jpg new file mode 100644 index 00000000..a24e64ab Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-locke-2014-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-locke-2014-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-locke-2014-poster.jpg new file mode 100644 index 00000000..94ebe97d Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-locke-2014-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-look-who-s-back-2015-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-look-who-s-back-2015-backdrop.jpg new file mode 100644 index 00000000..a064686f Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-look-who-s-back-2015-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-look-who-s-back-2015-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-look-who-s-back-2015-poster.jpg new file mode 100644 index 00000000..423043cf Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-look-who-s-back-2015-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-memories-1995-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-memories-1995-backdrop.jpg new file mode 100644 index 00000000..2fcaeaad Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-memories-1995-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-memories-1995-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-memories-1995-poster.jpg new file mode 100644 index 00000000..f3725b42 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-memories-1995-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-mercy-2026-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-mercy-2026-backdrop.jpg new file mode 100644 index 00000000..45591cc8 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-mercy-2026-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-mercy-2026-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-mercy-2026-poster.jpg new file mode 100644 index 00000000..cfc0d812 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-mercy-2026-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-night-is-short-walk-on-girl-2017-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-night-is-short-walk-on-girl-2017-backdrop.jpg new file mode 100644 index 00000000..da38be48 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-night-is-short-walk-on-girl-2017-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-night-is-short-walk-on-girl-2017-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-night-is-short-walk-on-girl-2017-poster.jpg new file mode 100644 index 00000000..c6fa6a26 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-night-is-short-walk-on-girl-2017-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-normal-2026-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-normal-2026-backdrop.jpg new file mode 100644 index 00000000..51711864 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-normal-2026-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-normal-2026-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-normal-2026-poster.jpg new file mode 100644 index 00000000..f4c89db7 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-normal-2026-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-outlaw-king-2018-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-outlaw-king-2018-backdrop.jpg new file mode 100644 index 00000000..54ac5a2a Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-outlaw-king-2018-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-outlaw-king-2018-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-outlaw-king-2018-poster.jpg new file mode 100644 index 00000000..d6760468 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-outlaw-king-2018-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-panic-room-2002-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-panic-room-2002-backdrop.jpg new file mode 100644 index 00000000..ec877751 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-panic-room-2002-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-panic-room-2002-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-panic-room-2002-poster.jpg new file mode 100644 index 00000000..0b2a0d30 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-panic-room-2002-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-peaky-blinders-the-immortal-man-2026-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-peaky-blinders-the-immortal-man-2026-backdrop.jpg new file mode 100644 index 00000000..e9d7155a Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-peaky-blinders-the-immortal-man-2026-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-peaky-blinders-the-immortal-man-2026-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-peaky-blinders-the-immortal-man-2026-poster.jpg new file mode 100644 index 00000000..a4814472 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-peaky-blinders-the-immortal-man-2026-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-ponyo-2008-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-ponyo-2008-backdrop.jpg new file mode 100644 index 00000000..8aa4ecbd Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-ponyo-2008-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-ponyo-2008-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-ponyo-2008-poster.jpg new file mode 100644 index 00000000..f548e831 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-ponyo-2008-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-porco-rosso-1992-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-porco-rosso-1992-backdrop.jpg new file mode 100644 index 00000000..63d8112e Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-porco-rosso-1992-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-porco-rosso-1992-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-porco-rosso-1992-poster.jpg new file mode 100644 index 00000000..4a6a2bd1 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-porco-rosso-1992-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-princess-mononoke-1997-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-princess-mononoke-1997-backdrop.jpg new file mode 100644 index 00000000..9486d2cf Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-princess-mononoke-1997-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-princess-mononoke-1997-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-princess-mononoke-1997-poster.jpg new file mode 100644 index 00000000..21dd9359 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-princess-mononoke-1997-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-prisoners-2013-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-prisoners-2013-backdrop.jpg new file mode 100644 index 00000000..724008c5 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-prisoners-2013-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-prisoners-2013-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-prisoners-2013-poster.jpg new file mode 100644 index 00000000..411b149b Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-prisoners-2013-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-racing-stripes-2005-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-racing-stripes-2005-backdrop.jpg new file mode 100644 index 00000000..1d332cec Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-racing-stripes-2005-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-racing-stripes-2005-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-racing-stripes-2005-poster.jpg new file mode 100644 index 00000000..cc2ef2ce Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-racing-stripes-2005-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-reincarnated-2012-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-reincarnated-2012-backdrop.jpg new file mode 100644 index 00000000..326e8ebc Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-reincarnated-2012-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-reincarnated-2012-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-reincarnated-2012-poster.jpg new file mode 100644 index 00000000..e9ab449c Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-reincarnated-2012-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-requiem-for-a-dream-2000-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-requiem-for-a-dream-2000-backdrop.jpg new file mode 100644 index 00000000..21842ef2 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-requiem-for-a-dream-2000-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-requiem-for-a-dream-2000-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-requiem-for-a-dream-2000-poster.jpg new file mode 100644 index 00000000..6f89abc3 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-requiem-for-a-dream-2000-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-rogue-one-a-star-wars-story-2016-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-rogue-one-a-star-wars-story-2016-backdrop.jpg new file mode 100644 index 00000000..ff0c14c0 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-rogue-one-a-star-wars-story-2016-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-rogue-one-a-star-wars-story-2016-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-rogue-one-a-star-wars-story-2016-poster.jpg new file mode 100644 index 00000000..c79e70a1 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-rogue-one-a-star-wars-story-2016-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-secondhand-lions-2003-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-secondhand-lions-2003-backdrop.jpg new file mode 100644 index 00000000..7ea43937 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-secondhand-lions-2003-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-secondhand-lions-2003-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-secondhand-lions-2003-poster.jpg new file mode 100644 index 00000000..33408fa0 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-secondhand-lions-2003-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-self-reliance-2024-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-self-reliance-2024-backdrop.jpg new file mode 100644 index 00000000..3cc0d123 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-self-reliance-2024-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-self-reliance-2024-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-self-reliance-2024-poster.jpg new file mode 100644 index 00000000..0f6017ed Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-self-reliance-2024-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-serpico-1973-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-serpico-1973-backdrop.jpg new file mode 100644 index 00000000..c9649485 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-serpico-1973-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-serpico-1973-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-serpico-1973-poster.jpg new file mode 100644 index 00000000..8ddd6e52 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-serpico-1973-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-shaft-2000-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-shaft-2000-backdrop.jpg new file mode 100644 index 00000000..13f14f93 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-shaft-2000-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-shaft-2000-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-shaft-2000-poster.jpg new file mode 100644 index 00000000..6b2b79ce Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-shaft-2000-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-shipwrecked-nightmare-at-sea-2026-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-shipwrecked-nightmare-at-sea-2026-backdrop.jpg new file mode 100644 index 00000000..6e98e242 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-shipwrecked-nightmare-at-sea-2026-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-shipwrecked-nightmare-at-sea-2026-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-shipwrecked-nightmare-at-sea-2026-poster.jpg new file mode 100644 index 00000000..f200a28d Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-shipwrecked-nightmare-at-sea-2026-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-sinbad-legend-of-the-seven-seas-2003-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-sinbad-legend-of-the-seven-seas-2003-backdrop.jpg new file mode 100644 index 00000000..d04685fe Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-sinbad-legend-of-the-seven-seas-2003-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-sinbad-legend-of-the-seven-seas-2003-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-sinbad-legend-of-the-seven-seas-2003-poster.jpg new file mode 100644 index 00000000..428bb38b Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-sinbad-legend-of-the-seven-seas-2003-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-sleeping-with-other-people-2015-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-sleeping-with-other-people-2015-backdrop.jpg new file mode 100644 index 00000000..df5bb046 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-sleeping-with-other-people-2015-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-sleeping-with-other-people-2015-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-sleeping-with-other-people-2015-poster.jpg new file mode 100644 index 00000000..a5ffff1f Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-sleeping-with-other-people-2015-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-snowpiercer-2013-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-snowpiercer-2013-backdrop.jpg new file mode 100644 index 00000000..c1281a9c Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-snowpiercer-2013-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-snowpiercer-2013-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-snowpiercer-2013-poster.jpg new file mode 100644 index 00000000..be1873fd Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-snowpiercer-2013-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-spider-man-brand-new-day-2026-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-spider-man-brand-new-day-2026-backdrop.jpg new file mode 100644 index 00000000..2fdaadb4 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-spider-man-brand-new-day-2026-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-spider-man-brand-new-day-2026-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-spider-man-brand-new-day-2026-poster.jpg new file mode 100644 index 00000000..51fa155f Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-spider-man-brand-new-day-2026-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-spider-man-far-from-home-2019-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-spider-man-far-from-home-2019-backdrop.jpg new file mode 100644 index 00000000..d09f655a Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-spider-man-far-from-home-2019-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-spider-man-far-from-home-2019-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-spider-man-far-from-home-2019-poster.jpg new file mode 100644 index 00000000..28521997 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-spider-man-far-from-home-2019-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-spider-man-homecoming-2017-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-spider-man-homecoming-2017-backdrop.jpg new file mode 100644 index 00000000..6088fadf Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-spider-man-homecoming-2017-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-spider-man-homecoming-2017-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-spider-man-homecoming-2017-poster.jpg new file mode 100644 index 00000000..97fc9c05 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-spider-man-homecoming-2017-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-spider-man-no-way-home-2021-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-spider-man-no-way-home-2021-backdrop.jpg new file mode 100644 index 00000000..a76afec9 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-spider-man-no-way-home-2021-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-spider-man-no-way-home-2021-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-spider-man-no-way-home-2021-poster.jpg new file mode 100644 index 00000000..bc92a4cd Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-spider-man-no-way-home-2021-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-spirited-away-2001-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-spirited-away-2001-backdrop.jpg new file mode 100644 index 00000000..af91c123 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-spirited-away-2001-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-spirited-away-2001-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-spirited-away-2001-poster.jpg new file mode 100644 index 00000000..5a79da62 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-spirited-away-2001-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-steins-gate-the-movie-load-region-of-deja-vu-2013-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-steins-gate-the-movie-load-region-of-deja-vu-2013-backdrop.jpg new file mode 100644 index 00000000..f6675060 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-steins-gate-the-movie-load-region-of-deja-vu-2013-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-steins-gate-the-movie-load-region-of-deja-vu-2013-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-steins-gate-the-movie-load-region-of-deja-vu-2013-poster.jpg new file mode 100644 index 00000000..e85ddb90 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-steins-gate-the-movie-load-region-of-deja-vu-2013-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-taxi-driver-1976-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-taxi-driver-1976-backdrop.jpg new file mode 100644 index 00000000..7946ea40 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-taxi-driver-1976-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-taxi-driver-1976-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-taxi-driver-1976-poster.jpg new file mode 100644 index 00000000..c410b981 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-taxi-driver-1976-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-alto-knights-2025-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-alto-knights-2025-backdrop.jpg new file mode 100644 index 00000000..ea619c14 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-alto-knights-2025-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-alto-knights-2025-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-alto-knights-2025-poster.jpg new file mode 100644 index 00000000..bb009cbf Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-alto-knights-2025-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-amazing-spider-man-2-2014-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-amazing-spider-man-2-2014-backdrop.jpg new file mode 100644 index 00000000..7c5878b7 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-amazing-spider-man-2-2014-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-amazing-spider-man-2-2014-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-amazing-spider-man-2-2014-poster.jpg new file mode 100644 index 00000000..4de2711f Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-amazing-spider-man-2-2014-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-amazing-spider-man-2012-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-amazing-spider-man-2012-backdrop.jpg new file mode 100644 index 00000000..75ae9b64 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-amazing-spider-man-2012-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-amazing-spider-man-2012-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-amazing-spider-man-2012-poster.jpg new file mode 100644 index 00000000..c1aec876 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-amazing-spider-man-2012-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-animatrix-2003-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-animatrix-2003-backdrop.jpg new file mode 100644 index 00000000..7d67ccb0 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-animatrix-2003-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-animatrix-2003-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-animatrix-2003-poster.jpg new file mode 100644 index 00000000..65553d47 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-animatrix-2003-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-avengers-2012-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-avengers-2012-backdrop.jpg new file mode 100644 index 00000000..83636f25 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-avengers-2012-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-avengers-2012-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-avengers-2012-poster.jpg new file mode 100644 index 00000000..be87067c Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-avengers-2012-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-boy-and-the-heron-2023-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-boy-and-the-heron-2023-backdrop.jpg new file mode 100644 index 00000000..a19dc77b Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-boy-and-the-heron-2023-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-boy-and-the-heron-2023-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-boy-and-the-heron-2023-poster.jpg new file mode 100644 index 00000000..094df64e Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-boy-and-the-heron-2023-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-breakfast-club-1985-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-breakfast-club-1985-backdrop.jpg new file mode 100644 index 00000000..9f979cd8 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-breakfast-club-1985-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-breakfast-club-1985-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-breakfast-club-1985-poster.jpg new file mode 100644 index 00000000..d1823120 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-breakfast-club-1985-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-dry-2021-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-dry-2021-backdrop.jpg new file mode 100644 index 00000000..2f016498 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-dry-2021-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-dry-2021-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-dry-2021-poster.jpg new file mode 100644 index 00000000..a0379f2f Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-dry-2021-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-girl-who-leapt-through-time-2006-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-girl-who-leapt-through-time-2006-backdrop.jpg new file mode 100644 index 00000000..6e920467 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-girl-who-leapt-through-time-2006-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-girl-who-leapt-through-time-2006-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-girl-who-leapt-through-time-2006-poster.jpg new file mode 100644 index 00000000..ded6cd2a Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-girl-who-leapt-through-time-2006-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-godfather-1972-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-godfather-1972-backdrop.jpg new file mode 100644 index 00000000..8fbb8f2a Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-godfather-1972-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-godfather-1972-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-godfather-1972-poster.jpg new file mode 100644 index 00000000..94111d9b Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-godfather-1972-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-godfather-part-ii-1974-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-godfather-part-ii-1974-backdrop.jpg new file mode 100644 index 00000000..6dbff534 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-godfather-part-ii-1974-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-godfather-part-ii-1974-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-godfather-part-ii-1974-poster.jpg new file mode 100644 index 00000000..1328c99e Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-godfather-part-ii-1974-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-godfather-part-iii-1990-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-godfather-part-iii-1990-backdrop.jpg new file mode 100644 index 00000000..d2080487 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-godfather-part-iii-1990-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-godfather-part-iii-1990-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-godfather-part-iii-1990-poster.jpg new file mode 100644 index 00000000..abbe5e99 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-godfather-part-iii-1990-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-great-gatsby-2013-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-great-gatsby-2013-backdrop.jpg new file mode 100644 index 00000000..7a4f8a32 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-great-gatsby-2013-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-great-gatsby-2013-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-great-gatsby-2013-poster.jpg new file mode 100644 index 00000000..e949fb3e Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-great-gatsby-2013-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-hours-2002-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-hours-2002-backdrop.jpg new file mode 100644 index 00000000..507a78c4 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-hours-2002-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-hours-2002-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-hours-2002-poster.jpg new file mode 100644 index 00000000..f87e919f Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-hours-2002-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-killing-of-a-sacred-deer-2017-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-killing-of-a-sacred-deer-2017-backdrop.jpg new file mode 100644 index 00000000..dd755ee8 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-killing-of-a-sacred-deer-2017-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-killing-of-a-sacred-deer-2017-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-killing-of-a-sacred-deer-2017-poster.jpg new file mode 100644 index 00000000..83464d4c Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-killing-of-a-sacred-deer-2017-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-last-of-the-mohicans-1992-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-last-of-the-mohicans-1992-backdrop.jpg new file mode 100644 index 00000000..3256d38d Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-last-of-the-mohicans-1992-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-last-of-the-mohicans-1992-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-last-of-the-mohicans-1992-poster.jpg new file mode 100644 index 00000000..950e9ba1 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-last-of-the-mohicans-1992-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-last-samurai-2003-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-last-samurai-2003-backdrop.jpg new file mode 100644 index 00000000..509af2d6 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-last-samurai-2003-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-last-samurai-2003-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-last-samurai-2003-poster.jpg new file mode 100644 index 00000000..0d58440c Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-last-samurai-2003-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-lighthouse-2019-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-lighthouse-2019-backdrop.jpg new file mode 100644 index 00000000..862c064a Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-lighthouse-2019-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-lighthouse-2019-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-lighthouse-2019-poster.jpg new file mode 100644 index 00000000..15f26533 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-lighthouse-2019-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-matrix-1999-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-matrix-1999-backdrop.jpg new file mode 100644 index 00000000..c3bf8f52 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-matrix-1999-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-matrix-1999-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-matrix-1999-poster.jpg new file mode 100644 index 00000000..c292dce5 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-matrix-1999-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-matrix-reloaded-2003-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-matrix-reloaded-2003-backdrop.jpg new file mode 100644 index 00000000..dcbf7b96 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-matrix-reloaded-2003-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-matrix-reloaded-2003-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-matrix-reloaded-2003-poster.jpg new file mode 100644 index 00000000..ab11f50f Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-matrix-reloaded-2003-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-matrix-resurrections-2021-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-matrix-resurrections-2021-backdrop.jpg new file mode 100644 index 00000000..99d85a40 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-matrix-resurrections-2021-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-matrix-resurrections-2021-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-matrix-resurrections-2021-poster.jpg new file mode 100644 index 00000000..4477ef1e Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-matrix-resurrections-2021-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-matrix-revolutions-2003-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-matrix-revolutions-2003-backdrop.jpg new file mode 100644 index 00000000..2cc02692 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-matrix-revolutions-2003-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-matrix-revolutions-2003-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-matrix-revolutions-2003-poster.jpg new file mode 100644 index 00000000..7236cc4e Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-matrix-revolutions-2003-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-patriot-2000-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-patriot-2000-backdrop.jpg new file mode 100644 index 00000000..ce7ce498 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-patriot-2000-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-patriot-2000-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-patriot-2000-poster.jpg new file mode 100644 index 00000000..c8582133 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-patriot-2000-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-secret-life-of-pets-2-2019-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-secret-life-of-pets-2-2019-backdrop.jpg new file mode 100644 index 00000000..698f4ad2 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-secret-life-of-pets-2-2019-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-secret-life-of-pets-2-2019-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-secret-life-of-pets-2-2019-poster.jpg new file mode 100644 index 00000000..fe50c2f8 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-secret-life-of-pets-2-2019-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-substance-2024-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-substance-2024-backdrop.jpg new file mode 100644 index 00000000..47a8b372 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-substance-2024-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-substance-2024-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-substance-2024-poster.jpg new file mode 100644 index 00000000..72accfed Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-substance-2024-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-tale-of-the-princess-kaguya-2013-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-tale-of-the-princess-kaguya-2013-backdrop.jpg new file mode 100644 index 00000000..b9157b91 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-tale-of-the-princess-kaguya-2013-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-tale-of-the-princess-kaguya-2013-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-tale-of-the-princess-kaguya-2013-poster.jpg new file mode 100644 index 00000000..882621cb Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-tale-of-the-princess-kaguya-2013-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-uprising-2026-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-uprising-2026-backdrop.jpg new file mode 100644 index 00000000..638eab3d Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-uprising-2026-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-uprising-2026-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-uprising-2026-poster.jpg new file mode 100644 index 00000000..21976395 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-uprising-2026-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-wild-robot-2024-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-wild-robot-2024-backdrop.jpg new file mode 100644 index 00000000..bb48460b Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-wild-robot-2024-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-wild-robot-2024-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-wild-robot-2024-poster.jpg new file mode 100644 index 00000000..0894754d Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-wild-robot-2024-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-wind-rises-2013-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-wind-rises-2013-backdrop.jpg new file mode 100644 index 00000000..2af40e28 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-wind-rises-2013-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-wind-rises-2013-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-wind-rises-2013-poster.jpg new file mode 100644 index 00000000..b7688c77 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-wind-rises-2013-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-witcher-nightmare-of-the-wolf-2021-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-witcher-nightmare-of-the-wolf-2021-backdrop.jpg new file mode 100644 index 00000000..cb5bfb28 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-witcher-nightmare-of-the-wolf-2021-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-witcher-nightmare-of-the-wolf-2021-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-witcher-nightmare-of-the-wolf-2021-poster.jpg new file mode 100644 index 00000000..6db33db1 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-witcher-nightmare-of-the-wolf-2021-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-witcher-sirens-of-the-deep-2025-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-witcher-sirens-of-the-deep-2025-backdrop.jpg new file mode 100644 index 00000000..aad8ea2c Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-witcher-sirens-of-the-deep-2025-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-witcher-sirens-of-the-deep-2025-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-witcher-sirens-of-the-deep-2025-poster.jpg new file mode 100644 index 00000000..8a54bc7f Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-witcher-sirens-of-the-deep-2025-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-thelma-louise-1991-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-thelma-louise-1991-backdrop.jpg new file mode 100644 index 00000000..a35327a9 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-thelma-louise-1991-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-thelma-louise-1991-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-thelma-louise-1991-poster.jpg new file mode 100644 index 00000000..5a4ff0ab Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-thelma-louise-1991-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-triangle-of-sadness-2022-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-triangle-of-sadness-2022-backdrop.jpg new file mode 100644 index 00000000..7c9b445d Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-triangle-of-sadness-2022-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-triangle-of-sadness-2022-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-triangle-of-sadness-2022-poster.jpg new file mode 100644 index 00000000..aee092c6 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-triangle-of-sadness-2022-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-tsotsi-2005-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-tsotsi-2005-backdrop.jpg new file mode 100644 index 00000000..e119de53 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-tsotsi-2005-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-tsotsi-2005-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-tsotsi-2005-poster.jpg new file mode 100644 index 00000000..362ddd82 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-tsotsi-2005-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-unknown-number-the-high-school-catfish-2025-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-unknown-number-the-high-school-catfish-2025-backdrop.jpg new file mode 100644 index 00000000..f456c01d Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-unknown-number-the-high-school-catfish-2025-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-unknown-number-the-high-school-catfish-2025-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-unknown-number-the-high-school-catfish-2025-poster.jpg new file mode 100644 index 00000000..e32685f3 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-unknown-number-the-high-school-catfish-2025-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-vice-2018-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-vice-2018-backdrop.jpg new file mode 100644 index 00000000..2918db3a Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-vice-2018-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-vice-2018-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-vice-2018-poster.jpg new file mode 100644 index 00000000..76855126 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-vice-2018-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-vicky-cristina-barcelona-2008-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-vicky-cristina-barcelona-2008-backdrop.jpg new file mode 100644 index 00000000..20eaa159 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-vicky-cristina-barcelona-2008-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-vicky-cristina-barcelona-2008-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-vicky-cristina-barcelona-2008-poster.jpg new file mode 100644 index 00000000..1351cad1 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-vicky-cristina-barcelona-2008-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-warriors-of-the-wind-1984-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-warriors-of-the-wind-1984-backdrop.jpg new file mode 100644 index 00000000..1fb22b96 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-warriors-of-the-wind-1984-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-warriors-of-the-wind-1984-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-warriors-of-the-wind-1984-poster.jpg new file mode 100644 index 00000000..918e424f Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-warriors-of-the-wind-1984-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-we-bury-the-dead-2026-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-we-bury-the-dead-2026-backdrop.jpg new file mode 100644 index 00000000..4a24720d Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-we-bury-the-dead-2026-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-we-bury-the-dead-2026-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-we-bury-the-dead-2026-poster.jpg new file mode 100644 index 00000000..a0d65904 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-we-bury-the-dead-2026-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-white-hot-the-rise-fall-of-abercrombie-fitch-2022-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-white-hot-the-rise-fall-of-abercrombie-fitch-2022-backdrop.jpg new file mode 100644 index 00000000..2f186030 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-white-hot-the-rise-fall-of-abercrombie-fitch-2022-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-white-hot-the-rise-fall-of-abercrombie-fitch-2022-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-white-hot-the-rise-fall-of-abercrombie-fitch-2022-poster.jpg new file mode 100644 index 00000000..3a123e80 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-white-hot-the-rise-fall-of-abercrombie-fitch-2022-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-12-monkeys-2015-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-12-monkeys-2015-backdrop.jpg new file mode 100644 index 00000000..3d2825f5 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-12-monkeys-2015-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-12-monkeys-2015-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-12-monkeys-2015-poster.jpg new file mode 100644 index 00000000..96e3596e Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-12-monkeys-2015-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-56-days-2026-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-56-days-2026-backdrop.jpg new file mode 100644 index 00000000..dcda667a Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-56-days-2026-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-56-days-2026-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-56-days-2026-poster.jpg new file mode 100644 index 00000000..64ca587b Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-56-days-2026-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-90-day-fiance-before-the-90-days-2017-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-90-day-fiance-before-the-90-days-2017-backdrop.jpg new file mode 100644 index 00000000..24f66f2d Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-90-day-fiance-before-the-90-days-2017-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-90-day-fiance-before-the-90-days-2017-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-90-day-fiance-before-the-90-days-2017-poster.jpg new file mode 100644 index 00000000..3362da59 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-90-day-fiance-before-the-90-days-2017-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-90-day-fiance-the-other-way-2019-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-90-day-fiance-the-other-way-2019-backdrop.jpg new file mode 100644 index 00000000..b18891fd Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-90-day-fiance-the-other-way-2019-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-90-day-fiance-the-other-way-2019-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-90-day-fiance-the-other-way-2019-poster.jpg new file mode 100644 index 00000000..b4d256eb Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-90-day-fiance-the-other-way-2019-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-a-knight-of-the-seven-kingdoms-2026-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-a-knight-of-the-seven-kingdoms-2026-backdrop.jpg new file mode 100644 index 00000000..ce12fc71 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-a-knight-of-the-seven-kingdoms-2026-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-a-knight-of-the-seven-kingdoms-2026-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-a-knight-of-the-seven-kingdoms-2026-poster.jpg new file mode 100644 index 00000000..79f1351c Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-a-knight-of-the-seven-kingdoms-2026-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-absolutely-fabulous-1992-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-absolutely-fabulous-1992-backdrop.jpg new file mode 100644 index 00000000..2df9722d Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-absolutely-fabulous-1992-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-absolutely-fabulous-1992-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-absolutely-fabulous-1992-poster.jpg new file mode 100644 index 00000000..afd371a9 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-absolutely-fabulous-1992-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-accel-world-2012-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-accel-world-2012-backdrop.jpg new file mode 100644 index 00000000..1f5f6bdc Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-accel-world-2012-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-accel-world-2012-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-accel-world-2012-poster.jpg new file mode 100644 index 00000000..207b1dd1 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-accel-world-2012-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-accused-2023-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-accused-2023-backdrop.jpg new file mode 100644 index 00000000..d41c6397 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-accused-2023-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-accused-2023-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-accused-2023-poster.jpg new file mode 100644 index 00000000..74af010a Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-accused-2023-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-adults-2025-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-adults-2025-backdrop.jpg new file mode 100644 index 00000000..9cc0ffe2 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-adults-2025-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-adults-2025-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-adults-2025-poster.jpg new file mode 100644 index 00000000..53a7ba33 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-adults-2025-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-after-life-2019-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-after-life-2019-backdrop.jpg new file mode 100644 index 00000000..eaf9a4fa Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-after-life-2019-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-after-life-2019-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-after-life-2019-poster.jpg new file mode 100644 index 00000000..4fae4e33 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-after-life-2019-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-agatha-christie-s-marple-2004-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-agatha-christie-s-marple-2004-backdrop.jpg new file mode 100644 index 00000000..0c52f2e6 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-agatha-christie-s-marple-2004-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-agatha-christie-s-marple-2004-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-agatha-christie-s-marple-2004-poster.jpg new file mode 100644 index 00000000..ea7dda99 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-agatha-christie-s-marple-2004-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-ahsoka-2023-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-ahsoka-2023-backdrop.jpg new file mode 100644 index 00000000..7343fb32 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-ahsoka-2023-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-ahsoka-2023-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-ahsoka-2023-poster.jpg new file mode 100644 index 00000000..a29d6cb4 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-ahsoka-2023-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-all-american-homecoming-2022-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-all-american-homecoming-2022-backdrop.jpg new file mode 100644 index 00000000..09fbb9dc Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-all-american-homecoming-2022-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-all-american-homecoming-2022-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-all-american-homecoming-2022-poster.jpg new file mode 100644 index 00000000..09ccfc33 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-all-american-homecoming-2022-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-all-creatures-great-and-small-1978-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-all-creatures-great-and-small-1978-backdrop.jpg new file mode 100644 index 00000000..909ae1ba Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-all-creatures-great-and-small-1978-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-all-creatures-great-and-small-1978-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-all-creatures-great-and-small-1978-poster.jpg new file mode 100644 index 00000000..5b1666e6 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-all-creatures-great-and-small-1978-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-all-creatures-great-small-2020-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-all-creatures-great-small-2020-backdrop.jpg new file mode 100644 index 00000000..d2bcc88d Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-all-creatures-great-small-2020-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-all-creatures-great-small-2020-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-all-creatures-great-small-2020-poster.jpg new file mode 100644 index 00000000..b506f990 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-all-creatures-great-small-2020-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-american-crime-story-2016-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-american-crime-story-2016-backdrop.jpg new file mode 100644 index 00000000..84944d39 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-american-crime-story-2016-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-american-crime-story-2016-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-american-crime-story-2016-poster.jpg new file mode 100644 index 00000000..4086bf17 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-american-crime-story-2016-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-american-dad-2005-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-american-dad-2005-backdrop.jpg new file mode 100644 index 00000000..75a1948b Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-american-dad-2005-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-american-dad-2005-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-american-dad-2005-poster.jpg new file mode 100644 index 00000000..1f9e239a Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-american-dad-2005-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-american-gods-2017-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-american-gods-2017-backdrop.jpg new file mode 100644 index 00000000..4817c690 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-american-gods-2017-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-american-gods-2017-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-american-gods-2017-poster.jpg new file mode 100644 index 00000000..c02c46da Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-american-gods-2017-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-american-housewife-2016-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-american-housewife-2016-backdrop.jpg new file mode 100644 index 00000000..bf758ae0 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-american-housewife-2016-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-american-housewife-2016-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-american-housewife-2016-poster.jpg new file mode 100644 index 00000000..5f92bf05 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-american-housewife-2016-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-american-primeval-2025-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-american-primeval-2025-backdrop.jpg new file mode 100644 index 00000000..c3bfe5e1 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-american-primeval-2025-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-american-primeval-2025-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-american-primeval-2025-poster.jpg new file mode 100644 index 00000000..7f10e6aa Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-american-primeval-2025-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-and-just-like-that-2021-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-and-just-like-that-2021-backdrop.jpg new file mode 100644 index 00000000..59c981e5 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-and-just-like-that-2021-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-and-just-like-that-2021-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-and-just-like-that-2021-poster.jpg new file mode 100644 index 00000000..9904c74b Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-and-just-like-that-2021-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-andor-2022-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-andor-2022-backdrop.jpg new file mode 100644 index 00000000..a874a0c0 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-andor-2022-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-andor-2022-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-andor-2022-poster.jpg new file mode 100644 index 00000000..df5dc498 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-andor-2022-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-andromeda-2000-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-andromeda-2000-backdrop.jpg new file mode 100644 index 00000000..199249d7 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-andromeda-2000-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-andromeda-2000-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-andromeda-2000-poster.jpg new file mode 100644 index 00000000..67b766b0 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-andromeda-2000-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-anne-with-an-e-2017-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-anne-with-an-e-2017-backdrop.jpg new file mode 100644 index 00000000..15e325c9 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-anne-with-an-e-2017-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-anne-with-an-e-2017-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-anne-with-an-e-2017-poster.jpg new file mode 100644 index 00000000..55d443b4 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-anne-with-an-e-2017-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-aqua-teen-hunger-force-2000-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-aqua-teen-hunger-force-2000-backdrop.jpg new file mode 100644 index 00000000..d0e37959 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-aqua-teen-hunger-force-2000-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-aqua-teen-hunger-force-2000-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-aqua-teen-hunger-force-2000-poster.jpg new file mode 100644 index 00000000..e0c97321 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-aqua-teen-hunger-force-2000-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-arifureta-from-commonplace-to-world-s-strongest-2019-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-arifureta-from-commonplace-to-world-s-strongest-2019-backdrop.jpg new file mode 100644 index 00000000..9300e20f Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-arifureta-from-commonplace-to-world-s-strongest-2019-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-arifureta-from-commonplace-to-world-s-strongest-2019-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-arifureta-from-commonplace-to-world-s-strongest-2019-poster.jpg new file mode 100644 index 00000000..6a8f9a24 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-arifureta-from-commonplace-to-world-s-strongest-2019-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-arrested-development-2003-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-arrested-development-2003-backdrop.jpg new file mode 100644 index 00000000..ecb7e8cd Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-arrested-development-2003-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-arrested-development-2003-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-arrested-development-2003-poster.jpg new file mode 100644 index 00000000..02b0dd43 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-arrested-development-2003-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-arrow-2012-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-arrow-2012-backdrop.jpg new file mode 100644 index 00000000..b32f5aca Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-arrow-2012-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-arrow-2012-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-arrow-2012-poster.jpg new file mode 100644 index 00000000..adee3bb7 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-arrow-2012-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-atlantis-2013-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-atlantis-2013-backdrop.jpg new file mode 100644 index 00000000..39ecc0d6 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-atlantis-2013-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-atlantis-2013-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-atlantis-2013-poster.jpg new file mode 100644 index 00000000..5cd5f098 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-atlantis-2013-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-avatar-seven-havens-2026-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-avatar-seven-havens-2026-backdrop.jpg new file mode 100644 index 00000000..dacfd59e Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-avatar-seven-havens-2026-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-avatar-seven-havens-2026-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-avatar-seven-havens-2026-poster.jpg new file mode 100644 index 00000000..61f459c6 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-avatar-seven-havens-2026-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-avatar-the-last-airbender-2005-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-avatar-the-last-airbender-2005-backdrop.jpg new file mode 100644 index 00000000..f60120da Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-avatar-the-last-airbender-2005-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-avatar-the-last-airbender-2005-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-avatar-the-last-airbender-2005-poster.jpg new file mode 100644 index 00000000..6f99263a Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-avatar-the-last-airbender-2005-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-avatar-the-last-airbender-2024-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-avatar-the-last-airbender-2024-backdrop.jpg new file mode 100644 index 00000000..ad740218 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-avatar-the-last-airbender-2024-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-avatar-the-last-airbender-2024-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-avatar-the-last-airbender-2024-poster.jpg new file mode 100644 index 00000000..9d46c2ff Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-avatar-the-last-airbender-2024-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-awkward-2011-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-awkward-2011-backdrop.jpg new file mode 100644 index 00000000..1ca5e56c Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-awkward-2011-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-awkward-2011-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-awkward-2011-poster.jpg new file mode 100644 index 00000000..8da6e8e2 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-awkward-2011-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-b-b-vol-liefde-2021-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-b-b-vol-liefde-2021-backdrop.jpg new file mode 100644 index 00000000..57ece15e Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-b-b-vol-liefde-2021-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-b-b-vol-liefde-2021-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-b-b-vol-liefde-2021-poster.jpg new file mode 100644 index 00000000..a12ac33e Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-b-b-vol-liefde-2021-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-back-to-life-2019-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-back-to-life-2019-backdrop.jpg new file mode 100644 index 00000000..7ffef681 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-back-to-life-2019-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-back-to-life-2019-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-back-to-life-2019-poster.jpg new file mode 100644 index 00000000..b00730a8 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-back-to-life-2019-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-ballers-2015-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-ballers-2015-backdrop.jpg new file mode 100644 index 00000000..c4685e84 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-ballers-2015-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-ballers-2015-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-ballers-2015-poster.jpg new file mode 100644 index 00000000..f1c18f69 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-ballers-2015-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-baretta-1975-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-baretta-1975-backdrop.jpg new file mode 100644 index 00000000..4d61dfbe Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-baretta-1975-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-baretta-1975-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-baretta-1975-poster.jpg new file mode 100644 index 00000000..ceef0c9d Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-baretta-1975-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-based-on-a-true-story-2023-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-based-on-a-true-story-2023-backdrop.jpg new file mode 100644 index 00000000..b59c0e58 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-based-on-a-true-story-2023-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-based-on-a-true-story-2023-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-based-on-a-true-story-2023-poster.jpg new file mode 100644 index 00000000..386d3d90 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-based-on-a-true-story-2023-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-batman-the-brave-and-the-bold-2008-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-batman-the-brave-and-the-bold-2008-backdrop.jpg new file mode 100644 index 00000000..69e567cb Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-batman-the-brave-and-the-bold-2008-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-batman-the-brave-and-the-bold-2008-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-batman-the-brave-and-the-bold-2008-poster.jpg new file mode 100644 index 00000000..a864f50c Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-batman-the-brave-and-the-bold-2008-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-batwoman-2019-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-batwoman-2019-backdrop.jpg new file mode 100644 index 00000000..d04a1a20 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-batwoman-2019-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-batwoman-2019-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-batwoman-2019-poster.jpg new file mode 100644 index 00000000..7ca86592 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-batwoman-2019-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-beat-bobby-flay-2013-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-beat-bobby-flay-2013-backdrop.jpg new file mode 100644 index 00000000..e6c36ddd Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-beat-bobby-flay-2013-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-beat-bobby-flay-2013-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-beat-bobby-flay-2013-poster.jpg new file mode 100644 index 00000000..fe11122d Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-beat-bobby-flay-2013-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-benson-1979-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-benson-1979-backdrop.jpg new file mode 100644 index 00000000..32bd63a9 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-benson-1979-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-benson-1979-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-benson-1979-poster.jpg new file mode 100644 index 00000000..fe264c76 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-benson-1979-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-big-boys-2022-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-big-boys-2022-backdrop.jpg new file mode 100644 index 00000000..c7c91e2d Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-big-boys-2022-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-big-boys-2022-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-big-boys-2022-poster.jpg new file mode 100644 index 00000000..93e1e86a Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-big-boys-2022-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-big-city-greens-2018-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-big-city-greens-2018-backdrop.jpg new file mode 100644 index 00000000..381774e7 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-big-city-greens-2018-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-big-city-greens-2018-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-big-city-greens-2018-poster.jpg new file mode 100644 index 00000000..ef024946 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-big-city-greens-2018-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-big-mouth-2017-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-big-mouth-2017-backdrop.jpg new file mode 100644 index 00000000..760e0262 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-big-mouth-2017-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-big-mouth-2017-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-big-mouth-2017-poster.jpg new file mode 100644 index 00000000..4f535fec Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-big-mouth-2017-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-black-ish-2014-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-black-ish-2014-backdrop.jpg new file mode 100644 index 00000000..967fb621 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-black-ish-2014-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-black-ish-2014-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-black-ish-2014-poster.jpg new file mode 100644 index 00000000..97c885ad Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-black-ish-2014-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-black-lightning-2018-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-black-lightning-2018-backdrop.jpg new file mode 100644 index 00000000..3a0854c9 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-black-lightning-2018-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-black-lightning-2018-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-black-lightning-2018-poster.jpg new file mode 100644 index 00000000..733f5cbc Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-black-lightning-2018-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-black-rabbit-2025-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-black-rabbit-2025-backdrop.jpg new file mode 100644 index 00000000..48df9d46 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-black-rabbit-2025-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-black-rabbit-2025-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-black-rabbit-2025-poster.jpg new file mode 100644 index 00000000..3ff9aeed Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-black-rabbit-2025-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-bleach-2004-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-bleach-2004-backdrop.jpg new file mode 100644 index 00000000..ef8eaaa3 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-bleach-2004-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-bleach-2004-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-bleach-2004-poster.jpg new file mode 100644 index 00000000..b233e033 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-bleach-2004-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-blue-planet-ii-2017-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-blue-planet-ii-2017-backdrop.jpg new file mode 100644 index 00000000..8175e568 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-blue-planet-ii-2017-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-blue-planet-ii-2017-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-blue-planet-ii-2017-poster.jpg new file mode 100644 index 00000000..5e193ab2 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-blue-planet-ii-2017-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-brassic-2019-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-brassic-2019-backdrop.jpg new file mode 100644 index 00000000..86d3d806 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-brassic-2019-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-brassic-2019-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-brassic-2019-poster.jpg new file mode 100644 index 00000000..73b77df2 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-brassic-2019-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-breaking-bad-2008-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-breaking-bad-2008-backdrop.jpg new file mode 100644 index 00000000..e4ac4ae3 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-breaking-bad-2008-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-breaking-bad-2008-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-breaking-bad-2008-poster.jpg new file mode 100644 index 00000000..c4f7217c Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-breaking-bad-2008-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-breeders-2020-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-breeders-2020-backdrop.jpg new file mode 100644 index 00000000..9d95ba77 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-breeders-2020-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-breeders-2020-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-breeders-2020-poster.jpg new file mode 100644 index 00000000..49c61208 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-breeders-2020-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-californication-2007-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-californication-2007-backdrop.jpg new file mode 100644 index 00000000..ffff979b Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-californication-2007-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-californication-2007-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-californication-2007-poster.jpg new file mode 100644 index 00000000..38428e76 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-californication-2007-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-call-the-midwife-2012-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-call-the-midwife-2012-backdrop.jpg new file mode 100644 index 00000000..1ee28b8d Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-call-the-midwife-2012-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-call-the-midwife-2012-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-call-the-midwife-2012-poster.jpg new file mode 100644 index 00000000..466a1a7b Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-call-the-midwife-2012-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-chainsaw-man-2022-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-chainsaw-man-2022-backdrop.jpg new file mode 100644 index 00000000..f1fbee3e Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-chainsaw-man-2022-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-chainsaw-man-2022-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-chainsaw-man-2022-poster.jpg new file mode 100644 index 00000000..a3779772 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-chainsaw-man-2022-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-chernobyl-2019-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-chernobyl-2019-backdrop.jpg new file mode 100644 index 00000000..818a6665 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-chernobyl-2019-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-chernobyl-2019-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-chernobyl-2019-poster.jpg new file mode 100644 index 00000000..7664f4b0 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-chernobyl-2019-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-community-2009-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-community-2009-backdrop.jpg new file mode 100644 index 00000000..8deb8d5c Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-community-2009-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-community-2009-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-community-2009-poster.jpg new file mode 100644 index 00000000..e75072ae Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-community-2009-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-cyberpunk-edgerunners-2022-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-cyberpunk-edgerunners-2022-backdrop.jpg new file mode 100644 index 00000000..2920cf5a Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-cyberpunk-edgerunners-2022-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-cyberpunk-edgerunners-2022-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-cyberpunk-edgerunners-2022-poster.jpg new file mode 100644 index 00000000..8d338532 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-cyberpunk-edgerunners-2022-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-dan-da-dan-2024-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-dan-da-dan-2024-backdrop.jpg new file mode 100644 index 00000000..0cbc4b07 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-dan-da-dan-2024-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-dan-da-dan-2024-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-dan-da-dan-2024-poster.jpg new file mode 100644 index 00000000..f42cec16 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-dan-da-dan-2024-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-dead-end-job-2026-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-dead-end-job-2026-backdrop.jpg new file mode 100644 index 00000000..3525c2fc Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-dead-end-job-2026-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-dead-end-job-2026-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-dead-end-job-2026-poster.jpg new file mode 100644 index 00000000..f5546d88 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-dead-end-job-2026-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-derry-girls-2018-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-derry-girls-2018-backdrop.jpg new file mode 100644 index 00000000..76fae98c Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-derry-girls-2018-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-derry-girls-2018-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-derry-girls-2018-poster.jpg new file mode 100644 index 00000000..56a8cd79 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-derry-girls-2018-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-desperate-housewives-2004-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-desperate-housewives-2004-backdrop.jpg new file mode 100644 index 00000000..40ec3001 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-desperate-housewives-2004-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-desperate-housewives-2004-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-desperate-housewives-2004-poster.jpg new file mode 100644 index 00000000..0c4c38bc Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-desperate-housewives-2004-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-dopesick-2021-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-dopesick-2021-backdrop.jpg new file mode 100644 index 00000000..8d9f2727 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-dopesick-2021-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-dopesick-2021-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-dopesick-2021-poster.jpg new file mode 100644 index 00000000..ac0b5900 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-dopesick-2021-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-dr-stone-2019-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-dr-stone-2019-backdrop.jpg new file mode 100644 index 00000000..5ac396b6 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-dr-stone-2019-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-dr-stone-2019-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-dr-stone-2019-poster.jpg new file mode 100644 index 00000000..0783ed8c Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-dr-stone-2019-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-dune-prophecy-2024-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-dune-prophecy-2024-backdrop.jpg new file mode 100644 index 00000000..5b940b9f Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-dune-prophecy-2024-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-dune-prophecy-2024-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-dune-prophecy-2024-poster.jpg new file mode 100644 index 00000000..eef2a4f3 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-dune-prophecy-2024-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-east-of-eden-2026-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-east-of-eden-2026-backdrop.jpg new file mode 100644 index 00000000..89dfafbf Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-east-of-eden-2026-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-east-of-eden-2026-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-east-of-eden-2026-poster.jpg new file mode 100644 index 00000000..6f06f2c9 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-east-of-eden-2026-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-etoile-2025-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-etoile-2025-backdrop.jpg new file mode 100644 index 00000000..a8e9576c Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-etoile-2025-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-etoile-2025-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-etoile-2025-poster.jpg new file mode 100644 index 00000000..f078bc89 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-etoile-2025-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-fallout-2024-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-fallout-2024-backdrop.jpg new file mode 100644 index 00000000..c9d6a190 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-fallout-2024-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-fallout-2024-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-fallout-2024-poster.jpg new file mode 100644 index 00000000..5e87a07d Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-fallout-2024-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-fawlty-towers-1975-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-fawlty-towers-1975-backdrop.jpg new file mode 100644 index 00000000..2c9c258f Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-fawlty-towers-1975-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-fawlty-towers-1975-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-fawlty-towers-1975-poster.jpg new file mode 100644 index 00000000..53b406da Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-fawlty-towers-1975-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-firefly-2002-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-firefly-2002-backdrop.jpg new file mode 100644 index 00000000..fa3b7604 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-firefly-2002-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-firefly-2002-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-firefly-2002-poster.jpg new file mode 100644 index 00000000..9ad599b3 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-firefly-2002-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-fleabag-2016-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-fleabag-2016-backdrop.jpg new file mode 100644 index 00000000..e5a83462 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-fleabag-2016-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-fleabag-2016-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-fleabag-2016-poster.jpg new file mode 100644 index 00000000..bd3ccea5 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-fleabag-2016-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-frieren-beyond-journey-s-end-2023-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-frieren-beyond-journey-s-end-2023-backdrop.jpg new file mode 100644 index 00000000..a42a2c94 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-frieren-beyond-journey-s-end-2023-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-frieren-beyond-journey-s-end-2023-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-frieren-beyond-journey-s-end-2023-poster.jpg new file mode 100644 index 00000000..cb94104e Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-frieren-beyond-journey-s-end-2023-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-fullmetal-alchemist-brotherhood-2009-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-fullmetal-alchemist-brotherhood-2009-backdrop.jpg new file mode 100644 index 00000000..91ce4909 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-fullmetal-alchemist-brotherhood-2009-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-fullmetal-alchemist-brotherhood-2009-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-fullmetal-alchemist-brotherhood-2009-poster.jpg new file mode 100644 index 00000000..935e4944 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-fullmetal-alchemist-brotherhood-2009-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-gilmore-girls-2000-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-gilmore-girls-2000-backdrop.jpg new file mode 100644 index 00000000..b42ba953 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-gilmore-girls-2000-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-gilmore-girls-2000-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-gilmore-girls-2000-poster.jpg new file mode 100644 index 00000000..23b668cb Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-gilmore-girls-2000-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-ginny-georgia-2021-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-ginny-georgia-2021-backdrop.jpg new file mode 100644 index 00000000..1271d577 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-ginny-georgia-2021-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-ginny-georgia-2021-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-ginny-georgia-2021-poster.jpg new file mode 100644 index 00000000..1fca7bf6 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-ginny-georgia-2021-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-gravity-falls-2012-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-gravity-falls-2012-backdrop.jpg new file mode 100644 index 00000000..b9ef40fa Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-gravity-falls-2012-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-gravity-falls-2012-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-gravity-falls-2012-poster.jpg new file mode 100644 index 00000000..41c051e0 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-gravity-falls-2012-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-hellcats-2010-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-hellcats-2010-backdrop.jpg new file mode 100644 index 00000000..38e856b0 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-hellcats-2010-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-hellcats-2010-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-hellcats-2010-poster.jpg new file mode 100644 index 00000000..2427108b Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-hellcats-2010-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-high-potential-2024-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-high-potential-2024-backdrop.jpg new file mode 100644 index 00000000..66bb4d00 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-high-potential-2024-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-high-potential-2024-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-high-potential-2024-poster.jpg new file mode 100644 index 00000000..00a07ca7 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-high-potential-2024-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-how-to-change-your-mind-2022-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-how-to-change-your-mind-2022-backdrop.jpg new file mode 100644 index 00000000..9e4171e3 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-how-to-change-your-mind-2022-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-how-to-change-your-mind-2022-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-how-to-change-your-mind-2022-poster.jpg new file mode 100644 index 00000000..e80c7999 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-how-to-change-your-mind-2022-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-how-to-get-to-heaven-from-belfast-2026-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-how-to-get-to-heaven-from-belfast-2026-backdrop.jpg new file mode 100644 index 00000000..a605895b Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-how-to-get-to-heaven-from-belfast-2026-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-how-to-get-to-heaven-from-belfast-2026-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-how-to-get-to-heaven-from-belfast-2026-poster.jpg new file mode 100644 index 00000000..eb5fc487 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-how-to-get-to-heaven-from-belfast-2026-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-interrogation-2020-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-interrogation-2020-backdrop.jpg new file mode 100644 index 00000000..c347e016 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-interrogation-2020-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-interrogation-2020-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-interrogation-2020-poster.jpg new file mode 100644 index 00000000..f3a21b66 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-interrogation-2020-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-invincible-2021-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-invincible-2021-backdrop.jpg new file mode 100644 index 00000000..e8120387 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-invincible-2021-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-invincible-2021-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-invincible-2021-poster.jpg new file mode 100644 index 00000000..c278e37e Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-invincible-2021-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-it-s-always-sunny-in-philadelphia-2005-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-it-s-always-sunny-in-philadelphia-2005-backdrop.jpg new file mode 100644 index 00000000..cbdc6525 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-it-s-always-sunny-in-philadelphia-2005-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-it-s-always-sunny-in-philadelphia-2005-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-it-s-always-sunny-in-philadelphia-2005-poster.jpg new file mode 100644 index 00000000..652923ee Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-it-s-always-sunny-in-philadelphia-2005-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-jujutsu-kaisen-2020-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-jujutsu-kaisen-2020-backdrop.jpg new file mode 100644 index 00000000..79e7d6bd Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-jujutsu-kaisen-2020-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-jujutsu-kaisen-2020-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-jujutsu-kaisen-2020-poster.jpg new file mode 100644 index 00000000..45f5ff22 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-jujutsu-kaisen-2020-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-kaiju-no-8-2024-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-kaiju-no-8-2024-backdrop.jpg new file mode 100644 index 00000000..480b2a8e Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-kaiju-no-8-2024-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-kaiju-no-8-2024-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-kaiju-no-8-2024-poster.jpg new file mode 100644 index 00000000..062496fa Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-kaiju-no-8-2024-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-landman-2024-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-landman-2024-backdrop.jpg new file mode 100644 index 00000000..009fbfc2 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-landman-2024-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-landman-2024-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-landman-2024-poster.jpg new file mode 100644 index 00000000..3d348798 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-landman-2024-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-last-samurai-standing-2025-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-last-samurai-standing-2025-backdrop.jpg new file mode 100644 index 00000000..c85a210b Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-last-samurai-standing-2025-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-last-samurai-standing-2025-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-last-samurai-standing-2025-poster.jpg new file mode 100644 index 00000000..a9789909 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-last-samurai-standing-2025-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-lord-of-the-flies-2026-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-lord-of-the-flies-2026-backdrop.jpg new file mode 100644 index 00000000..1249a8f3 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-lord-of-the-flies-2026-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-lord-of-the-flies-2026-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-lord-of-the-flies-2026-poster.jpg new file mode 100644 index 00000000..b1229ed8 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-lord-of-the-flies-2026-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-love-death-robots-2019-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-love-death-robots-2019-backdrop.jpg new file mode 100644 index 00000000..3694bfc4 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-love-death-robots-2019-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-love-death-robots-2019-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-love-death-robots-2019-poster.jpg new file mode 100644 index 00000000..08449aa9 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-love-death-robots-2019-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-love-is-blind-2020-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-love-is-blind-2020-backdrop.jpg new file mode 100644 index 00000000..5cea5b52 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-love-is-blind-2020-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-love-is-blind-2020-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-love-is-blind-2020-poster.jpg new file mode 100644 index 00000000..62ccf357 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-love-is-blind-2020-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-maniac-2018-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-maniac-2018-backdrop.jpg new file mode 100644 index 00000000..9d46de1d Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-maniac-2018-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-maniac-2018-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-maniac-2018-poster.jpg new file mode 100644 index 00000000..a1985217 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-maniac-2018-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-maximum-pleasure-guaranteed-2026-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-maximum-pleasure-guaranteed-2026-backdrop.jpg new file mode 100644 index 00000000..20748a70 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-maximum-pleasure-guaranteed-2026-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-maximum-pleasure-guaranteed-2026-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-maximum-pleasure-guaranteed-2026-poster.jpg new file mode 100644 index 00000000..abe1b2e2 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-maximum-pleasure-guaranteed-2026-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-minx-2022-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-minx-2022-backdrop.jpg new file mode 100644 index 00000000..bd459fde Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-minx-2022-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-minx-2022-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-minx-2022-poster.jpg new file mode 100644 index 00000000..a8797fe5 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-minx-2022-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-mussolini-son-of-the-century-2024-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-mussolini-son-of-the-century-2024-backdrop.jpg new file mode 100644 index 00000000..21674163 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-mussolini-son-of-the-century-2024-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-mussolini-son-of-the-century-2024-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-mussolini-son-of-the-century-2024-poster.jpg new file mode 100644 index 00000000..925d5c49 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-mussolini-son-of-the-century-2024-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-my-hero-academia-2016-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-my-hero-academia-2016-backdrop.jpg new file mode 100644 index 00000000..f5b2f94c Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-my-hero-academia-2016-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-my-hero-academia-2016-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-my-hero-academia-2016-poster.jpg new file mode 100644 index 00000000..cf571b93 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-my-hero-academia-2016-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-neon-genesis-evangelion-1995-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-neon-genesis-evangelion-1995-backdrop.jpg new file mode 100644 index 00000000..d80e17c1 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-neon-genesis-evangelion-1995-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-neon-genesis-evangelion-1995-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-neon-genesis-evangelion-1995-poster.jpg new file mode 100644 index 00000000..f86cd698 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-neon-genesis-evangelion-1995-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-norsemen-2016-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-norsemen-2016-backdrop.jpg new file mode 100644 index 00000000..58805e67 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-norsemen-2016-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-norsemen-2016-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-norsemen-2016-poster.jpg new file mode 100644 index 00000000..e7206b88 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-norsemen-2016-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-offspring-2010-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-offspring-2010-backdrop.jpg new file mode 100644 index 00000000..bc22e11a Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-offspring-2010-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-offspring-2010-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-offspring-2010-poster.jpg new file mode 100644 index 00000000..ae305043 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-offspring-2010-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-one-piece-2023-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-one-piece-2023-backdrop.jpg new file mode 100644 index 00000000..3fa4a282 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-one-piece-2023-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-one-piece-2023-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-one-piece-2023-poster.jpg new file mode 100644 index 00000000..286e6260 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-one-piece-2023-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-only-murders-in-the-building-2021-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-only-murders-in-the-building-2021-backdrop.jpg new file mode 100644 index 00000000..0b768d54 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-only-murders-in-the-building-2021-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-only-murders-in-the-building-2021-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-only-murders-in-the-building-2021-poster.jpg new file mode 100644 index 00000000..a6b147a4 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-only-murders-in-the-building-2021-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-outlander-blood-of-my-blood-2025-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-outlander-blood-of-my-blood-2025-backdrop.jpg new file mode 100644 index 00000000..23a242da Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-outlander-blood-of-my-blood-2025-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-outlander-blood-of-my-blood-2025-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-outlander-blood-of-my-blood-2025-poster.jpg new file mode 100644 index 00000000..930943b0 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-outlander-blood-of-my-blood-2025-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-over-the-garden-wall-2014-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-over-the-garden-wall-2014-backdrop.jpg new file mode 100644 index 00000000..43ac4428 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-over-the-garden-wall-2014-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-over-the-garden-wall-2014-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-over-the-garden-wall-2014-poster.jpg new file mode 100644 index 00000000..34b1ed84 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-over-the-garden-wall-2014-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-peaky-blinders-2013-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-peaky-blinders-2013-backdrop.jpg new file mode 100644 index 00000000..da587ec3 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-peaky-blinders-2013-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-peaky-blinders-2013-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-peaky-blinders-2013-poster.jpg new file mode 100644 index 00000000..25ceea88 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-peaky-blinders-2013-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-pluribus-2025-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-pluribus-2025-backdrop.jpg new file mode 100644 index 00000000..e64c0268 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-pluribus-2025-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-pluribus-2025-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-pluribus-2025-poster.jpg new file mode 100644 index 00000000..1ea4711b Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-pluribus-2025-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-record-of-ragnarok-2021-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-record-of-ragnarok-2021-backdrop.jpg new file mode 100644 index 00000000..d68a4060 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-record-of-ragnarok-2021-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-record-of-ragnarok-2021-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-record-of-ragnarok-2021-poster.jpg new file mode 100644 index 00000000..97d481d6 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-record-of-ragnarok-2021-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-rick-and-morty-2013-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-rick-and-morty-2013-backdrop.jpg new file mode 100644 index 00000000..1c4c0ffc Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-rick-and-morty-2013-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-rick-and-morty-2013-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-rick-and-morty-2013-poster.jpg new file mode 100644 index 00000000..b95c4890 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-rick-and-morty-2013-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-ripley-2024-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-ripley-2024-backdrop.jpg new file mode 100644 index 00000000..2e7399c1 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-ripley-2024-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-ripley-2024-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-ripley-2024-poster.jpg new file mode 100644 index 00000000..de0b32dd Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-ripley-2024-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-rivals-2024-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-rivals-2024-backdrop.jpg new file mode 100644 index 00000000..fe7025e4 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-rivals-2024-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-rivals-2024-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-rivals-2024-poster.jpg new file mode 100644 index 00000000..296ac67a Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-rivals-2024-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-rooster-2026-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-rooster-2026-backdrop.jpg new file mode 100644 index 00000000..f0f7a19e Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-rooster-2026-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-rooster-2026-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-rooster-2026-poster.jpg new file mode 100644 index 00000000..b698f8a6 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-rooster-2026-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-sakamoto-days-2025-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-sakamoto-days-2025-backdrop.jpg new file mode 100644 index 00000000..3186e89a Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-sakamoto-days-2025-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-sakamoto-days-2025-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-sakamoto-days-2025-poster.jpg new file mode 100644 index 00000000..ed9358b3 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-sakamoto-days-2025-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-say-nothing-2024-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-say-nothing-2024-backdrop.jpg new file mode 100644 index 00000000..445862dc Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-say-nothing-2024-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-say-nothing-2024-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-say-nothing-2024-poster.jpg new file mode 100644 index 00000000..cf50fc09 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-say-nothing-2024-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-secret-level-2024-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-secret-level-2024-backdrop.jpg new file mode 100644 index 00000000..54d2a13e Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-secret-level-2024-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-secret-level-2024-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-secret-level-2024-poster.jpg new file mode 100644 index 00000000..1b6e67f6 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-secret-level-2024-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-sentenced-to-be-a-hero-2025-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-sentenced-to-be-a-hero-2025-backdrop.jpg new file mode 100644 index 00000000..8d081a15 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-sentenced-to-be-a-hero-2025-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-sentenced-to-be-a-hero-2025-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-sentenced-to-be-a-hero-2025-poster.jpg new file mode 100644 index 00000000..2fb1c815 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-sentenced-to-be-a-hero-2025-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-severance-2022-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-severance-2022-backdrop.jpg new file mode 100644 index 00000000..a24c03bd Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-severance-2022-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-severance-2022-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-severance-2022-poster.jpg new file mode 100644 index 00000000..5a29d6f9 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-severance-2022-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-sex-and-the-city-1998-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-sex-and-the-city-1998-backdrop.jpg new file mode 100644 index 00000000..57beacc1 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-sex-and-the-city-1998-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-sex-and-the-city-1998-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-sex-and-the-city-1998-poster.jpg new file mode 100644 index 00000000..e546cc6d Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-sex-and-the-city-1998-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-silo-2023-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-silo-2023-backdrop.jpg new file mode 100644 index 00000000..bcc1c4b7 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-silo-2023-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-silo-2023-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-silo-2023-poster.jpg new file mode 100644 index 00000000..b493bc7b Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-silo-2023-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-six-feet-under-2001-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-six-feet-under-2001-backdrop.jpg new file mode 100644 index 00000000..4d2c02ad Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-six-feet-under-2001-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-six-feet-under-2001-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-six-feet-under-2001-poster.jpg new file mode 100644 index 00000000..559d6bd1 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-six-feet-under-2001-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-skymed-2022-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-skymed-2022-backdrop.jpg new file mode 100644 index 00000000..c7ec326b Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-skymed-2022-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-skymed-2022-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-skymed-2022-poster.jpg new file mode 100644 index 00000000..2540dad7 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-skymed-2022-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-so-you-think-you-can-dance-2008-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-so-you-think-you-can-dance-2008-backdrop.jpg new file mode 100644 index 00000000..feb777b2 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-so-you-think-you-can-dance-2008-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-so-you-think-you-can-dance-2008-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-so-you-think-you-can-dance-2008-poster.jpg new file mode 100644 index 00000000..a2e91496 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-so-you-think-you-can-dance-2008-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-south-park-1997-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-south-park-1997-backdrop.jpg new file mode 100644 index 00000000..cd0aec39 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-south-park-1997-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-south-park-1997-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-south-park-1997-poster.jpg new file mode 100644 index 00000000..d8568d54 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-south-park-1997-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-spy-x-family-2022-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-spy-x-family-2022-backdrop.jpg new file mode 100644 index 00000000..8764e6eb Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-spy-x-family-2022-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-spy-x-family-2022-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-spy-x-family-2022-poster.jpg new file mode 100644 index 00000000..fc326930 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-spy-x-family-2022-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-100-2014-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-100-2014-backdrop.jpg new file mode 100644 index 00000000..8d41cb22 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-100-2014-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-100-2014-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-100-2014-poster.jpg new file mode 100644 index 00000000..4d254cb0 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-100-2014-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-abbott-and-costello-show-1952-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-abbott-and-costello-show-1952-backdrop.jpg new file mode 100644 index 00000000..53e1d593 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-abbott-and-costello-show-1952-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-abbott-and-costello-show-1952-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-abbott-and-costello-show-1952-poster.jpg new file mode 100644 index 00000000..7d6768ea Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-abbott-and-costello-show-1952-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-afterparty-2022-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-afterparty-2022-backdrop.jpg new file mode 100644 index 00000000..72ca19db Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-afterparty-2022-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-afterparty-2022-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-afterparty-2022-poster.jpg new file mode 100644 index 00000000..9beacde2 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-afterparty-2022-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-baby-sitters-club-2020-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-baby-sitters-club-2020-backdrop.jpg new file mode 100644 index 00000000..d72d7c7e Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-baby-sitters-club-2020-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-baby-sitters-club-2020-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-baby-sitters-club-2020-poster.jpg new file mode 100644 index 00000000..7e4e7be2 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-baby-sitters-club-2020-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-bear-2022-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-bear-2022-backdrop.jpg new file mode 100644 index 00000000..9c9cca57 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-bear-2022-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-bear-2022-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-bear-2022-poster.jpg new file mode 100644 index 00000000..bbf0ab28 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-bear-2022-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-beast-in-me-2025-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-beast-in-me-2025-backdrop.jpg new file mode 100644 index 00000000..e7727a79 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-beast-in-me-2025-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-beast-in-me-2025-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-beast-in-me-2025-poster.jpg new file mode 100644 index 00000000..c78c5cf0 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-beast-in-me-2025-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-bold-type-2017-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-bold-type-2017-backdrop.jpg new file mode 100644 index 00000000..dd84deab Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-bold-type-2017-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-bold-type-2017-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-bold-type-2017-poster.jpg new file mode 100644 index 00000000..f663eb8b Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-bold-type-2017-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-boys-2019-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-boys-2019-backdrop.jpg new file mode 100644 index 00000000..a834d9fb Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-boys-2019-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-boys-2019-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-boys-2019-poster.jpg new file mode 100644 index 00000000..f67ff16f Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-boys-2019-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-capture-2019-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-capture-2019-backdrop.jpg new file mode 100644 index 00000000..42256fc1 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-capture-2019-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-capture-2019-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-capture-2019-poster.jpg new file mode 100644 index 00000000..a4039011 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-capture-2019-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-diplomat-2023-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-diplomat-2023-backdrop.jpg new file mode 100644 index 00000000..b25ab5f3 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-diplomat-2023-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-diplomat-2023-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-diplomat-2023-poster.jpg new file mode 100644 index 00000000..2eb48b62 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-diplomat-2023-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-durrells-2016-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-durrells-2016-backdrop.jpg new file mode 100644 index 00000000..3f8bdeec Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-durrells-2016-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-durrells-2016-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-durrells-2016-poster.jpg new file mode 100644 index 00000000..d29448bb Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-durrells-2016-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-final-problem-2026-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-final-problem-2026-backdrop.jpg new file mode 100644 index 00000000..61a310d8 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-final-problem-2026-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-final-problem-2026-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-final-problem-2026-poster.jpg new file mode 100644 index 00000000..f0332826 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-final-problem-2026-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-four-seasons-2025-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-four-seasons-2025-backdrop.jpg new file mode 100644 index 00000000..a358664c Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-four-seasons-2025-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-four-seasons-2025-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-four-seasons-2025-poster.jpg new file mode 100644 index 00000000..fd252088 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-four-seasons-2025-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-get-down-2016-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-get-down-2016-backdrop.jpg new file mode 100644 index 00000000..1de4a3ca Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-get-down-2016-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-get-down-2016-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-get-down-2016-poster.jpg new file mode 100644 index 00000000..7bb38abd Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-get-down-2016-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-hunting-wives-2025-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-hunting-wives-2025-backdrop.jpg new file mode 100644 index 00000000..d1758fef Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-hunting-wives-2025-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-hunting-wives-2025-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-hunting-wives-2025-poster.jpg new file mode 100644 index 00000000..034273eb Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-hunting-wives-2025-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-last-of-us-2023-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-last-of-us-2023-backdrop.jpg new file mode 100644 index 00000000..fa28e38d Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-last-of-us-2023-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-last-of-us-2023-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-last-of-us-2023-poster.jpg new file mode 100644 index 00000000..7da462da Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-last-of-us-2023-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-legend-of-korra-2012-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-legend-of-korra-2012-backdrop.jpg new file mode 100644 index 00000000..ab6086bd Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-legend-of-korra-2012-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-legend-of-korra-2012-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-legend-of-korra-2012-poster.jpg new file mode 100644 index 00000000..a7086d5e Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-legend-of-korra-2012-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-lord-of-the-rings-the-rings-of-power-2022-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-lord-of-the-rings-the-rings-of-power-2022-backdrop.jpg new file mode 100644 index 00000000..0b88fd4b Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-lord-of-the-rings-the-rings-of-power-2022-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-lord-of-the-rings-the-rings-of-power-2022-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-lord-of-the-rings-the-rings-of-power-2022-poster.jpg new file mode 100644 index 00000000..dc387fcd Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-lord-of-the-rings-the-rings-of-power-2022-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-lost-flowers-of-alice-hart-2023-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-lost-flowers-of-alice-hart-2023-backdrop.jpg new file mode 100644 index 00000000..c5d17d97 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-lost-flowers-of-alice-hart-2023-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-lost-flowers-of-alice-hart-2023-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-lost-flowers-of-alice-hart-2023-poster.jpg new file mode 100644 index 00000000..71407f36 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-lost-flowers-of-alice-hart-2023-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-night-manager-2016-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-night-manager-2016-backdrop.jpg new file mode 100644 index 00000000..327aa449 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-night-manager-2016-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-night-manager-2016-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-night-manager-2016-poster.jpg new file mode 100644 index 00000000..7ab2dbf5 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-night-manager-2016-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-pitt-2025-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-pitt-2025-backdrop.jpg new file mode 100644 index 00000000..b593444a Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-pitt-2025-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-pitt-2025-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-pitt-2025-poster.jpg new file mode 100644 index 00000000..ad1ec8ff Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-pitt-2025-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-tatami-galaxy-2010-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-tatami-galaxy-2010-backdrop.jpg new file mode 100644 index 00000000..a267b4cb Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-tatami-galaxy-2010-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-tatami-galaxy-2010-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-tatami-galaxy-2010-poster.jpg new file mode 100644 index 00000000..5324dc75 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-tatami-galaxy-2010-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-walk-in-2022-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-walk-in-2022-backdrop.jpg new file mode 100644 index 00000000..e37f8d5c Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-walk-in-2022-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-walk-in-2022-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-walk-in-2022-poster.jpg new file mode 100644 index 00000000..715d0c80 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-walk-in-2022-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-white-lotus-2021-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-white-lotus-2021-backdrop.jpg new file mode 100644 index 00000000..1f747323 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-white-lotus-2021-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-white-lotus-2021-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-white-lotus-2021-poster.jpg new file mode 100644 index 00000000..2f23d1fa Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-white-lotus-2021-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-witcher-2019-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-witcher-2019-backdrop.jpg new file mode 100644 index 00000000..acca834e Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-witcher-2019-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-witcher-2019-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-witcher-2019-poster.jpg new file mode 100644 index 00000000..f5eb8f59 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-witcher-2019-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-undercover-2019-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-undercover-2019-backdrop.jpg new file mode 100644 index 00000000..ad972c17 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-undercover-2019-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-undercover-2019-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-undercover-2019-poster.jpg new file mode 100644 index 00000000..8611eab4 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-undercover-2019-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-upload-2020-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-upload-2020-backdrop.jpg new file mode 100644 index 00000000..f350a49c Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-upload-2020-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-upload-2020-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-upload-2020-poster.jpg new file mode 100644 index 00000000..547d3b7a Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-upload-2020-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-vinland-saga-2019-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-vinland-saga-2019-backdrop.jpg new file mode 100644 index 00000000..9ae58ec4 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-vinland-saga-2019-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-vinland-saga-2019-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-vinland-saga-2019-poster.jpg new file mode 100644 index 00000000..7d0ba17a Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-vinland-saga-2019-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-widow-s-bay-2026-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-widow-s-bay-2026-backdrop.jpg new file mode 100644 index 00000000..03b1c335 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-widow-s-bay-2026-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-widow-s-bay-2026-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-widow-s-bay-2026-poster.jpg new file mode 100644 index 00000000..8c02a0d7 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-widow-s-bay-2026-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-yellowjackets-2021-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-yellowjackets-2021-backdrop.jpg new file mode 100644 index 00000000..960c2d23 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-yellowjackets-2021-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-yellowjackets-2021-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-yellowjackets-2021-poster.jpg new file mode 100644 index 00000000..c7e16a57 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-yellowjackets-2021-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-yu-yu-hakusho-1992-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-yu-yu-hakusho-1992-backdrop.jpg new file mode 100644 index 00000000..ba65ca2d Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-yu-yu-hakusho-1992-backdrop.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-yu-yu-hakusho-1992-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-yu-yu-hakusho-1992-poster.jpg new file mode 100644 index 00000000..f5843766 Binary files /dev/null and b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-yu-yu-hakusho-1992-poster.jpg differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/fixture.json b/wolfskin-design/src/debug/assets/wolfskin-home/fixture.json new file mode 100644 index 00000000..450f0ece --- /dev/null +++ b/wolfskin-design/src/debug/assets/wolfskin-home/fixture.json @@ -0,0 +1 @@ +{"schemaVersion": 1, "generatedAt": "2026-11-04T20:42:12.030108+00:00", "profiles": [{"key": "justin", "name": "Justin", "initial": "J", "color": "#62c4ff", "destinations": {"Home": {"kind": "home"}, "Movies": {"kind": "library", "collection": "movies"}, "Shows": {"kind": "library", "collection": "shows"}, "Anime": {"kind": "library", "collection": "anime"}, "Discover": {"kind": "discover", "collection": "discover"}}, "movies": [{"id": "movie-the-girl-who-leapt-through-time-2006", "title": "The Girl Who Leapt Through Time", "year": 2006, "type": "movie", "runtimeSeconds": 5886, "seasonCount": null, "communityRating": 7.772, "criticRating": 87, "genres": ["Fantasy", "Animation", "Drama"], "overview": "When 17-year-old Makoto Konno gains the ability to 'leap' backwards through time, she immediately sets about improving her grades and preventing personal mishaps. However, she soon realises that changing the past\u2026", "poster": "assets/movie-the-girl-who-leapt-through-time-2006-poster.jpg", "backdrop": "assets/movie-the-girl-who-leapt-through-time-2006-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T17:39:11.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 14069, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 87, "rtCriticsLabel": "Fresh", "rtAudience": 90, "rtAudienceLabel": "Upright", "imdb": 7.6, "imdbVotes": 76917, "tmdb": 7.775, "tmdbVotes": 2345}}, {"id": "movie-locke-2014", "title": "Locke", "year": 2014, "type": "movie", "runtimeSeconds": 5090, "seasonCount": null, "communityRating": 6.891, "criticRating": 91, "genres": ["Drama", "Thriller"], "overview": "Ivan Locke has worked hard to craft a good life for himself. Tonight, that life will collapse around him. On the eve of the biggest challenge of his career, Ivan receives a phone call that sets in motion a series of\u2026", "poster": "assets/movie-locke-2014-poster.jpg", "backdrop": "assets/movie-locke-2014-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T17:38:48.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 210479, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 90, "rtCriticsLabel": "Certified Fresh", "rtAudience": 72, "rtAudienceLabel": "Upright", "imdb": 7.1, "imdbVotes": 171265, "tmdb": 6.889, "tmdbVotes": 3088}}, {"id": "movie-look-who-s-back-2015", "title": "Look Who's Back", "year": 2015, "type": "movie", "runtimeSeconds": 6936, "seasonCount": null, "communityRating": 6.793, "criticRating": null, "genres": ["Comedy"], "overview": "When Adolf Hitler reawakens at the site of his former bunker in present-day Berlin, he is mistaken for a comedian and quickly becomes a media phenomenon.", "poster": "assets/movie-look-who-s-back-2015-poster.jpg", "backdrop": "assets/movie-look-who-s-back-2015-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:27:48.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 318917, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": 81, "rtAudienceLabel": "Upright", "imdb": 7, "imdbVotes": 54672, "tmdb": 6.796, "tmdbVotes": 1990}}, {"id": "movie-memories-1995", "title": "Memories", "year": 1995, "type": "movie", "runtimeSeconds": 6805, "seasonCount": null, "communityRating": 7.465, "criticRating": null, "genres": ["Fantasy", "Animation", "Science Fiction"], "overview": "Three back-to-back anime films by three different directors make up this sci-fi trilogy three years in the making.", "poster": "assets/movie-memories-1995-poster.jpg", "backdrop": "assets/movie-memories-1995-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-30T21:36:03.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 42994, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": 82, "rtAudienceLabel": "Upright", "imdb": 7.5, "imdbVotes": 26638, "tmdb": 7.459, "tmdbVotes": 622}}, {"id": "movie-reincarnated-2012", "title": "Reincarnated", "year": 2012, "type": "movie", "runtimeSeconds": 5759, "seasonCount": null, "communityRating": 6.145, "criticRating": 28, "genres": ["Music", "Documentary"], "overview": "Evolution as an artist is often times what separates legends from the more mundane. After being heavily influenced by his experience in Jamaica \u2013 and his subsequent name change from Snoop Dogg to Snoop Lion \u2013 the LBC\u2026", "poster": "assets/movie-reincarnated-2012-poster.jpg", "backdrop": "assets/movie-reincarnated-2012-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:27:26.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 123431, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 28, "rtCriticsLabel": "Rotten", "rtAudience": 41, "rtAudienceLabel": "Spilled", "imdb": 6.2, "imdbVotes": 2563, "tmdb": 6.1, "tmdbVotes": 69}}, {"id": "movie-shipwrecked-nightmare-at-sea-2026", "title": "Shipwrecked: Nightmare at Sea", "year": 2026, "type": "movie", "runtimeSeconds": 5385, "seasonCount": null, "communityRating": 7.181, "criticRating": null, "genres": ["Documentary"], "overview": "Never-before-seen footage and survivor accounts trace the 2012 shipwreck of a luxury cruise and the disaster that ensued, in this immersive documentary.", "poster": "assets/movie-shipwrecked-nightmare-at-sea-2026-poster.jpg", "backdrop": "assets/movie-shipwrecked-nightmare-at-sea-2026-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-20T20:11:43.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 1715492, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": 6.5, "imdbVotes": 5080, "tmdb": 7.172, "tmdbVotes": 87}}, {"id": "movie-steins-gate-the-movie-load-region-of-deja-vu-2013", "title": "Steins;Gate: The Movie - Load Region of D\u00e9j\u00e0 Vu", "year": 2013, "type": "movie", "runtimeSeconds": 5403, "seasonCount": null, "communityRating": 7.8, "criticRating": null, "genres": ["Animation", "Mystery", "Sci-Fi"], "overview": "After the events of the anime, Rintarou begins to feel the repercussions of extensive time travel, and eventually completely fades from reality. Kurisu, being the only companion to remember him, now must find a way\u2026", "poster": "assets/movie-steins-gate-the-movie-load-region-of-deja-vu-2013-poster.jpg", "backdrop": "assets/movie-steins-gate-the-movie-load-region-of-deja-vu-2013-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-22T14:55:54.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}], "shows": [{"id": "tv-andor-2022", "title": "Andor", "year": 2022, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 8.293, "criticRating": null, "genres": ["Sci-Fi & Fantasy", "Action & Adventure", "Drama"], "overview": "In an era filled with danger, deception and intrigue, Cassian Andor will discover the difference he can make in the struggle against the tyrannical Galactic Empire. He embarks on a path that is destined to turn him\u2026", "poster": "assets/tv-andor-2022-poster.jpg", "backdrop": "assets/tv-andor-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0432996Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 83867, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 96, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.321, "tmdbVotes": 2312}}, {"id": "tv-the-boys-2019", "title": "The Boys", "year": 2019, "type": "tv", "runtimeSeconds": null, "seasonCount": 5, "communityRating": 8.443, "criticRating": null, "genres": ["Sci-Fi & Fantasy", "Action & Adventure"], "overview": "A group of vigilantes known informally as \u201cThe Boys\u201d set out to take down corrupt superheroes with no more than blue-collar grit and a willingness to fight dirty.", "poster": "assets/tv-the-boys-2019-poster.jpg", "backdrop": "assets/tv-the-boys-2019-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0410131Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 76479, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 92, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.4, "tmdbVotes": 13628}}, {"id": "tv-californication-2007", "title": "Californication", "year": 2007, "type": "tv", "runtimeSeconds": 1680, "seasonCount": 7, "communityRating": 7.632, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "A self-loathing, alcoholic writer attempts to repair his damaged relationships with his daughter and her mother while combating sex addiction, a budding drug problem, and the seeming inability to avoid making bad\u2026", "poster": "assets/tv-californication-2007-poster.jpg", "backdrop": "assets/tv-californication-2007-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0428037Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 1215, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 57, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.62, "tmdbVotes": 1406}}, {"id": "tv-firefly-2002", "title": "Firefly", "year": 2002, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 8.347, "criticRating": null, "genres": ["Drama", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "In the year 2517, the universe is a vast frontier where lawlessness reigns and adventure awaits! Join Captain Malcolm Reynolds and his ragtag crew aboard the spaceship Serenity as they navigate the fringes of\u2026", "poster": "assets/tv-firefly-2002-poster.jpg", "backdrop": "assets/tv-firefly-2002-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-20T11:40:08.4202369Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 1437, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 77, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.351, "tmdbVotes": 2550}}, {"id": "tv-fleabag-2016", "title": "Fleabag", "year": 2016, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 8.278, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "A portrait into the mind of a dry-witted, sexual, angry, porn-watching, grief-riddled woman, trying to make sense of the world. As she hurls herself headlong at modern living, Fleabag is thrown roughly up against the\u2026", "poster": "assets/tv-fleabag-2016-poster.jpg", "backdrop": "assets/tv-fleabag-2016-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T17:42:25.4848214Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 67070, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.3, "tmdbVotes": 1993}}, {"id": "tv-gravity-falls-2012", "title": "Gravity Falls", "year": 2012, "type": "tv", "runtimeSeconds": 1320, "seasonCount": 2, "communityRating": 8.619, "criticRating": null, "genres": ["Action & Adventure", "Animation", "Comedy"], "overview": "Twin brother and sister Dipper and Mabel Pines are in for an unexpected adventure when they spend the summer helping their great uncle Stan run a tourist trap in the mysterious town of Gravity Falls, Oregon.", "poster": "assets/tv-gravity-falls-2012-poster.jpg", "backdrop": "assets/tv-gravity-falls-2012-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0435827Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 40075, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.624, "tmdbVotes": 3647}}, {"id": "tv-invincible-2021", "title": "INVINCIBLE", "year": 2021, "type": "tv", "runtimeSeconds": null, "seasonCount": 4, "communityRating": 8.638, "criticRating": null, "genres": ["Animation", "Drama", "Sci-Fi & Fantasy"], "overview": "Mark Grayson is a normal teenager except for the fact that his father is the most powerful superhero on the planet. Shortly after his seventeenth birthday, Mark begins to develop powers of his own and enters into his\u2026", "poster": "assets/tv-invincible-2021-poster.jpg", "backdrop": "assets/tv-invincible-2021-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.040159Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 95557, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 99, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.6, "tmdbVotes": 6155}}, {"id": "tv-the-legend-of-korra-2012", "title": "The Legend of Korra", "year": 2012, "type": "tv", "runtimeSeconds": 1380, "seasonCount": 4, "communityRating": 8.2, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "Avatar Korra, a headstrong, rebellious, feisty young woman who continually challenges and breaks with tradition, is on her quest to become a fully realized Avatar. In this story, the Avatar struggles to find balance\u2026", "poster": "assets/tv-the-legend-of-korra-2012-poster.jpg", "backdrop": "assets/tv-the-legend-of-korra-2012-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-20T00:19:23.2494945Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 33880, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 88, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.221, "tmdbVotes": 2482}}, {"id": "tv-love-death-robots-2019", "title": "Love, Death & Robots", "year": 2019, "type": "tv", "runtimeSeconds": null, "seasonCount": 4, "communityRating": 8.205, "criticRating": null, "genres": ["Animation", "Sci-Fi & Fantasy"], "overview": "Terrifying creatures, wicked surprises and dark comedy converge in this NSFW anthology of animated stories presented by Tim Miller and David Fincher.", "poster": "assets/tv-love-death-robots-2019-poster.jpg", "backdrop": "assets/tv-love-death-robots-2019-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-20T11:31:20.8818757Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 86831, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 89, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.203, "tmdbVotes": 4238}}, {"id": "tv-norsemen-2016", "title": "Norsemen", "year": 2016, "type": "tv", "runtimeSeconds": 1800, "seasonCount": 3, "communityRating": 7.177, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "Norsemen is an epic and humorous drama series set in the Viking Age. The residents of an 8th-century Viking village experience political rivalry, social change and innovations that upend their culture and way of life.", "poster": "assets/tv-norsemen-2016-poster.jpg", "backdrop": "assets/tv-norsemen-2016-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.034182Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 68126, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.2, "tmdbVotes": 233}}, {"id": "tv-rick-and-morty-2013", "title": "Rick and Morty", "year": 2013, "type": "tv", "runtimeSeconds": null, "seasonCount": 9, "communityRating": 8.679, "criticRating": null, "genres": ["Animation", "Comedy", "Sci-Fi & Fantasy"], "overview": "Follows a sociopathic genius scientist who drags his inherently timid grandson on adventures across the universe.", "poster": "assets/tv-rick-and-morty-2013-poster.jpg", "backdrop": "assets/tv-rick-and-morty-2013-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0419581Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 60625, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 91, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.7, "tmdbVotes": 11548}}, {"id": "tv-secret-level-2024", "title": "Secret Level", "year": 2024, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.411, "criticRating": null, "genres": ["Animation", "Sci-Fi & Fantasy", "Action & Adventure"], "overview": "Adult animated series of original short stories which are set within the worlds of beloved video games. Each episode serves as a gateway to a new adventure, unlocking exciting worlds from beloved gaming classics and\u2026", "poster": "assets/tv-secret-level-2024-poster.jpg", "backdrop": "assets/tv-secret-level-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0336417Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 261579, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 65, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.392, "tmdbVotes": 612}}, {"id": "tv-silo-2023", "title": "Silo", "year": 2023, "type": "tv", "runtimeSeconds": null, "seasonCount": 3, "communityRating": 8.178, "criticRating": null, "genres": ["Sci-Fi & Fantasy", "Drama"], "overview": "In a ruined and toxic future, thousands live in a giant silo deep underground. After its sheriff breaks a cardinal rule and residents die mysteriously, engineer Juliette starts to uncover shocking secrets and the\u2026", "poster": "assets/tv-silo-2023-poster.jpg", "backdrop": "assets/tv-silo-2023-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0346526Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 125988, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 92, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.232, "tmdbVotes": 2881}}, {"id": "tv-south-park-1997", "title": "South Park", "year": 1997, "type": "tv", "runtimeSeconds": null, "seasonCount": 29, "communityRating": 8.336, "criticRating": null, "genres": ["Animation", "Comedy"], "overview": "Follow the misadventures of four irreverent grade-schoolers in the quiet, dysfunctional town of South Park, Colorado.", "poster": "assets/tv-south-park-1997-poster.jpg", "backdrop": "assets/tv-south-park-1997-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0456124Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 2190, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 80, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.332, "tmdbVotes": 5250}}], "anime": [{"id": "tv-bleach-2004", "title": "Bleach", "year": 2004, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 17, "communityRating": 8.363, "criticRating": null, "genres": ["Action & Adventure", "Animation", "Sci-Fi & Fantasy"], "overview": "For as long as he can remember, Ichigo Kurosaki has been able to see ghosts. But when he meets Rukia, a Soul Reaper who battles evil spirits known as Hollows, he finds his life is changed forever. Now, with a\u2026", "poster": "assets/tv-bleach-2004-poster.jpg", "backdrop": "assets/tv-bleach-2004-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0359827Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 30984, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.4, "tmdbVotes": 2331}}, {"id": "tv-chainsaw-man-2022", "title": "Chainsaw Man", "year": 2022, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 1, "communityRating": 8.515, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "Denji has a simple dream\u2014to live a happy and peaceful life, spending time with a girl he likes. This is a far cry from reality, however, as Denji is forced by the yakuza into killing devils in order to pay off his\u2026", "poster": "assets/tv-chainsaw-man-2022-poster.jpg", "backdrop": "assets/tv-chainsaw-man-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-20T22:28:13.2095501Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 114410, "artwork": {"posterKind": "center_crop", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 97, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.508, "tmdbVotes": 2364}}, {"id": "tv-cyberpunk-edgerunners-2022", "title": "Cyberpunk: Edgerunners", "year": 2022, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 8.466, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "In a dystopia riddled with corruption and cybernetic implants, a talented but reckless street kid strives to become a mercenary outlaw \u2014 an edgerunner.", "poster": "assets/tv-cyberpunk-edgerunners-2022-poster.jpg", "backdrop": "assets/tv-cyberpunk-edgerunners-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-20T22:28:13.2093107Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 105248, "artwork": {"posterKind": "center_crop", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 89, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.507, "tmdbVotes": 2132}}, {"id": "tv-dan-da-dan-2024", "title": "Dan Da Dan", "year": 2024, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 8.538, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Comedy"], "overview": "In a bet to prove whether ghosts or aliens exist, two high schoolers face terrifying paranormal threats, gain superpowers and maybe even fall in love?!", "poster": "assets/tv-dan-da-dan-2024-poster.jpg", "backdrop": "assets/tv-dan-da-dan-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-22T10:31:16.2652252Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 240411, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.517, "tmdbVotes": 986}}, {"id": "tv-dr-stone-2019", "title": "Dr. STONE", "year": 2019, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 4, "communityRating": 8.541, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Comedy"], "overview": "One fateful day, all of humanity was petrified by a blinding flash of light. After several millennia, high schooler Taiju awakens and finds himself lost in a world of statues. However, he\u2019s not alone! His\u2026", "poster": "assets/tv-dr-stone-2019-poster.jpg", "backdrop": "assets/tv-dr-stone-2019-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-20T22:28:13.2091579Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 86031, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.53, "tmdbVotes": 1814}}, {"id": "tv-frieren-beyond-journey-s-end-2023", "title": "Frieren: Beyond Journey's End", "year": 2023, "type": "tv", "runtimeSeconds": 1500, "seasonCount": 2, "communityRating": 8.803, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Drama"], "overview": "After the party of heroes defeated the Demon King, they restored peace to the land and returned to lives of solitude. Generations pass, and the elven mage Frieren comes face to face with humanity\u2019s mortality. She\u2026", "poster": "assets/tv-frieren-beyond-journey-s-end-2023-poster.jpg", "backdrop": "assets/tv-frieren-beyond-journey-s-end-2023-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-20T22:28:13.2094435Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 209867, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.826, "tmdbVotes": 1016}}, {"id": "tv-fullmetal-alchemist-brotherhood-2009", "title": "Fullmetal Alchemist: Brotherhood", "year": 2009, "type": "tv", "runtimeSeconds": 1500, "seasonCount": 1, "communityRating": 8.7, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "Disregard for alchemy\u2019s laws ripped half of Edward Elric\u2019s limbs from his body and left his brother Alphonse\u2019s soul clinging to a suit of armor. To restore what was lost, the brothers seek the Philosopher\u2019s Stone.\u2026", "poster": "assets/tv-fullmetal-alchemist-brotherhood-2009-poster.jpg", "backdrop": "assets/tv-fullmetal-alchemist-brotherhood-2009-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-20T10:27:12.3573939Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 31911, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.7, "tmdbVotes": 2633}}, {"id": "tv-jujutsu-kaisen-2020", "title": "JUJUTSU KAISEN", "year": 2020, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 3, "communityRating": 8.583, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "Yuji Itadori is a boy with tremendous physical strength, though he lives a completely ordinary high school life. One day, to save a classmate who has been attacked by curses, he eats the finger of Ryomen Sukuna,\u2026", "poster": "assets/tv-jujutsu-kaisen-2020-poster.jpg", "backdrop": "assets/tv-jujutsu-kaisen-2020-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-20T22:28:13.207573Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 95479, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.574, "tmdbVotes": 4735}}, {"id": "tv-kaiju-no-8-2024", "title": "Kaiju No. 8", "year": 2024, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 2, "communityRating": 8.436, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "In a world plagued by creatures known as Kaiju, Kafka Hibino aspired to enlist in The Defense Force. He makes a promise to enlist with his childhood friend, Mina Ashiro. Soon, life takes them in separate ways. While\u2026", "poster": "assets/tv-kaiju-no-8-2024-poster.jpg", "backdrop": "assets/tv-kaiju-no-8-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-22T10:31:16.2646566Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 207468, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.4, "tmdbVotes": 836}}, {"id": "tv-my-hero-academia-2016", "title": "My Hero Academia", "year": 2016, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 8, "communityRating": 8.607, "criticRating": null, "genres": ["Action & Adventure", "Animation", "Sci-Fi & Fantasy"], "overview": "After he saves a bully from a Villain, a normal student is granted a superpower that allows him to attend a high school training academy for Heroes.", "poster": "assets/tv-my-hero-academia-2016-poster.jpg", "backdrop": "assets/tv-my-hero-academia-2016-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-20T22:28:13.2089315Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 65930, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.6, "tmdbVotes": 5426}}, {"id": "tv-neon-genesis-evangelion-1995", "title": "Neon Genesis Evangelion", "year": 1995, "type": "tv", "runtimeSeconds": 1500, "seasonCount": 1, "communityRating": 8.568, "criticRating": null, "genres": ["Sci-Fi & Fantasy", "Animation", "Drama"], "overview": "At the turn of the century, the Angels returned to Earth, seeking to wipe out humanity in an apocalyptic fury. Devastated, mankind's last remnants moved underground to wait for the day when the Angels would come back\u2026", "poster": "assets/tv-neon-genesis-evangelion-1995-poster.jpg", "backdrop": "assets/tv-neon-genesis-evangelion-1995-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0340629Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 890, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.56, "tmdbVotes": 2205}}, {"id": "tv-record-of-ragnarok-2021", "title": "Record of Ragnarok", "year": 2021, "type": "tv", "runtimeSeconds": null, "seasonCount": 3, "communityRating": 8.402, "criticRating": null, "genres": ["Action & Adventure", "Animation", "Sci-Fi & Fantasy"], "overview": "Before eradicating humankind from the world, the gods give them one last chance to prove themselves worthy of survival. Let the Ragnarok battles begin.", "poster": "assets/tv-record-of-ragnarok-2021-poster.jpg", "backdrop": "assets/tv-record-of-ragnarok-2021-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-20T22:28:13.2085766Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 114868, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.403, "tmdbVotes": 1929}}, {"id": "tv-sakamoto-days-2025", "title": "SAKAMOTO DAYS", "year": 2025, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 8.071, "criticRating": null, "genres": ["Animation", "Comedy", "Action & Adventure"], "overview": "Once the greatest hitman of all, Taro Sakamoto retired in the name of love. But when his past catches up, he must fight to protect his beloved family.", "poster": "assets/tv-sakamoto-days-2025-poster.jpg", "backdrop": "assets/tv-sakamoto-days-2025-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-22T10:31:16.266066Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 207332, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.057, "tmdbVotes": 375}}, {"id": "tv-sentenced-to-be-a-hero-2025", "title": "Sentenced to Be a Hero", "year": 2025, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 1, "communityRating": 8.338, "criticRating": null, "genres": ["Animation", "Sci-Fi & Fantasy", "Action & Adventure"], "overview": "In a world where heroism is a punishment, Xylo Forbartz, a condemned goddess killer, battles endless hordes of monstrous abominations as part of Penal Hero Unit 9004. Death is no escape, only a cycle of resurrection\u2026", "poster": "assets/tv-sentenced-to-be-a-hero-2025-poster.jpg", "backdrop": "assets/tv-sentenced-to-be-a-hero-2025-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-22T10:31:16.2666791Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 249907, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.244, "tmdbVotes": 129}}, {"id": "tv-spy-x-family-2022", "title": "SPY x FAMILY", "year": 2022, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 3, "communityRating": 8.506, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Comedy"], "overview": "A spy, an assassin and a telepath come together to pose as a family, each for their own reasons, while hiding their true identities from each other.", "poster": "assets/tv-spy-x-family-2022-poster.jpg", "backdrop": "assets/tv-spy-x-family-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-22T10:31:16.2670364Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 120089, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.5, "tmdbVotes": 2382}}, {"id": "tv-the-tatami-galaxy-2010", "title": "The Tatami Galaxy", "year": 2010, "type": "tv", "runtimeSeconds": 1500, "seasonCount": 2, "communityRating": 8.271, "criticRating": null, "genres": ["Animation", "Comedy", "Drama"], "overview": "When a college dropout stops for a late night bite at a mysterious ramen stand, he crosses paths with a self-proclaimed deity of matrimony. This bizarre meeting sends the young man hurtling through a horrifying\u2026", "poster": "assets/tv-the-tatami-galaxy-2010-poster.jpg", "backdrop": "assets/tv-the-tatami-galaxy-2010-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-18T05:52:51.0023256Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 36243, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.3, "tmdbVotes": 152}}, {"id": "tv-vinland-saga-2019", "title": "Vinland Saga", "year": 2019, "type": "tv", "runtimeSeconds": 1500, "seasonCount": 2, "communityRating": 8.527, "criticRating": null, "genres": ["Animation", "War & Politics", "Action & Adventure"], "overview": "For a thousand years, the Vikings have made quite a name and reputation for themselves as the strongest families with a thirst for violence. Thorfinn, the son of one of the Vikings' greatest warriors, spends his\u2026", "poster": "assets/tv-vinland-saga-2019-poster.jpg", "backdrop": "assets/tv-vinland-saga-2019-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-20T22:28:13.207389Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 88803, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.509, "tmdbVotes": 1064}}, {"id": "tv-yu-yu-hakusho-1992", "title": "Yu Yu Hakusho", "year": 1992, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 4, "communityRating": 8.5, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "After dying to save a boy, delinquent tough guy Yusuke Urameshi is granted another chance at life by redeeming himself as a \"Spirit Detective.\"", "poster": "assets/tv-yu-yu-hakusho-1992-poster.jpg", "backdrop": "assets/tv-yu-yu-hakusho-1992-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-19T21:46:01.5733049Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 30669, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.475, "tmdbVotes": 519}}], "resume": [], "nextUp": [{"id": "episode-three-monsters-1992-s1e6", "title": "Three Monsters", "year": 1992, "type": "episode", "runtimeSeconds": 1421, "seasonCount": null, "communityRating": 7.4, "criticRating": null, "genres": ["Animation", "Action", "Adventure"], "overview": "Yusuke receives his first real case as Spirit Detective. Three demon thieves have muscled their way into King Yama's sacred vault, stealing the three Artifacts of Darkness. Yusuke must recover the artifacts and bring\u2026", "poster": "assets/episode-three-monsters-1992-s1e6-poster.jpg", "backdrop": "assets/episode-three-monsters-1992-s1e6-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-19T21:59:21.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "Yu Yu Hakusho", "seasonNumber": 1, "episodeNumber": 6, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}, {"id": "episode-all-hands-on-deck-class-1-a-2021-s5e1", "title": "All Hands on Deck! Class 1-A", "year": 2021, "type": "episode", "runtimeSeconds": 1433, "seasonCount": null, "communityRating": 6.938, "criticRating": null, "genres": ["Animation", "Action", "Adventure"], "overview": "There's an emergency drill at U.A. High School, and Class 1-A is requested to report for duty!", "poster": "assets/episode-all-hands-on-deck-class-1-a-2021-s5e1-poster.jpg", "backdrop": "assets/episode-all-hands-on-deck-class-1-a-2021-s5e1-backdrop.jpg", "played": false, "playCount": 2, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-22T04:04:50.0000000Z", "lastPlayedAt": "2026-10-19T17:47:09.3028025Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "My Hero Academia", "seasonNumber": 5, "episodeNumber": 1, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}, {"id": "episode-erickerhead-2026-s9e6", "title": "Erickerhead", "year": 2026, "type": "episode", "runtimeSeconds": 1357, "seasonCount": null, "communityRating": 8.196, "criticRating": null, "genres": [], "overview": "Rick's stomach is mad, broh. But it's all-you-can-eat-and-drink here, broh!", "poster": "assets/episode-erickerhead-2026-s9e6-poster.jpg", "backdrop": "assets/episode-erickerhead-2026-s9e6-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-28T10:23:23.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "Rick and Morty", "seasonNumber": 9, "episodeNumber": 6, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}, {"id": "episode-girls-interrupted-2007-s1e7", "title": "Girls, Interrupted", "year": 2007, "type": "episode", "runtimeSeconds": 1642, "seasonCount": null, "communityRating": 7.4, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "Hank is disturbed by Becca's negative reaction towards the concept of happy endings, even more so when he finds out that it is partially his fault.\nMarcy comes up with the idea of a threesome with Charlie and Dani,\u2026", "poster": "assets/episode-girls-interrupted-2007-s1e7-poster.jpg", "backdrop": "assets/episode-girls-interrupted-2007-s1e7-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-05T02:46:18.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "Californication", "seasonNumber": 1, "episodeNumber": 7, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}, {"id": "episode-announcement-2022-s1e7", "title": "Announcement", "year": 2022, "type": "episode", "runtimeSeconds": 3012, "seasonCount": null, "communityRating": 7.8, "criticRating": null, "genres": ["Action", "Adventure", "Drama"], "overview": "The repercussions of the Aldhani heist reach all the way to Coruscant, while Cassian returns home.", "poster": "assets/episode-announcement-2022-s1e7-poster.jpg", "backdrop": "assets/episode-announcement-2022-s1e7-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T12:56:24.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "Andor", "seasonNumber": 1, "episodeNumber": 7, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}, {"id": "episode-south-american-biker-gangs-2026-s29e1", "title": "South American Biker Gangs", "year": 2026, "type": "episode", "runtimeSeconds": 1335, "seasonCount": null, "communityRating": 6.4, "criticRating": null, "genres": ["Animation", "Comedy"], "overview": "When e-bikes arrive in South America, the boys take over the streets and the parents of South America can hardly recognize South America.", "poster": "assets/episode-south-american-biker-gangs-2026-s29e1-poster.jpg", "backdrop": "assets/episode-south-american-biker-gangs-2026-s29e1-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-01T06:40:46.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "South Park", "seasonNumber": 29, "episodeNumber": 1, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}], "recentlyAdded": [{"id": "movie-steins-gate-the-movie-load-region-of-deja-vu-2013", "title": "Steins;Gate: The Movie - Load Region of D\u00e9j\u00e0 Vu", "year": 2013, "type": "movie", "runtimeSeconds": 5403, "seasonCount": null, "communityRating": 7.8, "criticRating": null, "genres": ["Animation", "Mystery", "Sci-Fi"], "overview": "After the events of the anime, Rintarou begins to feel the repercussions of extensive time travel, and eventually completely fades from reality. Kurisu, being the only companion to remember him, now must find a way\u2026", "poster": "assets/movie-steins-gate-the-movie-load-region-of-deja-vu-2013-poster.jpg", "backdrop": "assets/movie-steins-gate-the-movie-load-region-of-deja-vu-2013-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-22T14:55:54.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}, {"id": "tv-yu-yu-hakusho-1992", "title": "Yu Yu Hakusho", "year": 1992, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 4, "communityRating": 8.5, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "After dying to save a boy, delinquent tough guy Yusuke Urameshi is granted another chance at life by redeeming himself as a \"Spirit Detective.\"", "poster": "assets/tv-yu-yu-hakusho-1992-poster.jpg", "backdrop": "assets/tv-yu-yu-hakusho-1992-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-19T21:46:01.5733049Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 30669, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.475, "tmdbVotes": 519}}, {"id": "movie-memories-1995", "title": "Memories", "year": 1995, "type": "movie", "runtimeSeconds": 6805, "seasonCount": null, "communityRating": 7.465, "criticRating": null, "genres": ["Fantasy", "Animation", "Science Fiction"], "overview": "Three back-to-back anime films by three different directors make up this sci-fi trilogy three years in the making.", "poster": "assets/movie-memories-1995-poster.jpg", "backdrop": "assets/movie-memories-1995-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-30T21:36:03.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 42994, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": 82, "rtAudienceLabel": "Upright", "imdb": 7.5, "imdbVotes": 26638, "tmdb": 7.459, "tmdbVotes": 622}}, {"id": "tv-firefly-2002", "title": "Firefly", "year": 2002, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 8.347, "criticRating": null, "genres": ["Drama", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "In the year 2517, the universe is a vast frontier where lawlessness reigns and adventure awaits! Join Captain Malcolm Reynolds and his ragtag crew aboard the spaceship Serenity as they navigate the fringes of\u2026", "poster": "assets/tv-firefly-2002-poster.jpg", "backdrop": "assets/tv-firefly-2002-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-20T11:40:08.4202369Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 1437, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 77, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.351, "tmdbVotes": 2550}}, {"id": "tv-love-death-robots-2019", "title": "Love, Death & Robots", "year": 2019, "type": "tv", "runtimeSeconds": null, "seasonCount": 4, "communityRating": 8.205, "criticRating": null, "genres": ["Animation", "Sci-Fi & Fantasy"], "overview": "Terrifying creatures, wicked surprises and dark comedy converge in this NSFW anthology of animated stories presented by Tim Miller and David Fincher.", "poster": "assets/tv-love-death-robots-2019-poster.jpg", "backdrop": "assets/tv-love-death-robots-2019-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-20T11:31:20.8818757Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 86831, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 89, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.203, "tmdbVotes": 4238}}, {"id": "tv-the-legend-of-korra-2012", "title": "The Legend of Korra", "year": 2012, "type": "tv", "runtimeSeconds": 1380, "seasonCount": 4, "communityRating": 8.2, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "Avatar Korra, a headstrong, rebellious, feisty young woman who continually challenges and breaks with tradition, is on her quest to become a fully realized Avatar. In this story, the Avatar struggles to find balance\u2026", "poster": "assets/tv-the-legend-of-korra-2012-poster.jpg", "backdrop": "assets/tv-the-legend-of-korra-2012-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-20T00:19:23.2494945Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 33880, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 88, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.221, "tmdbVotes": 2482}}, {"id": "tv-spy-x-family-2022", "title": "SPY x FAMILY", "year": 2022, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 3, "communityRating": 8.506, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Comedy"], "overview": "A spy, an assassin and a telepath come together to pose as a family, each for their own reasons, while hiding their true identities from each other.", "poster": "assets/tv-spy-x-family-2022-poster.jpg", "backdrop": "assets/tv-spy-x-family-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-22T10:31:16.2670364Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 120089, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.5, "tmdbVotes": 2382}}, {"id": "tv-sentenced-to-be-a-hero-2025", "title": "Sentenced to Be a Hero", "year": 2025, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 1, "communityRating": 8.338, "criticRating": null, "genres": ["Animation", "Sci-Fi & Fantasy", "Action & Adventure"], "overview": "In a world where heroism is a punishment, Xylo Forbartz, a condemned goddess killer, battles endless hordes of monstrous abominations as part of Penal Hero Unit 9004. Death is no escape, only a cycle of resurrection\u2026", "poster": "assets/tv-sentenced-to-be-a-hero-2025-poster.jpg", "backdrop": "assets/tv-sentenced-to-be-a-hero-2025-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-22T10:31:16.2666791Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 249907, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.244, "tmdbVotes": 129}}, {"id": "tv-sakamoto-days-2025", "title": "SAKAMOTO DAYS", "year": 2025, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 8.071, "criticRating": null, "genres": ["Animation", "Comedy", "Action & Adventure"], "overview": "Once the greatest hitman of all, Taro Sakamoto retired in the name of love. But when his past catches up, he must fight to protect his beloved family.", "poster": "assets/tv-sakamoto-days-2025-poster.jpg", "backdrop": "assets/tv-sakamoto-days-2025-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-22T10:31:16.266066Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 207332, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.057, "tmdbVotes": 375}}, {"id": "tv-dan-da-dan-2024", "title": "Dan Da Dan", "year": 2024, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 8.538, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Comedy"], "overview": "In a bet to prove whether ghosts or aliens exist, two high schoolers face terrifying paranormal threats, gain superpowers and maybe even fall in love?!", "poster": "assets/tv-dan-da-dan-2024-poster.jpg", "backdrop": "assets/tv-dan-da-dan-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-22T10:31:16.2652252Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 240411, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.517, "tmdbVotes": 986}}, {"id": "tv-kaiju-no-8-2024", "title": "Kaiju No. 8", "year": 2024, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 2, "communityRating": 8.436, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "In a world plagued by creatures known as Kaiju, Kafka Hibino aspired to enlist in The Defense Force. He makes a promise to enlist with his childhood friend, Mina Ashiro. Soon, life takes them in separate ways. While\u2026", "poster": "assets/tv-kaiju-no-8-2024-poster.jpg", "backdrop": "assets/tv-kaiju-no-8-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-22T10:31:16.2646566Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 207468, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.4, "tmdbVotes": 836}}, {"id": "tv-chainsaw-man-2022", "title": "Chainsaw Man", "year": 2022, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 1, "communityRating": 8.515, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "Denji has a simple dream\u2014to live a happy and peaceful life, spending time with a girl he likes. This is a far cry from reality, however, as Denji is forced by the yakuza into killing devils in order to pay off his\u2026", "poster": "assets/tv-chainsaw-man-2022-poster.jpg", "backdrop": "assets/tv-chainsaw-man-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-20T22:28:13.2095501Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 114410, "artwork": {"posterKind": "center_crop", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 97, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.508, "tmdbVotes": 2364}}], "discover": [{"id": "movie-the-amazing-spider-man-2012", "title": "The Amazing Spider-Man", "year": 2012, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Adventure", "Science Fiction"], "overview": "A teenage Peter Parker grapples with both high school and amazing super-human crises as his alter-ego Spider-Man.", "poster": "assets/movie-the-amazing-spider-man-2012-poster.jpg", "backdrop": "assets/movie-the-amazing-spider-man-2012-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 1930, "ratings": {"rtCritics": 71, "rtCriticsLabel": "Certified Fresh", "rtAudience": 77, "rtAudienceLabel": "Upright", "imdb": 7, "imdbVotes": 791938, "tmdb": 6.768, "tmdbVotes": 19312}}, {"id": "movie-the-amazing-spider-man-2-2014", "title": "The Amazing Spider-Man 2", "year": 2014, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Adventure", "Science Fiction"], "overview": "For Peter Parker, life is busy. Between taking out the bad guys as Spider-Man and spending time with the person he loves, Gwen Stacy, high school graduation cannot come quickly enough. Peter has not forgotten about\u2026", "poster": "assets/movie-the-amazing-spider-man-2-2014-poster.jpg", "backdrop": "assets/movie-the-amazing-spider-man-2-2014-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 102382, "ratings": {"rtCritics": 51, "rtCriticsLabel": "Rotten", "rtAudience": 64, "rtAudienceLabel": "Upright", "imdb": 6.6, "imdbVotes": 611981, "tmdb": 6.567, "tmdbVotes": 14919}}, {"id": "movie-the-animatrix-2003", "title": "The Animatrix", "year": 2003, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Animation", "Science Fiction"], "overview": "Straight from the creators of the groundbreaking Matrix trilogy, this collection of short animated films from the world's leading anime directors fuses computer graphics and Japanese anime to provide the background\u2026", "poster": "assets/movie-the-animatrix-2003-poster.jpg", "backdrop": "assets/movie-the-animatrix-2003-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 55931, "ratings": {"rtCritics": 86, "rtCriticsLabel": "Fresh", "rtAudience": 78, "rtAudienceLabel": "Upright", "imdb": 7.3, "imdbVotes": 91929, "tmdb": 7.221, "tmdbVotes": 1872}}, {"id": "movie-ant-man-and-the-wasp-quantumania-2023", "title": "Ant-Man and the Wasp: Quantumania", "year": 2023, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Adventure", "Science Fiction"], "overview": "Super-Hero partners Scott Lang and Hope van Dyne, along with with Hope's parents Janet van Dyne and Hank Pym, and Scott's daughter Cassie Lang, find themselves exploring the Quantum Realm, interacting with strange\u2026", "poster": "assets/movie-ant-man-and-the-wasp-quantumania-2023-poster.jpg", "backdrop": "assets/movie-ant-man-and-the-wasp-quantumania-2023-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 640146, "ratings": {"rtCritics": 46, "rtCriticsLabel": "Rotten", "rtAudience": 81, "rtAudienceLabel": "Upright", "imdb": 6, "imdbVotes": 281738, "tmdb": 6.197, "tmdbVotes": 6236}}, {"id": "movie-appleseed-alpha-2014", "title": "Appleseed Alpha", "year": 2014, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Animation", "Science Fiction", "Action"], "overview": "The Third World War (2099\u20132126) devastated part of the Earth. Former nations are reforming while new powers emerge, and groups of humans struggle to survive in ruined cities. The story follows Deunan Knute and her\u2026", "poster": "assets/movie-appleseed-alpha-2014-poster.jpg", "backdrop": "assets/movie-appleseed-alpha-2014-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "center_crop", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 269650, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": 59, "rtAudienceLabel": "Spilled", "imdb": 6.5, "imdbVotes": 9689, "tmdb": 6.808, "tmdbVotes": 436}}, {"id": "movie-aquaman-and-the-lost-kingdom-2023", "title": "Aquaman and the Lost Kingdom", "year": 2023, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Adventure", "Fantasy"], "overview": "Black Manta seeks revenge on Aquaman for his father's death. Wielding the Black Trident's power, he becomes a formidable foe. To defend Atlantis, Arthur (Aquaman) forges an alliance with his imprisoned brother. They\u2026", "poster": "assets/movie-aquaman-and-the-lost-kingdom-2023-poster.jpg", "backdrop": "assets/movie-aquaman-and-the-lost-kingdom-2023-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 572802, "ratings": {"rtCritics": 33, "rtCriticsLabel": "Rotten", "rtAudience": 79, "rtAudienceLabel": "Upright", "imdb": 5.6, "imdbVotes": 122422, "tmdb": 6.48, "tmdbVotes": 3748}}, {"id": "movie-the-avengers-2012", "title": "The Avengers", "year": 2012, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Science Fiction", "Action", "Adventure"], "overview": "When an unexpected enemy emerges and threatens global safety and security, Nick Fury, director of the international peacekeeping agency known as S.H.I.E.L.D., finds himself in need of a team to pull the world back\u2026", "poster": "assets/movie-the-avengers-2012-poster.jpg", "backdrop": "assets/movie-the-avengers-2012-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 24428, "ratings": {"rtCritics": 91, "rtCriticsLabel": "Certified Fresh", "rtAudience": 91, "rtAudienceLabel": "Upright", "imdb": 8, "imdbVotes": 1590693, "tmdb": 8.084, "tmdbVotes": 40262}}, {"id": "movie-avengers-confidential-black-widow-punisher-2014", "title": "Avengers Confidential: Black Widow & Punisher", "year": 2014, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Animation", "Science Fiction", "Action"], "overview": "When the Punisher takes out a black-market weapons dealer, he stumbles upon a far-reaching terrorist plot devised by a group known as Leviathan.", "poster": "assets/movie-avengers-confidential-black-widow-punisher-2014-poster.jpg", "backdrop": "assets/movie-avengers-confidential-black-widow-punisher-2014-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 257346, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": 55, "rtAudienceLabel": "Spilled", "imdb": 5.7, "imdbVotes": 5166, "tmdb": 6.368, "tmdbVotes": 303}}, {"id": "movie-avengers-age-of-ultron-2015", "title": "Avengers: Age of Ultron", "year": 2015, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Adventure", "Science Fiction"], "overview": "When Tony Stark tries to jumpstart a dormant peacekeeping program, things go awry and Earth\u2019s Mightiest Heroes are put to the ultimate test as the fate of the planet hangs in the balance. As the villainous Ultron\u2026", "poster": "assets/movie-avengers-age-of-ultron-2015-poster.jpg", "backdrop": "assets/movie-avengers-age-of-ultron-2015-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 99861, "ratings": {"rtCritics": 75, "rtCriticsLabel": "Certified Fresh", "rtAudience": 82, "rtAudienceLabel": "Upright", "imdb": 7.3, "imdbVotes": 1025876, "tmdb": 7.278, "tmdbVotes": 25289}}, {"id": "movie-avengers-infinity-war-2018", "title": "Avengers: Infinity War", "year": 2018, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Adventure", "Action", "Science Fiction"], "overview": "As the Avengers and their allies have continued to protect the world from threats too large for any one hero to handle, a new danger has emerged from the cosmic shadows: Thanos. A despot of intergalactic infamy, his\u2026", "poster": "assets/movie-avengers-infinity-war-2018-poster.jpg", "backdrop": "assets/movie-avengers-infinity-war-2018-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 299536, "ratings": {"rtCritics": 85, "rtCriticsLabel": "Certified Fresh", "rtAudience": 92, "rtAudienceLabel": "Upright", "imdb": 8.4, "imdbVotes": 1421954, "tmdb": 8.246, "tmdbVotes": 33331}}, {"id": "movie-batman-ninja-2018", "title": "Batman Ninja", "year": 2018, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Animation", "Action", "Science Fiction"], "overview": "Batman, along with many of his allies and adversaries, finds himself transported to feudal Japan by Gorilla Grodd's time displacement machine.", "poster": "assets/movie-batman-ninja-2018-poster.jpg", "backdrop": "assets/movie-batman-ninja-2018-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 485942, "ratings": {"rtCritics": 84, "rtCriticsLabel": "Fresh", "rtAudience": 60, "rtAudienceLabel": "Upright", "imdb": 5.6, "imdbVotes": 24579, "tmdb": 5.9, "tmdbVotes": 1106}}, {"id": "movie-batman-v-superman-dawn-of-justice-2016", "title": "Batman v Superman: Dawn of Justice", "year": 2016, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Adventure", "Fantasy"], "overview": "Fearing the actions of a god-like Super Hero left unchecked, Gotham City\u2019s own formidable, forceful vigilante takes on Metropolis\u2019s most revered, modern-day savior, while the world wrestles with what sort of hero it\u2026", "poster": "assets/movie-batman-v-superman-dawn-of-justice-2016-poster.jpg", "backdrop": "assets/movie-batman-v-superman-dawn-of-justice-2016-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 209112, "ratings": {"rtCritics": 28, "rtCriticsLabel": "Rotten", "rtAudience": 63, "rtAudienceLabel": "Upright", "imdb": 6.4, "imdbVotes": 801382, "tmdb": 6.014, "tmdbVotes": 19527}}, {"id": "movie-batman-the-dark-knight-returns-part-1-2012", "title": "Batman: The Dark Knight Returns, Part 1", "year": 2012, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Science Fiction", "Action", "Animation"], "overview": "Batman has not been seen for ten years. A new breed of criminal ravages Gotham City, forcing 55-year-old Bruce Wayne back into the cape and cowl. But, does he still have what it takes to fight crime in a new era?", "poster": "assets/movie-batman-the-dark-knight-returns-part-1-2012-poster.jpg", "backdrop": "assets/movie-batman-the-dark-knight-returns-part-1-2012-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 123025, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": 93, "rtAudienceLabel": "Upright", "imdb": 7.9, "imdbVotes": 68943, "tmdb": 7.742, "tmdbVotes": 1766}}, {"id": "movie-black-adam-2022", "title": "Black Adam", "year": 2022, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Adventure", "Science Fiction"], "overview": "Nearly 5,000 years after he was bestowed with the almighty powers of the Egyptian gods\u2014and imprisoned just as quickly\u2014Black Adam is freed from his earthly tomb, ready to unleash his unique form of justice on the\u2026", "poster": "assets/movie-black-adam-2022-poster.jpg", "backdrop": "assets/movie-black-adam-2022-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 436270, "ratings": {"rtCritics": 39, "rtCriticsLabel": "Rotten", "rtAudience": 87, "rtAudienceLabel": "Upright", "imdb": 6.1, "imdbVotes": 309624, "tmdb": 6.747, "tmdbVotes": 7370}}, {"id": "movie-black-panther-2018", "title": "Black Panther", "year": 2018, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Adventure", "Science Fiction"], "overview": "King T'Challa returns home to the reclusive, technologically advanced African nation of Wakanda to serve as his country's new leader. However, T'Challa soon finds that he is challenged for the throne by factions\u2026", "poster": "assets/movie-black-panther-2018-poster.jpg", "backdrop": "assets/movie-black-panther-2018-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 284054, "ratings": {"rtCritics": 96, "rtCriticsLabel": "Certified Fresh", "rtAudience": null, "rtAudienceLabel": "Spilled", "imdb": 7.3, "imdbVotes": 931120, "tmdb": 7.36, "tmdbVotes": 24190}}, {"id": "movie-black-panther-wakanda-forever-2022", "title": "Black Panther: Wakanda Forever", "year": 2022, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Adventure", "Science Fiction"], "overview": "Queen Ramonda, Shuri, M\u2019Baku, Okoye and the Dora Milaje fight to protect their nation from intervening world powers in the wake of King T\u2019Challa\u2019s death. As the Wakandans strive to embrace their next chapter, the\u2026", "poster": "assets/movie-black-panther-wakanda-forever-2022-poster.jpg", "backdrop": "assets/movie-black-panther-wakanda-forever-2022-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 505642, "ratings": {"rtCritics": 84, "rtCriticsLabel": "Certified Fresh", "rtAudience": 93, "rtAudienceLabel": "Upright", "imdb": 6.6, "imdbVotes": 405088, "tmdb": 6.984, "tmdbVotes": 7920}}, {"id": "movie-blame-2017", "title": "BLAME!", "year": 2017, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Drama", "Science Fiction"], "overview": "In the distant technological future, civilization has reached its ultimate Net-based form. An \"infection\" in the past caused the automated systems to spiral out of order, resulting in a multi-leveled city structure\u2026", "poster": "assets/movie-blame-2017-poster.jpg", "backdrop": "assets/movie-blame-2017-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 409421, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": 61, "rtAudienceLabel": "Upright", "imdb": 6.6, "imdbVotes": 10258, "tmdb": 6.523, "tmdbVotes": 370}}, {"id": "movie-bleach-the-movie-fade-to-black-2008", "title": "Bleach the Movie: Fade to Black", "year": 2008, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Animation", "Fantasy"], "overview": "A strange disturbance in the World of the Living sends Ichigo and Kon to the Soul Society, where the Seireitei has been devastated by an explosion of reishi and Rukia is missing. Stranger still, none of the Soul\u2026", "poster": "assets/movie-bleach-the-movie-fade-to-black-2008-poster.jpg", "backdrop": "assets/movie-bleach-the-movie-fade-to-black-2008-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 17165, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": 67, "rtAudienceLabel": "Upright", "imdb": 7.1, "imdbVotes": 3693, "tmdb": 7.1, "tmdbVotes": 206}}, {"id": "tv-90-day-fiance-before-the-90-days-2017", "title": "90 Day Fianc\u00e9: Before the 90 Days", "year": 2017, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Reality"], "overview": "Follow couples who have an existing relationship online, but haven't met in person. We'll experience their journey as they travel to the other's foreign country for the first time in an attempt to establish an\u2026", "poster": "assets/tv-90-day-fiance-before-the-90-days-2017-poster.jpg", "backdrop": "assets/tv-90-day-fiance-before-the-90-days-2017-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 73319, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.5, "tmdbVotes": 45}}, {"id": "tv-90-day-fiance-the-other-way-2019", "title": "90 Day Fianc\u00e9: The Other Way", "year": 2019, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Reality"], "overview": "Americans move to a foreign country all in the name of love. Will uprooting their lives in the United States pay off, or will they be packing up and moving back to America brokenhearted?", "poster": "assets/tv-90-day-fiance-the-other-way-2019-poster.jpg", "backdrop": "assets/tv-90-day-fiance-the-other-way-2019-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 90046, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.8, "tmdbVotes": 34}}, {"id": "tv-the-100-2014", "title": "The 100", "year": 2014, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Sci-Fi & Fantasy", "Action & Adventure", "Drama"], "overview": "100 years in the future, when the Earth has been abandoned due to radioactivity, the last surviving humans live on an ark orbiting the planet \u2014 but the ark won't last forever. So the repressive regime picks 100\u2026", "poster": "assets/tv-the-100-2014-poster.jpg", "backdrop": "assets/tv-the-100-2014-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 48866, "ratings": {"rtCritics": 93, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.878, "tmdbVotes": 8855}}, {"id": "tv-the-abbott-and-costello-show-1952", "title": "The Abbott and Costello Show", "year": 1952, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Comedy"], "overview": "Bud and Lou are unemployed actors living in Mr. Fields\u2019 boarding house. Lou\u2019s girlfriend Hillary lives across the hall. Many situations arise leading to slapstick and puns.", "poster": "assets/tv-the-abbott-and-costello-show-1952-poster.jpg", "backdrop": "assets/tv-the-abbott-and-costello-show-1952-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 11821, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.2, "tmdbVotes": 22}}, {"id": "tv-accel-world-2012", "title": "Accel World", "year": 2012, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Drama"], "overview": "A short, overweight student uses the Neuro Link, an evolution of the Nerve Gear, to escape himself from the reality. But his skills in the virtual world brings him to the attention of Kuroyukihime, a popular student\u2026", "poster": "assets/tv-accel-world-2012-poster.jpg", "backdrop": "assets/tv-accel-world-2012-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "center_crop", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 43167, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.429, "tmdbVotes": 163}}, {"id": "tv-ahsoka-2023", "title": "Ahsoka", "year": 2023, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Sci-Fi & Fantasy", "Action & Adventure", "Drama"], "overview": "Former Jedi Knight Ahsoka Tano investigates an emerging threat to a vulnerable galaxy.", "poster": "assets/tv-ahsoka-2023-poster.jpg", "backdrop": "assets/tv-ahsoka-2023-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 114461, "ratings": {"rtCritics": 85, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.4, "tmdbVotes": 1351}}, {"id": "tv-american-dad-2005", "title": "American Dad!", "year": 2005, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Animation", "Comedy"], "overview": "The series focuses on an eccentric motley crew that is the Smith family and their three housemates: Father, husband, and breadwinner Stan Smith; his better half housewife, Francine Smith; their college-aged daughter,\u2026", "poster": "assets/tv-american-dad-2005-poster.jpg", "backdrop": "assets/tv-american-dad-2005-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 1433, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.022, "tmdbVotes": 2481}}, {"id": "tv-american-gods-2017", "title": "American Gods", "year": 2017, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Mystery", "Sci-Fi & Fantasy"], "overview": "An ex-con becomes the traveling partner of a conman who turns out to be one of the older gods trying to recruit troops to battle the upstart deities. Based on Neil Gaiman's fantasy novel.", "poster": "assets/tv-american-gods-2017-poster.jpg", "backdrop": "assets/tv-american-gods-2017-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 46639, "ratings": {"rtCritics": 75, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.1, "tmdbVotes": 1852}}, {"id": "tv-andromeda-2000", "title": "Andromeda", "year": 2000, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action & Adventure", "Drama", "Sci-Fi & Fantasy"], "overview": "Captain Dylan Hunt and his crew quest to restore a government that once presided over an extended peace and prosperity.", "poster": "assets/tv-andromeda-2000-poster.jpg", "backdrop": "assets/tv-andromeda-2000-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 2985, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.068, "tmdbVotes": 315}}, {"id": "tv-aqua-teen-hunger-force-2000", "title": "Aqua Teen Hunger Force", "year": 2000, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Animation", "Comedy"], "overview": "The surreal adventures of three anthropomorphic fast food items: Master Shake, Frylock and Meatwad, and their human nextdoor neighbor, Carl Brutananadilewski.", "poster": "assets/tv-aqua-teen-hunger-force-2000-poster.jpg", "backdrop": "assets/tv-aqua-teen-hunger-force-2000-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 251, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.5, "tmdbVotes": 272}}, {"id": "tv-arifureta-from-commonplace-to-world-s-strongest-2019", "title": "Arifureta: From Commonplace to World's Strongest", "year": 2019, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "Hajime Nagumo and his high school class are suddenly summoned to a fantastical land as heroes. But while most of his classmates have powerful stats and abilities, Hajime does not. Underappreciated and unprepared, he\u2026", "poster": "assets/tv-arifureta-from-commonplace-to-world-s-strongest-2019-poster.jpg", "backdrop": "assets/tv-arifureta-from-commonplace-to-world-s-strongest-2019-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 86034, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.382, "tmdbVotes": 662}}, {"id": "tv-arrow-2012", "title": "Arrow", "year": 2012, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Crime", "Drama", "Action & Adventure"], "overview": "Spoiled billionaire playboy Oliver Queen is missing and presumed dead when his yacht is lost at sea. He returns five years later a changed man, determined to clean up the city as a hooded vigilante armed with a bow.", "poster": "assets/tv-arrow-2012-poster.jpg", "backdrop": "assets/tv-arrow-2012-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 1412, "ratings": {"rtCritics": 86, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.834, "tmdbVotes": 6537}}, {"id": "tv-avatar-the-last-airbender-2024", "title": "Avatar: The Last Airbender", "year": 2024, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action & Adventure", "Sci-Fi & Fantasy", "Drama"], "overview": "A young boy known as the Avatar must master the four elemental powers to save a world at war \u2014 and fight a ruthless enemy bent on stopping him.", "poster": "assets/tv-avatar-the-last-airbender-2024-poster.jpg", "backdrop": "assets/tv-avatar-the-last-airbender-2024-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 82452, "ratings": {"rtCritics": 66, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.8, "tmdbVotes": 1406}}, {"id": "tv-avatar-seven-havens-2026", "title": "Avatar: Seven Havens", "year": 2026, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "In a world shattered by a devastating cataclysm, a young Earthbender discovers she's the new Avatar after Korra. But in this dangerous era, that title marks her as humanity's destroyer, not its savior. Hunted by both\u2026", "poster": "assets/tv-avatar-seven-havens-2026-poster.jpg", "backdrop": "assets/tv-avatar-seven-havens-2026-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 284833, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.9, "tmdbVotes": 183}}, {"id": "tv-batman-the-brave-and-the-bold-2008", "title": "Batman: The Brave and the Bold", "year": 2008, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "The Caped Crusader is teamed up with Blue Beetle, Green Arrow, Aquaman and countless others in his quest to uphold justice.", "poster": "assets/tv-batman-the-brave-and-the-bold-2008-poster.jpg", "backdrop": "assets/tv-batman-the-brave-and-the-bold-2008-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 15804, "ratings": {"rtCritics": 96, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.6, "tmdbVotes": 295}}, {"id": "tv-batwoman-2019", "title": "Batwoman", "year": 2019, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Sci-Fi & Fantasy", "Action & Adventure"], "overview": "Kate Kane, armed with a passion for social justice and a flair for speaking her mind, soars onto the streets of Gotham as Batwoman, an out lesbian and highly trained street fighter primed to snuff out the failing\u2026", "poster": "assets/tv-batwoman-2019-poster.jpg", "backdrop": "assets/tv-batwoman-2019-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 89247, "ratings": {"rtCritics": 83, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.8, "tmdbVotes": 1619}}, {"id": "tv-big-mouth-2017", "title": "Big Mouth", "year": 2017, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Comedy", "Animation"], "overview": "Teenage friends find their lives upended by the wonders and horrors of puberty in this edgy comedy from real-life pals Nick Kroll and Andrew Goldberg.", "poster": "assets/tv-big-mouth-2017-poster.jpg", "backdrop": "assets/tv-big-mouth-2017-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 74204, "ratings": {"rtCritics": 95, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.591, "tmdbVotes": 1274}}, {"id": "tv-black-lightning-2018", "title": "Black Lightning", "year": 2018, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action & Adventure", "Sci-Fi & Fantasy", "Drama"], "overview": "Jefferson Pierce is a man wrestling with a secret. As the father of two daughters and principal of a charter high school that also serves as a safe haven for young people in a New Orleans neighborhood overrun by gang\u2026", "poster": "assets/tv-black-lightning-2018-poster.jpg", "backdrop": "assets/tv-black-lightning-2018-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 71663, "ratings": {"rtCritics": 92, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.907, "tmdbVotes": 991}}], "discoverySource": {"state": "materialized_snapshot", "rankKnown": false, "selection": "First 18 alphabetically indexed movie stubs and first 18 series stubs, not ranked feed order", "observedAt": "2026-11-04T20:42:12.030108+00:00"}}, {"key": "mindel", "name": "Mindel", "initial": "M", "color": "#ff9d72", "destinations": {"Home": {"kind": "home"}, "Movies": {"kind": "library", "collection": "movies"}, "Shows": {"kind": "library", "collection": "shows"}, "Discover": {"kind": "discover", "collection": "discover"}}, "movies": [{"id": "movie-chicago-2002", "title": "Chicago", "year": 2002, "type": "movie", "runtimeSeconds": 6808, "seasonCount": null, "communityRating": 7.131, "criticRating": 87, "genres": ["Comedy", "Crime", "Drama"], "overview": "Murderesses Velma Kelly and Roxie Hart find themselves on death row together and fight for the fame that will keep them from the gallows in 1920s Chicago.", "poster": "assets/movie-chicago-2002-poster.jpg", "backdrop": "assets/movie-chicago-2002-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:24:53.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 1574, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 87, "rtCriticsLabel": "Certified Fresh", "rtAudience": 83, "rtAudienceLabel": "Upright", "imdb": 7.2, "imdbVotes": 259272, "tmdb": 7.135, "tmdbVotes": 3025}}, {"id": "movie-coherence-2014", "title": "Coherence", "year": 2014, "type": "movie", "runtimeSeconds": 5286, "seasonCount": null, "communityRating": 7.21, "criticRating": 89, "genres": ["Thriller", "Science Fiction"], "overview": "Four couples gather for dinner the night a mysterious and powerful comet passes overhead.", "poster": "assets/movie-coherence-2014-poster.jpg", "backdrop": "assets/movie-coherence-2014-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2017-08-12T23:49:00.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 220289, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 89, "rtCriticsLabel": "Certified Fresh", "rtAudience": 81, "rtAudienceLabel": "Upright", "imdb": 7.1, "imdbVotes": 180354, "tmdb": 7.208, "tmdbVotes": 3702}}, {"id": "movie-the-killing-of-a-sacred-deer-2017", "title": "The Killing of a Sacred Deer", "year": 2017, "type": "movie", "runtimeSeconds": 7262, "seasonCount": null, "communityRating": 7.003, "criticRating": 79, "genres": ["Drama", "Thriller", "Mystery"], "overview": "Dr. Steven Murphy is a renowned cardiovascular surgeon who presides over a spotless household with his wife and two children. Lurking at the margins of his idyllic suburban existence is Martin, a fatherless teen who\u2026", "poster": "assets/movie-the-killing-of-a-sacred-deer-2017-poster.jpg", "backdrop": "assets/movie-the-killing-of-a-sacred-deer-2017-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-22T21:49:26.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 399057, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 79, "rtCriticsLabel": "Certified Fresh", "rtAudience": 63, "rtAudienceLabel": "Upright", "imdb": 7, "imdbVotes": 219488, "tmdb": 7.001, "tmdbVotes": 4640}}, {"id": "movie-night-is-short-walk-on-girl-2017", "title": "Night Is Short, Walk On Girl", "year": 2017, "type": "movie", "runtimeSeconds": 5548, "seasonCount": null, "communityRating": 7.421, "criticRating": 90, "genres": ["Comedy", "Romance", "Animation"], "overview": "As a group of university students go out for a night on the town, a sophomore known only as \"The Girl with Black Hair\" experiences a series of surreal encounters with the local nightlife \u2013 all the while unaware of\u2026", "poster": "assets/movie-night-is-short-walk-on-girl-2017-poster.jpg", "backdrop": "assets/movie-night-is-short-walk-on-girl-2017-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T17:39:14.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 430214, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 90, "rtCriticsLabel": "Fresh", "rtAudience": 84, "rtAudienceLabel": "Upright", "imdb": 7.5, "imdbVotes": 7640, "tmdb": 7.4, "tmdbVotes": 267}}, {"id": "movie-thelma-louise-1991", "title": "Thelma & Louise", "year": 1991, "type": "movie", "runtimeSeconds": 7786, "seasonCount": null, "communityRating": 7.596, "criticRating": 87, "genres": ["Drama", "Crime", "Adventure"], "overview": "Taking a break from their dreary lives, close friends Thelma and Louise embark on a short weekend trip that ends in unforeseen incriminating circumstances. As fugitives, both women rediscover the strength of their\u2026", "poster": "assets/movie-thelma-louise-1991-poster.jpg", "backdrop": "assets/movie-thelma-louise-1991-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:24:40.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 1541, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 87, "rtCriticsLabel": "Certified Fresh", "rtAudience": 82, "rtAudienceLabel": "Upright", "imdb": 7.6, "imdbVotes": 197947, "tmdb": 7.594, "tmdbVotes": 4063}}, {"id": "movie-vicky-cristina-barcelona-2008", "title": "Vicky Cristina Barcelona", "year": 2008, "type": "movie", "runtimeSeconds": 5804, "seasonCount": null, "communityRating": 6.798, "criticRating": 80, "genres": ["Drama", "Romance"], "overview": "Two girlfriends on a summer holiday in Spain become enamored with the same painter, unaware that his ex-wife, with whom he has a tempestuous relationship, is about to re-enter the picture.", "poster": "assets/movie-vicky-cristina-barcelona-2008-poster.jpg", "backdrop": "assets/movie-vicky-cristina-barcelona-2008-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:25:34.0000000Z", "lastPlayedAt": "2026-08-11T20:12:57.3064089Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 5038, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 80, "rtCriticsLabel": "Certified Fresh", "rtAudience": 74, "rtAudienceLabel": "Upright", "imdb": 7.1, "imdbVotes": 283286, "tmdb": 6.797, "tmdbVotes": 4164}}], "shows": [{"id": "tv-all-creatures-great-small-2020", "title": "All Creatures Great & Small", "year": 2020, "type": "tv", "runtimeSeconds": 3000, "seasonCount": 7, "communityRating": 8.2, "criticRating": null, "genres": ["Drama", "Comedy"], "overview": "The heartwarming and humorous adventures of a young country vet in the Yorkshire Dales in the 1930s. A remake of the 1978 series.", "poster": "assets/tv-all-creatures-great-small-2020-poster.jpg", "backdrop": "assets/tv-all-creatures-great-small-2020-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-11-04T20:13:03.4266156Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 108255, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 99, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.2, "tmdbVotes": 168}}, {"id": "tv-back-to-life-2019", "title": "Back to Life", "year": 2019, "type": "tv", "runtimeSeconds": 1500, "seasonCount": 2, "communityRating": 7.367, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "When Miri Matteson returns home after eighteen years, can she integrate back into her old life? With a terrible event from her past hanging over her, it won\u2019t be easy.", "poster": "assets/tv-back-to-life-2019-poster.jpg", "backdrop": "assets/tv-back-to-life-2019-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0688758Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 88510, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.4, "tmdbVotes": 65}}, {"id": "tv-big-boys-2022", "title": "Big Boys", "year": 2022, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 3, "communityRating": 7.6, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "Two boys from very different ends of the 'spectrum of masculinity' become best friends at Brent University freshers' week; in their first year of uni, they explore, experiment and try to discover themselves, helping\u2026", "poster": "assets/tv-big-boys-2022-poster.jpg", "backdrop": "assets/tv-big-boys-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-11-04T20:25:58.2156837Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 202851, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.6, "tmdbVotes": 49}}, {"id": "tv-call-the-midwife-2012", "title": "Call the Midwife", "year": 2012, "type": "tv", "runtimeSeconds": 3120, "seasonCount": 15, "communityRating": 7.5, "criticRating": null, "genres": ["Drama"], "overview": "Drama following the lives of a group of midwives working in the poverty-stricken East End of London during the 1950s, based on the best-selling memoirs of Jennifer Worth.", "poster": "assets/tv-call-the-midwife-2012-poster.jpg", "backdrop": "assets/tv-call-the-midwife-2012-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0377216Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 39793, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.577, "tmdbVotes": 267}}, {"id": "tv-derry-girls-2018", "title": "Derry Girls", "year": 2018, "type": "tv", "runtimeSeconds": 1380, "seasonCount": 3, "communityRating": 8.144, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "Amidst the political conflict of Northern Ireland in the 1990s, five secondary school students square off with the universal challenges of being a teenager.", "poster": "assets/tv-derry-girls-2018-poster.jpg", "backdrop": "assets/tv-derry-girls-2018-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-02T20:01:55.8955537Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 76148, "artwork": {"posterKind": "center_crop", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 99, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.138, "tmdbVotes": 454}}, {"id": "tv-desperate-housewives-2004", "title": "Desperate Housewives", "year": 2004, "type": "tv", "runtimeSeconds": 2520, "seasonCount": 8, "communityRating": 7.896, "criticRating": null, "genres": ["Mystery", "Drama", "Comedy"], "overview": "Looking down on her friends and family isn't a way of life for Mary Alice Young... it's a way of death. One day, in her perfect house, in the loveliest of suburbs, Mary Alice ended it all. Now she's taking us into\u2026", "poster": "assets/tv-desperate-housewives-2004-poster.jpg", "backdrop": "assets/tv-desperate-housewives-2004-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-29T20:56:40.3505596Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 693, "artwork": {"posterKind": "poster", "backdropKind": "center_crop"}, "ratings": {"rtCritics": 72, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.896, "tmdbVotes": 2171}}, {"id": "tv-the-diplomat-2023", "title": "The Diplomat", "year": 2023, "type": "tv", "runtimeSeconds": null, "seasonCount": 4, "communityRating": 7.569, "criticRating": null, "genres": ["Drama", "War & Politics"], "overview": "Amid an international crisis, a US diplomat contends with her high-profile job as ambassador to the UK and her strained marriage to a political star.", "poster": "assets/tv-the-diplomat-2023-poster.jpg", "backdrop": "assets/tv-the-diplomat-2023-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0640348Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 203857, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 93, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.652, "tmdbVotes": 521}}, {"id": "tv-the-durrells-2016", "title": "The Durrells", "year": 2016, "type": "tv", "runtimeSeconds": 2760, "seasonCount": 4, "communityRating": 7.402, "criticRating": null, "genres": ["Drama", "Comedy", "Family"], "overview": "In 1935, financially strapped widow Louisa Durrell, whose life has fallen apart, decides to move from England, with her four children (three sons, one daughter), to the island of Corfu, Greece. Once there, the family\u2026", "poster": "assets/tv-the-durrells-2016-poster.jpg", "backdrop": "assets/tv-the-durrells-2016-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0619436Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 66134, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 94, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.4, "tmdbVotes": 127}}, {"id": "tv-etoile-2025", "title": "\u00c9toile", "year": 2025, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.197, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "In an ambitious gambit to save their storied institutions, two world-renowned ballet companies in New York City and Paris swap their most talented stars.", "poster": "assets/tv-etoile-2025-poster.jpg", "backdrop": "assets/tv-etoile-2025-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0601383Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 225383, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 86, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.3, "tmdbVotes": 40}}, {"id": "tv-gilmore-girls-2000", "title": "Gilmore Girls", "year": 2000, "type": "tv", "runtimeSeconds": 2640, "seasonCount": 7, "communityRating": 7.893, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "Set in the charming town of Stars Hollow, Connecticut, the series follows the captivating lives of Lorelai and Rory Gilmore, a mother/daughter pair who have a relationship most people only dream of.", "poster": "assets/tv-gilmore-girls-2000-poster.jpg", "backdrop": "assets/tv-gilmore-girls-2000-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0631035Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 4586, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.9, "tmdbVotes": 1339}}, {"id": "tv-ginny-georgia-2021", "title": "Ginny & Georgia", "year": 2021, "type": "tv", "runtimeSeconds": null, "seasonCount": 3, "communityRating": 8.026, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "Free-spirited Georgia and her two kids, Ginny and Austin, move north in search of a fresh start but find that the road to new beginnings can be bumpy.", "poster": "assets/tv-ginny-georgia-2021-poster.jpg", "backdrop": "assets/tv-ginny-georgia-2021-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0694828Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 117581, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 68, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8, "tmdbVotes": 1840}}, {"id": "tv-hellcats-2010", "title": "Hellcats", "year": 2010, "type": "tv", "runtimeSeconds": 2460, "seasonCount": 1, "communityRating": 7.5, "criticRating": null, "genres": ["Drama"], "overview": "Pre-law student Marti Perkins' world is flipped upside down when she loses her scholarship, and realizes the only way she can stay in school is by reigniting her dormant teen gymnastic skills to win a place on Lancer\u2026", "poster": "assets/tv-hellcats-2010-poster.jpg", "backdrop": "assets/tv-hellcats-2010-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-22T20:39:02.7719318Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 32504, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 65, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.5, "tmdbVotes": 116}}, {"id": "tv-high-potential-2024", "title": "High Potential", "year": 2024, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 8.201, "criticRating": null, "genres": ["Drama", "Crime", "Mystery"], "overview": "Morgan is a single mom with an exceptional mind, whose unconventional knack for solving crimes leads to an unusual and unstoppable partnership with a by-the-book seasoned detective.", "poster": "assets/tv-high-potential-2024-poster.jpg", "backdrop": "assets/tv-high-potential-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0686091Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 226637, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 98, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.3, "tmdbVotes": 707}}, {"id": "tv-how-to-get-to-heaven-from-belfast-2026", "title": "How to Get to Heaven from Belfast", "year": 2026, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.027, "criticRating": null, "genres": ["Mystery", "Comedy", "Drama"], "overview": "Three lifelong pals embark on a chaotic quest to solve the mystery of their old friend's suspicious death and keep their own dark secret under wraps.", "poster": "assets/tv-how-to-get-to-heaven-from-belfast-2026-poster.jpg", "backdrop": "assets/tv-how-to-get-to-heaven-from-belfast-2026-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0683192Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 233079, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 95, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.063, "tmdbVotes": 95}}, {"id": "tv-the-hunting-wives-2025", "title": "The Hunting Wives", "year": 2025, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 6.932, "criticRating": null, "genres": ["Drama", "Mystery"], "overview": "Sophie trades New England for East Texas and falls into a wealthy socialite's magnetic orbit \u2014 where a clique of housewives hide deadly secrets.", "poster": "assets/tv-the-hunting-wives-2025-poster.jpg", "backdrop": "assets/tv-the-hunting-wives-2025-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-20T19:42:46.3227892Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 244447, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 81, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.964, "tmdbVotes": 112}}, {"id": "tv-the-lost-flowers-of-alice-hart-2023", "title": "The Lost Flowers of Alice Hart", "year": 2023, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.634, "criticRating": null, "genres": ["Drama", "Mystery"], "overview": "After losing her parents in a mysterious fire, nine-year-old Alice Hart is raised by her grandmother June on a flower farm where she learns there are secrets within secrets. But years on, an unearthed betrayal sees\u2026", "poster": "assets/tv-the-lost-flowers-of-alice-hart-2023-poster.jpg", "backdrop": "assets/tv-the-lost-flowers-of-alice-hart-2023-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0607236Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 125816, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 82, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.64, "tmdbVotes": 161}}, {"id": "tv-love-is-blind-2020", "title": "Love Is Blind", "year": 2020, "type": "tv", "runtimeSeconds": null, "seasonCount": 11, "communityRating": 6.261, "criticRating": null, "genres": ["Reality"], "overview": "Nick and Vanessa Lachey host this social experiment where single men and women look for love and get engaged, all before meeting in person.", "poster": "assets/tv-love-is-blind-2020-poster.jpg", "backdrop": "assets/tv-love-is-blind-2020-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-22T17:11:08.2447226Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 99353, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 66, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.233, "tmdbVotes": 118}}, {"id": "tv-maximum-pleasure-guaranteed-2026", "title": "Maximum Pleasure Guaranteed", "year": 2026, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 6.676, "criticRating": null, "genres": ["Comedy"], "overview": "A newly divorced mom falls down a dangerous rabbit hole of blackmail, murder, and youth soccer.", "poster": "assets/tv-maximum-pleasure-guaranteed-2026-poster.jpg", "backdrop": "assets/tv-maximum-pleasure-guaranteed-2026-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-07T21:04:02.8277793Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 285404, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 93, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.799, "tmdbVotes": 92}}, {"id": "tv-minx-2022", "title": "Minx", "year": 2022, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 6.833, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "An earnest young feminist joins forces with a low-rent publisher to make the world's first erotic magazine for women.", "poster": "assets/tv-minx-2022-poster.jpg", "backdrop": "assets/tv-minx-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0653613Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 118303, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 93, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.9, "tmdbVotes": 123}}, {"id": "tv-offspring-2010", "title": "Offspring", "year": 2010, "type": "tv", "runtimeSeconds": 3600, "seasonCount": 7, "communityRating": 6.9, "criticRating": null, "genres": ["Drama"], "overview": "An exuberant drama set in Melbourne's Fitzroy, centering on Nina Proudman and her struggle to deal with her fabulously messy family, her hunt for a decent love life and her tendency to overthink and fly off into fantasy.", "poster": "assets/tv-offspring-2010-poster.jpg", "backdrop": "assets/tv-offspring-2010-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0664581Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 33155, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.9, "tmdbVotes": 27}}, {"id": "tv-only-murders-in-the-building-2021", "title": "Only Murders in the Building", "year": 2021, "type": "tv", "runtimeSeconds": null, "seasonCount": 5, "communityRating": 8.398, "criticRating": null, "genres": ["Comedy", "Mystery", "Crime"], "overview": "Three strangers share an obsession with true crime and suddenly find themselves wrapped up in one.", "poster": "assets/tv-only-murders-in-the-building-2021-poster.jpg", "backdrop": "assets/tv-only-murders-in-the-building-2021-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-11-04T20:25:58.2163301Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 107113, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 95, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.398, "tmdbVotes": 2333}}, {"id": "tv-outlander-blood-of-my-blood-2025", "title": "Outlander: Blood of My Blood", "year": 2025, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 8.542, "criticRating": null, "genres": ["Drama"], "overview": "From the battlefields of World War I to the rugged Highlands of 18th century Scotland, two fated couples must defy the forces that seek to tear them apart, unfolding in surprising and unforeseen ways in this romantic\u2026", "poster": "assets/tv-outlander-blood-of-my-blood-2025-poster.jpg", "backdrop": "assets/tv-outlander-blood-of-my-blood-2025-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0691373Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 207484, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 94, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.738, "tmdbVotes": 124}}, {"id": "tv-rivals-2024", "title": "Rivals", "year": 2024, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 7.44, "criticRating": null, "genres": ["Drama"], "overview": "It is 1986 and Britain is booming. \"Rivals\" dives headfirst into the cutthroat world of TV where hair-dos are big and ambitions are even bigger. Deals are brokered in boardrooms, as well as bedrooms. Nobody can be\u2026", "poster": "assets/tv-rivals-2024-poster.jpg", "backdrop": "assets/tv-rivals-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0645815Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 208921, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 96, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.4, "tmdbVotes": 93}}, {"id": "tv-rooster-2026", "title": "Rooster", "year": 2026, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.431, "criticRating": null, "genres": ["Comedy"], "overview": "On a college campus, an author navigates a complicated relationship with his daughter.", "poster": "assets/tv-rooster-2026-poster.jpg", "backdrop": "assets/tv-rooster-2026-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.063389Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 254953, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 88, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.423, "tmdbVotes": 144}}, {"id": "tv-sex-and-the-city-1998", "title": "Sex and the City", "year": 1998, "type": "tv", "runtimeSeconds": null, "seasonCount": 6, "communityRating": 7.365, "criticRating": null, "genres": ["Drama", "Comedy"], "overview": "Based on the bestselling book by Candace Bushnell, Sex and the City tells the story of four best friends, all single and in their late thirties, as they pursue their careers and talk about their sex lives, all while\u2026", "poster": "assets/tv-sex-and-the-city-1998-poster.jpg", "backdrop": "assets/tv-sex-and-the-city-1998-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0679324Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 105, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 70, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.351, "tmdbVotes": 2220}}, {"id": "tv-skymed-2022", "title": "SkyMed", "year": 2022, "type": "tv", "runtimeSeconds": 2640, "seasonCount": 4, "communityRating": 6.665, "criticRating": null, "genres": ["Drama"], "overview": "Life, death and drama at 20,000 feet. The series weaves together intense character journeys and high-stakes medical rescues, as we follow the triumphs, heartbreaks and tribulations of budding nurses and pilots flying\u2026", "poster": "assets/tv-skymed-2022-poster.jpg", "backdrop": "assets/tv-skymed-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-22T19:44:29.2431354Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 133908, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.699, "tmdbVotes": 98}}, {"id": "tv-so-you-think-you-can-dance-2008", "title": "So You Think You Can Dance", "year": 2008, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 9.8, "criticRating": null, "genres": ["Family", "Reality"], "overview": "So You Think You Can Dance is a joint Belgian-Dutch dance competition show broadcast on RTL 5 and vtm television stations. The first season was broadcast only in the Netherlands, whereas starting with season 2, the\u2026", "poster": "assets/tv-so-you-think-you-can-dance-2008-poster.jpg", "backdrop": "assets/tv-so-you-think-you-can-dance-2008-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-23T11:11:35.4124418Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 40231, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 9.8, "tmdbVotes": 4}}, {"id": "tv-the-white-lotus-2021", "title": "The White Lotus", "year": 2021, "type": "tv", "runtimeSeconds": null, "seasonCount": 3, "communityRating": 7.597, "criticRating": null, "genres": ["Comedy", "Drama", "Mystery"], "overview": "Follow the exploits of various guests and employees at an exclusive tropical resort over the span of a week as with each passing day, a darker complexity emerges in these picture-perfect travelers, the hotel\u2019s\u2026", "poster": "assets/tv-the-white-lotus-2021-poster.jpg", "backdrop": "assets/tv-the-white-lotus-2021-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-20T02:45:52.8742674Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 111803, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 90, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.599, "tmdbVotes": 1604}}, {"id": "tv-yellowjackets-2021", "title": "Yellowjackets", "year": 2021, "type": "tv", "runtimeSeconds": null, "seasonCount": 3, "communityRating": 7.431, "criticRating": null, "genres": ["Drama", "Mystery"], "overview": "This equal parts survival epic, psychological horror story and coming-of-age drama tells the saga of a team of wildly talented high school girls soccer players who become the (un)lucky survivors of a plane crash deep\u2026", "poster": "assets/tv-yellowjackets-2021-poster.jpg", "backdrop": "assets/tv-yellowjackets-2021-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-11-04T20:31:47.4592756Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 117488, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 92, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.431, "tmdbVotes": 1070}}], "anime": [], "resume": [{"id": "episode-sex-and-the-country-2001-s4e9", "title": "Sex and the Country", "year": 2001, "type": "episode", "runtimeSeconds": 1751, "seasonCount": null, "communityRating": 5.9, "criticRating": null, "genres": ["Comedy", "Drama", "Romance"], "overview": "Carrie is dragged to Aidan's country home in Suffern, New York. Charlotte visits the MacDougal \"compound\" in Connecticut. Miranda yells at Steve when he tells her he has testicular cancer but has no clue about his\u2026", "poster": "assets/episode-sex-and-the-country-2001-s4e9-poster.jpg", "backdrop": "assets/episode-sex-and-the-country-2001-s4e9-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 96.08, "progress": 0.0549, "addedAt": "2026-07-17T10:53:15.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "Sex and the City", "seasonNumber": 4, "episodeNumber": 9, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}], "nextUp": [{"id": "episode-what-more-do-i-need-2008-s5e7", "title": "What More Do I Need?", "year": 2008, "type": "episode", "runtimeSeconds": 2521, "seasonCount": null, "communityRating": 8, "criticRating": null, "genres": ["Comedy", "Drama", "Mystery"], "overview": "Lynette and Tom uncover the unsettling truth about son Porter. Gabrielle suspects Carlos' best client, Virginia, has ulterior motives. Bree's moment of weakness could bring her great embarrassment and shame.\u2026", "poster": "assets/episode-what-more-do-i-need-2008-s5e7-poster.jpg", "backdrop": "assets/episode-what-more-do-i-need-2008-s5e7-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-29T14:37:28.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "Desperate Housewives", "seasonNumber": 5, "episodeNumber": 7, "artwork": {"posterKind": "center_crop", "backdropKind": "backdrop"}}, {"id": "episode-new-day-2021-s1e2", "title": "New Day", "year": 2021, "type": "episode", "runtimeSeconds": 3445, "seasonCount": null, "communityRating": 7.143, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "A cautiously optimistic Belinda walks a fine line with Tanya, while Armond brainstorms ways to deal with Shane. After her husband belittles her work, Rachel approaches Nicole for advice. Paula and Olivia lose track\u2026", "poster": "assets/episode-new-day-2021-s1e2-poster.jpg", "backdrop": "assets/episode-new-day-2021-s1e2-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-19T22:18:57.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "The White Lotus", "seasonNumber": 1, "episodeNumber": 2, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}, {"id": "episode-the-uncomfortability-2010-s1e2", "title": "The Uncomfortability", "year": 2010, "type": "episode", "runtimeSeconds": 2687, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "Nina's insecurities are revealed when a patient poses an awkward request. Meanwhile Geraldine decides to try online dating, and Jimmy is an unexpected saviour to Cherie and Darcy.", "poster": "assets/episode-the-uncomfortability-2010-s1e2-poster.jpg", "backdrop": "assets/episode-the-uncomfortability-2010-s1e2-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T11:47:42.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "Offspring", "seasonNumber": 1, "episodeNumber": 2, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}, {"id": "episode-episode-2-2016-s1e2", "title": "Episode 2", "year": 2016, "type": "episode", "runtimeSeconds": 2766, "seasonCount": null, "communityRating": 8.3, "criticRating": null, "genres": ["Biography", "Comedy", "Drama"], "overview": "Larry falls dangerously ill, and it falls to Theo and his neighbour Sven to put their rudimentary medical skills to the task of saving his life. The family's money worries continue, but Spiros brings them good news\u2026", "poster": "assets/episode-episode-2-2016-s1e2-poster.jpg", "backdrop": "assets/episode-episode-2-2016-s1e2-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-31T07:29:17.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "The Durrells", "seasonNumber": 1, "episodeNumber": 2, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}, {"id": "episode-remembrance-day-2026-s2e1", "title": "Remembrance Day", "year": 2026, "type": "episode", "runtimeSeconds": 3822, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Fantasy", "Romance"], "overview": "The fiery crosses have been lit across the Highlands; lovers must part, as the call to arms brings friends and enemies home and the Jacobite Rebellion begins in earnest.", "poster": "assets/episode-remembrance-day-2026-s2e1-poster.jpg", "backdrop": "assets/episode-remembrance-day-2026-s2e1-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-19T03:50:07.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "Outlander: Blood of My Blood", "seasonNumber": 2, "episodeNumber": 1, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}, {"id": "episode-one-of-us-2024-s1e7", "title": "One of Us", "year": 2024, "type": "episode", "runtimeSeconds": 2555, "seasonCount": null, "communityRating": 7.7, "criticRating": null, "genres": [], "overview": "Morgan agrees to show Ava around the LAPD, but things quickly take a dangerous turn when the precinct is held hostage by the friends of a recently convicted man. Can Morgan and the detectives outsmart their captors\u2026", "poster": "assets/episode-one-of-us-2024-s1e7-poster.jpg", "backdrop": "assets/episode-one-of-us-2024-s1e7-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T13:57:22.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "High Potential", "seasonNumber": 1, "episodeNumber": 7, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}, {"id": "episode-the-breakup-part-2-2001-s1e17", "title": "The Breakup, Part 2", "year": 2001, "type": "episode", "runtimeSeconds": 2669, "seasonCount": null, "communityRating": 7.4, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "After recent events, Rory keeps busy, refusing to give in to self-pity. Meanwhile, Stars Hollow's residents rally around her, much to her dismay.", "poster": "assets/episode-the-breakup-part-2-2001-s1e17-poster.jpg", "backdrop": "assets/episode-the-breakup-part-2-2001-s1e17-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T11:28:05.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "Gilmore Girls", "seasonNumber": 1, "episodeNumber": 17, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}, {"id": "episode-i-heart-ny-2002-s4e18", "title": "I Heart NY", "year": 2002, "type": "episode", "runtimeSeconds": 1905, "seasonCount": null, "communityRating": 6.667, "criticRating": null, "genres": ["Comedy", "Drama", "Romance"], "overview": "Carrie is shocked to find out that Big is moving to Napa, California and plans to give him a New York send-off. Charlotte begins dating another recent divorc\u00e9. Miranda prepares for the new baby as her delivery date\u2026", "poster": "assets/episode-i-heart-ny-2002-s4e18-poster.jpg", "backdrop": "assets/episode-i-heart-ny-2002-s4e18-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T12:35:14.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "Sex and the City", "seasonNumber": 4, "episodeNumber": 18, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}], "recentlyAdded": [{"id": "tv-yellowjackets-2021", "title": "Yellowjackets", "year": 2021, "type": "tv", "runtimeSeconds": null, "seasonCount": 3, "communityRating": 7.431, "criticRating": null, "genres": ["Drama", "Mystery"], "overview": "This equal parts survival epic, psychological horror story and coming-of-age drama tells the saga of a team of wildly talented high school girls soccer players who become the (un)lucky survivors of a plane crash deep\u2026", "poster": "assets/tv-yellowjackets-2021-poster.jpg", "backdrop": "assets/tv-yellowjackets-2021-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-11-04T20:31:47.4592756Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 117488, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 92, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.431, "tmdbVotes": 1070}}, {"id": "tv-only-murders-in-the-building-2021", "title": "Only Murders in the Building", "year": 2021, "type": "tv", "runtimeSeconds": null, "seasonCount": 5, "communityRating": 8.398, "criticRating": null, "genres": ["Comedy", "Mystery", "Crime"], "overview": "Three strangers share an obsession with true crime and suddenly find themselves wrapped up in one.", "poster": "assets/tv-only-murders-in-the-building-2021-poster.jpg", "backdrop": "assets/tv-only-murders-in-the-building-2021-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-11-04T20:25:58.2163301Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 107113, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 95, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.398, "tmdbVotes": 2333}}, {"id": "tv-big-boys-2022", "title": "Big Boys", "year": 2022, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 3, "communityRating": 7.6, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "Two boys from very different ends of the 'spectrum of masculinity' become best friends at Brent University freshers' week; in their first year of uni, they explore, experiment and try to discover themselves, helping\u2026", "poster": "assets/tv-big-boys-2022-poster.jpg", "backdrop": "assets/tv-big-boys-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-11-04T20:25:58.2156837Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 202851, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.6, "tmdbVotes": 49}}, {"id": "tv-all-creatures-great-small-2020", "title": "All Creatures Great & Small", "year": 2020, "type": "tv", "runtimeSeconds": 3000, "seasonCount": 7, "communityRating": 8.2, "criticRating": null, "genres": ["Drama", "Comedy"], "overview": "The heartwarming and humorous adventures of a young country vet in the Yorkshire Dales in the 1930s. A remake of the 1978 series.", "poster": "assets/tv-all-creatures-great-small-2020-poster.jpg", "backdrop": "assets/tv-all-creatures-great-small-2020-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-11-04T20:13:03.4266156Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 108255, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 99, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.2, "tmdbVotes": 168}}, {"id": "movie-the-killing-of-a-sacred-deer-2017", "title": "The Killing of a Sacred Deer", "year": 2017, "type": "movie", "runtimeSeconds": 7262, "seasonCount": null, "communityRating": 7.003, "criticRating": 79, "genres": ["Drama", "Thriller", "Mystery"], "overview": "Dr. Steven Murphy is a renowned cardiovascular surgeon who presides over a spotless household with his wife and two children. Lurking at the margins of his idyllic suburban existence is Martin, a fatherless teen who\u2026", "poster": "assets/movie-the-killing-of-a-sacred-deer-2017-poster.jpg", "backdrop": "assets/movie-the-killing-of-a-sacred-deer-2017-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-22T21:49:26.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 399057, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 79, "rtCriticsLabel": "Certified Fresh", "rtAudience": 63, "rtAudienceLabel": "Upright", "imdb": 7, "imdbVotes": 219488, "tmdb": 7.001, "tmdbVotes": 4640}}, {"id": "tv-derry-girls-2018", "title": "Derry Girls", "year": 2018, "type": "tv", "runtimeSeconds": 1380, "seasonCount": 3, "communityRating": 8.144, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "Amidst the political conflict of Northern Ireland in the 1990s, five secondary school students square off with the universal challenges of being a teenager.", "poster": "assets/tv-derry-girls-2018-poster.jpg", "backdrop": "assets/tv-derry-girls-2018-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-02T20:01:55.8955537Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 76148, "artwork": {"posterKind": "center_crop", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 99, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.138, "tmdbVotes": 454}}, {"id": "tv-so-you-think-you-can-dance-2008", "title": "So You Think You Can Dance", "year": 2008, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 9.8, "criticRating": null, "genres": ["Family", "Reality"], "overview": "So You Think You Can Dance is a joint Belgian-Dutch dance competition show broadcast on RTL 5 and vtm television stations. The first season was broadcast only in the Netherlands, whereas starting with season 2, the\u2026", "poster": "assets/tv-so-you-think-you-can-dance-2008-poster.jpg", "backdrop": "assets/tv-so-you-think-you-can-dance-2008-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-23T11:11:35.4124418Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 40231, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 9.8, "tmdbVotes": 4}}, {"id": "tv-hellcats-2010", "title": "Hellcats", "year": 2010, "type": "tv", "runtimeSeconds": 2460, "seasonCount": 1, "communityRating": 7.5, "criticRating": null, "genres": ["Drama"], "overview": "Pre-law student Marti Perkins' world is flipped upside down when she loses her scholarship, and realizes the only way she can stay in school is by reigniting her dormant teen gymnastic skills to win a place on Lancer\u2026", "poster": "assets/tv-hellcats-2010-poster.jpg", "backdrop": "assets/tv-hellcats-2010-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-22T20:39:02.7719318Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 32504, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 65, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.5, "tmdbVotes": 116}}, {"id": "tv-skymed-2022", "title": "SkyMed", "year": 2022, "type": "tv", "runtimeSeconds": 2640, "seasonCount": 4, "communityRating": 6.665, "criticRating": null, "genres": ["Drama"], "overview": "Life, death and drama at 20,000 feet. The series weaves together intense character journeys and high-stakes medical rescues, as we follow the triumphs, heartbreaks and tribulations of budding nurses and pilots flying\u2026", "poster": "assets/tv-skymed-2022-poster.jpg", "backdrop": "assets/tv-skymed-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-22T19:44:29.2431354Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 133908, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.699, "tmdbVotes": 98}}, {"id": "tv-love-is-blind-2020", "title": "Love Is Blind", "year": 2020, "type": "tv", "runtimeSeconds": null, "seasonCount": 11, "communityRating": 6.261, "criticRating": null, "genres": ["Reality"], "overview": "Nick and Vanessa Lachey host this social experiment where single men and women look for love and get engaged, all before meeting in person.", "poster": "assets/tv-love-is-blind-2020-poster.jpg", "backdrop": "assets/tv-love-is-blind-2020-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-22T17:11:08.2447226Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 99353, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 66, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.233, "tmdbVotes": 118}}, {"id": "tv-the-hunting-wives-2025", "title": "The Hunting Wives", "year": 2025, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 6.932, "criticRating": null, "genres": ["Drama", "Mystery"], "overview": "Sophie trades New England for East Texas and falls into a wealthy socialite's magnetic orbit \u2014 where a clique of housewives hide deadly secrets.", "poster": "assets/tv-the-hunting-wives-2025-poster.jpg", "backdrop": "assets/tv-the-hunting-wives-2025-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-20T19:42:46.3227892Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 244447, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 81, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.964, "tmdbVotes": 112}}, {"id": "tv-the-white-lotus-2021", "title": "The White Lotus", "year": 2021, "type": "tv", "runtimeSeconds": null, "seasonCount": 3, "communityRating": 7.597, "criticRating": null, "genres": ["Comedy", "Drama", "Mystery"], "overview": "Follow the exploits of various guests and employees at an exclusive tropical resort over the span of a week as with each passing day, a darker complexity emerges in these picture-perfect travelers, the hotel\u2019s\u2026", "poster": "assets/tv-the-white-lotus-2021-poster.jpg", "backdrop": "assets/tv-the-white-lotus-2021-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-20T02:45:52.8742674Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 111803, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 90, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.599, "tmdbVotes": 1604}}], "discover": [{"id": "movie-across-the-hall-2009", "title": "Across the Hall", "year": 2009, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Thriller", "Mystery"], "overview": "Convinced that his fiancee is cheating on him, a man follows her to a hotel and calls his best friend to help him avoid a calamity.", "poster": "assets/movie-across-the-hall-2009-poster.jpg", "backdrop": "assets/movie-across-the-hall-2009-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 21469, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": 39, "rtAudienceLabel": "Spilled", "imdb": 5.8, "imdbVotes": 5130, "tmdb": 6, "tmdbVotes": 129}}, {"id": "movie-addicted-2014", "title": "Addicted", "year": 2014, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Thriller"], "overview": "A gallerist risks her family and flourishing career when she enters into an affair with a talented painter and slowly loses control of her life.", "poster": "assets/movie-addicted-2014-poster.jpg", "backdrop": "assets/movie-addicted-2014-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 235271, "ratings": {"rtCritics": 7, "rtCriticsLabel": "Rotten", "rtAudience": 51, "rtAudienceLabel": "Spilled", "imdb": 5, "imdbVotes": 13131, "tmdb": 5.928, "tmdbVotes": 466}}, {"id": "movie-adulterers-2016", "title": "Adulterers", "year": 2016, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Thriller", "Drama"], "overview": "On a steamy New Orleans afternoon, Sam Deuprey decides to surprise his beautiful wife, Ashley, for their anniversary and comes home early. But Ashley is not alone in the bedroom. She and her lover, Damien are about\u2026", "poster": "assets/movie-adulterers-2016-poster.jpg", "backdrop": "assets/movie-adulterers-2016-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 376004, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": 37, "rtAudienceLabel": "Spilled", "imdb": 5.1, "imdbVotes": 2144, "tmdb": 6.2, "tmdbVotes": 116}}, {"id": "movie-alone-with-her-2006", "title": "Alone With Her", "year": 2006, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Crime", "Drama", "Thriller"], "overview": "The harrowing story of a disturbed young man's attempts to win the affections of an unsuspecting young woman. When Doug first sees Amy, he instantly falls for her and begins to watch her every move, going so far as\u2026", "poster": "assets/movie-alone-with-her-2006-poster.jpg", "backdrop": "assets/movie-alone-with-her-2006-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 18206, "ratings": {"rtCritics": 70, "rtCriticsLabel": "Fresh", "rtAudience": 46, "rtAudienceLabel": "Spilled", "imdb": 6, "imdbVotes": 4359, "tmdb": 5.9, "tmdbVotes": 94}}, {"id": "movie-awake-2007", "title": "Awake", "year": 2007, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Thriller", "Crime", "Mystery"], "overview": "While undergoing heart surgery, a man experiences a phenomenon called \u2018anesthetic awareness\u2019, which leaves him awake but paralyzed throughout the operation. As various obstacles present themselves, his wife must make\u2026", "poster": "assets/movie-awake-2007-poster.jpg", "backdrop": "assets/movie-awake-2007-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 13483, "ratings": {"rtCritics": 23, "rtCriticsLabel": "Rotten", "rtAudience": 55, "rtAudienceLabel": "Spilled", "imdb": 6.5, "imdbVotes": 89389, "tmdb": 6.586, "tmdbVotes": 1649}}, {"id": "movie-black-bear-2020", "title": "Black Bear", "year": 2020, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Thriller"], "overview": "At a remote lake house in the Adirondack Mountains, a couple entertains an out-of-town guest looking for inspiration in her filmmaking. The group quickly falls into a calculated game of desire, manipulation, and\u2026", "poster": "assets/movie-black-bear-2020-poster.jpg", "backdrop": "assets/movie-black-bear-2020-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 624788, "ratings": {"rtCritics": 89, "rtCriticsLabel": "Certified Fresh", "rtAudience": 64, "rtAudienceLabel": "Upright", "imdb": 6.5, "imdbVotes": 18408, "tmdb": 6.337, "tmdbVotes": 315}}, {"id": "movie-butterfly-on-a-wheel-2007", "title": "Butterfly on a Wheel", "year": 2007, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Crime", "Drama", "Thriller"], "overview": "A sociopathic kidnapper methodically pushes a desperate pair of parents to their absolute breaking point.", "poster": "assets/movie-butterfly-on-a-wheel-2007-poster.jpg", "backdrop": "assets/movie-butterfly-on-a-wheel-2007-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 13250, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": 61, "rtAudienceLabel": "Upright", "imdb": 6.6, "imdbVotes": 35409, "tmdb": 6.457, "tmdbVotes": 595}}, {"id": "movie-careful-what-you-wish-for-2015", "title": "Careful What You Wish For", "year": 2015, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Thriller"], "overview": "A guy gets more than he bargained for after entering into an affair with the wife of an investment banker. Soon, a suspicious death and substantial life insurance policy embroil him in a scandal.", "poster": "assets/movie-careful-what-you-wish-for-2015-poster.jpg", "backdrop": "assets/movie-careful-what-you-wish-for-2015-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 317744, "ratings": {"rtCritics": 17, "rtCriticsLabel": "Rotten", "rtAudience": 61, "rtAudienceLabel": "Upright", "imdb": 5.6, "imdbVotes": 9452, "tmdb": 6.129, "tmdbVotes": 446}}, {"id": "movie-crime-of-passion-1956", "title": "Crime of Passion", "year": 1956, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Crime", "Thriller"], "overview": "Kathy leaves the newspaper business to marry homicide detective Bill, but is frustrated by his lack of ambition and the banality of life in the suburbs. Her drive to advance Bill's career soon takes her down a\u2026", "poster": "assets/movie-crime-of-passion-1956-poster.jpg", "backdrop": "assets/movie-crime-of-passion-1956-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "center_crop", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 1920, "ratings": {"rtCritics": 80, "rtCriticsLabel": "Fresh", "rtAudience": 34, "rtAudienceLabel": "Spilled", "imdb": 6.4, "imdbVotes": 3750, "tmdb": 6.1, "tmdbVotes": 57}}, {"id": "movie-deep-water-2022", "title": "Deep Water", "year": 2022, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Mystery", "Thriller"], "overview": "A well-to-do husband who allows his wife to have affairs in order to avoid a divorce becomes a prime suspect in the disappearance of her lovers.", "poster": "assets/movie-deep-water-2022-poster.jpg", "backdrop": "assets/movie-deep-water-2022-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 619979, "ratings": {"rtCritics": 35, "rtCriticsLabel": "Rotten", "rtAudience": 25, "rtAudienceLabel": "Spilled", "imdb": 5.5, "imdbVotes": 74098, "tmdb": 5.834, "tmdbVotes": 1755}}, {"id": "movie-derailed-2005", "title": "Derailed", "year": 2005, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Thriller"], "overview": "When two married business executives having an affair are blackmailed by a violent criminal, they are forced to turn the tables on him to save their families.", "poster": "assets/movie-derailed-2005-poster.jpg", "backdrop": "assets/movie-derailed-2005-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 8999, "ratings": {"rtCritics": 20, "rtCriticsLabel": "Rotten", "rtAudience": 58, "rtAudienceLabel": "Spilled", "imdb": 6.6, "imdbVotes": 79563, "tmdb": 6.405, "tmdbVotes": 1193}}, {"id": "movie-die-my-love-2025", "title": "Die My Love", "year": 2025, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama"], "overview": "After inheriting a remote Montana house, Jackson moves there from New York with his partner Grace, and the couple soon welcome a child. As Jackson becomes increasingly absent and rural isolation sets in, Grace\u2026", "poster": "assets/movie-die-my-love-2025-poster.jpg", "backdrop": "assets/movie-die-my-love-2025-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 1033148, "ratings": {"rtCritics": 74, "rtCriticsLabel": "Certified Fresh", "rtAudience": 45, "rtAudienceLabel": "Spilled", "imdb": 6, "imdbVotes": 50664, "tmdb": 6.1, "tmdbVotes": 683}}, {"id": "movie-dream-house-2011", "title": "Dream House", "year": 2011, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Mystery", "Drama", "Thriller"], "overview": "Publisher Will Atenton quits a lucrative job in New York to relocate his wife, Libby, and their daughters to a quaint town in New England. However, as they settle into their home the Atentons discover that a woman\u2026", "poster": "assets/movie-dream-house-2011-poster.jpg", "backdrop": "assets/movie-dream-house-2011-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 69668, "ratings": {"rtCritics": 7, "rtCriticsLabel": "Rotten", "rtAudience": 36, "rtAudienceLabel": "Spilled", "imdb": 6, "imdbVotes": 71386, "tmdb": 6.212, "tmdbVotes": 1480}}, {"id": "movie-dream-lover-1994", "title": "Dream Lover", "year": 1994, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Mystery", "Thriller"], "overview": "Not long after they cross paths at an art gallery, architect Ray Reardon and hypnotically sensual Lena are married with children. But as strange incidents occur, Ray begins to realize he may not really know the woman\u2026", "poster": "assets/movie-dream-lover-1994-poster.jpg", "backdrop": "assets/movie-dream-lover-1994-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 18190, "ratings": {"rtCritics": 57, "rtCriticsLabel": "Rotten", "rtAudience": 38, "rtAudienceLabel": "Spilled", "imdb": 6.2, "imdbVotes": 7865, "tmdb": 6.844, "tmdbVotes": 167}}, {"id": "movie-drop-2025", "title": "Drop", "year": 2025, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Mystery", "Thriller"], "overview": "Violet, a widowed mother on her first date in years, arrives at an upscale restaurant where she is relieved that her date, Henry, is more charming and handsome than she expected. But their chemistry begins to curdle\u2026", "poster": "assets/movie-drop-2025-poster.jpg", "backdrop": "assets/movie-drop-2025-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 1249213, "ratings": {"rtCritics": 83, "rtCriticsLabel": "Certified Fresh", "rtAudience": 79, "rtAudienceLabel": "Upright", "imdb": 6.1, "imdbVotes": 61018, "tmdb": 6.275, "tmdbVotes": 1023}}, {"id": "movie-eastern-promises-2007", "title": "Eastern Promises", "year": 2007, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Thriller", "Crime", "Mystery"], "overview": "A Russian teenager living in London dies during childbirth but leaves clues in her diary that could tie her child to a rape involving a violent Russian mob family.", "poster": "assets/movie-eastern-promises-2007-poster.jpg", "backdrop": "assets/movie-eastern-promises-2007-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 2252, "ratings": {"rtCritics": 89, "rtCriticsLabel": "Certified Fresh", "rtAudience": 83, "rtAudienceLabel": "Upright", "imdb": 7.6, "imdbVotes": 281015, "tmdb": 7.349, "tmdbVotes": 3900}}, {"id": "movie-faces-1968", "title": "Faces", "year": 1968, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama"], "overview": "Middle-aged suburban husband Richard abruptly tells his wife, Maria, that he wants a divorce. As Richard takes up with a younger woman, Maria enjoys a night on the town with her friends and meets a younger man. As\u2026", "poster": "assets/movie-faces-1968-poster.jpg", "backdrop": "assets/movie-faces-1968-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "center_crop", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 753, "ratings": {"rtCritics": 83, "rtCriticsLabel": "Fresh", "rtAudience": 86, "rtAudienceLabel": "Upright", "imdb": 7.4, "imdbVotes": 12950, "tmdb": 7.093, "tmdbVotes": 227}}, {"id": "movie-fatale-2020", "title": "Fatale", "year": 2020, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Thriller"], "overview": "After a wild one-night stand, successful sports agent Derrick Tyler watches his perfect life slowly disappear when he discovers the sexy and mysterious woman he risked everything for is determined police detective\u2026", "poster": "assets/movie-fatale-2020-poster.jpg", "backdrop": "assets/movie-fatale-2020-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 549514, "ratings": {"rtCritics": 46, "rtCriticsLabel": "Rotten", "rtAudience": 87, "rtAudienceLabel": "Upright", "imdb": 5.5, "imdbVotes": 16392, "tmdb": 6, "tmdbVotes": 401}}, {"id": "tv-56-days-2026", "title": "56 Days", "year": 2026, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Crime", "Mystery"], "overview": "When an unidentified body is found in a luxury apartment linked to Oliver Kennedy and his girlfriend Ciara Wyse, Detectives Lee Reardon and Karl Connolly reconstruct the couple's deadly romance across the past 56 days.", "poster": "assets/tv-56-days-2026-poster.jpg", "backdrop": "assets/tv-56-days-2026-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 241372, "ratings": {"rtCritics": 60, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.388, "tmdbVotes": 245}}, {"id": "tv-90-day-fiance-before-the-90-days-2017", "title": "90 Day Fianc\u00e9: Before the 90 Days", "year": 2017, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Reality"], "overview": "Follow couples who have an existing relationship online, but haven't met in person. We'll experience their journey as they travel to the other's foreign country for the first time in an attempt to establish an\u2026", "poster": "assets/tv-90-day-fiance-before-the-90-days-2017-poster.jpg", "backdrop": "assets/tv-90-day-fiance-before-the-90-days-2017-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 73319, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.5, "tmdbVotes": 45}}, {"id": "tv-90-day-fiance-the-other-way-2019", "title": "90 Day Fianc\u00e9: The Other Way", "year": 2019, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Reality"], "overview": "Americans move to a foreign country all in the name of love. Will uprooting their lives in the United States pay off, or will they be packing up and moving back to America brokenhearted?", "poster": "assets/tv-90-day-fiance-the-other-way-2019-poster.jpg", "backdrop": "assets/tv-90-day-fiance-the-other-way-2019-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 90046, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.8, "tmdbVotes": 34}}, {"id": "tv-absolutely-fabulous-1992", "title": "Absolutely Fabulous", "year": 1992, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Comedy"], "overview": "Set in the world of fashion and PR, immature fun-loving mother Edina Monsoon and her best friend Patsy drive Eddie's sensible daughter, Saffron, up the wall with their constant drug abuse and outrageous selfishness.\u2026", "poster": "assets/tv-absolutely-fabulous-1992-poster.jpg", "backdrop": "assets/tv-absolutely-fabulous-1992-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 4493, "ratings": {"rtCritics": 96, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.6, "tmdbVotes": 198}}, {"id": "tv-the-afterparty-2022", "title": "The Afterparty", "year": 2022, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Mystery", "Comedy", "Crime"], "overview": "Nothing is as it seems in this hilarious murder mystery where each suspect's story is told through a different film genre.", "poster": "assets/tv-the-afterparty-2022-poster.jpg", "backdrop": "assets/tv-the-afterparty-2022-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 106454, "ratings": {"rtCritics": 92, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.071, "tmdbVotes": 245}}, {"id": "tv-agatha-christie-s-marple-2004", "title": "Agatha Christie's Marple", "year": 2004, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Crime", "Mystery"], "overview": "The adventures of Miss Jane Marple, an elderly spinster living in the quiet little village of St Mary Mead. During her many visits to friends and relatives in other villages, Miss Marple often stumbles upon\u2026", "poster": "assets/tv-agatha-christie-s-marple-2004-poster.jpg", "backdrop": "assets/tv-agatha-christie-s-marple-2004-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 6127, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.9, "tmdbVotes": 152}}, {"id": "tv-all-american-homecoming-2022", "title": "All American: Homecoming", "year": 2022, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama"], "overview": "Follow the journeys of Simone Hicks, a tennis hopeful from Beverly Hills, and elite baseball player Damon Sims from Chicago, as they navigate life at the prestigious HBCU Bringston University.", "poster": "assets/tv-all-american-homecoming-2022-poster.jpg", "backdrop": "assets/tv-all-american-homecoming-2022-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 153723, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.3, "tmdbVotes": 70}}, {"id": "tv-and-just-like-that-2021", "title": "And Just Like That\u2026", "year": 2021, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "This new chapter of \u201cSex and the City\u201d follows Carrie, Miranda and Charlotte as they navigate the journey from the complicated reality of life and friendship in their 30s to the even more complicated reality of life\u2026", "poster": "assets/tv-and-just-like-that-2021-poster.jpg", "backdrop": "assets/tv-and-just-like-that-2021-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 116450, "ratings": {"rtCritics": 52, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.211, "tmdbVotes": 556}}, {"id": "tv-anne-with-an-e-2017", "title": "Anne with an E", "year": 2017, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Family"], "overview": "A coming-of-age story about an outsider who, against all odds and numerous challenges, fights for love and acceptance and for her place in the world. The series centers on a young orphaned girl in the late 1890s,\u2026", "poster": "assets/tv-anne-with-an-e-2017-poster.jpg", "backdrop": "assets/tv-anne-with-an-e-2017-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 70785, "ratings": {"rtCritics": 63, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.7, "tmdbVotes": 5019}}, {"id": "tv-awkward-2011", "title": "Awkward.", "year": 2011, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "An irreverent look at the conflict, chaos and humor that defines teenage life through the eyes of 15-year-old Jenna Hamilton whose life begins to change when a simple accident becomes an epic misunderstanding and is\u2026", "poster": "assets/tv-awkward-2011-poster.jpg", "backdrop": "assets/tv-awkward-2011-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 39859, "ratings": {"rtCritics": 78, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.4, "tmdbVotes": 427}}, {"id": "tv-the-baby-sitters-club-2020", "title": "The Baby-Sitters Club", "year": 2020, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Kids", "Drama"], "overview": "In this contemporary take on the beloved book series, five best friends launch a baby-sitting business that's big on fun and adventure.", "poster": "assets/tv-the-baby-sitters-club-2020-poster.jpg", "backdrop": "assets/tv-the-baby-sitters-club-2020-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 100412, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.9, "tmdbVotes": 506}}, {"id": "tv-ballers-2015", "title": "Ballers", "year": 2015, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Comedy"], "overview": "Looking at the lives of former and current football players, the show follows former superstar Spencer Strasmore as he gets his life on track in retirement while mentoring other current and former players through the\u2026", "poster": "assets/tv-ballers-2015-poster.jpg", "backdrop": "assets/tv-ballers-2015-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 62704, "ratings": {"rtCritics": 72, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.809, "tmdbVotes": 820}}, {"id": "tv-baretta-1975", "title": "Baretta", "year": 1975, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Crime", "Drama", "Mystery"], "overview": "Baretta is an American detective television series which ran on ABC from 1975 to 1978. The show was a milder version of a successful 1973\u201374 ABC series, Toma, starring Tony Musante as chameleon-like, real-life New\u2026", "poster": "assets/tv-baretta-1975-poster.jpg", "backdrop": "assets/tv-baretta-1975-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 3216, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.5, "tmdbVotes": 43}}, {"id": "tv-based-on-a-true-story-2023", "title": "Based on a True Story", "year": 2023, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "The lives of a realtor, a plumber and a former tennis star unexpectedly collide, exposing America\u2019s obsession with true crime, murder and the slow-close toilet seat.", "poster": "assets/tv-based-on-a-true-story-2023-poster.jpg", "backdrop": "assets/tv-based-on-a-true-story-2023-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 197373, "ratings": {"rtCritics": 79, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7, "tmdbVotes": 160}}, {"id": "tv-the-beast-in-me-2025", "title": "The Beast in Me", "year": 2025, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Mystery"], "overview": "A famous author is pulled into a twisted mind game with her rich, powerful new neighbor \u2014 who might be a murderer.", "poster": "assets/tv-the-beast-in-me-2025-poster.jpg", "backdrop": "assets/tv-the-beast-in-me-2025-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 250504, "ratings": {"rtCritics": 85, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.072, "tmdbVotes": 334}}, {"id": "tv-big-city-greens-2018", "title": "Big City Greens", "year": 2018, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Family", "Animation", "Comedy"], "overview": "The offbeat adventures of 10-year-old Cricket Green, a mischievous and optimistic country boy who moves to the big city with his wildly out of place family \u2013 older sister Tilly, father Bill and Gramma Alice.", "poster": "assets/tv-big-city-greens-2018-poster.jpg", "backdrop": "assets/tv-big-city-greens-2018-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 80587, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.555, "tmdbVotes": 82}}, {"id": "tv-the-bold-type-2017", "title": "The Bold Type", "year": 2017, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Comedy"], "overview": "A glimpse into the outrageous lives of Jane, Kat and Sutton, who are working at the nation's top women's magazine, Scarlet, while navigating their careers, identities and individual voices.", "poster": "assets/tv-the-bold-type-2017-poster.jpg", "backdrop": "assets/tv-the-bold-type-2017-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 69630, "ratings": {"rtCritics": 89, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.7, "tmdbVotes": 204}}, {"id": "tv-brassic-2019", "title": "Brassic", "year": 2019, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Comedy", "Drama", "Crime"], "overview": "A group of working-class friends finding unconventional ways to win at life in suburban northern England. These lads have dealt, scammed, bribed and conned their way through adolescence, but now, their dealing and\u2026", "poster": "assets/tv-brassic-2019-poster.jpg", "backdrop": "assets/tv-brassic-2019-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 92621, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.336, "tmdbVotes": 263}}], "discoverySource": {"state": "materialized_snapshot", "rankKnown": false, "selection": "First 18 alphabetically indexed movie stubs and first 18 series stubs, not ranked feed order", "observedAt": "2026-11-04T20:42:12.030108+00:00"}}, {"key": "shared", "name": "Shared", "initial": "S", "color": "#9be38f", "destinations": {"Home": {"kind": "home"}, "Movies": {"kind": "library", "collection": "movies"}, "Shows": {"kind": "library", "collection": "shows"}, "Discover": {"kind": "discover", "collection": "discover"}}, "movies": [{"id": "movie-american-gangster-2007", "title": "American Gangster", "year": 2007, "type": "movie", "runtimeSeconds": 10546, "seasonCount": null, "communityRating": 7.595, "criticRating": 81, "genres": ["Drama", "Crime"], "overview": "Loosely based on the criminal career of Frank Lucas, a gangster from La Grange, North Carolina, who smuggled heroin into the United States on American service planes returning from the Vietnam War, before being\u2026", "poster": "assets/movie-american-gangster-2007-poster.jpg", "backdrop": "assets/movie-american-gangster-2007-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-08-23T10:22:36.0000000Z", "lastPlayedAt": "2026-08-30T21:05:39.7952451Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 4982, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 81, "rtCriticsLabel": "Certified Fresh", "rtAudience": 90, "rtAudienceLabel": "Upright", "imdb": 7.8, "imdbVotes": 495656, "tmdb": 7.599, "tmdbVotes": 6350}}, {"id": "movie-annie-hall-1977", "title": "Annie Hall", "year": 1977, "type": "movie", "runtimeSeconds": 5604, "seasonCount": null, "communityRating": 7.712, "criticRating": 97, "genres": ["Comedy", "Drama", "Romance"], "overview": "New York comedian Alvy Singer falls in love with the ditsy Annie Hall.", "poster": "assets/movie-annie-hall-1977-poster.jpg", "backdrop": "assets/movie-annie-hall-1977-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:24:23.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 703, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 97, "rtCriticsLabel": "Certified Fresh", "rtAudience": 92, "rtAudienceLabel": "Upright", "imdb": 7.9, "imdbVotes": 293349, "tmdb": 7.71, "tmdbVotes": 4391}}, {"id": "movie-apocalypse-now-1979", "title": "Apocalypse Now", "year": 1979, "type": "movie", "runtimeSeconds": 8835, "seasonCount": null, "communityRating": 8.261, "criticRating": 91, "genres": ["Drama", "War"], "overview": "At the height of the Vietnam war, Captain Benjamin Willard is sent on a dangerous mission that, officially, \"does not exist, nor will it ever exist.\" His goal is to locate - and eliminate - a mysterious Green Beret\u2026", "poster": "assets/movie-apocalypse-now-1979-poster.jpg", "backdrop": "assets/movie-apocalypse-now-1979-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:22:25.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 28, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 91, "rtCriticsLabel": "Certified Fresh", "rtAudience": 94, "rtAudienceLabel": "Upright", "imdb": 8.4, "imdbVotes": 781424, "tmdb": 8.3, "tmdbVotes": 9526}}, {"id": "movie-arrival-2016", "title": "Arrival", "year": 2016, "type": "movie", "runtimeSeconds": 6967, "seasonCount": null, "communityRating": 7.636, "criticRating": 94, "genres": ["Drama", "Science Fiction", "Mystery"], "overview": "Taking place after alien crafts land around the world, an expert linguist is recruited by the military to determine whether they come in peace or are a threat.", "poster": "assets/movie-arrival-2016-poster.jpg", "backdrop": "assets/movie-arrival-2016-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-14T19:54:12.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 329865, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 94, "rtCriticsLabel": "Certified Fresh", "rtAudience": 83, "rtAudienceLabel": "Upright", "imdb": 7.9, "imdbVotes": 883621, "tmdb": 7.638, "tmdbVotes": 20125}}, {"id": "movie-avatar-aang-the-last-airbender-2026", "title": "Avatar Aang: The Last Airbender", "year": 2026, "type": "movie", "runtimeSeconds": 5930, "seasonCount": null, "communityRating": 9.147, "criticRating": 90, "genres": ["Animation", "Adventure", "Fantasy"], "overview": "Avatar Aang, the world's last Airbender, learns of an ancient power that could save his culture from extinction. With the help of his friends, he embarks on a global quest to find it before it falls into the wrong\u2026", "poster": "assets/movie-avatar-aang-the-last-airbender-2026-poster.jpg", "backdrop": "assets/movie-avatar-aang-the-last-airbender-2026-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-25T09:53:09.0000000Z", "lastPlayedAt": "2026-08-02T19:22:33.6155383Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 980431, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 90, "rtCriticsLabel": "Certified Fresh", "rtAudience": 99, "rtAudienceLabel": "Upright", "imdb": 7.7, "imdbVotes": 30792, "tmdb": 9, "tmdbVotes": 1417}}, {"id": "movie-bohemian-rhapsody-2018", "title": "Bohemian Rhapsody", "year": 2018, "type": "movie", "runtimeSeconds": 8077, "seasonCount": null, "communityRating": 7.95, "criticRating": 60, "genres": ["Music", "Drama"], "overview": "Singer Freddie Mercury, guitarist Brian May, drummer Roger Taylor and bass guitarist John Deacon take the music world by storm when they form the rock 'n' roll band Queen in 1970. Hit songs become instant classics.\u2026", "poster": "assets/movie-bohemian-rhapsody-2018-poster.jpg", "backdrop": "assets/movie-bohemian-rhapsody-2018-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:28:20.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 424694, "artwork": {"posterKind": "center_crop", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 60, "rtCriticsLabel": "Fresh", "rtAudience": 85, "rtAudienceLabel": "Upright", "imdb": 7.9, "imdbVotes": 655894, "tmdb": 7.947, "tmdbVotes": 18196}}, {"id": "movie-the-boy-and-the-heron-2023", "title": "The Boy and the Heron", "year": 2023, "type": "movie", "runtimeSeconds": 7437, "seasonCount": null, "communityRating": 7.4, "criticRating": 96, "genres": ["Animation", "Fantasy", "Drama"], "overview": "While the Second World War rages, the teenage Mahito, haunted by his mother's tragic death, is relocated from Tokyo to the serene rural home of his new stepmother Natsuko, a woman who bears a striking resemblance to\u2026", "poster": "assets/movie-the-boy-and-the-heron-2023-poster.jpg", "backdrop": "assets/movie-the-boy-and-the-heron-2023-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:25:15.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 508883, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 96, "rtCriticsLabel": "Certified Fresh", "rtAudience": 88, "rtAudienceLabel": "Upright", "imdb": 7.3, "imdbVotes": 152169, "tmdb": 7.387, "tmdbVotes": 2893}}, {"id": "movie-braveheart-1995", "title": "Braveheart", "year": 1995, "type": "movie", "runtimeSeconds": 10665, "seasonCount": null, "communityRating": 7.945, "criticRating": 74, "genres": ["Drama", "History", "War"], "overview": "Enraged at the slaughter of Murron, his new bride and childhood love, Scottish warrior William Wallace slays a platoon of the local English lord's soldiers. This leads the village to revolt and, eventually, the\u2026", "poster": "assets/movie-braveheart-1995-poster.jpg", "backdrop": "assets/movie-braveheart-1995-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-10T21:24:28.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 197, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 74, "rtCriticsLabel": "Certified Fresh", "rtAudience": 85, "rtAudienceLabel": "Upright", "imdb": 8.3, "imdbVotes": 1174990, "tmdb": 7.947, "tmdbVotes": 11584}}, {"id": "movie-the-breakfast-club-1985", "title": "The Breakfast Club", "year": 1985, "type": "movie", "runtimeSeconds": 5833, "seasonCount": null, "communityRating": 7.7, "criticRating": 87, "genres": ["Comedy", "Drama"], "overview": "Five high school students from different walks of life endure a Saturday detention under a power-hungry principal. The disparate group includes rebel John, princess Claire, outcast Allison, brainy Brian and Andrew,\u2026", "poster": "assets/movie-the-breakfast-club-1985-poster.jpg", "backdrop": "assets/movie-the-breakfast-club-1985-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:25:03.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 2108, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 87, "rtCriticsLabel": "Certified Fresh", "rtAudience": 92, "rtAudienceLabel": "Upright", "imdb": 7.8, "imdbVotes": 478743, "tmdb": 7.713, "tmdbVotes": 8815}}, {"id": "movie-castle-in-the-sky-1986", "title": "Castle in the Sky", "year": 1986, "type": "movie", "runtimeSeconds": 7474, "seasonCount": null, "communityRating": 8, "criticRating": 97, "genres": ["Adventure", "Fantasy", "Animation"], "overview": "A young boy and a girl with a magic crystal must race against pirates and foreign agents in a search for a legendary floating castle.", "poster": "assets/movie-castle-in-the-sky-1986-poster.jpg", "backdrop": "assets/movie-castle-in-the-sky-1986-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:23:09.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 10515, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 96, "rtCriticsLabel": "Fresh", "rtAudience": 91, "rtAudienceLabel": "Upright", "imdb": 8, "imdbVotes": 201545, "tmdb": 7.972, "tmdbVotes": 4868}}, {"id": "movie-citizen-kane-1941", "title": "Citizen Kane", "year": 1941, "type": "movie", "runtimeSeconds": 7173, "seasonCount": null, "communityRating": 7.966, "criticRating": 99, "genres": ["Mystery", "Drama"], "overview": "Newspaper magnate Charles Foster Kane is taken from his mother as a boy and made the ward of a rich industrialist. As a result, every well-meaning, tyrannical or self-destructive move he makes for the rest of his\u2026", "poster": "assets/movie-citizen-kane-1941-poster.jpg", "backdrop": "assets/movie-citizen-kane-1941-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:22:16.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 15, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 99, "rtCriticsLabel": "Certified Fresh", "rtAudience": 98, "rtAudienceLabel": "Upright", "imdb": 8.2, "imdbVotes": 498321, "tmdb": 7.962, "tmdbVotes": 6324}}, {"id": "movie-conclave-2024", "title": "Conclave", "year": 2024, "type": "movie", "runtimeSeconds": 7230, "seasonCount": null, "communityRating": 7.19, "criticRating": 93, "genres": ["Drama", "Thriller"], "overview": "After the unexpected death of the Pope, Cardinal Lawrence is tasked with managing the covert and ancient ritual of electing a new one. Sequestered in the Vatican with the Catholic Church\u2019s most powerful leaders until\u2026", "poster": "assets/movie-conclave-2024-poster.jpg", "backdrop": "assets/movie-conclave-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:30:41.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 974576, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 93, "rtCriticsLabel": "Certified Fresh", "rtAudience": 86, "rtAudienceLabel": "Upright", "imdb": 7.4, "imdbVotes": 290498, "tmdb": 7.19, "tmdbVotes": 3744}}, {"id": "movie-the-dry-2021", "title": "The Dry", "year": 2021, "type": "movie", "runtimeSeconds": 7037, "seasonCount": null, "communityRating": 6.779, "criticRating": null, "genres": ["Crime", "Thriller", "Mystery"], "overview": "Aaron Falk returns to his drought-stricken hometown to attend a tragic funeral. But his return opens a decades-old wound - the unsolved death of a teenage girl.", "poster": "assets/movie-the-dry-2021-poster.jpg", "backdrop": "assets/movie-the-dry-2021-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:29:20.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 567797, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 90, "rtCriticsLabel": "Certified Fresh", "rtAudience": 89, "rtAudienceLabel": "Upright", "imdb": 6.8, "imdbVotes": 41935, "tmdb": 6.8, "tmdbVotes": 892}}, {"id": "movie-easy-a-2010", "title": "Easy A", "year": 2010, "type": "movie", "runtimeSeconds": 5545, "seasonCount": null, "communityRating": 6.8, "criticRating": 85, "genres": ["Comedy"], "overview": "Olive, an average high school student, sees her below-the-radar existence turn around overnight once she decides to use the school's gossip grapevine to advance her social standing. Now her classmates are turning\u2026", "poster": "assets/movie-easy-a-2010-poster.jpg", "backdrop": "assets/movie-easy-a-2010-backdrop.jpg", "played": true, "playCount": 1, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-17T19:59:04.0000000Z", "lastPlayedAt": "2026-10-17T20:02:51.1083337Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 37735, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 85, "rtCriticsLabel": "Certified Fresh", "rtAudience": 77, "rtAudienceLabel": "Upright", "imdb": 7, "imdbVotes": 448780, "tmdb": 6.8, "tmdbVotes": 7619}}, {"id": "movie-el-camino-a-breaking-bad-movie-2019", "title": "El Camino: A Breaking Bad Movie", "year": 2019, "type": "movie", "runtimeSeconds": 7362, "seasonCount": null, "communityRating": 7.055, "criticRating": 92, "genres": ["Crime", "Drama", "Thriller"], "overview": "In the wake of his dramatic escape from captivity, Jesse Pinkman must come to terms with his past in order to forge some kind of future.", "poster": "assets/movie-el-camino-a-breaking-bad-movie-2019-poster.jpg", "backdrop": "assets/movie-el-camino-a-breaking-bad-movie-2019-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-21T23:31:11.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 559969, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 92, "rtCriticsLabel": "Certified Fresh", "rtAudience": 81, "rtAudienceLabel": "Upright", "imdb": 7.3, "imdbVotes": 354488, "tmdb": 7.056, "tmdbVotes": 5950}}, {"id": "movie-emancipation-2022", "title": "Emancipation", "year": 2022, "type": "movie", "runtimeSeconds": 7949, "seasonCount": null, "communityRating": 7.7, "criticRating": 45, "genres": ["Drama", "War", "History"], "overview": "Inspired by the gripping true story of a man who would do anything for his family\u2014and for freedom. When Peter, an enslaved man, risks his life to escape and return to his family, he embarks on a perilous journey of\u2026", "poster": "assets/movie-emancipation-2022-poster.jpg", "backdrop": "assets/movie-emancipation-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-10T20:37:34.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 715931, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 45, "rtCriticsLabel": "Rotten", "rtAudience": 58, "rtAudienceLabel": "Spilled", "imdb": 6.3, "imdbVotes": 32079, "tmdb": 7.744, "tmdbVotes": 1319}}, {"id": "movie-fighting-with-my-family-2019", "title": "Fighting with My Family", "year": 2019, "type": "movie", "runtimeSeconds": 6509, "seasonCount": null, "communityRating": 6.84, "criticRating": 93, "genres": ["Comedy"], "overview": "Born into a tight-knit wrestling family, Paige and her brother Zak are ecstatic when they get the once-in-a-lifetime opportunity to try out for the WWE. But when only Paige earns a spot in the competitive training\u2026", "poster": "assets/movie-fighting-with-my-family-2019-poster.jpg", "backdrop": "assets/movie-fighting-with-my-family-2019-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-22T22:10:59.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 445629, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 93, "rtCriticsLabel": "Certified Fresh", "rtAudience": 86, "rtAudienceLabel": "Upright", "imdb": 7, "imdbVotes": 94576, "tmdb": 6.837, "tmdbVotes": 1952}}, {"id": "movie-force-of-nature-the-dry-2-2024", "title": "Force of Nature: The Dry 2", "year": 2024, "type": "movie", "runtimeSeconds": 6753, "seasonCount": null, "communityRating": 6.3, "criticRating": 66, "genres": ["Mystery", "Thriller"], "overview": "When five women take part in a corporate hiking retreat and only four come out on the other side, Federal Agents Aaron Falk and Carmen Cooper head deep into the Victorian mountain ranges to investigate in the hopes\u2026", "poster": "assets/movie-force-of-nature-the-dry-2-2024-poster.jpg", "backdrop": "assets/movie-force-of-nature-the-dry-2-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:29:49.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 832262, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 66, "rtCriticsLabel": "Fresh", "rtAudience": 43, "rtAudienceLabel": "Spilled", "imdb": 5.9, "imdbVotes": 10343, "tmdb": 6.269, "tmdbVotes": 305}}, {"id": "movie-free-state-of-jones-2016", "title": "Free State of Jones", "year": 2016, "type": "movie", "runtimeSeconds": 8376, "seasonCount": null, "communityRating": 6.79, "criticRating": 48, "genres": ["Action", "Drama", "History"], "overview": "In 1863, Mississippi farmer Newt Knight serves as a medic for the Confederate Army. Opposed to slavery, Knight would rather help the wounded than fight the Union. After his nephew dies in battle, Newt returns home to\u2026", "poster": "assets/movie-free-state-of-jones-2016-poster.jpg", "backdrop": "assets/movie-free-state-of-jones-2016-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-11T17:56:53.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 316152, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 48, "rtCriticsLabel": "Rotten", "rtAudience": 64, "rtAudienceLabel": "Upright", "imdb": 6.9, "imdbVotes": 68931, "tmdb": 6.788, "tmdbVotes": 1730}}, {"id": "movie-from-up-on-poppy-hill-2011", "title": "From Up on Poppy Hill", "year": 2011, "type": "movie", "runtimeSeconds": 5482, "seasonCount": null, "communityRating": 7.5, "criticRating": 87, "genres": ["Animation", "Drama"], "overview": "Yokohama, 1963. Japan is picking itself up from the devastation of World War II and preparing to host the 1964 Olympics\u2014and the mood is one of both optimism and conflict as the young generation struggles to throw off\u2026", "poster": "assets/movie-from-up-on-poppy-hill-2011-poster.jpg", "backdrop": "assets/movie-from-up-on-poppy-hill-2011-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-07T14:41:03.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 83389, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 87, "rtCriticsLabel": "Certified Fresh", "rtAudience": 80, "rtAudienceLabel": "Upright", "imdb": 7.4, "imdbVotes": 60769, "tmdb": 7.509, "tmdbVotes": 2026}}, {"id": "movie-ghost-in-the-shell-1995", "title": "Ghost in the Shell", "year": 1995, "type": "movie", "runtimeSeconds": 4959, "seasonCount": null, "communityRating": 7.929, "criticRating": 95, "genres": ["Action", "Animation", "Science Fiction"], "overview": "In the year 2029, the barriers of our world have been broken down by the net and by cybernetics, but this brings new vulnerability to humans in the form of brain-hacking. When a highly-wanted hacker known as 'The\u2026", "poster": "assets/movie-ghost-in-the-shell-1995-poster.jpg", "backdrop": "assets/movie-ghost-in-the-shell-1995-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:22:57.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 9323, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 97, "rtCriticsLabel": "Certified Fresh", "rtAudience": 90, "rtAudienceLabel": "Upright", "imdb": 7.9, "imdbVotes": 178061, "tmdb": 7.928, "tmdbVotes": 4130}}, {"id": "movie-ghost-in-the-shell-2-innocence-2004", "title": "Ghost in the Shell 2: Innocence", "year": 2004, "type": "movie", "runtimeSeconds": 5950, "seasonCount": null, "communityRating": 7.352, "criticRating": 65, "genres": ["Animation", "Drama", "Science Fiction"], "overview": "Cyborg detective Batou is assigned to investigate a series of murders committed by gynoids\u2014doll-like cyborgs, which all malfunctioned, killed, then self-destructed afterwards. The brains of the gynoids initialize in\u2026", "poster": "assets/movie-ghost-in-the-shell-2-innocence-2004-poster.jpg", "backdrop": "assets/movie-ghost-in-the-shell-2-innocence-2004-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:23:38.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 12140, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 65, "rtCriticsLabel": "Fresh", "rtAudience": 80, "rtAudienceLabel": "Upright", "imdb": 7.4, "imdbVotes": 43280, "tmdb": 7.356, "tmdbVotes": 1049}}, {"id": "movie-the-godfather-1972", "title": "The Godfather", "year": 1972, "type": "movie", "runtimeSeconds": 10603, "seasonCount": null, "communityRating": 8.687, "criticRating": 97, "genres": ["Drama", "Crime"], "overview": "Spanning the years 1945 to 1955, a chronicle of the fictional Italian-American Corleone crime family. When organized crime family patriarch, Vito Corleone barely survives an attempt on his life, his youngest son,\u2026", "poster": "assets/movie-the-godfather-1972-poster.jpg", "backdrop": "assets/movie-the-godfather-1972-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:24:12.0000000Z", "lastPlayedAt": "2026-09-18T19:24:44.9855867Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 238, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 97, "rtCriticsLabel": "Certified Fresh", "rtAudience": 98, "rtAudienceLabel": "Upright", "imdb": 9.2, "imdbVotes": 2262024, "tmdb": 8.688, "tmdbVotes": 23907}}, {"id": "movie-the-godfather-part-ii-1974", "title": "The Godfather Part II", "year": 1974, "type": "movie", "runtimeSeconds": 12031, "seasonCount": null, "communityRating": 8.573, "criticRating": 96, "genres": ["Drama", "Crime"], "overview": "In the continuing saga of the Corleone crime family, a young Vito Corleone grows up in Sicily and in 1910s New York. In the 1950s, Michael Corleone attempts to expand the family business into Las Vegas, Hollywood and\u2026", "poster": "assets/movie-the-godfather-part-ii-1974-poster.jpg", "backdrop": "assets/movie-the-godfather-part-ii-1974-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:24:18.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 240, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 96, "rtCriticsLabel": "Certified Fresh", "rtAudience": 97, "rtAudienceLabel": "Upright", "imdb": 9, "imdbVotes": 1521308, "tmdb": 8.571, "tmdbVotes": 14559}}, {"id": "movie-the-godfather-part-iii-1990", "title": "The Godfather Part III", "year": 1990, "type": "movie", "runtimeSeconds": 9476, "seasonCount": null, "communityRating": 7.416, "criticRating": 67, "genres": ["Crime", "Drama", "Thriller"], "overview": "In the midst of trying to legitimize his business dealings in 1979 New York and Italy, aging mafia don, Michael Corleone seeks forgiveness for his sins while taking a young protege under his wing.", "poster": "assets/movie-the-godfather-part-iii-1990-poster.jpg", "backdrop": "assets/movie-the-godfather-part-iii-1990-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:26:29.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 242, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 68, "rtCriticsLabel": "Fresh", "rtAudience": 68, "rtAudienceLabel": "Upright", "imdb": 7.5, "imdbVotes": 462448, "tmdb": 7.413, "tmdbVotes": 7159}}, {"id": "movie-gosford-park-2001", "title": "Gosford Park", "year": 2001, "type": "movie", "runtimeSeconds": 8247, "seasonCount": null, "communityRating": 6.815, "criticRating": 87, "genres": ["Drama", "Mystery", "Thriller"], "overview": "In 1930s England, a group of pretentious rich and famous gather together for a weekend of relaxation at a hunting resort. But when a murder occurs, each one of these interesting characters becomes a suspect.", "poster": "assets/movie-gosford-park-2001-poster.jpg", "backdrop": "assets/movie-gosford-park-2001-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:25:49.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 5279, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 87, "rtCriticsLabel": "Certified Fresh", "rtAudience": 78, "rtAudienceLabel": "Upright", "imdb": 7.2, "imdbVotes": 103883, "tmdb": 6.822, "tmdbVotes": 1317}}, {"id": "movie-grave-of-the-fireflies-1988", "title": "Grave of the Fireflies", "year": 1988, "type": "movie", "runtimeSeconds": 5313, "seasonCount": null, "communityRating": 8.45, "criticRating": 100, "genres": ["Animation", "Drama", "War"], "overview": "In the final months of World War II, 14-year-old Seita and his sister Setsuko are orphaned when their mother is killed during an air raid in Kobe, Japan. After a falling out with their aunt, they move into an\u2026", "poster": "assets/movie-grave-of-the-fireflies-1988-poster.jpg", "backdrop": "assets/movie-grave-of-the-fireflies-1988-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:24:14.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 12477, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Certified Fresh", "rtAudience": 95, "rtAudienceLabel": "Upright", "imdb": 8.5, "imdbVotes": 399802, "tmdb": 8.45, "tmdbVotes": 6938}}, {"id": "movie-the-great-gatsby-2013", "title": "The Great Gatsby", "year": 2013, "type": "movie", "runtimeSeconds": 8525, "seasonCount": null, "communityRating": 7.346, "criticRating": 49, "genres": ["Drama", "Romance"], "overview": "Nick Carraway arrives in New York in 1922, an era of loose morals, glittering jazz and bootleg kings. Chasing his own American Dream, Nick encounters the mysterious millionaire Gatsby and his bewitching cousin Daisy.\u2026", "poster": "assets/movie-the-great-gatsby-2013-poster.jpg", "backdrop": "assets/movie-the-great-gatsby-2013-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:26:50.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 64682, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 48, "rtCriticsLabel": "Rotten", "rtAudience": 67, "rtAudienceLabel": "Upright", "imdb": 7.2, "imdbVotes": 651882, "tmdb": 7.346, "tmdbVotes": 13290}}, {"id": "movie-the-hours-2002", "title": "The Hours", "year": 2002, "type": "movie", "runtimeSeconds": 6887, "seasonCount": null, "communityRating": 7.25, "criticRating": 79, "genres": ["Drama"], "overview": "The story of three women searching for more potent, meaningful lives. Each is alive at a different time and place, all are linked by their yearnings and their fears. Their stories intertwine, and finally come\u2026", "poster": "assets/movie-the-hours-2002-poster.jpg", "backdrop": "assets/movie-the-hours-2002-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:23:27.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 590, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 79, "rtCriticsLabel": "Certified Fresh", "rtAudience": 84, "rtAudienceLabel": "Upright", "imdb": 7.5, "imdbVotes": 148367, "tmdb": 7.249, "tmdbVotes": 1931}}, {"id": "movie-howl-s-moving-castle-2004", "title": "Howl's Moving Castle", "year": 2004, "type": "movie", "runtimeSeconds": 7149, "seasonCount": null, "communityRating": 8.4, "criticRating": 88, "genres": ["Fantasy", "Animation", "Adventure"], "overview": "Sophie, a young milliner, is turned into an elderly woman by a witch who enters her shop and curses her. She encounters a wizard named Howl and gets caught up in his resistance to fighting for the king.", "poster": "assets/movie-howl-s-moving-castle-2004-poster.jpg", "backdrop": "assets/movie-howl-s-moving-castle-2004-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:22:47.0000000Z", "lastPlayedAt": "2026-05-21T18:46:08.0000000Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 4935, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 87, "rtCriticsLabel": "Certified Fresh", "rtAudience": 93, "rtAudienceLabel": "Upright", "imdb": 8.2, "imdbVotes": 524520, "tmdb": 8.391, "tmdbVotes": 11642}}, {"id": "movie-in-the-heights-2021", "title": "In the Heights", "year": 2021, "type": "movie", "runtimeSeconds": 8579, "seasonCount": null, "communityRating": 7.111, "criticRating": null, "genres": ["Drama", "Romance"], "overview": "The story of Usnavi, a bodega owner who has mixed feelings about closing his store and retiring to the Dominican Republic or staying in Washington Heights.", "poster": "assets/movie-in-the-heights-2021-poster.jpg", "backdrop": "assets/movie-in-the-heights-2021-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:29:11.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 467909, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 94, "rtCriticsLabel": "Certified Fresh", "rtAudience": 94, "rtAudienceLabel": "Upright", "imdb": 7.2, "imdbVotes": 56513, "tmdb": 7.104, "tmdbVotes": 826}}, {"id": "movie-jennifer-s-body-2009", "title": "Jennifer's Body", "year": 2009, "type": "movie", "runtimeSeconds": 6450, "seasonCount": null, "communityRating": 6.012, "criticRating": 47, "genres": ["Horror", "Comedy"], "overview": "Jennifer, a gorgeous, seductive cheerleader takes evil to a whole new level after she's possessed by a sinister demon. Now it's up to her best friend to stop Jennifer's reign of terror before it's too late.", "poster": "assets/movie-jennifer-s-body-2009-poster.jpg", "backdrop": "assets/movie-jennifer-s-body-2009-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-30T03:37:04.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 19994, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 47, "rtCriticsLabel": "Rotten", "rtAudience": 36, "rtAudienceLabel": "Spilled", "imdb": 5.6, "imdbVotes": 179710, "tmdb": 6.013, "tmdbVotes": 4481}}, {"id": "movie-kiki-s-delivery-service-1989", "title": "Kiki's Delivery Service", "year": 1989, "type": "movie", "runtimeSeconds": 6183, "seasonCount": null, "communityRating": 7.839, "criticRating": null, "genres": ["Animation", "Family", "Fantasy"], "overview": "A young witch, on her mandatory year of independent life, finds fitting into a new community difficult while she supports herself by running an air courier service.", "poster": "assets/movie-kiki-s-delivery-service-1989-poster.jpg", "backdrop": "assets/movie-kiki-s-delivery-service-1989-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:24:20.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 16859, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 98, "rtCriticsLabel": "Certified Fresh", "rtAudience": 89, "rtAudienceLabel": "Upright", "imdb": 7.8, "imdbVotes": 192477, "tmdb": 7.839, "tmdbVotes": 4811}}, {"id": "movie-the-last-of-the-mohicans-1992", "title": "The Last of the Mohicans", "year": 1992, "type": "movie", "runtimeSeconds": 6874, "seasonCount": null, "communityRating": 7.388, "criticRating": 88, "genres": ["History", "War", "Drama"], "overview": "In war-torn colonial America, in the midst of a bloody battle between British, the French and Native American allies, the aristocratic daughter of a British Colonel and her party are captured by a group of Huron\u2026", "poster": "assets/movie-the-last-of-the-mohicans-1992-poster.jpg", "backdrop": "assets/movie-the-last-of-the-mohicans-1992-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-10T20:59:22.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 9361, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 88, "rtCriticsLabel": "Certified Fresh", "rtAudience": 88, "rtAudienceLabel": "Upright", "imdb": 7.6, "imdbVotes": 210791, "tmdb": 7.39, "tmdbVotes": 3593}}, {"id": "movie-the-last-samurai-2003", "title": "The Last Samurai", "year": 2003, "type": "movie", "runtimeSeconds": 9251, "seasonCount": null, "communityRating": 7.61, "criticRating": 65, "genres": ["Drama", "Action", "War"], "overview": "Nathan Algren is an American hired to instruct the Japanese army in the ways of modern warfare, which finds him learning to respect the samurai and the honorable principles that rule them. Pressed to destroy the\u2026", "poster": "assets/movie-the-last-samurai-2003-poster.jpg", "backdrop": "assets/movie-the-last-samurai-2003-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-26T05:25:10.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 616, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 66, "rtCriticsLabel": "Fresh", "rtAudience": 83, "rtAudienceLabel": "Upright", "imdb": 7.8, "imdbVotes": 512982, "tmdb": 7.614, "tmdbVotes": 7887}}, {"id": "movie-lemony-snicket-s-a-series-of-unfortunate-events-2004", "title": "Lemony Snicket's A Series of Unfortunate Events", "year": 2004, "type": "movie", "runtimeSeconds": 6479, "seasonCount": null, "communityRating": 6.8, "criticRating": 72, "genres": ["Adventure", "Comedy", "Family"], "overview": "Three wealthy children's parents are killed in a fire. When they are sent to a distant relative, they find out that he is plotting to kill them and seize their fortune.", "poster": "assets/movie-lemony-snicket-s-a-series-of-unfortunate-events-2004-poster.jpg", "backdrop": "assets/movie-lemony-snicket-s-a-series-of-unfortunate-events-2004-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:26:09.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 11774, "artwork": {"posterKind": "center_crop", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 72, "rtCriticsLabel": "Fresh", "rtAudience": 63, "rtAudienceLabel": "Upright", "imdb": 6.8, "imdbVotes": 233636, "tmdb": 6.82, "tmdbVotes": 5447}}, {"id": "movie-the-lighthouse-2019", "title": "The Lighthouse", "year": 2019, "type": "movie", "runtimeSeconds": 6549, "seasonCount": null, "communityRating": 7.463, "criticRating": 90, "genres": ["Drama", "Fantasy", "Thriller"], "overview": "Two lighthouse keepers try to maintain their sanity while living on a remote and mysterious New England island in the 1890s.", "poster": "assets/movie-the-lighthouse-2019-poster.jpg", "backdrop": "assets/movie-the-lighthouse-2019-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:29:01.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 503919, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 90, "rtCriticsLabel": "Certified Fresh", "rtAudience": 72, "rtAudienceLabel": "Upright", "imdb": 7.3, "imdbVotes": 312344, "tmdb": 7.46, "tmdbVotes": 5895}}, {"id": "movie-locke-2014", "title": "Locke", "year": 2014, "type": "movie", "runtimeSeconds": 5090, "seasonCount": null, "communityRating": 6.888, "criticRating": 91, "genres": ["Drama", "Thriller"], "overview": "Ivan Locke has worked hard to craft a good life for himself. Tonight, that life will collapse around him. On the eve of the biggest challenge of his career, Ivan receives a phone call that sets in motion a series of\u2026", "poster": "assets/movie-locke-2014-poster.jpg", "backdrop": "assets/movie-locke-2014-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T17:38:48.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 210479, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 90, "rtCriticsLabel": "Certified Fresh", "rtAudience": 72, "rtAudienceLabel": "Upright", "imdb": 7.1, "imdbVotes": 171265, "tmdb": 6.889, "tmdbVotes": 3088}}, {"id": "movie-the-matrix-1999", "title": "The Matrix", "year": 1999, "type": "movie", "runtimeSeconds": 8179, "seasonCount": null, "communityRating": 8.259, "criticRating": 83, "genres": ["Action", "Science Fiction"], "overview": "Set in the 22nd century, The Matrix tells the story of a computer hacker who joins a group of underground insurgents fighting the vast and powerful computers who now rule the earth.", "poster": "assets/movie-the-matrix-1999-poster.jpg", "backdrop": "assets/movie-the-matrix-1999-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:18:12.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 603, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 83, "rtCriticsLabel": "Certified Fresh", "rtAudience": 85, "rtAudienceLabel": "Upright", "imdb": 8.7, "imdbVotes": 2289808, "tmdb": 8.262, "tmdbVotes": 29153}}, {"id": "movie-the-matrix-reloaded-2003", "title": "The Matrix Reloaded", "year": 2003, "type": "movie", "runtimeSeconds": 8298, "seasonCount": null, "communityRating": 7.085, "criticRating": 74, "genres": ["Adventure", "Action", "Thriller"], "overview": "The Resistance builds in numbers as humans are freed from the Matrix and brought to the city of Zion. Neo discovers his superpowers, including the ability to see the code inside the Matrix. With machine sentinels\u2026", "poster": "assets/movie-the-matrix-reloaded-2003-poster.jpg", "backdrop": "assets/movie-the-matrix-reloaded-2003-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:24:03.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 604, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 73, "rtCriticsLabel": "Certified Fresh", "rtAudience": 72, "rtAudienceLabel": "Upright", "imdb": 7.2, "imdbVotes": 678013, "tmdb": 7.087, "tmdbVotes": 12504}}, {"id": "movie-the-matrix-resurrections-2021", "title": "The Matrix Resurrections", "year": 2021, "type": "movie", "runtimeSeconds": 8875, "seasonCount": null, "communityRating": 6.303, "criticRating": 63, "genres": ["Science Fiction", "Action", "Adventure"], "overview": "Plagued by strange memories, Neo's life takes an unexpected turn when he finds himself back inside the Matrix.", "poster": "assets/movie-the-matrix-resurrections-2021-poster.jpg", "backdrop": "assets/movie-the-matrix-resurrections-2021-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:31:22.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 624860, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 63, "rtCriticsLabel": "Fresh", "rtAudience": 63, "rtAudienceLabel": "Upright", "imdb": 5.6, "imdbVotes": 307780, "tmdb": 6.294, "tmdbVotes": 6842}}, {"id": "movie-the-matrix-revolutions-2003", "title": "The Matrix Revolutions", "year": 2003, "type": "movie", "runtimeSeconds": 7756, "seasonCount": null, "communityRating": 6.758, "criticRating": 33, "genres": ["Adventure", "Action", "Thriller"], "overview": "The human city of Zion defends itself against the massive invasion of the machines as Neo fights to end the war at another front while also opposing the rogue Agent Smith.", "poster": "assets/movie-the-matrix-revolutions-2003-poster.jpg", "backdrop": "assets/movie-the-matrix-revolutions-2003-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:25:47.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 605, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 34, "rtCriticsLabel": "Rotten", "rtAudience": 60, "rtAudienceLabel": "Upright", "imdb": 6.8, "imdbVotes": 581553, "tmdb": 6.762, "tmdbVotes": 11292}}, {"id": "movie-mercy-2026", "title": "Mercy", "year": 2026, "type": "movie", "runtimeSeconds": 5968, "seasonCount": null, "communityRating": 7.077, "criticRating": null, "genres": ["Science Fiction", "Thriller", "Crime"], "overview": "In the near future, a detective stands on trial accused of murdering his wife. He has ninety minutes to prove his innocence to the advanced AI Judge he once championed, before it determines his fate.", "poster": "assets/movie-mercy-2026-poster.jpg", "backdrop": "assets/movie-mercy-2026-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:31:40.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 1236153, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 24, "rtCriticsLabel": "Rotten", "rtAudience": 81, "rtAudienceLabel": "Upright", "imdb": 6.2, "imdbVotes": 83878, "tmdb": 7.054, "tmdbVotes": 1812}}, {"id": "movie-normal-2026", "title": "Normal", "year": 2026, "type": "movie", "runtimeSeconds": 5514, "seasonCount": null, "communityRating": 7.267, "criticRating": 77, "genres": ["Action", "Crime", "Thriller"], "overview": "An unassuming substitute sheriff with a troubled past responds to a bank robbery in a small, sleepy town and unknowingly uncovers something far more explosive.", "poster": "assets/movie-normal-2026-poster.jpg", "backdrop": "assets/movie-normal-2026-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-08-21T08:33:18.0000000Z", "lastPlayedAt": "2026-09-24T23:07:52.1161581Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 1242332, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 78, "rtCriticsLabel": "Certified Fresh", "rtAudience": 75, "rtAudienceLabel": "Upright", "imdb": 6.3, "imdbVotes": 37501, "tmdb": 7.241, "tmdbVotes": 786}}, {"id": "movie-outlaw-king-2018", "title": "Outlaw King", "year": 2018, "type": "movie", "runtimeSeconds": 7297, "seasonCount": null, "communityRating": 6.829, "criticRating": 63, "genres": ["Action", "History", "War"], "overview": "Forced into exile by the English after being crowned King of Scotland, legendary warrior Robert the Bruce fights to reclaim the throne.", "poster": "assets/movie-outlaw-king-2018-poster.jpg", "backdrop": "assets/movie-outlaw-king-2018-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:28:32.0000000Z", "lastPlayedAt": "2026-07-26T22:35:35.0000000Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 454227, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 63, "rtCriticsLabel": "Fresh", "rtAudience": 69, "rtAudienceLabel": "Upright", "imdb": 6.9, "imdbVotes": 89876, "tmdb": 6.844, "tmdbVotes": 1780}}, {"id": "movie-panic-room-2002", "title": "Panic Room", "year": 2002, "type": "movie", "runtimeSeconds": 6717, "seasonCount": null, "communityRating": 6.794, "criticRating": 76, "genres": ["Crime", "Drama", "Thriller"], "overview": "Trapped in their New York brownstone's panic room, a hidden chamber built as a sanctuary in the event of break-ins, newly divorced Meg Altman and her young daughter Sarah play a deadly game of cat-and-mouse with\u2026", "poster": "assets/movie-panic-room-2002-poster.jpg", "backdrop": "assets/movie-panic-room-2002-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-19T20:50:01.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 4547, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 76, "rtCriticsLabel": "Certified Fresh", "rtAudience": 64, "rtAudienceLabel": "Upright", "imdb": 6.8, "imdbVotes": 317468, "tmdb": 6.796, "tmdbVotes": 5653}}, {"id": "movie-the-patriot-2000", "title": "The Patriot", "year": 2000, "type": "movie", "runtimeSeconds": 9887, "seasonCount": null, "communityRating": 7.214, "criticRating": 61, "genres": ["Drama", "History", "War"], "overview": "After proving himself on the field of battle in the French and Indian War, Benjamin Martin wants nothing more to do with such things, preferring the simple life of a farmer. But when his son Gabriel enlists in the\u2026", "poster": "assets/movie-the-patriot-2000-poster.jpg", "backdrop": "assets/movie-the-patriot-2000-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-10T20:50:00.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 2024, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 61, "rtCriticsLabel": "Fresh", "rtAudience": 81, "rtAudienceLabel": "Upright", "imdb": 7.2, "imdbVotes": 317639, "tmdb": 7.215, "tmdbVotes": 4535}}, {"id": "movie-peaky-blinders-the-immortal-man-2026", "title": "Peaky Blinders: The Immortal Man", "year": 2026, "type": "movie", "runtimeSeconds": 6844, "seasonCount": null, "communityRating": 7.187, "criticRating": 90, "genres": ["Crime", "Drama"], "overview": "After his estranged son gets embroiled in a Nazi plot, self-exiled gangster Tommy Shelby must return to Birmingham to save his family \u2014 and his nation.", "poster": "assets/movie-peaky-blinders-the-immortal-man-2026-poster.jpg", "backdrop": "assets/movie-peaky-blinders-the-immortal-man-2026-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:30:01.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 875828, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 90, "rtCriticsLabel": "Certified Fresh", "rtAudience": 87, "rtAudienceLabel": "Upright", "imdb": 6.5, "imdbVotes": 92130, "tmdb": 7.189, "tmdbVotes": 1304}}, {"id": "movie-ponyo-2008", "title": "Ponyo", "year": 2008, "type": "movie", "runtimeSeconds": 6530, "seasonCount": null, "communityRating": 7.763, "criticRating": 91, "genres": ["Animation", "Fantasy", "Family"], "overview": "When Sosuke, a young boy who lives on a clifftop overlooking the sea, rescues a stranded goldfish named Ponyo, he discovers more than he bargained for. Ponyo is a curious, energetic young creature who yearns to be\u2026", "poster": "assets/movie-ponyo-2008-poster.jpg", "backdrop": "assets/movie-ponyo-2008-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:23:53.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 12429, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 91, "rtCriticsLabel": "Certified Fresh", "rtAudience": 84, "rtAudienceLabel": "Upright", "imdb": 7.6, "imdbVotes": 190294, "tmdb": 7.759, "tmdbVotes": 5050}}, {"id": "movie-porco-rosso-1992", "title": "Porco Rosso", "year": 1992, "type": "movie", "runtimeSeconds": 5600, "seasonCount": null, "communityRating": 7.794, "criticRating": 96, "genres": ["Animation", "Adventure", "Fantasy"], "overview": "In Italy in the 1930s, sky pirates in biplanes terrorize wealthy cruise ships as they sail the Adriatic Sea. The only pilot brave enough to stop the scourge is the mysterious Porco Rosso, a former World War I flying\u2026", "poster": "assets/movie-porco-rosso-1992-poster.jpg", "backdrop": "assets/movie-porco-rosso-1992-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:23:22.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 11621, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 96, "rtCriticsLabel": "Fresh", "rtAudience": 87, "rtAudienceLabel": "Upright", "imdb": 7.7, "imdbVotes": 119396, "tmdb": 7.789, "tmdbVotes": 3879}}, {"id": "movie-princess-mononoke-1997", "title": "Princess Mononoke", "year": 1997, "type": "movie", "runtimeSeconds": 8003, "seasonCount": null, "communityRating": 8.321, "criticRating": 93, "genres": ["Adventure", "Fantasy", "Animation"], "overview": "Ashitaka, a prince of the disappearing Emishi people, is cursed by a demonized boar god and must journey to the west to find a cure. Along the way, he encounters San, a young human woman fighting to protect the\u2026", "poster": "assets/movie-princess-mononoke-1997-poster.jpg", "backdrop": "assets/movie-princess-mononoke-1997-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:22:28.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 128, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 94, "rtCriticsLabel": "Certified Fresh", "rtAudience": 99, "rtAudienceLabel": "Upright", "imdb": 8.3, "imdbVotes": 493735, "tmdb": 8.321, "tmdbVotes": 9438}}, {"id": "movie-prisoners-2013", "title": "Prisoners", "year": 2013, "type": "movie", "runtimeSeconds": 9192, "seasonCount": null, "communityRating": 8.106, "criticRating": 81, "genres": ["Drama", "Thriller", "Crime"], "overview": "After his six-year-old daughter is abducted, a desperate father takes justice into his own hands when he loses faith in the detective leading the investigation, setting both men on a collision course as the search\u2026", "poster": "assets/movie-prisoners-2013-poster.jpg", "backdrop": "assets/movie-prisoners-2013-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:27:33.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 146233, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 82, "rtCriticsLabel": "Certified Fresh", "rtAudience": 87, "rtAudienceLabel": "Upright", "imdb": 8.2, "imdbVotes": 963062, "tmdb": 8.105, "tmdbVotes": 13722}}, {"id": "movie-racing-stripes-2005", "title": "Racing Stripes", "year": 2005, "type": "movie", "runtimeSeconds": 6117, "seasonCount": null, "communityRating": 5.945, "criticRating": 35, "genres": ["Comedy", "Family"], "overview": "Shattered illusions are hard to repair -- especially for a good-hearted zebra named Stripes who's spent his life on a Kentucky farm amidst the sorely mistaken notion that he's a debonair thoroughbred. Once he faces\u2026", "poster": "assets/movie-racing-stripes-2005-poster.jpg", "backdrop": "assets/movie-racing-stripes-2005-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:26:02.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 6439, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 35, "rtCriticsLabel": "Rotten", "rtAudience": 42, "rtAudienceLabel": "Spilled", "imdb": 5.3, "imdbVotes": 19569, "tmdb": 5.962, "tmdbVotes": 705}}, {"id": "movie-requiem-for-a-dream-2000", "title": "Requiem for a Dream", "year": 2000, "type": "movie", "runtimeSeconds": 6086, "seasonCount": null, "communityRating": 8.005, "criticRating": 80, "genres": ["Crime", "Drama"], "overview": "The drug-induced utopias of four Coney Island residents are shattered when their addictions run deep.", "poster": "assets/movie-requiem-for-a-dream-2000-poster.jpg", "backdrop": "assets/movie-requiem-for-a-dream-2000-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:24:15.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 641, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 81, "rtCriticsLabel": "Certified Fresh", "rtAudience": 93, "rtAudienceLabel": "Upright", "imdb": 8.3, "imdbVotes": 989090, "tmdb": 8, "tmdbVotes": 11454}}, {"id": "movie-rogue-one-a-star-wars-story-2016", "title": "Rogue One: A Star Wars Story", "year": 2016, "type": "movie", "runtimeSeconds": 8038, "seasonCount": null, "communityRating": 7.51, "criticRating": 84, "genres": ["Action", "Adventure", "Science Fiction"], "overview": "A rogue band of resistance fighters unite for a mission to steal the Death Star plans and bring a new hope to the galaxy.", "poster": "assets/movie-rogue-one-a-star-wars-story-2016-poster.jpg", "backdrop": "assets/movie-rogue-one-a-star-wars-story-2016-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:28:00.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 330459, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 84, "rtCriticsLabel": "Certified Fresh", "rtAudience": 87, "rtAudienceLabel": "Upright", "imdb": 7.8, "imdbVotes": 768365, "tmdb": 7.512, "tmdbVotes": 16937}}, {"id": "movie-secondhand-lions-2003", "title": "Secondhand Lions", "year": 2003, "type": "movie", "runtimeSeconds": 6547, "seasonCount": null, "communityRating": 7.237, "criticRating": 60, "genres": ["Family", "Comedy", "Drama"], "overview": "The comedic adventures of an introverted boy left on the doorstep of a pair of reluctant, eccentric great-uncles, whose exotic remembrances stir the boy's spirit and re-ignite the men's lives.", "poster": "assets/movie-secondhand-lions-2003-poster.jpg", "backdrop": "assets/movie-secondhand-lions-2003-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-12T07:16:13.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 13156, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 59, "rtCriticsLabel": "Rotten", "rtAudience": 84, "rtAudienceLabel": "Upright", "imdb": 7.5, "imdbVotes": 66911, "tmdb": 7.242, "tmdbVotes": 872}}, {"id": "movie-the-secret-life-of-pets-2-2019", "title": "The Secret Life of Pets 2", "year": 2019, "type": "movie", "runtimeSeconds": 5164, "seasonCount": null, "communityRating": 6.95, "criticRating": 60, "genres": ["Animation", "Comedy", "Family"], "overview": "Max the terrier must cope with some major life changes when his owner gets married and has a baby. When the family takes a trip to the countryside, nervous Max has numerous run-ins with canine-intolerant cows,\u2026", "poster": "assets/movie-the-secret-life-of-pets-2-2019-poster.jpg", "backdrop": "assets/movie-the-secret-life-of-pets-2-2019-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-05T07:00:53.0000000Z", "lastPlayedAt": "2026-09-17T18:07:00.7024231Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 412117, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 60, "rtCriticsLabel": "Fresh", "rtAudience": 90, "rtAudienceLabel": "Upright", "imdb": 6.4, "imdbVotes": 80464, "tmdb": 6.948, "tmdbVotes": 3450}}, {"id": "movie-self-reliance-2024", "title": "Self Reliance", "year": 2024, "type": "movie", "runtimeSeconds": 5461, "seasonCount": null, "communityRating": 5.888, "criticRating": 73, "genres": ["Comedy", "Thriller"], "overview": "When a man is offered a million dollars to play a game in which hunters try to kill him, he thinks he has found the perfect loophole: they can only attack when he\u2019s alone. His only problem is that none of his friends\u2026", "poster": "assets/movie-self-reliance-2024-poster.jpg", "backdrop": "assets/movie-self-reliance-2024-backdrop.jpg", "played": false, "playCount": 1, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:31:00.0000000Z", "lastPlayedAt": "2026-10-02T19:59:16.9286868Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 1072876, "artwork": {"posterKind": "center_crop", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 73, "rtCriticsLabel": "Certified Fresh", "rtAudience": 56, "rtAudienceLabel": "Spilled", "imdb": 6, "imdbVotes": 23771, "tmdb": 5.866, "tmdbVotes": 346}}, {"id": "movie-serpico-1973", "title": "Serpico", "year": 1973, "type": "movie", "runtimeSeconds": 7805, "seasonCount": null, "communityRating": 7.5, "criticRating": 93, "genres": ["Crime", "Drama", "History"], "overview": "New York cop Frank Serpico blows the whistle on the rampant corruption in the force only to have his comrades turn against him.", "poster": "assets/movie-serpico-1973-poster.jpg", "backdrop": "assets/movie-serpico-1973-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-10T21:45:02.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 9040, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 93, "rtCriticsLabel": "Certified Fresh", "rtAudience": 88, "rtAudienceLabel": "Upright", "imdb": 7.7, "imdbVotes": 149750, "tmdb": 7.525, "tmdbVotes": 2323}}, {"id": "movie-shaft-2000", "title": "Shaft", "year": 2000, "type": "movie", "runtimeSeconds": 5960, "seasonCount": null, "communityRating": 6.026, "criticRating": 67, "genres": ["Action", "Adventure", "Crime"], "overview": "New York police detective John Shaft arrests Walter Wade Jr. for a racially motivated slaying. But the only eyewitness disappears, and Wade jumps bail for Switzerland. Two years later Wade returns to face trial,\u2026", "poster": "assets/movie-shaft-2000-poster.jpg", "backdrop": "assets/movie-shaft-2000-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-10T21:30:40.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 479, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 68, "rtCriticsLabel": "Fresh", "rtAudience": 40, "rtAudienceLabel": "Spilled", "imdb": 6, "imdbVotes": 86257, "tmdb": 6.024, "tmdbVotes": 1531}}, {"id": "movie-a-silent-voice-the-movie-2016", "title": "A Silent Voice: The Movie", "year": 2016, "type": "movie", "runtimeSeconds": 7804, "seasonCount": null, "communityRating": 8.398, "criticRating": null, "genres": ["Animation", "Drama", "Romance"], "overview": "Shouya Ishida starts bullying the new girl in class, Shouko Nishimiya, because she is deaf. But as the teasing continues, the rest of the class starts to turn on Shouya for his lack of compassion. When they leave\u2026", "poster": "assets/movie-a-silent-voice-the-movie-2016-poster.jpg", "backdrop": "assets/movie-a-silent-voice-the-movie-2016-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T17:39:46.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 378064, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 95, "rtCriticsLabel": "Fresh", "rtAudience": 94, "rtAudienceLabel": "Upright", "imdb": 8.2, "imdbVotes": 151926, "tmdb": 8.396, "tmdbVotes": 4764}}, {"id": "movie-sinbad-legend-of-the-seven-seas-2003", "title": "Sinbad: Legend of the Seven Seas", "year": 2003, "type": "movie", "runtimeSeconds": 5143, "seasonCount": null, "communityRating": 7.067, "criticRating": 44, "genres": ["Action", "Adventure", "Animation"], "overview": "The sailor of legend is framed by the goddess Eris for the theft of the Book of Peace, and must travel to her realm at the end of the world to retrieve it and save the life of his childhood friend Prince Proteus.", "poster": "assets/movie-sinbad-legend-of-the-seven-seas-2003-poster.jpg", "backdrop": "assets/movie-sinbad-legend-of-the-seven-seas-2003-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:26:25.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 14411, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 46, "rtCriticsLabel": "Rotten", "rtAudience": 57, "rtAudienceLabel": "Spilled", "imdb": 6.8, "imdbVotes": 65298, "tmdb": 7.071, "tmdbVotes": 2323}}, {"id": "movie-sleeping-with-other-people-2015", "title": "Sleeping with Other People", "year": 2015, "type": "movie", "runtimeSeconds": 6076, "seasonCount": null, "communityRating": 6.274, "criticRating": 64, "genres": ["Comedy", "Romance"], "overview": "Can two serial cheaters get a second chance at love? After a one-night stand in college, New Yorkers Lainey and Jake meet by chance twelve years later and discover they each have the same problem: because of their\u2026", "poster": "assets/movie-sleeping-with-other-people-2015-poster.jpg", "backdrop": "assets/movie-sleeping-with-other-people-2015-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:27:42.0000000Z", "lastPlayedAt": "2026-07-17T17:50:44.0000000Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 288036, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 64, "rtCriticsLabel": "Fresh", "rtAudience": 56, "rtAudienceLabel": "Spilled", "imdb": 6.5, "imdbVotes": 52275, "tmdb": 6.275, "tmdbVotes": 1020}}, {"id": "movie-snowpiercer-2013", "title": "Snowpiercer", "year": 2013, "type": "movie", "runtimeSeconds": 7573, "seasonCount": null, "communityRating": 6.909, "criticRating": 94, "genres": ["Action", "Science Fiction", "Drama"], "overview": "In a future where a failed global-warming experiment kills off most life on the planet, a class system evolves aboard the Snowpiercer; a train that travels around the globe via a perpetual-motion engine.", "poster": "assets/movie-snowpiercer-2013-poster.jpg", "backdrop": "assets/movie-snowpiercer-2013-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:27:07.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 110415, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 94, "rtCriticsLabel": "Certified Fresh", "rtAudience": 72, "rtAudienceLabel": "Upright", "imdb": 7.1, "imdbVotes": 426976, "tmdb": 6.9, "tmdbVotes": 10807}}, {"id": "movie-spider-man-brand-new-day-2026", "title": "Spider-Man: Brand New Day", "year": 2026, "type": "movie", "runtimeSeconds": 8701, "seasonCount": null, "communityRating": 8.374, "criticRating": 90, "genres": ["Science Fiction", "Action", "Adventure"], "overview": "Fighting crime full-time as Spider-Man in a world that doesn't remember him\u2014and the pressure of seeing his old friends move on without him\u2014sparks a change in Peter Parker he may not have the power to control. But\u2026", "poster": "assets/movie-spider-man-brand-new-day-2026-poster.jpg", "backdrop": "assets/movie-spider-man-brand-new-day-2026-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-10T14:14:27.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 969681, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 90, "rtCriticsLabel": "Certified Fresh", "rtAudience": 97, "rtAudienceLabel": "Upright", "imdb": 7.9, "imdbVotes": 366886, "tmdb": 8.5, "tmdbVotes": 6537}}, {"id": "movie-spider-man-far-from-home-2019", "title": "Spider-Man: Far From Home", "year": 2019, "type": "movie", "runtimeSeconds": 7762, "seasonCount": null, "communityRating": 7.389, "criticRating": 91, "genres": ["Action", "Adventure", "Science Fiction"], "overview": "Peter Parker and his friends go on a summer trip to Europe. However, they will hardly be able to rest - Peter will have to agree to help Nick Fury uncover the mystery of creatures that cause natural disasters and\u2026", "poster": "assets/movie-spider-man-far-from-home-2019-poster.jpg", "backdrop": "assets/movie-spider-man-far-from-home-2019-backdrop.jpg", "played": true, "playCount": 1, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-10T14:25:47.0000000Z", "lastPlayedAt": "2026-10-10T17:50:35.6193119Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 429617, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 91, "rtCriticsLabel": "Certified Fresh", "rtAudience": 95, "rtAudienceLabel": "Upright", "imdb": 7.3, "imdbVotes": 682887, "tmdb": 7.387, "tmdbVotes": 17847}}, {"id": "movie-spider-man-homecoming-2017", "title": "Spider-Man: Homecoming", "year": 2017, "type": "movie", "runtimeSeconds": 8008, "seasonCount": null, "communityRating": 7.333, "criticRating": 92, "genres": ["Action", "Adventure", "Science Fiction"], "overview": "Following the events of Captain America: Civil War, Peter Parker, with the help of his mentor Tony Stark, tries to balance his life as an ordinary high school student in Queens, New York City, with fighting crime as\u2026", "poster": "assets/movie-spider-man-homecoming-2017-poster.jpg", "backdrop": "assets/movie-spider-man-homecoming-2017-backdrop.jpg", "played": true, "playCount": 1, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-10T14:33:13.0000000Z", "lastPlayedAt": "2026-10-10T20:31:23.4444213Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 315635, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 92, "rtCriticsLabel": "Certified Fresh", "rtAudience": 87, "rtAudienceLabel": "Upright", "imdb": 7.4, "imdbVotes": 840974, "tmdb": 7.332, "tmdbVotes": 24183}}, {"id": "movie-spider-man-no-way-home-2021", "title": "Spider-Man: No Way Home", "year": 2021, "type": "movie", "runtimeSeconds": 8890, "seasonCount": null, "communityRating": 7.947, "criticRating": 93, "genres": ["Action", "Adventure", "Science Fiction"], "overview": "Peter Parker is unmasked and no longer able to separate his normal life from the high-stakes of being a super-hero. When he asks for help from Doctor Strange the stakes become even more dangerous, forcing him to\u2026", "poster": "assets/movie-spider-man-no-way-home-2021-poster.jpg", "backdrop": "assets/movie-spider-man-no-way-home-2021-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-10T14:21:05.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 634649, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 93, "rtCriticsLabel": "Certified Fresh", "rtAudience": 97, "rtAudienceLabel": "Upright", "imdb": 8.1, "imdbVotes": 1082660, "tmdb": 7.947, "tmdbVotes": 23505}}, {"id": "movie-spirited-away-2001", "title": "Spirited Away", "year": 2001, "type": "movie", "runtimeSeconds": 7472, "seasonCount": null, "communityRating": 8.535, "criticRating": 96, "genres": ["Animation", "Family", "Fantasy"], "overview": "A young girl, Chihiro, becomes trapped in a strange new world of spirits. When her parents undergo a mysterious transformation, she must call upon the courage she never knew she had to free her family.", "poster": "assets/movie-spirited-away-2001-poster.jpg", "backdrop": "assets/movie-spirited-away-2001-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:22:42.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 129, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 96, "rtCriticsLabel": "Certified Fresh", "rtAudience": 99, "rtAudienceLabel": "Upright", "imdb": 8.6, "imdbVotes": 987658, "tmdb": 8.536, "tmdbVotes": 19176}}, {"id": "movie-the-substance-2024", "title": "The Substance", "year": 2024, "type": "movie", "runtimeSeconds": 8531, "seasonCount": null, "communityRating": 7.135, "criticRating": 89, "genres": ["Horror", "Science Fiction"], "overview": "A fading celebrity decides to use a black market drug, a cell-replicating substance that temporarily creates a younger, better version of herself.", "poster": "assets/movie-the-substance-2024-poster.jpg", "backdrop": "assets/movie-the-substance-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:30:22.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 933260, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 89, "rtCriticsLabel": "Certified Fresh", "rtAudience": 76, "rtAudienceLabel": "Upright", "imdb": 7.2, "imdbVotes": 466542, "tmdb": 7.13, "tmdbVotes": 6746}}, {"id": "movie-the-tale-of-the-princess-kaguya-2013", "title": "The Tale of The Princess Kaguya", "year": 2013, "type": "movie", "runtimeSeconds": 8218, "seasonCount": null, "communityRating": 8.1, "criticRating": 100, "genres": ["Animation", "Drama", "Fantasy"], "overview": "Found inside a shining stalk of bamboo by an old bamboo cutter and his wife, a tiny girl grows rapidly into an exquisite young lady. The mysterious young princess enthrals all who encounter her. But, ultimately, she\u2026", "poster": "assets/movie-the-tale-of-the-princess-kaguya-2013-poster.jpg", "backdrop": "assets/movie-the-tale-of-the-princess-kaguya-2013-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-07T13:40:15.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 149871, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Certified Fresh", "rtAudience": 90, "rtAudienceLabel": "Upright", "imdb": 8, "imdbVotes": 63574, "tmdb": 8.083, "tmdbVotes": 2079}}, {"id": "movie-taxi-driver-1976", "title": "Taxi Driver", "year": 1976, "type": "movie", "runtimeSeconds": 6831, "seasonCount": null, "communityRating": 8.121, "criticRating": 89, "genres": ["Crime", "Drama"], "overview": "Suffering from insomnia, disturbed loner Travis Bickle takes a job as a New York City cabbie, haunting the streets nightly, growing increasingly detached from reality as he dreams of cleaning up the filthy city.", "poster": "assets/movie-taxi-driver-1976-poster.jpg", "backdrop": "assets/movie-taxi-driver-1976-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:23:04.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 103, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 89, "rtCriticsLabel": "Certified Fresh", "rtAudience": 93, "rtAudienceLabel": "Upright", "imdb": 8.2, "imdbVotes": 1046700, "tmdb": 8.116, "tmdbVotes": 13973}}, {"id": "movie-triangle-of-sadness-2022", "title": "Triangle of Sadness", "year": 2022, "type": "movie", "runtimeSeconds": 8833, "seasonCount": null, "communityRating": 7.005, "criticRating": 71, "genres": ["Comedy", "Drama"], "overview": "A celebrity model couple are invited on a luxury cruise for the uber-rich, helmed by an unhinged, alcoholic captain. What first appears Instagrammable ends catastrophically, leaving the survivors stranded on a desert\u2026", "poster": "assets/movie-triangle-of-sadness-2022-poster.jpg", "backdrop": "assets/movie-triangle-of-sadness-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:29:00.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 497828, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 72, "rtCriticsLabel": "Fresh", "rtAudience": 80, "rtAudienceLabel": "Upright", "imdb": 7.2, "imdbVotes": 221069, "tmdb": 7, "tmdbVotes": 3104}}, {"id": "movie-tsotsi-2005", "title": "Tsotsi", "year": 2005, "type": "movie", "runtimeSeconds": 5663, "seasonCount": null, "communityRating": 6.937, "criticRating": 82, "genres": ["Crime", "Drama"], "overview": "A young South African boy from the Johannesburg ghetto named Tsotsi, meaning Gangster, leaves home as a child to get away from his helpless parents. Now a teenage thug, Tsotsi finds a baby in the back seat of a car\u2026", "poster": "assets/movie-tsotsi-2005-poster.jpg", "backdrop": "assets/movie-tsotsi-2005-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:24:58.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 868, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 82, "rtCriticsLabel": "Certified Fresh", "rtAudience": 86, "rtAudienceLabel": "Upright", "imdb": 7.2, "imdbVotes": 32526, "tmdb": 6.941, "tmdbVotes": 372}}, {"id": "movie-unknown-number-the-high-school-catfish-2025", "title": "Unknown Number: The High School Catfish", "year": 2025, "type": "movie", "runtimeSeconds": 5691, "seasonCount": null, "communityRating": 6.746, "criticRating": 85, "genres": ["Documentary"], "overview": "Vulgar, taunting texts blow up the phones of a teen and her boyfriend. Who's sending them \u2014 and why? This twisty documentary reveals the shocking answer.", "poster": "assets/movie-unknown-number-the-high-school-catfish-2025-poster.jpg", "backdrop": "assets/movie-unknown-number-the-high-school-catfish-2025-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:32:10.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 1517772, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 85, "rtCriticsLabel": "Fresh", "rtAudience": 69, "rtAudienceLabel": "Upright", "imdb": 6.7, "imdbVotes": 23039, "tmdb": 6.737, "tmdbVotes": 257}}, {"id": "movie-the-uprising-2026", "title": "The Uprising", "year": 2026, "type": "movie", "runtimeSeconds": 7684, "seasonCount": null, "communityRating": 7.846, "criticRating": 55, "genres": ["Action", "Drama", "History"], "overview": "As war and plague devastate 14th-century England, a humble peasant ignites a rebellion, uniting an army of commoners to defy the King\u2019s might in a desperate fight for justice and survival.", "poster": "assets/movie-the-uprising-2026-poster.jpg", "backdrop": "assets/movie-the-uprising-2026-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-11-02T16:37:03.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 977942, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 57, "rtCriticsLabel": "Rotten", "rtAudience": 65, "rtAudienceLabel": "Upright", "imdb": 6.2, "imdbVotes": 6622, "tmdb": 7.8, "tmdbVotes": 404}}, {"id": "movie-vice-2018", "title": "Vice", "year": 2018, "type": "movie", "runtimeSeconds": 7948, "seasonCount": null, "communityRating": 7.013, "criticRating": 64, "genres": ["Drama", "Comedy", "History"], "overview": "George W. Bush picks Dick Cheney, the CEO of Halliburton Co., to be his Republican running mate in the 2000 presidential election. No stranger to politics, Cheney's impressive r\u00e9sum\u00e9 includes stints as White House\u2026", "poster": "assets/movie-vice-2018-poster.jpg", "backdrop": "assets/movie-vice-2018-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:28:30.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 429197, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 64, "rtCriticsLabel": "Fresh", "rtAudience": 61, "rtAudienceLabel": "Upright", "imdb": 7.2, "imdbVotes": 177299, "tmdb": 7.014, "tmdbVotes": 3723}}, {"id": "movie-warriors-of-the-wind-1984", "title": "Warriors of the Wind", "year": 1984, "type": "movie", "runtimeSeconds": 7022, "seasonCount": null, "communityRating": 7.941, "criticRating": 87, "genres": ["Adventure", "Animation", "Fantasy"], "overview": "After a global war, the seaside kingdom known as the Valley of the Wind remains one of the last strongholds on Earth untouched by a poisonous jungle and the powerful insects that guard it. Led by the courageous\u2026", "poster": "assets/movie-warriors-of-the-wind-1984-poster.jpg", "backdrop": "assets/movie-warriors-of-the-wind-1984-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:22:20.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 81, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 87, "rtCriticsLabel": "Fresh", "rtAudience": 91, "rtAudienceLabel": "Upright", "imdb": 8, "imdbVotes": 200154, "tmdb": 7.94, "tmdbVotes": 4260}}, {"id": "movie-we-bury-the-dead-2026", "title": "We Bury the Dead", "year": 2026, "type": "movie", "runtimeSeconds": 5693, "seasonCount": null, "communityRating": 6.153, "criticRating": 87, "genres": ["Horror", "Thriller"], "overview": "After a catastrophic military disaster reanimates the dead, Ava searches for her missing husband amidst an increasingly hostile world.", "poster": "assets/movie-we-bury-the-dead-2026-poster.jpg", "backdrop": "assets/movie-we-bury-the-dead-2026-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-08-26T05:36:00.4272328Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 1198984, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 87, "rtCriticsLabel": "Certified Fresh", "rtAudience": 45, "rtAudienceLabel": "Spilled", "imdb": 5.6, "imdbVotes": 20693, "tmdb": 6.137, "tmdbVotes": 433}}, {"id": "movie-white-hot-the-rise-fall-of-abercrombie-fitch-2022", "title": "White Hot: The Rise & Fall of Abercrombie & Fitch", "year": 2022, "type": "movie", "runtimeSeconds": 5187, "seasonCount": null, "communityRating": 5.663, "criticRating": 66, "genres": ["Documentary"], "overview": "All the cool kids were wearing it. This documentary explores A&F's pop culture reign in the late '90s and early 2000s and how it thrived on exclusion.", "poster": "assets/movie-white-hot-the-rise-fall-of-abercrombie-fitch-2022-poster.jpg", "backdrop": "assets/movie-white-hot-the-rise-fall-of-abercrombie-fitch-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:30:34.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 953371, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 66, "rtCriticsLabel": "Fresh", "rtAudience": 29, "rtAudienceLabel": "Spilled", "imdb": 5.8, "imdbVotes": 5655, "tmdb": 5.7, "tmdbVotes": 102}}, {"id": "movie-the-wild-robot-2024", "title": "The Wild Robot", "year": 2024, "type": "movie", "runtimeSeconds": 6101, "seasonCount": null, "communityRating": 8.308, "criticRating": 97, "genres": ["Family", "Animation", "Science Fiction"], "overview": "After a shipwreck, an intelligent robot called Roz is stranded on an uninhabited island. To survive the harsh environment, Roz bonds with the island's animals and cares for an orphaned baby goose.", "poster": "assets/movie-the-wild-robot-2024-poster.jpg", "backdrop": "assets/movie-the-wild-robot-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:31:49.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 1184918, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 97, "rtCriticsLabel": "Certified Fresh", "rtAudience": 98, "rtAudienceLabel": "Upright", "imdb": 8.1, "imdbVotes": 241134, "tmdb": 8.309, "tmdbVotes": 6819}}, {"id": "movie-the-wind-rises-2013", "title": "The Wind Rises", "year": 2013, "type": "movie", "runtimeSeconds": 8031, "seasonCount": null, "communityRating": 7.784, "criticRating": 88, "genres": ["Animation", "Drama", "History"], "overview": "A lifelong love of flight inspires Japanese aviation engineer Jiro Horikoshi, whose storied career includes the creation of the A-6M World War II fighter plane.", "poster": "assets/movie-the-wind-rises-2013-poster.jpg", "backdrop": "assets/movie-the-wind-rises-2013-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:24:38.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 149870, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 89, "rtCriticsLabel": "Certified Fresh", "rtAudience": 85, "rtAudienceLabel": "Upright", "imdb": 7.8, "imdbVotes": 115756, "tmdb": 7.783, "tmdbVotes": 3536}}, {"id": "movie-the-witcher-nightmare-of-the-wolf-2021", "title": "The Witcher: Nightmare of the Wolf", "year": 2021, "type": "movie", "runtimeSeconds": 5015, "seasonCount": null, "communityRating": 7.422, "criticRating": 100, "genres": ["Animation", "Action", "Fantasy"], "overview": "Escaping from poverty to become a witcher, Vesemir slays monsters for coin and glory, but when a new menace rises, he must face the demons of his past.", "poster": "assets/movie-the-witcher-nightmare-of-the-wolf-2021-poster.jpg", "backdrop": "assets/movie-the-witcher-nightmare-of-the-wolf-2021-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-26T04:42:03.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 666243, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": 83, "rtAudienceLabel": "Upright", "imdb": 7.2, "imdbVotes": 55759, "tmdb": 7.407, "tmdbVotes": 1159}}, {"id": "movie-the-witcher-sirens-of-the-deep-2025", "title": "The Witcher: Sirens of the Deep", "year": 2025, "type": "movie", "runtimeSeconds": 5517, "seasonCount": null, "communityRating": 6.8, "criticRating": 60, "genres": ["Animation", "Fantasy", "Action"], "overview": "When human sailors are attacked by mysterious creatures of the deep, only one person can stop the war between land and sea: the Witcher, Geralt of Rivia.", "poster": "assets/movie-the-witcher-sirens-of-the-deep-2025-poster.jpg", "backdrop": "assets/movie-the-witcher-sirens-of-the-deep-2025-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-26T05:03:26.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 1203329, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 60, "rtCriticsLabel": "Fresh", "rtAudience": 37, "rtAudienceLabel": "Spilled", "imdb": 6.1, "imdbVotes": 11799, "tmdb": 6.741, "tmdbVotes": 303}}], "shows": [{"id": "tv-adults-2025", "title": "Adults", "year": 2025, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 6.495, "criticRating": null, "genres": ["Comedy"], "overview": "A group of twenty-somethings in New York trying to be good people, despite being neither \"good\" nor \"people\" yet.", "poster": "assets/tv-adults-2025-poster.jpg", "backdrop": "assets/tv-adults-2025-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-23T21:01:41.1814765Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 260458, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 88, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.5, "tmdbVotes": 102}}, {"id": "tv-american-crime-story-2016", "title": "American Crime Story", "year": 2016, "type": "tv", "runtimeSeconds": null, "seasonCount": 3, "communityRating": 7.573, "criticRating": null, "genres": ["Crime", "Drama"], "overview": "An anthology series centered around some of history's most famous criminal investigations.", "poster": "assets/tv-american-crime-story-2016-poster.jpg", "backdrop": "assets/tv-american-crime-story-2016-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0490488Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 64513, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 84, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.585, "tmdbVotes": 1094}}, {"id": "tv-american-primeval-2025", "title": "American Primeval", "year": 2025, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.691, "criticRating": null, "genres": ["Western", "Drama", "Action & Adventure"], "overview": "A mother and son fleeing from their past form a found family while confronting a harsh landscape of freedom and cruelty in the American West.", "poster": "assets/tv-american-primeval-2025-poster.jpg", "backdrop": "assets/tv-american-primeval-2025-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0573221Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 215866, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 71, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.741, "tmdbVotes": 538}}, {"id": "tv-arrested-development-2003", "title": "Arrested Development", "year": 2003, "type": "tv", "runtimeSeconds": null, "seasonCount": 5, "communityRating": 7.925, "criticRating": null, "genres": ["Comedy"], "overview": "The story of a wealthy family that lost everything, and the one son who had no choice but to keep them all together.", "poster": "assets/tv-arrested-development-2003-poster.jpg", "backdrop": "assets/tv-arrested-development-2003-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0513048Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 4589, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 75, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.906, "tmdbVotes": 1793}}, {"id": "tv-avatar-the-last-airbender-2005", "title": "Avatar: The Last Airbender", "year": 2005, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 3, "communityRating": 8.78, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "In a war-torn world of elemental magic, a young boy reawakens to undertake a dangerous mystic quest to fulfill his destiny as the Avatar, and bring peace to the world.", "poster": "assets/tv-avatar-the-last-airbender-2005-poster.jpg", "backdrop": "assets/tv-avatar-the-last-airbender-2005-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0504536Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 246, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.787, "tmdbVotes": 5215}}, {"id": "tv-b-b-vol-liefde-2021", "title": "B&B Vol Liefde", "year": 2021, "type": "tv", "runtimeSeconds": 3720, "seasonCount": 1, "communityRating": 7.2, "criticRating": null, "genres": ["Reality"], "overview": "B&B Vol Liefde is a heartwarming adventure, full of romance, excitement, and unforgettable moments. Adventurous Dutch B&B owners abroad throw their doors wide open and let their hearts speak, determined to find that\u2026", "poster": "assets/tv-b-b-vol-liefde-2021-poster.jpg", "backdrop": "assets/tv-b-b-vol-liefde-2021-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-31T22:30:11.129657Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 124428, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.2, "tmdbVotes": 10}}, {"id": "tv-the-bear-2022", "title": "The Bear", "year": 2022, "type": "tv", "runtimeSeconds": null, "seasonCount": 5, "communityRating": 8.139, "criticRating": null, "genres": ["Drama", "Comedy"], "overview": "Carmy, a young fine-dining chef, comes home to Chicago to run his family sandwich shop. As he fights to transform the shop and himself, he works alongside a rough-around-the-edges crew that ultimately reveal\u2026", "poster": "assets/tv-the-bear-2022-poster.jpg", "backdrop": "assets/tv-the-bear-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0467371Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 136315, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 93, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.158, "tmdbVotes": 2025}}, {"id": "tv-blue-planet-ii-2017", "title": "Blue Planet II", "year": 2017, "type": "tv", "runtimeSeconds": 3600, "seasonCount": 1, "communityRating": 8.64, "criticRating": null, "genres": ["Documentary", "Family"], "overview": "There is nowhere more powerful and unforgiving yet more beautiful and compelling than the ocean. Join us and explore the greatest yet least known parts of our planet.", "poster": "assets/tv-blue-planet-ii-2017-poster.jpg", "backdrop": "assets/tv-blue-planet-ii-2017-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0634849Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 74313, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 97, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.6, "tmdbVotes": 380}}, {"id": "tv-breeders-2020", "title": "Breeders", "year": 2020, "type": "tv", "runtimeSeconds": 1500, "seasonCount": 4, "communityRating": 6.8, "criticRating": null, "genres": ["Comedy"], "overview": "Exposing the parental-paradox that it is possible, in the very same moment, to love your child to the horizon of the universe, while being apoplectically angry enough to want to send them there.", "poster": "assets/tv-breeders-2020-poster.jpg", "backdrop": "assets/tv-breeders-2020-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-28T18:54:21.8885435Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 93290, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 92, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.8, "tmdbVotes": 104}}, {"id": "tv-chernobyl-2019", "title": "Chernobyl", "year": 2019, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 8.718, "criticRating": null, "genres": ["Drama"], "overview": "The true story of one of the worst man-made catastrophes in history: the catastrophic nuclear accident at Chernobyl. A tale of the brave men and women who sacrificed to save Europe from unimaginable disaster.", "poster": "assets/tv-chernobyl-2019-poster.jpg", "backdrop": "assets/tv-chernobyl-2019-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0469903Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 87108, "artwork": {"posterKind": "poster", "backdropKind": "center_crop"}, "ratings": {"rtCritics": 95, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.731, "tmdbVotes": 8496}}, {"id": "tv-community-2009", "title": "Community", "year": 2009, "type": "tv", "runtimeSeconds": null, "seasonCount": 6, "communityRating": 7.997, "criticRating": null, "genres": ["Comedy"], "overview": "Follow the lives of a group of students at what is possibly the world\u2019s worst community college in the fictional locale of Greendale, Colorado.", "poster": "assets/tv-community-2009-poster.jpg", "backdrop": "assets/tv-community-2009-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0531416Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 18347, "artwork": {"posterKind": "center_crop", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 88, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.994, "tmdbVotes": 2226}}, {"id": "tv-dead-end-job-2026", "title": "Dead-End Job", "year": 2026, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.7, "criticRating": null, "genres": ["Mystery"], "overview": "Drowning in debt, a young man takes lucrative part-time jobs from a mysterious agency \u2014 only to find his hustle for more money turning nightmarish.", "poster": "assets/tv-dead-end-job-2026-poster.jpg", "backdrop": "assets/tv-dead-end-job-2026-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-11-04T18:58:01.8352857Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 289517, "artwork": {"posterKind": "center_crop", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.7, "tmdbVotes": 38}}, {"id": "tv-dopesick-2021", "title": "Dopesick", "year": 2021, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 8.015, "criticRating": null, "genres": ["Crime", "Drama"], "overview": "The story of how one company triggered the worst drug epidemic in American history. Look into the epicenter of America's struggle with opioid addiction, from a distressed Virginia mining community, to the hallways of\u2026", "poster": "assets/tv-dopesick-2021-poster.jpg", "backdrop": "assets/tv-dopesick-2021-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0580168Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 110695, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 89, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.035, "tmdbVotes": 565}}, {"id": "tv-dune-prophecy-2024", "title": "Dune: Prophecy", "year": 2024, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.213, "criticRating": null, "genres": ["Sci-Fi & Fantasy", "Drama", "Action & Adventure"], "overview": "Ten thousand years before the ascension of Paul Atreides, sisters Valya and Tula Harkonnen establish the fabled sect and female order that would become known as the Bene Gesserit to control the future of humankind.", "poster": "assets/tv-dune-prophecy-2024-poster.jpg", "backdrop": "assets/tv-dune-prophecy-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-11-04T18:23:01.2908568Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 90228, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 64, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.213, "tmdbVotes": 677}}, {"id": "tv-east-of-eden-2026", "title": "East of Eden", "year": 2026, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.931, "criticRating": null, "genres": ["Drama", "Crime"], "overview": "In the early 1900s, a fiercely independent woman drives a rift between two brothers, igniting a saga that unfolds across two generations.", "poster": "assets/tv-east-of-eden-2026-poster.jpg", "backdrop": "assets/tv-east-of-eden-2026-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-23T18:07:11.3965096Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 258165, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 88, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.982, "tmdbVotes": 139}}, {"id": "tv-fallout-2024", "title": "Fallout", "year": 2024, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 8.124, "criticRating": null, "genres": ["Action & Adventure", "Sci-Fi & Fantasy", "Drama"], "overview": "The story of haves and have-nots in a world in which there's almost nothing left to have. 200 years after the apocalypse, the gentle denizens of luxury fallout shelters are forced to return to the irradiated\u2026", "poster": "assets/tv-fallout-2024-poster.jpg", "backdrop": "assets/tv-fallout-2024-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0669262Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 106379, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 94, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.106, "tmdbVotes": 3233}}, {"id": "tv-fawlty-towers-1975", "title": "Fawlty Towers", "year": 1975, "type": "tv", "runtimeSeconds": 1800, "seasonCount": 2, "communityRating": 8.295, "criticRating": null, "genres": ["Comedy"], "overview": "Owner Basil Fawlty, his wife Sybil, a chambermaid Polly, and Spanish waiter Manuel attempt to run their hotel amidst farcical situations and an array of demanding guests.", "poster": "assets/tv-fawlty-towers-1975-poster.jpg", "backdrop": "assets/tv-fawlty-towers-1975-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-19T23:26:39.7658344Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 2207, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.296, "tmdbVotes": 867}}, {"id": "tv-the-final-problem-2026", "title": "The Final Problem", "year": 2026, "type": "tv", "runtimeSeconds": 3420, "seasonCount": 1, "communityRating": 7.041, "criticRating": null, "genres": ["Crime", "Mystery"], "overview": "In the spring of 1959, a retired actor, who previously played Sherlock Holmes, must investigate a murder at a small hotel on a tiny island near Mallorca.", "poster": "assets/tv-the-final-problem-2026-poster.jpg", "backdrop": "assets/tv-the-final-problem-2026-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-11-04T19:16:12.4822791Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 290720, "artwork": {"posterKind": "center_crop", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.041, "tmdbVotes": 49}}, {"id": "tv-the-four-seasons-2025", "title": "The Four Seasons", "year": 2025, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 6.879, "criticRating": null, "genres": ["Comedy"], "overview": "The decades-long friendship between three married couples is tested when one divorces, complicating their tradition of quarterly weekend getaways.", "poster": "assets/tv-the-four-seasons-2025-poster.jpg", "backdrop": "assets/tv-the-four-seasons-2025-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-28T18:36:42.5326384Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 243316, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 81, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.892, "tmdbVotes": 204}}, {"id": "tv-the-get-down-2016", "title": "The Get Down", "year": 2016, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.3, "criticRating": null, "genres": ["Drama"], "overview": "In 1977 New York City, the talented and soulful youth of the South Bronx chase dreams and breakneck beats to transform music history.", "poster": "assets/tv-the-get-down-2016-poster.jpg", "backdrop": "assets/tv-the-get-down-2016-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0497737Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 65345, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 81, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.3, "tmdbVotes": 196}}, {"id": "tv-how-to-change-your-mind-2022", "title": "How to Change Your Mind", "year": 2022, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.76, "criticRating": null, "genres": ["Documentary", "Mystery"], "overview": "Author Michael Pollan leads the way in this docuseries exploring the history and uses of psychedelics, including LSD, psilocybin, MDMA and mescaline.", "poster": "assets/tv-how-to-change-your-mind-2022-poster.jpg", "backdrop": "assets/tv-how-to-change-your-mind-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0601399Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 204304, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.8, "tmdbVotes": 53}}, {"id": "tv-interrogation-2020", "title": "Interrogation", "year": 2020, "type": "tv", "runtimeSeconds": 2700, "seasonCount": 1, "communityRating": 6.788, "criticRating": null, "genres": ["Drama", "Crime"], "overview": "Based on a true story that spanned more than 30 years in which a young man was charged and convicted of brutally murdering his mother. Each episode is structured around an interrogation taken directly from the real\u2026", "poster": "assets/tv-interrogation-2020-poster.jpg", "backdrop": "assets/tv-interrogation-2020-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-26T05:52:06.1068207Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 96488, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 57, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.9, "tmdbVotes": 54}}, {"id": "tv-it-s-always-sunny-in-philadelphia-2005", "title": "It's Always Sunny in Philadelphia", "year": 2005, "type": "tv", "runtimeSeconds": null, "seasonCount": 18, "communityRating": 8.2, "criticRating": null, "genres": ["Comedy"], "overview": "Four egocentric friends run a neighborhood Irish pub in Philadelphia and try to find their way through the adult world of work and relationships. Unfortunately, their warped views and precarious judgments often lead\u2026", "poster": "assets/tv-it-s-always-sunny-in-philadelphia-2005-poster.jpg", "backdrop": "assets/tv-it-s-always-sunny-in-philadelphia-2005-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0485774Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 2710, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 94, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.253, "tmdbVotes": 1457}}, {"id": "tv-a-knight-of-the-seven-kingdoms-2026", "title": "A Knight of the Seven Kingdoms", "year": 2026, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 8.431, "criticRating": null, "genres": ["Drama", "Sci-Fi & Fantasy", "Action & Adventure"], "overview": "A century before the events of Game of Thrones, two unlikely heroes wandered Westeros: a young, naive but courageous knight, Ser Duncan the Tall, and his diminutive squire, Egg. Set in an age when the Targaryen line\u2026", "poster": "assets/tv-a-knight-of-the-seven-kingdoms-2026-poster.jpg", "backdrop": "assets/tv-a-knight-of-the-seven-kingdoms-2026-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0493344Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 224372, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 94, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.4, "tmdbVotes": 1266}}, {"id": "tv-landman-2024", "title": "Landman", "year": 2024, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 8.194, "criticRating": null, "genres": ["Drama"], "overview": "Set in the proverbial boomtowns of West-Texas and a modern-day tale of fortune-seeking in the world of oil rigs, the series is an upstairs/downstairs story of roughnecks and wildcat billionaires that are fueling a\u2026", "poster": "assets/tv-landman-2024-poster.jpg", "backdrop": "assets/tv-landman-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-22T21:38:18.0973875Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 157741, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 80, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.209, "tmdbVotes": 668}}, {"id": "tv-the-last-of-us-2023", "title": "The Last of Us", "year": 2023, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 8.419, "criticRating": null, "genres": ["Drama"], "overview": "Twenty years after modern civilization has been destroyed, Joel, a hardened survivor, is hired to smuggle Ellie, a 14-year-old girl, out of an oppressive quarantine zone. What starts as a small job soon becomes a\u2026", "poster": "assets/tv-the-last-of-us-2023-poster.jpg", "backdrop": "assets/tv-the-last-of-us-2023-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0640955Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 100088, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 94, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.4, "tmdbVotes": 7485}}, {"id": "tv-last-samurai-standing-2025", "title": "Last Samurai Standing", "year": 2025, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.805, "criticRating": null, "genres": ["Action & Adventure", "Drama"], "overview": "In the early Meiji era, Shujiro, once known as an undefeated samurai, decides to participate in a deadly survival game to save his family and villagers.", "poster": "assets/tv-last-samurai-standing-2025-poster.jpg", "backdrop": "assets/tv-last-samurai-standing-2025-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0622526Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 252193, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.812, "tmdbVotes": 271}}, {"id": "tv-lord-of-the-flies-2026", "title": "Lord of the Flies", "year": 2026, "type": "tv", "runtimeSeconds": 3480, "seasonCount": 1, "communityRating": 7.571, "criticRating": null, "genres": ["Drama", "Action & Adventure"], "overview": "After a plane crash, a group of schoolboys find themselves stranded on a tropical island without adults. Ralph is elected leader and, with the help of the intelligent Piggy, strives to maintain order and\u2026", "poster": "assets/tv-lord-of-the-flies-2026-poster.jpg", "backdrop": "assets/tv-lord-of-the-flies-2026-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0645648Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 270572, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 91, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.58, "tmdbVotes": 138}}, {"id": "tv-the-lord-of-the-rings-the-rings-of-power-2022", "title": "The Lord of the Rings: The Rings of Power", "year": 2022, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 7.233, "criticRating": null, "genres": ["Action & Adventure", "Sci-Fi & Fantasy", "Drama"], "overview": "Beginning in a time of relative peace, we follow an ensemble cast of characters as they confront the re-emergence of evil to Middle-earth. From the darkest depths of the Misty Mountains, to the majestic forests of\u2026", "poster": "assets/tv-the-lord-of-the-rings-the-rings-of-power-2022-poster.jpg", "backdrop": "assets/tv-the-lord-of-the-rings-the-rings-of-power-2022-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0618904Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 84773, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 84, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.211, "tmdbVotes": 4003}}, {"id": "tv-love-death-robots-2019", "title": "Love, Death & Robots", "year": 2019, "type": "tv", "runtimeSeconds": null, "seasonCount": 4, "communityRating": 8.21, "criticRating": null, "genres": ["Animation", "Sci-Fi & Fantasy"], "overview": "Terrifying creatures, wicked surprises and dark comedy converge in this NSFW anthology of animated stories presented by Tim Miller and David Fincher.", "poster": "assets/tv-love-death-robots-2019-poster.jpg", "backdrop": "assets/tv-love-death-robots-2019-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0460964Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 86831, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 89, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.203, "tmdbVotes": 4238}}, {"id": "tv-maniac-2018", "title": "Maniac", "year": 2018, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.338, "criticRating": null, "genres": ["Comedy", "Drama", "Sci-Fi & Fantasy"], "overview": "Two strangers are drawn to a mysterious pharmaceutical trial that will, they're assured, with no complications or side-effects whatsoever, solve all of their problems, permanently. Things do not go as planned.", "poster": "assets/tv-maniac-2018-poster.jpg", "backdrop": "assets/tv-maniac-2018-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-20T19:42:30.955548Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 73411, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 84, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.323, "tmdbVotes": 1073}}, {"id": "tv-mussolini-son-of-the-century-2024", "title": "Mussolini: Son of the Century", "year": 2024, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 8.135, "criticRating": null, "genres": ["Drama", "War & Politics"], "overview": "The rise of Benito Mussolini and the birth of fascism in Italy, chronicling a country\u2019s surrender to dictatorship and the relentless ascent of a man who rose from his ashes time and again.", "poster": "assets/tv-mussolini-son-of-the-century-2024-poster.jpg", "backdrop": "assets/tv-mussolini-son-of-the-century-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0586588Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 212333, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 96, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.1, "tmdbVotes": 179}}, {"id": "tv-the-night-manager-2016", "title": "The Night Manager", "year": 2016, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 7.74, "criticRating": null, "genres": ["Drama", "Mystery", "Crime"], "overview": "Former British soldier Jonathan Pine navigates the shadowy recesses of Whitehall and Washington where an unholy alliance operates between the intelligence community and the secret arms trade. To infiltrate the inner\u2026", "poster": "assets/tv-the-night-manager-2016-poster.jpg", "backdrop": "assets/tv-the-night-manager-2016-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0663672Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 61859, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 88, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.7, "tmdbVotes": 1422}}, {"id": "tv-one-piece-2023", "title": "ONE PIECE", "year": 2023, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 8.118, "criticRating": null, "genres": ["Action & Adventure", "Sci-Fi & Fantasy"], "overview": "With his straw hat and ragtag crew, young pirate Monkey D. Luffy goes on an epic voyage for treasure.", "poster": "assets/tv-one-piece-2023-poster.jpg", "backdrop": "assets/tv-one-piece-2023-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0439378Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 111110, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 93, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.104, "tmdbVotes": 1988}}, {"id": "tv-only-murders-in-the-building-2021", "title": "Only Murders in the Building", "year": 2021, "type": "tv", "runtimeSeconds": null, "seasonCount": 5, "communityRating": 8.438, "criticRating": null, "genres": ["Comedy", "Mystery", "Crime"], "overview": "Three strangers share an obsession with true crime and suddenly find themselves wrapped up in one.", "poster": "assets/tv-only-murders-in-the-building-2021-poster.jpg", "backdrop": "assets/tv-only-murders-in-the-building-2021-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0594614Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 107113, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 95, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.398, "tmdbVotes": 2333}}, {"id": "tv-over-the-garden-wall-2014", "title": "Over the Garden Wall", "year": 2014, "type": "tv", "runtimeSeconds": 720, "seasonCount": 1, "communityRating": 8.6, "criticRating": null, "genres": ["Mystery", "Sci-Fi & Fantasy", "Animation"], "overview": "Two brothers, Wirt and Greg, find themselves lost in the Unknown; a strange forest adrift in time. With the help of a wise old Woodsman and a foul-tempered bluebird named Beatrice, Wirt and Greg must travel across\u2026", "poster": "assets/tv-over-the-garden-wall-2014-poster.jpg", "backdrop": "assets/tv-over-the-garden-wall-2014-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0455757Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 61617, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.6, "tmdbVotes": 1854}}, {"id": "tv-peaky-blinders-2013", "title": "Peaky Blinders", "year": 2013, "type": "tv", "runtimeSeconds": null, "seasonCount": 6, "communityRating": 8.5, "criticRating": null, "genres": ["Drama", "Crime"], "overview": "A gangster family epic set in 1919 Birmingham, England and centered on a gang who sew razor blades in the peaks of their caps, and their fierce boss Tommy Shelby, who means to move up in the world.", "poster": "assets/tv-peaky-blinders-2013-poster.jpg", "backdrop": "assets/tv-peaky-blinders-2013-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0539185Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 60574, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 93, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.5, "tmdbVotes": 11506}}, {"id": "tv-the-pitt-2025", "title": "The Pitt", "year": 2025, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 8.729, "criticRating": null, "genres": ["Drama"], "overview": "The staff of Pittsburgh's Trauma Medical Center work around the clock to save lives in an overcrowded and underfunded emergency department.", "poster": "assets/tv-the-pitt-2025-poster.jpg", "backdrop": "assets/tv-the-pitt-2025-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0582942Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 250307, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 96, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.731, "tmdbVotes": 1046}}, {"id": "tv-pluribus-2025", "title": "Pluribus", "year": 2025, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.936, "criticRating": null, "genres": ["Drama", "Sci-Fi & Fantasy"], "overview": "The most miserable person on Earth must save the world from happiness.", "poster": "assets/tv-pluribus-2025-poster.jpg", "backdrop": "assets/tv-pluribus-2025-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0674462Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 225171, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 99, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.921, "tmdbVotes": 1191}}, {"id": "tv-ripley-2024", "title": "RIPLEY", "year": 2024, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.911, "criticRating": null, "genres": ["Drama"], "overview": "A grifter in 1960s New York is hired to convince a wealthy man's son to return home from Italy and begins a life of deceit, fraud and murder.", "poster": "assets/tv-ripley-2024-poster.jpg", "backdrop": "assets/tv-ripley-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0658942Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 94028, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 86, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.909, "tmdbVotes": 453}}, {"id": "tv-say-nothing-2024", "title": "Say Nothing", "year": 2024, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.462, "criticRating": null, "genres": ["Drama", "Crime"], "overview": "Through the eyes of various Irish Republican Army (IRA) members, explore the extremes some people will go to in the name of their beliefs, the way a deeply divided society can suddenly tip over into armed conflict,\u2026", "poster": "assets/tv-say-nothing-2024-poster.jpg", "backdrop": "assets/tv-say-nothing-2024-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0499752Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 245256, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 94, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.475, "tmdbVotes": 122}}, {"id": "tv-severance-2022", "title": "Severance", "year": 2022, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 8.396, "criticRating": null, "genres": ["Drama", "Mystery", "Sci-Fi & Fantasy"], "overview": "Mark leads a team of office workers whose memories have been surgically divided between their work and personal lives. When a mysterious colleague appears outside of work, it begins a journey to discover the truth\u2026", "poster": "assets/tv-severance-2022-poster.jpg", "backdrop": "assets/tv-severance-2022-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0598478Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 95396, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 95, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.389, "tmdbVotes": 3095}}, {"id": "tv-six-feet-under-2001", "title": "Six Feet Under", "year": 2001, "type": "tv", "runtimeSeconds": null, "seasonCount": 5, "communityRating": 8.115, "criticRating": null, "genres": ["Drama"], "overview": "When death is your business, what is your life? For the Fisher family, the world outside of their family-owned funeral home continues to be at least as challenging as\u2014and far less predictable than\u2014the one inside.", "poster": "assets/tv-six-feet-under-2001-poster.jpg", "backdrop": "assets/tv-six-feet-under-2001-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-26T04:04:05.108787Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 1274, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 81, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.1, "tmdbVotes": 1192}}, {"id": "tv-undercover-2019", "title": "Undercover", "year": 2019, "type": "tv", "runtimeSeconds": 3000, "seasonCount": 3, "communityRating": 7.5, "criticRating": null, "genres": ["Crime", "Drama"], "overview": "Undercover agents infiltrate a drug kingpin's operation by posing as a couple at the campground where he spends his weekends. Inspired by real events.", "poster": "assets/tv-undercover-2019-poster.jpg", "backdrop": "assets/tv-undercover-2019-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0570153Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 86942, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.5, "tmdbVotes": 186}}, {"id": "tv-upload-2020", "title": "Upload", "year": 2020, "type": "tv", "runtimeSeconds": null, "seasonCount": 4, "communityRating": 7.781, "criticRating": null, "genres": ["Comedy", "Sci-Fi & Fantasy", "Drama"], "overview": "In 2033, people who are near death can be \u201cuploaded\u201d into virtual reality hotels run by 6 tech firms. Cash-strapped Nora lives in Brooklyn and works customer service for the luxurious \u201cLakeview\u201d digital afterlife.\u2026", "poster": "assets/tv-upload-2020-poster.jpg", "backdrop": "assets/tv-upload-2020-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0566967Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 86248, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 87, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.757, "tmdbVotes": 1511}}, {"id": "tv-the-walk-in-2022", "title": "The Walk-In", "year": 2022, "type": "tv", "runtimeSeconds": 2760, "seasonCount": 1, "communityRating": 6.9, "criticRating": null, "genres": ["Drama", "Crime"], "overview": "Forced to flee the UK and go into hiding in the 1990s, when he became a mole from within the BNP, Matthew Collins returned to Britain to make a new life for himself, working with young white men in the fight against\u2026", "poster": "assets/tv-the-walk-in-2022-poster.jpg", "backdrop": "assets/tv-the-walk-in-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0654569Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 210664, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 80, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7, "tmdbVotes": 50}}, {"id": "tv-the-white-lotus-2021", "title": "The White Lotus", "year": 2021, "type": "tv", "runtimeSeconds": null, "seasonCount": 3, "communityRating": 7.596, "criticRating": null, "genres": ["Comedy", "Drama", "Mystery"], "overview": "Follow the exploits of various guests and employees at an exclusive tropical resort over the span of a week as with each passing day, a darker complexity emerges in these picture-perfect travelers, the hotel\u2019s\u2026", "poster": "assets/tv-the-white-lotus-2021-poster.jpg", "backdrop": "assets/tv-the-white-lotus-2021-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0546438Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 111803, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 90, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.599, "tmdbVotes": 1604}}, {"id": "tv-widow-s-bay-2026", "title": "Widow's Bay", "year": 2026, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 8.172, "criticRating": null, "genres": ["Drama", "Comedy", "Mystery"], "overview": "The mayor of a New England town resolves to turn it into the next tourist hot spot, despite local warnings that it's cursed.", "poster": "assets/tv-widow-s-bay-2026-poster.jpg", "backdrop": "assets/tv-widow-s-bay-2026-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-20T10:27:12.7003979Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 270476, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 98, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8, "tmdbVotes": 585}}, {"id": "tv-the-witcher-2019", "title": "The Witcher", "year": 2019, "type": "tv", "runtimeSeconds": null, "seasonCount": 4, "communityRating": 7.905, "criticRating": null, "genres": ["Drama", "Action & Adventure"], "overview": "Geralt of Rivia, a mutated monster-hunter for hire, journeys toward his destiny in a turbulent world where people often prove more wicked than beasts.", "poster": "assets/tv-the-witcher-2019-poster.jpg", "backdrop": "assets/tv-the-witcher-2019-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0552369Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 71912, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 75, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.88, "tmdbVotes": 6997}}, {"id": "tv-yellowjackets-2021", "title": "Yellowjackets", "year": 2021, "type": "tv", "runtimeSeconds": null, "seasonCount": 3, "communityRating": 7.449, "criticRating": null, "genres": ["Drama", "Mystery"], "overview": "This equal parts survival epic, psychological horror story and coming-of-age drama tells the saga of a team of wildly talented high school girls soccer players who become the (un)lucky survivors of a plane crash deep\u2026", "poster": "assets/tv-yellowjackets-2021-poster.jpg", "backdrop": "assets/tv-yellowjackets-2021-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0577612Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 117488, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 92, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.431, "tmdbVotes": 1070}}], "anime": [], "resume": [{"id": "episode-episode-1-2025-s1e1", "title": "Episode 1", "year": 2025, "type": "episode", "runtimeSeconds": 3618, "seasonCount": null, "communityRating": 7.357, "criticRating": null, "genres": ["Biography", "Drama", "History"], "overview": "Mussolini had just founded the Fasci di Combattimento when D'Annunzio took Fiume. Mussolini hoped to capitalize on the poet's achievement in the electoral arena.", "poster": "assets/episode-episode-1-2025-s1e1-poster.jpg", "backdrop": "assets/episode-episode-1-2025-s1e1-backdrop.jpg", "played": false, "playCount": 1, "resumeSeconds": 489.55, "progress": 0.1353, "addedAt": "2026-07-17T13:52:48.0000000Z", "lastPlayedAt": "2026-11-04T19:01:49.9731435Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "Mussolini: Son of the Century", "seasonNumber": 1, "episodeNumber": 1, "artwork": {"posterKind": "center_crop", "backdropKind": "backdrop"}}], "nextUp": [{"id": "episode-no-honeymoon-2020-s1e8", "title": "No Honeymoon", "year": 2020, "type": "episode", "runtimeSeconds": 1499, "seasonCount": null, "communityRating": 6.7, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "Paul attempts to focus on his upcoming wedding but is constantly thwarted by his family; Ally is distracted by work, Luke is misbehaving at school and Leah (Stella Gonet) doesn\u2019t understand why they are even\u2026", "poster": "assets/episode-no-honeymoon-2020-s1e8-poster.jpg", "backdrop": "assets/episode-no-honeymoon-2020-s1e8-backdrop.jpg", "played": false, "playCount": 1, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-28T19:00:50.0000000Z", "lastPlayedAt": "2026-11-03T20:57:58.3314947Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "Breeders", "seasonNumber": 1, "episodeNumber": 8, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}, {"id": "episode-notapusy-2005-s3e4", "title": "Notapusy", "year": 2005, "type": "episode", "runtimeSeconds": 1334, "seasonCount": null, "communityRating": 8.125, "criticRating": null, "genres": ["Comedy"], "overview": "To prove he's a man to Rita, Michael and his nephew Steve Holt compete in the Church And State Fair's annual triathlon. George Sr. tries to scare some at-risk youth, and Tobias tries to help George-Michael's\u2026", "poster": "assets/episode-notapusy-2005-s3e4-poster.jpg", "backdrop": "assets/episode-notapusy-2005-s3e4-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T12:17:58.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "Arrested Development", "seasonNumber": 3, "episodeNumber": 4, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}, {"id": "episode-the-man-who-would-be-vogue-2018-s2e1", "title": "The Man Who Would Be Vogue", "year": 2018, "type": "episode", "runtimeSeconds": 3203, "seasonCount": null, "communityRating": 7.459, "criticRating": null, "genres": ["Biography", "Crime", "Drama"], "overview": "The murder of Gianni Versace turns the eyes of the world onto Miami Beach.", "poster": "assets/episode-the-man-who-would-be-vogue-2018-s2e1-poster.jpg", "backdrop": "assets/episode-the-man-who-would-be-vogue-2018-s2e1-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T13:29:35.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "American Crime Story", "seasonNumber": 2, "episodeNumber": 1, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}, {"id": "episode-tomorrow-2024-s3e1", "title": "Tomorrow", "year": 2024, "type": "episode", "runtimeSeconds": 2207, "seasonCount": null, "communityRating": 7.6, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "The next day and the days that led to it.", "poster": "assets/episode-tomorrow-2024-s3e1-poster.jpg", "backdrop": "assets/episode-tomorrow-2024-s3e1-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T13:46:39.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "The Bear", "seasonNumber": 3, "episodeNumber": 1, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}, {"id": "episode-episode-1-2025-s1e1", "title": "Episode 1", "year": 2025, "type": "episode", "runtimeSeconds": 3618, "seasonCount": null, "communityRating": 7.357, "criticRating": null, "genres": ["Biography", "Drama", "History"], "overview": "Mussolini had just founded the Fasci di Combattimento when D'Annunzio took Fiume. Mussolini hoped to capitalize on the poet's achievement in the electoral arena.", "poster": "assets/episode-episode-1-2025-s1e1-poster.jpg", "backdrop": "assets/episode-episode-1-2025-s1e1-backdrop.jpg", "played": false, "playCount": 1, "resumeSeconds": 489.55, "progress": 0.1353, "addedAt": "2026-07-17T13:52:48.0000000Z", "lastPlayedAt": "2026-11-04T19:01:49.9731435Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "Mussolini: Son of the Century", "seasonNumber": 1, "episodeNumber": 1, "artwork": {"posterKind": "center_crop", "backdropKind": "backdrop"}}], "recentlyAdded": [{"id": "tv-the-final-problem-2026", "title": "The Final Problem", "year": 2026, "type": "tv", "runtimeSeconds": 3420, "seasonCount": 1, "communityRating": 7.041, "criticRating": null, "genres": ["Crime", "Mystery"], "overview": "In the spring of 1959, a retired actor, who previously played Sherlock Holmes, must investigate a murder at a small hotel on a tiny island near Mallorca.", "poster": "assets/tv-the-final-problem-2026-poster.jpg", "backdrop": "assets/tv-the-final-problem-2026-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-11-04T19:16:12.4822791Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 290720, "artwork": {"posterKind": "center_crop", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.041, "tmdbVotes": 49}}, {"id": "tv-dead-end-job-2026", "title": "Dead-End Job", "year": 2026, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.7, "criticRating": null, "genres": ["Mystery"], "overview": "Drowning in debt, a young man takes lucrative part-time jobs from a mysterious agency \u2014 only to find his hustle for more money turning nightmarish.", "poster": "assets/tv-dead-end-job-2026-poster.jpg", "backdrop": "assets/tv-dead-end-job-2026-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-11-04T18:58:01.8352857Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 289517, "artwork": {"posterKind": "center_crop", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.7, "tmdbVotes": 38}}, {"id": "tv-dune-prophecy-2024", "title": "Dune: Prophecy", "year": 2024, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.213, "criticRating": null, "genres": ["Sci-Fi & Fantasy", "Drama", "Action & Adventure"], "overview": "Ten thousand years before the ascension of Paul Atreides, sisters Valya and Tula Harkonnen establish the fabled sect and female order that would become known as the Bene Gesserit to control the future of humankind.", "poster": "assets/tv-dune-prophecy-2024-poster.jpg", "backdrop": "assets/tv-dune-prophecy-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-11-04T18:23:01.2908568Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 90228, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 64, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.213, "tmdbVotes": 677}}, {"id": "movie-the-uprising-2026", "title": "The Uprising", "year": 2026, "type": "movie", "runtimeSeconds": 7684, "seasonCount": null, "communityRating": 7.846, "criticRating": 55, "genres": ["Action", "Drama", "History"], "overview": "As war and plague devastate 14th-century England, a humble peasant ignites a rebellion, uniting an army of commoners to defy the King\u2019s might in a desperate fight for justice and survival.", "poster": "assets/movie-the-uprising-2026-poster.jpg", "backdrop": "assets/movie-the-uprising-2026-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-11-02T16:37:03.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 977942, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 57, "rtCriticsLabel": "Rotten", "rtAudience": 65, "rtAudienceLabel": "Upright", "imdb": 6.2, "imdbVotes": 6622, "tmdb": 7.8, "tmdbVotes": 404}}, {"id": "movie-jennifer-s-body-2009", "title": "Jennifer's Body", "year": 2009, "type": "movie", "runtimeSeconds": 6450, "seasonCount": null, "communityRating": 6.012, "criticRating": 47, "genres": ["Horror", "Comedy"], "overview": "Jennifer, a gorgeous, seductive cheerleader takes evil to a whole new level after she's possessed by a sinister demon. Now it's up to her best friend to stop Jennifer's reign of terror before it's too late.", "poster": "assets/movie-jennifer-s-body-2009-poster.jpg", "backdrop": "assets/movie-jennifer-s-body-2009-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-30T03:37:04.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 19994, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 47, "rtCriticsLabel": "Rotten", "rtAudience": 36, "rtAudienceLabel": "Spilled", "imdb": 5.6, "imdbVotes": 179710, "tmdb": 6.013, "tmdbVotes": 4481}}, {"id": "tv-breeders-2020", "title": "Breeders", "year": 2020, "type": "tv", "runtimeSeconds": 1500, "seasonCount": 4, "communityRating": 6.8, "criticRating": null, "genres": ["Comedy"], "overview": "Exposing the parental-paradox that it is possible, in the very same moment, to love your child to the horizon of the universe, while being apoplectically angry enough to want to send them there.", "poster": "assets/tv-breeders-2020-poster.jpg", "backdrop": "assets/tv-breeders-2020-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-28T18:54:21.8885435Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 93290, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 92, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.8, "tmdbVotes": 104}}, {"id": "tv-the-four-seasons-2025", "title": "The Four Seasons", "year": 2025, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 6.879, "criticRating": null, "genres": ["Comedy"], "overview": "The decades-long friendship between three married couples is tested when one divorces, complicating their tradition of quarterly weekend getaways.", "poster": "assets/tv-the-four-seasons-2025-poster.jpg", "backdrop": "assets/tv-the-four-seasons-2025-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-28T18:36:42.5326384Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 243316, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 81, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.892, "tmdbVotes": 204}}, {"id": "tv-adults-2025", "title": "Adults", "year": 2025, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 6.495, "criticRating": null, "genres": ["Comedy"], "overview": "A group of twenty-somethings in New York trying to be good people, despite being neither \"good\" nor \"people\" yet.", "poster": "assets/tv-adults-2025-poster.jpg", "backdrop": "assets/tv-adults-2025-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-23T21:01:41.1814765Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 260458, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 88, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.5, "tmdbVotes": 102}}, {"id": "tv-east-of-eden-2026", "title": "East of Eden", "year": 2026, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.931, "criticRating": null, "genres": ["Drama", "Crime"], "overview": "In the early 1900s, a fiercely independent woman drives a rift between two brothers, igniting a saga that unfolds across two generations.", "poster": "assets/tv-east-of-eden-2026-poster.jpg", "backdrop": "assets/tv-east-of-eden-2026-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-23T18:07:11.3965096Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 258165, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 88, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.982, "tmdbVotes": 139}}, {"id": "movie-fighting-with-my-family-2019", "title": "Fighting with My Family", "year": 2019, "type": "movie", "runtimeSeconds": 6509, "seasonCount": null, "communityRating": 6.84, "criticRating": 93, "genres": ["Comedy"], "overview": "Born into a tight-knit wrestling family, Paige and her brother Zak are ecstatic when they get the once-in-a-lifetime opportunity to try out for the WWE. But when only Paige earns a spot in the competitive training\u2026", "poster": "assets/movie-fighting-with-my-family-2019-poster.jpg", "backdrop": "assets/movie-fighting-with-my-family-2019-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-22T22:10:59.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 445629, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 93, "rtCriticsLabel": "Certified Fresh", "rtAudience": 86, "rtAudienceLabel": "Upright", "imdb": 7, "imdbVotes": 94576, "tmdb": 6.837, "tmdbVotes": 1952}}, {"id": "tv-landman-2024", "title": "Landman", "year": 2024, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 8.194, "criticRating": null, "genres": ["Drama"], "overview": "Set in the proverbial boomtowns of West-Texas and a modern-day tale of fortune-seeking in the world of oil rigs, the series is an upstairs/downstairs story of roughnecks and wildcat billionaires that are fueling a\u2026", "poster": "assets/tv-landman-2024-poster.jpg", "backdrop": "assets/tv-landman-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-22T21:38:18.0973875Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 157741, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 80, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.209, "tmdbVotes": 668}}, {"id": "tv-fawlty-towers-1975", "title": "Fawlty Towers", "year": 1975, "type": "tv", "runtimeSeconds": 1800, "seasonCount": 2, "communityRating": 8.295, "criticRating": null, "genres": ["Comedy"], "overview": "Owner Basil Fawlty, his wife Sybil, a chambermaid Polly, and Spanish waiter Manuel attempt to run their hotel amidst farcical situations and an array of demanding guests.", "poster": "assets/tv-fawlty-towers-1975-poster.jpg", "backdrop": "assets/tv-fawlty-towers-1975-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-19T23:26:39.7658344Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 2207, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.296, "tmdbVotes": 867}}], "discover": [{"id": "movie-12-years-a-slave-2013", "title": "12 Years a Slave", "year": 2013, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "History"], "overview": "In the pre-Civil War United States, Solomon Northup, a free black man from upstate New York, is abducted and sold into slavery. Facing cruelty as well as unexpected kindnesses Solomon struggles not only to stay\u2026", "poster": "assets/movie-12-years-a-slave-2013-poster.jpg", "backdrop": "assets/movie-12-years-a-slave-2013-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 76203, "ratings": {"rtCritics": 95, "rtCriticsLabel": "Certified Fresh", "rtAudience": 90, "rtAudienceLabel": "Upright", "imdb": 8.1, "imdbVotes": 796313, "tmdb": 7.927, "tmdbVotes": 12335}}, {"id": "movie-101-dalmatians-ii-patch-s-london-adventure-2003", "title": "101 Dalmatians II: Patch's London Adventure", "year": 2003, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Family", "Animation", "Adventure"], "overview": "Being one of 101 takes its toll on Patch, who doesn't feel unique. When he's accidentally left behind on moving day, he meets his idol, Thunderbolt, who enlists him on a publicity campaign.", "poster": "assets/movie-101-dalmatians-ii-patch-s-london-adventure-2003-poster.jpg", "backdrop": "assets/movie-101-dalmatians-ii-patch-s-london-adventure-2003-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 13654, "ratings": {"rtCritics": 67, "rtCriticsLabel": "Fresh", "rtAudience": 41, "rtAudienceLabel": "Spilled", "imdb": 5.7, "imdbVotes": 12343, "tmdb": 6.025, "tmdbVotes": 1274}}, {"id": "movie-absolution-2024", "title": "Absolution", "year": 2024, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Crime", "Thriller"], "overview": "An aging ex-boxer gangster working as muscle for a Boston crime boss receives an upsetting diagnosis. Despite a faltering memory, he attempts to rectify the sins of his past and reconnect with his estranged\u2026", "poster": "assets/movie-absolution-2024-poster.jpg", "backdrop": "assets/movie-absolution-2024-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 974453, "ratings": {"rtCritics": 56, "rtCriticsLabel": "Rotten", "rtAudience": 32, "rtAudienceLabel": "Spilled", "imdb": 5.2, "imdbVotes": 11413, "tmdb": 5.906, "tmdbVotes": 564}}, {"id": "movie-addicted-2014", "title": "Addicted", "year": 2014, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Thriller"], "overview": "A gallerist risks her family and flourishing career when she enters into an affair with a talented painter and slowly loses control of her life.", "poster": "assets/movie-addicted-2014-poster.jpg", "backdrop": "assets/movie-addicted-2014-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 235271, "ratings": {"rtCritics": 7, "rtCriticsLabel": "Rotten", "rtAudience": 51, "rtAudienceLabel": "Spilled", "imdb": 5, "imdbVotes": 13131, "tmdb": 5.928, "tmdbVotes": 466}}, {"id": "movie-all-the-money-in-the-world-2017", "title": "All the Money in the World", "year": 2017, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Crime", "History", "Thriller"], "overview": "The story of the kidnapping of 16-year-old John Paul Getty III and the desperate attempt by his devoted mother to convince his billionaire grandfather Jean Paul Getty to pay the ransom.", "poster": "assets/movie-all-the-money-in-the-world-2017-poster.jpg", "backdrop": "assets/movie-all-the-money-in-the-world-2017-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 446791, "ratings": {"rtCritics": 79, "rtCriticsLabel": "Certified Fresh", "rtAudience": 66, "rtAudienceLabel": "Upright", "imdb": 6.8, "imdbVotes": 97448, "tmdb": 6.493, "tmdbVotes": 2493}}, {"id": "movie-the-alto-knights-2025", "title": "The Alto Knights", "year": 2025, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Crime", "Drama"], "overview": "Two of New York's most notorious organized crime bosses, Frank Costello and Vito Genovese, vie for control of the city's streets. Once the best of friends, petty jealousies and a series of betrayals place them on a\u2026", "poster": "assets/movie-the-alto-knights-2025-poster.jpg", "backdrop": "assets/movie-the-alto-knights-2025-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 1013601, "ratings": {"rtCritics": 39, "rtCriticsLabel": "Rotten", "rtAudience": 71, "rtAudienceLabel": "Upright", "imdb": 5.9, "imdbVotes": 23017, "tmdb": 6.223, "tmdbVotes": 485}}, {"id": "movie-the-amazing-spider-man-2-2014", "title": "The Amazing Spider-Man 2", "year": 2014, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Adventure", "Science Fiction"], "overview": "For Peter Parker, life is busy. Between taking out the bad guys as Spider-Man and spending time with the person he loves, Gwen Stacy, high school graduation cannot come quickly enough. Peter has not forgotten about\u2026", "poster": "assets/movie-the-amazing-spider-man-2-2014-poster.jpg", "backdrop": "assets/movie-the-amazing-spider-man-2-2014-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 102382, "ratings": {"rtCritics": 51, "rtCriticsLabel": "Rotten", "rtAudience": 64, "rtAudienceLabel": "Upright", "imdb": 6.6, "imdbVotes": 611981, "tmdb": 6.567, "tmdbVotes": 14919}}, {"id": "movie-ant-man-and-the-wasp-quantumania-2023", "title": "Ant-Man and the Wasp: Quantumania", "year": 2023, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Adventure", "Science Fiction"], "overview": "Super-Hero partners Scott Lang and Hope van Dyne, along with with Hope's parents Janet van Dyne and Hank Pym, and Scott's daughter Cassie Lang, find themselves exploring the Quantum Realm, interacting with strange\u2026", "poster": "assets/movie-ant-man-and-the-wasp-quantumania-2023-poster.jpg", "backdrop": "assets/movie-ant-man-and-the-wasp-quantumania-2023-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 640146, "ratings": {"rtCritics": 46, "rtCriticsLabel": "Rotten", "rtAudience": 81, "rtAudienceLabel": "Upright", "imdb": 6, "imdbVotes": 281738, "tmdb": 6.197, "tmdbVotes": 6236}}, {"id": "movie-arctic-dogs-2019", "title": "Arctic Dogs", "year": 2019, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Animation", "Adventure", "Comedy"], "overview": "Animals band together to save the day when the evil Otto Von Walrus hatches a sinister scheme to accelerate global warming and melt the Arctic Circle.", "poster": "assets/movie-arctic-dogs-2019-poster.jpg", "backdrop": "assets/movie-arctic-dogs-2019-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 416153, "ratings": {"rtCritics": 13, "rtCriticsLabel": "Rotten", "rtAudience": 60, "rtAudienceLabel": "Upright", "imdb": 4.9, "imdbVotes": 2580, "tmdb": 5.9, "tmdbVotes": 148}}, {"id": "movie-the-avengers-2012", "title": "The Avengers", "year": 2012, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Science Fiction", "Action", "Adventure"], "overview": "When an unexpected enemy emerges and threatens global safety and security, Nick Fury, director of the international peacekeeping agency known as S.H.I.E.L.D., finds himself in need of a team to pull the world back\u2026", "poster": "assets/movie-the-avengers-2012-poster.jpg", "backdrop": "assets/movie-the-avengers-2012-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 24428, "ratings": {"rtCritics": 91, "rtCriticsLabel": "Certified Fresh", "rtAudience": 91, "rtAudienceLabel": "Upright", "imdb": 8, "imdbVotes": 1590693, "tmdb": 8.084, "tmdbVotes": 40262}}, {"id": "movie-black-bear-2020", "title": "Black Bear", "year": 2020, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Thriller"], "overview": "At a remote lake house in the Adirondack Mountains, a couple entertains an out-of-town guest looking for inspiration in her filmmaking. The group quickly falls into a calculated game of desire, manipulation, and\u2026", "poster": "assets/movie-black-bear-2020-poster.jpg", "backdrop": "assets/movie-black-bear-2020-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 624788, "ratings": {"rtCritics": 89, "rtCriticsLabel": "Certified Fresh", "rtAudience": 64, "rtAudienceLabel": "Upright", "imdb": 6.5, "imdbVotes": 18408, "tmdb": 6.337, "tmdbVotes": 315}}, {"id": "movie-black-panther-2018", "title": "Black Panther", "year": 2018, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Adventure", "Science Fiction"], "overview": "King T'Challa returns home to the reclusive, technologically advanced African nation of Wakanda to serve as his country's new leader. However, T'Challa soon finds that he is challenged for the throne by factions\u2026", "poster": "assets/movie-black-panther-2018-poster.jpg", "backdrop": "assets/movie-black-panther-2018-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 284054, "ratings": {"rtCritics": 96, "rtCriticsLabel": "Certified Fresh", "rtAudience": null, "rtAudienceLabel": "Spilled", "imdb": 7.3, "imdbVotes": 931120, "tmdb": 7.36, "tmdbVotes": 24190}}, {"id": "movie-a-bronx-tale-1993", "title": "A Bronx Tale", "year": 1993, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Crime"], "overview": "Set in the Bronx during the tumultuous 1960s, an adolescent boy is torn between his honest, working-class father and a violent yet charismatic crime boss. Complicating matters is the youngster's growing attraction -\u2026", "poster": "assets/movie-a-bronx-tale-1993-poster.jpg", "backdrop": "assets/movie-a-bronx-tale-1993-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 1607, "ratings": {"rtCritics": 97, "rtCriticsLabel": "Fresh", "rtAudience": 92, "rtAudienceLabel": "Upright", "imdb": 7.8, "imdbVotes": 191930, "tmdb": 7.913, "tmdbVotes": 3075}}, {"id": "movie-by-any-means-2026", "title": "By Any Means", "year": 2026, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Thriller", "History", "Crime"], "overview": "In 1960s Mississippi, a young Black FBI agent is forced to partner with a notorious mafia hitman to investigate a wave of brutal civil rights murders.", "poster": "assets/movie-by-any-means-2026-poster.jpg", "backdrop": "assets/movie-by-any-means-2026-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 1380417, "ratings": {"rtCritics": 49, "rtCriticsLabel": "Rotten", "rtAudience": 88, "rtAudienceLabel": "Upright", "imdb": 6.8, "imdbVotes": 3534, "tmdb": 6.6, "tmdbVotes": 92}}, {"id": "movie-captain-america-civil-war-2016", "title": "Captain America: Civil War", "year": 2016, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Adventure", "Action", "Science Fiction"], "overview": "Following the events of Age of Ultron, the collective governments of the world pass an act designed to regulate all superhuman activity. This polarizes opinion amongst the Avengers, causing two factions to side with\u2026", "poster": "assets/movie-captain-america-civil-war-2016-poster.jpg", "backdrop": "assets/movie-captain-america-civil-war-2016-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 271110, "ratings": {"rtCritics": 90, "rtCriticsLabel": "Certified Fresh", "rtAudience": 89, "rtAudienceLabel": "Upright", "imdb": 7.8, "imdbVotes": 954943, "tmdb": 7.455, "tmdbVotes": 24808}}, {"id": "movie-captain-america-the-first-avenger-2011", "title": "Captain America: The First Avenger", "year": 2011, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Adventure", "Science Fiction"], "overview": "During World War II, Steve Rogers is a sickly man from Brooklyn who's transformed into super-soldier Captain America to aid in the war effort. Rogers must stop the Red Skull \u2013 Adolf Hitler's ruthless head of\u2026", "poster": "assets/movie-captain-america-the-first-avenger-2011-poster.jpg", "backdrop": "assets/movie-captain-america-the-first-avenger-2011-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 1771, "ratings": {"rtCritics": 80, "rtCriticsLabel": "Certified Fresh", "rtAudience": 75, "rtAudienceLabel": "Upright", "imdb": 6.9, "imdbVotes": 993453, "tmdb": 7.012, "tmdbVotes": 23473}}, {"id": "movie-captain-america-the-winter-soldier-2014", "title": "Captain America: The Winter Soldier", "year": 2014, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Adventure", "Science Fiction"], "overview": "After the cataclysmic events in New York with The Avengers, Steve Rogers, aka Captain America is living quietly in Washington, D.C. and trying to adjust to the modern world. But when a S.H.I.E.L.D. colleague comes\u2026", "poster": "assets/movie-captain-america-the-winter-soldier-2014-poster.jpg", "backdrop": "assets/movie-captain-america-the-winter-soldier-2014-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 100402, "ratings": {"rtCritics": 90, "rtCriticsLabel": "Certified Fresh", "rtAudience": 92, "rtAudienceLabel": "Upright", "imdb": 7.7, "imdbVotes": 995514, "tmdb": 7.654, "tmdbVotes": 20688}}, {"id": "movie-careful-what-you-wish-for-2015", "title": "Careful What You Wish For", "year": 2015, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Thriller"], "overview": "A guy gets more than he bargained for after entering into an affair with the wife of an investment banker. Soon, a suspicious death and substantial life insurance policy embroil him in a scandal.", "poster": "assets/movie-careful-what-you-wish-for-2015-poster.jpg", "backdrop": "assets/movie-careful-what-you-wish-for-2015-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 317744, "ratings": {"rtCritics": 17, "rtCriticsLabel": "Rotten", "rtAudience": 61, "rtAudienceLabel": "Upright", "imdb": 5.6, "imdbVotes": 9452, "tmdb": 6.129, "tmdbVotes": 446}}, {"id": "tv-12-monkeys-2015", "title": "12 Monkeys", "year": 2015, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Sci-Fi & Fantasy", "Drama"], "overview": "The provocative story of Cole, a time traveler from a decimated future in a high-stakes race against the clock. Utilizing a dangerous and untested method of time travel, he journeys from 2043 to the present day on a\u2026", "poster": "assets/tv-12-monkeys-2015-poster.jpg", "backdrop": "assets/tv-12-monkeys-2015-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 60948, "ratings": {"rtCritics": 88, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.441, "tmdbVotes": 1245}}, {"id": "tv-90-day-fiance-before-the-90-days-2017", "title": "90 Day Fianc\u00e9: Before the 90 Days", "year": 2017, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Reality"], "overview": "Follow couples who have an existing relationship online, but haven't met in person. We'll experience their journey as they travel to the other's foreign country for the first time in an attempt to establish an\u2026", "poster": "assets/tv-90-day-fiance-before-the-90-days-2017-poster.jpg", "backdrop": "assets/tv-90-day-fiance-before-the-90-days-2017-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 73319, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.5, "tmdbVotes": 45}}, {"id": "tv-90-day-fiance-the-other-way-2019", "title": "90 Day Fianc\u00e9: The Other Way", "year": 2019, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Reality"], "overview": "Americans move to a foreign country all in the name of love. Will uprooting their lives in the United States pay off, or will they be packing up and moving back to America brokenhearted?", "poster": "assets/tv-90-day-fiance-the-other-way-2019-poster.jpg", "backdrop": "assets/tv-90-day-fiance-the-other-way-2019-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 90046, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.8, "tmdbVotes": 34}}, {"id": "tv-the-100-2014", "title": "The 100", "year": 2014, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Sci-Fi & Fantasy", "Action & Adventure", "Drama"], "overview": "100 years in the future, when the Earth has been abandoned due to radioactivity, the last surviving humans live on an ark orbiting the planet \u2014 but the ark won't last forever. So the repressive regime picks 100\u2026", "poster": "assets/tv-the-100-2014-poster.jpg", "backdrop": "assets/tv-the-100-2014-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 48866, "ratings": {"rtCritics": 93, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.878, "tmdbVotes": 8855}}, {"id": "tv-accused-2023", "title": "Accused", "year": 2023, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Crime", "Drama"], "overview": "In this crime anthology series, viewers discover how an ordinary person got caught up in an extraordinary situation, ultimately revealing how one wrong turn leads to another, until it\u2019s too late to turn back. Told\u2026", "poster": "assets/tv-accused-2023-poster.jpg", "backdrop": "assets/tv-accused-2023-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 202097, "ratings": {"rtCritics": 61, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.851, "tmdbVotes": 87}}, {"id": "tv-after-life-2019", "title": "After Life", "year": 2019, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "Tony had a perfect life. But after his wife Lisa suddenly dies, Tony changes. After contemplating taking his own life, he decides instead to live long enough to punish the world by saying and doing whatever he likes\u2026", "poster": "assets/tv-after-life-2019-poster.jpg", "backdrop": "assets/tv-after-life-2019-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 79410, "ratings": {"rtCritics": 72, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.902, "tmdbVotes": 1169}}, {"id": "tv-all-creatures-great-and-small-1978", "title": "All Creatures Great and Small", "year": 1978, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "The trials and misadventures of the staff at a country veterinary office in Yorkshire. James Herriot, a young animal surgeon, moves to a small Yorkshire town to begin his first job.", "poster": "assets/tv-all-creatures-great-and-small-1978-poster.jpg", "backdrop": "assets/tv-all-creatures-great-and-small-1978-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "center_crop", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 7406, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.8, "tmdbVotes": 50}}, {"id": "tv-american-housewife-2016", "title": "American Housewife", "year": 2016, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Comedy"], "overview": "A family comedy narrated by Katie, a strong-willed mother, raising her flawed family in a wealthy town filled with perfect wives and their perfect offspring.", "poster": "assets/tv-american-housewife-2016-poster.jpg", "backdrop": "assets/tv-american-housewife-2016-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 67117, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.7, "tmdbVotes": 187}}, {"id": "tv-atlantis-2013", "title": "Atlantis", "year": 2013, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action & Adventure", "Sci-Fi & Fantasy"], "overview": "A fantasy drama set in a world of legendary heroes and mythical creatures. Far from home and desperate for answers, Jason washes up on the shores of an ancient land. A mysterious place; a world of bull leaping, of\u2026", "poster": "assets/tv-atlantis-2013-poster.jpg", "backdrop": "assets/tv-atlantis-2013-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 47054, "ratings": {"rtCritics": 62, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.4, "tmdbVotes": 155}}, {"id": "tv-avatar-the-last-airbender-2024", "title": "Avatar: The Last Airbender", "year": 2024, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action & Adventure", "Sci-Fi & Fantasy", "Drama"], "overview": "A young boy known as the Avatar must master the four elemental powers to save a world at war \u2014 and fight a ruthless enemy bent on stopping him.", "poster": "assets/tv-avatar-the-last-airbender-2024-poster.jpg", "backdrop": "assets/tv-avatar-the-last-airbender-2024-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 82452, "ratings": {"rtCritics": 66, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.8, "tmdbVotes": 1406}}, {"id": "tv-avatar-seven-havens-2026", "title": "Avatar: Seven Havens", "year": 2026, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "In a world shattered by a devastating cataclysm, a young Earthbender discovers she's the new Avatar after Korra. But in this dangerous era, that title marks her as humanity's destroyer, not its savior. Hunted by both\u2026", "poster": "assets/tv-avatar-seven-havens-2026-poster.jpg", "backdrop": "assets/tv-avatar-seven-havens-2026-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 284833, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.9, "tmdbVotes": 183}}, {"id": "tv-batman-the-brave-and-the-bold-2008", "title": "Batman: The Brave and the Bold", "year": 2008, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "The Caped Crusader is teamed up with Blue Beetle, Green Arrow, Aquaman and countless others in his quest to uphold justice.", "poster": "assets/tv-batman-the-brave-and-the-bold-2008-poster.jpg", "backdrop": "assets/tv-batman-the-brave-and-the-bold-2008-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 15804, "ratings": {"rtCritics": 96, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.6, "tmdbVotes": 295}}, {"id": "tv-beat-bobby-flay-2013", "title": "Beat Bobby Flay", "year": 2013, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Reality"], "overview": "Two talented chefs go head-to-head for the chance to Beat Bobby Flay. To get to Bobby the chefs must first face off against each other, creating a spectacular dish with a secret ingredient of Bobby's choice. Judges\u2026", "poster": "assets/tv-beat-bobby-flay-2013-poster.jpg", "backdrop": "assets/tv-beat-bobby-flay-2013-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 62481, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.5, "tmdbVotes": 31}}, {"id": "tv-benson-1979", "title": "Benson", "year": 1979, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Comedy"], "overview": "A butler deals with life at the governor's mansion.", "poster": "assets/tv-benson-1979-poster.jpg", "backdrop": "assets/tv-benson-1979-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 1849, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7, "tmdbVotes": 46}}, {"id": "tv-black-rabbit-2025", "title": "Black Rabbit", "year": 2025, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Crime"], "overview": "A rising-star restaurateur is forced into New York's criminal underworld when his chaotic brother returns to town with loan sharks on his trail.", "poster": "assets/tv-black-rabbit-2025-poster.jpg", "backdrop": "assets/tv-black-rabbit-2025-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 249039, "ratings": {"rtCritics": 66, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.983, "tmdbVotes": 240}}, {"id": "tv-black-ish-2014", "title": "black-ish", "year": 2014, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Comedy"], "overview": "A family man struggles to gain a sense of cultural identity while raising his kids in a predominantly white, upper-middle-class neighborhood.", "poster": "assets/tv-black-ish-2014-poster.jpg", "backdrop": "assets/tv-black-ish-2014-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 61381, "ratings": {"rtCritics": 92, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.1, "tmdbVotes": 352}}, {"id": "tv-breaking-bad-2008", "title": "Breaking Bad", "year": 2008, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Crime"], "overview": "Walter White, a New Mexico chemistry teacher, is diagnosed with Stage III cancer and given a prognosis of only two years left to live. He becomes filled with a sense of fearlessness and an unrelenting desire to\u2026", "poster": "assets/tv-breaking-bad-2008-poster.jpg", "backdrop": "assets/tv-breaking-bad-2008-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "center_crop", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 1396, "ratings": {"rtCritics": 96, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.959, "tmdbVotes": 19030}}, {"id": "tv-the-capture-2019", "title": "The Capture", "year": 2019, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Crime", "Drama", "Mystery"], "overview": "When soldier Shaun Emery's conviction for a murder in Afghanistan is overturned due to flawed video evidence, he returns to life as a free man with his young daughter. But when damning CCTV footage from a night out\u2026", "poster": "assets/tv-the-capture-2019-poster.jpg", "backdrop": "assets/tv-the-capture-2019-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 93166, "ratings": {"rtCritics": 97, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.634, "tmdbVotes": 361}}], "discoverySource": {"state": "materialized_snapshot", "rankKnown": false, "selection": "First 18 alphabetically indexed movie stubs and first 18 series stubs, not ranked feed order", "observedAt": "2026-11-04T20:42:12.030108+00:00"}}], "notes": {"real": ["Profile keys and library membership derive from deployed Conductarr configuration and viewer-scoped Jellyfin GET responses.", "Names, years, overviews, library ratings, genres, season-container counts and watch/resume data come from actual Jellyfin metadata.", "All local artwork is copied from Jellyfin; discovery posters are the real materialized Conductarr recommendation artwork.", "Discover contains 36 real materialized personal recommendations per profile, with profile-scoped availability from read-only retained membership/request state.", "Justin has no playable resume items. Mindel and Shared each have one. No progress or watched flags were invented."], "approximations": ["Default profile initials and display capitalization are frontend presentation choices; colours reuse the existing PWA palette, not live user appearance configuration.", "Conductarr catalogue and recommendation GET endpoints both returned 401. Authentication was not bypassed. SQLite contains no retained recommendation feed/candidate snapshot tables; the source keeps ranked feeds in memory. Discovery therefore reads existing per-profile Jellyfin recommendation stubs as metadata only.", "Discovery selection is 18 alphabetically indexed movie suggestions plus 18 series suggestions, not personal rank order; rank is null. Materialization age and original feed ordering are unknown.", "Discovery stub runtime is synthetic (two seconds), so runtime, season count, rating and watch state are null rather than presenting stub values as real media facts.", "93 selected identities lack original landscape art in the materialized metadata; their backdrop is a marked crop of the real poster, not an original film backdrop. Existing artwork is reused; new artwork is centre-cropped to the required dimensions.", "Jellyfin server-side resizing requests maxWidth/maxHeight/quality. When the result had the wrong aspect ratio, sequential single-thread ffmpeg center crops produce exact 300x450 and 1280x720 JPEGs.", "Series seasonCount is Jellyfin ChildCount, including season containers for specials when present.", "Discover availability is a retained profile-membership/request snapshot, not a fresh playback or acquisition guarantee. not_here means absent from that profile in retained state, not absent from the entire household."], "missing": ["Justin Movies has seven live viewer-scoped films, not 200. No titles are invented or borrowed across profile boundaries.", "Discovery community/critic ratings, reliable runtime/season counts, personal feed ranking/freshness and 93 original landscape backdrops are unavailable through the permitted read paths."], "unmetRequirements": ["At least 200 real profile-scoped Justin Movies"], "mustNotBeShown": ["Recs Movies", "Recs Shows", "Justin Movies", "Justin Shows", "Mindel Movies", "Mindel Shows", "Shared Movies", "Shared Shows", "DGiC", "Stored names padded with spaces", "Internal bucket and acquisition-instance names"], "privacy": ["Includes actual watched and resume history for Justin, Mindel and Shared independently; Shared is not a union.", "No Jellyfin item/user IDs, source paths, service URLs, credentials, request actor identities, or private hotlinks are included. tmdbId is a public external title identity.", "Only the seven configured playable libraries are selected. Extra observed DGiC views and all recommendation stubs are excluded from playable collections.", "No profile is assumed to be a child or age-restricted. Each playable list and episode shelf is limited to that profile\u2019s configured libraries."], "sourceReadPaths": ["GET /Users (allowlisted mapping only)", "GET /Users/{userId}/Views", "GET /Users/{userId}/Items (parent-scoped playable libraries and metadata-only recommendation libraries)", "GET /Users/{userId}/Items/Resume", "GET /Shows/NextUp?UserId=...", "GET /Items/{itemId}/Images/Primary", "GET /Items/{itemId}/Images/Backdrop/0", "Conductarr GET /catalogue/discover?page=1&media_type=movie (401)", "Conductarr GET /api/v1/recommendations/profiles/justin/feed (401)", "SQLite file URI mode=ro plus PRAGMA query_only=ON: catalogue_metadata, bucket_memberships, native_requests; sqlite_master for schema inspection"], "productionMutations": 0, "artwork": {"files": 632, "bytes": 35201423, "posterDimensions": [300, 450], "backdropDimensions": [1280, 720], "posterQualityRequested": 70, "backdropQualityRequested": 65, "posterDerivedBackdropCount": 93}, "consumerContract": ["profiles is an array in Justin/Mindel/Shared order. Every profile owns movies/shows/anime/resume/nextUp/recentlyAdded/discover item arrays; empty shelves are intentional.", "Item paths are relative to fixture.json. Resolve assets against the fixture directory, not against the outer prototype document.", "Use destinations for visible navigation. No raw library display/stored names belong in UI.", "Deduplicate library/resume/discover by stable local id or (type,tmdbId). Never treat a materialized recommendation as a playable library item.", "played=null and watchStateKnown=false mean unknown, not unwatched. Null ratings are missing, not zero; communityRating source is Jellyfin, not an asserted TMDB/IMDb score.", "availability values are in_library/requested/not_here. No requested examples were observed in the selected discover set; do not fabricate them.", "Render artwork.backdropKind=poster_crop as a poster-derived landscape; prefer the poster over pretending it is original landscape art.", "This static fixture preserves actual current collection sizes, viewer-scoped watch state and unknown discovery ranking; request and playback demonstrations must remain labelled simulations."]}} \ No newline at end of file diff --git a/wolfskin-design/src/debug/java/org/justinvisser/wolfskin/design/HomeFixture.kt b/wolfskin-design/src/debug/java/org/justinvisser/wolfskin/design/HomeFixture.kt new file mode 100644 index 00000000..c046c290 --- /dev/null +++ b/wolfskin-design/src/debug/java/org/justinvisser/wolfskin/design/HomeFixture.kt @@ -0,0 +1,129 @@ +package org.justinvisser.wolfskin.design + +import android.content.Context +import org.json.JSONArray +import org.json.JSONObject + +/** The locked fixture is debug-only; release variants cannot construct synthetic household data. */ +class HomeFixture(context: Context) { + private val source = context.assets.open("wolfskin-home/fixture.json").bufferedReader().use { JSONObject(it.readText()) } + private val profiles = source.getJSONArray("profiles").objects() + private val allSeries = profiles.flatMap { it.items("shows") + it.items("anime") } + private val pool = profiles.flatMap { it.items("discover") }.distinctBy { it.identity() } + private val owners = linkedMapOf>().apply { + profiles.forEach { profile -> + listOf("movies", "shows", "anime").flatMap { profile.items(it) }.forEach { item -> + getOrPut(item.identity()) { linkedSetOf() }.add(profile.getString("name")) + } + } + } + + fun snapshot(person: String, state: String = "ready"): HomeSnapshot { + val profile = profiles.first { it.getString("key") == person } + val library = HomeLibrary(profile.items("resume").map(::title), profile.items("nextUp").map(::title), profile.items("recentlyAdded").map(::title), listOf("movies", "shows", "anime").associateWith { key -> profile.items(key).map(::title) }) + val status = when (state) { "unsigned" -> HomeAvailability.SignedOut; "cond-down" -> HomeAvailability.Unavailable; "offline", "cond-cached" -> HomeAvailability.Saved; "loading" -> HomeAvailability.Loading; else -> HomeAvailability.Ready } + val continuation = if (status == HomeAvailability.SignedOut || status == HomeAvailability.Unavailable) listOf(homeDiscoverState(status)) else continuation(profile, state) + var rows = buildHomeRows(library, continuation, state != "jf-down", state == "loading") + rows = rows.map { row -> + if (!row.catalogue) row else { + fun owned(title: HomeTitle): HomeTitle { + val names = owners[title.tmdbId?.let { "${title.type}:$it" } ?: title.id].orEmpty() + val held = title.held || profile.getString("name") in names + val other = if (held) null else names.filter { it != profile.getString("name") }.takeIf { it.isNotEmpty() }?.joinToString(" and ") + return if (held == title.held && other == title.otherOwner) title else title.copy(held = held, otherOwner = other) + } + val cards = row.cards.map(::owned) + row.copy(cards = cards, allCards = if (row.allCards === row.cards) cards else row.allCards.map(::owned)) + } + } + if (state == "missing") { + val missing = profiles.flatMap { person -> listOf("movies", "shows", "anime", "discover", "nextUp", "recentlyAdded").flatMap { key -> person.items(key).filterIndexed { index, _ -> index % 3 == 0 } } }.map { it.getString("id") }.toSet() + rows = rows.map { row -> row.copy(cards = row.cards.map { if (it.id in missing) it.copy(poster = null, backdrop = null) else it }) } + } + return HomeSnapshot(rows, status, state != "jf-down" && state != "offline", if (status == HomeAvailability.Saved) "20:42" else null) + } + + private fun continuation(profile: JSONObject, state: String): List = buildList { + val picks = profile.items("discover") + val movies = picks.filter { it.getString("type") == "movie" } + val shows = picks.filter { it.getString("type") == "tv" } + val played = listOf("movies", "shows", "anime").flatMap { profile.items(it) }.filter { it.optBoolean("played") } + val groups = linkedMapOf>>() + (movies + shows).forEach { candidate -> + played.map { it to genres(it).intersect(genres(candidate)).size }.filter { it.second > 0 }.sortedByDescending { it.second }.take(2).forEach { (seed, _) -> + groups.getOrPut(seed.identity()) { seed to mutableListOf() }.second.add(candidate) + } + } + val seen = mutableSetOf() + if (state == "picks-unavailable" || state == "no-seeds") { + add(HomeRow("picks-na", HomeRowKind.Note, text = if (state == "no-seeds") "Watch something to get picks for ${profile.getString("name")}." else "Picks unavailable right now.", calm = state == "no-seeds")) + } else { + val groupRows = groups.values.take(3) + fun take(items: List, limit: Int): List = items.filter { it.identity() !in seen }.take(limit).filter { seen.add(it.identity()) } + val movieRow = take(movies, minOf(12, kotlin.math.ceil(movies.size.toDouble() / (groupRows.size + 1)).toInt())) + val showRow = take(shows, minOf(12, kotlin.math.ceil(shows.size.toDouble() / (groupRows.size + 1)).toInt())) + val remaining = (movies + shows).count { it.identity() !in seen } + val size = if (groupRows.isEmpty()) 0 else minOf(8, kotlin.math.ceil(remaining.toDouble() / groupRows.size).toInt()) + val watchedRows = groupRows.map { (seed, items) -> Triple(seed, take(items, size), items) }.filter { it.second.size >= 3 } + if (movieRow.isNotEmpty()) add(shelf("pk-m", "Movies for you", movieRow, movies.size, movies)) + if (showRow.isNotEmpty()) add(shelf("pk-s", "Series for you", showRow, shows.size, shows)) + watchedRows.take(2).forEachIndexed { index, (seed, items, all) -> add(shelf("pk-w$index", "Because you watched ${seed.getString("title")}", items, all.size, all)) } + } + val excluded = flatMap { row -> row.cards }.map { it.id }.toSet() + val base = pool.filter { it.getString("id") !in excluded } + listOf("Netflix", "Prime Video").forEachIndexed { index, service -> + val shift = if (base.isEmpty()) 0 else index * 7 % base.size + val rotated = base.drop(shift) + base.take(shift) + add(shelf("new-$index", "New on $service", rotated.take(12).sortedByDescending { it.optInt("year") })) + } + add(shelf("trending", "Trending", base.take(WolfskinTokens.Behaviour.rowLimit.toInt()), base.size, base)) + listOf("Comedy", "Animation", "Science Fiction", "Horror", "Romance").forEach { genre -> + val titles = base.filter { genre in genres(it) && (genre !in setOf("Science Fiction", "Horror", "Romance") || it.getString("type") == "movie") } + if (titles.isNotEmpty()) add(shelf("genre-$genre", genre, titles.take(WolfskinTokens.Behaviour.rowLimit.toInt()), titles.size, titles)) + } + add(HomeRow("explore", HomeRowKind.Explore, "Explore more in Discover")) + } + + private fun shelf(id: String, name: String, items: List, total: Int = items.size, allItems: List = items): HomeRow { + val cards = items.map(::title) + return HomeRow(id, HomeRowKind.Shelf, name, cards, catalogue = true, total = total, allCards = if (allItems === items) cards else allItems.map(::title)) + } + + private fun title(item: JSONObject): HomeTitle = HomeTitle( + id = item.getString("id"), title = item.getString("title"), type = item.getString("type"), + year = item.intOrNull("year"), runtimeSeconds = item.intOrNull("runtimeSeconds"), seasons = item.intOrNull("seasonCount"), + overview = item.optString("overview"), poster = item.stringOrNull("poster")?.let { "file:///android_asset/wolfskin-home/$it" }, + backdrop = item.stringOrNull("backdrop")?.let { "file:///android_asset/wolfskin-home/$it" }, + posterOnly = item.optJSONObject("artwork")?.optString("backdropKind") == "poster_crop", + seriesTitle = item.stringOrNull("seriesTitle"), season = item.intOrNull("seasonNumber"), episode = item.intOrNull("episodeNumber"), + resumeSeconds = item.optDouble("resumeSeconds", 0.0).takeIf { it.isFinite() } ?: 0.0, + played = item.optBoolean("played"), addedAt = item.optString("addedAt", ""), genres = genres(item).toList(), ratings = ratings(item), + held = item.optString("availability") == "in_library", tmdbId = item.intOrNull("tmdbId"), + ) + + private fun ratings(item: JSONObject): List { + val episode = item.optString("type") == "episode" + val series = if (episode) allSeries.firstOrNull { it.optString("title") == item.optString("seriesTitle") } else item + val source = series?.optJSONObject("ratings") ?: JSONObject().apply { + put("rtCritics", series?.opt("criticRating")); put("tmdb", series?.opt("communityRating")) + } + val result = mutableListOf() + fun score(key: String, max: Double): Double? = if (source.isNull(key)) null else source.optDouble(key).takeIf { it.isFinite() && it in 0.0..max } + score("rtCritics", 100.0)?.let { result += HomeRating(when (source.optString("rtCriticsLabel")) { "Fresh", "Certified Fresh" -> "fresh"; "Rotten" -> "rotten"; else -> "tomato" }, it, true) } + score("rtAudience", 100.0)?.let { result += HomeRating(if (source.optString("rtAudienceLabel") == "Spilled") "spilled" else "upright", it, true) } + score("imdb", 10.0)?.takeIf { source.isNull("imdbVotes") || source.optInt("imdbVotes") > 0 }?.let { result += HomeRating("imdb", it) } + if (result.isEmpty()) score("tmdb", 10.0)?.takeIf { source.isNull("tmdbVotes") || source.optInt("tmdbVotes") > 0 }?.let { result += HomeRating("tmdb", it) } + if (episode && !item.isNull("communityRating")) { + val score = item.optDouble("communityRating") + if (score.isFinite() && score in 0.0..10.0) { result.removeAll { it.mark == "tmdb" }; result += HomeRating("tmdb", score, episode = true) } + } + return result + } + + private fun genres(item: JSONObject): Set = item.optJSONArray("genres")?.let { array -> (0 until array.length()).flatMap { index -> when (val name = array.getString(index)) { "Sci-Fi & Fantasy" -> listOf("Science Fiction", "Fantasy"); "Action & Adventure" -> listOf("Action", "Adventure"); "Sci-Fi" -> listOf("Science Fiction"); else -> listOf(name) } }.toSet() }.orEmpty() + private fun JSONObject.identity(): String = if (!isNull("tmdbId")) "${optString("type")}:${optInt("tmdbId")}" else getString("id") + private fun JSONObject.items(key: String): List = getJSONArray(key).objects() + private fun JSONArray.objects(): List = List(length()) { getJSONObject(it) } + private fun JSONObject.intOrNull(key: String): Int? = if (isNull(key)) null else optInt(key) + private fun JSONObject.stringOrNull(key: String): String? = if (isNull(key)) null else optString(key).takeIf { it.isNotEmpty() } +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Caption.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Caption.kt new file mode 100644 index 00000000..5f9a0742 --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Caption.kt @@ -0,0 +1,72 @@ +package org.justinvisser.wolfskin.design + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.TextStyle +import androidx.tv.material3.Text +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.unit.constrainHeight + +/** Captions fade without moving the reserved band under the focused poster. */ +@Composable +fun Caption(title: String, facts: String, visible: Boolean, modifier: Modifier = Modifier, accentLastFact: Boolean = false, accentFirstFact: Boolean = false, + titleStyle: TextStyle = WolfskinTokens.Type.button, factsStyle: TextStyle = WolfskinTokens.Type.caption, + titleOverflow: TextOverflow = TextOverflow.Ellipsis) { + val alpha = remember(title, facts) { Animatable(0f) } + LaunchedEffect(alpha, visible) { + alpha.animateTo(if (visible) 1f else 0f, tween(WolfskinTokens.Motion.fast, easing = CubicBezierEasing(.25f, .1f, .25f, 1f))) + } + Column(modifier.graphicsLayer { this.alpha = alpha.value }) { + Text(title, color = WolfskinTokens.Color.fg, style = titleStyle, modifier = Modifier.exactLineBox(titleStyle), maxLines = 1, overflow = titleOverflow) + CaptionFacts(facts, accentFirstFact, accentLastFact, factsStyle) + } +} + +/** CSS flex facts: only coloured role/ownership spans shrink; ordinary facts and dots keep their width. */ +@Composable +private fun CaptionFacts(facts: String, accentFirst: Boolean, accentLast: Boolean, style: TextStyle) { + val parts = remember(facts) { facts.split(" · ") } + val muted = WolfskinTokens.Color.muted + val colour = WolfskinTokens.Color.accentText + Layout( + content = { + parts.forEachIndexed { index, part -> + val accented = (index == 0 && accentFirst) || (index == parts.lastIndex && accentLast) + Text(part, color = if (accented) colour else muted, style = style, modifier = Modifier.exactLineBox(style), maxLines = 1, overflow = TextOverflow.Clip) + if (index != parts.lastIndex) Text("·", color = muted.copy(alpha = muted.alpha * WolfskinTokens.Library.captionSeparatorOpacity), style = style, modifier = Modifier.exactLineBox(style)) + } + }, + modifier = Modifier.fillMaxWidth().clipToBounds(), + ) { children, constraints -> + val gap = WolfskinTokens.Library.captionFactsGap.roundToPx() + val basis = IntArray(children.size) + var fixed = gap * (children.size - 1).coerceAtLeast(0) + var flexible = 0 + fun shrinks(index: Int) = (index == 0 && accentFirst) || (index == children.lastIndex && accentLast) + children.forEachIndexed { index, child -> + val width = child.maxIntrinsicWidth(constraints.maxHeight) + basis[index] = width + if (shrinks(index)) flexible += width else fixed += width + } + val available = (constraints.maxWidth - fixed).coerceAtLeast(0) + val ratio = if (flexible > available) available.toFloat() / flexible else 1f + val measured = children.mapIndexed { index, child -> + val width = if (shrinks(index)) (basis[index] * ratio).toInt() else basis[index] + child.measure(constraints.copy(minWidth = 0, maxWidth = width, minHeight = 0)) + } + layout(constraints.maxWidth, constraints.constrainHeight(measured.maxOfOrNull { it.height } ?: 0)) { + var x = 0 + measured.forEach { child -> child.placeRelative(x, 0); x += child.width + gap } + } + } +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/CatalogueRecoveryCard.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/CatalogueRecoveryCard.kt new file mode 100644 index 00000000..4c7e5f8d --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/CatalogueRecoveryCard.kt @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.design + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.tv.material3.Text +import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C +import org.justinvisser.wolfskin.design.WolfskinTokens.Library as G + +/** Shelves and continuous grids share the same recoverable tail instead of a page selector. */ +@Composable +fun CatalogueRecoveryCard(retry: Boolean, focused: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) { + val shape = RoundedCornerShape(WolfskinTokens.Radius.card) + Column(modifier.size(WolfskinTokens.Card.posterW, WolfskinTokens.Card.posterH) + .focusScale(focused, WolfskinTokens.Focus.scaleCard) + .focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardRing, FocusLift.Card) + .background(C.surface, shape).border(G.borderWidth, C.border, shape).clickable(onClick = onClick).padding(G.tailPadding), + verticalArrangement = Arrangement.spacedBy(G.tailGap, Alignment.CenterVertically), horizontalAlignment = Alignment.CenterHorizontally) { + Glyph(if (retry) "alert" else "down", if (retry) C.warn else C.accentText, Modifier.size(G.tailIcon)) + Text(if (retry) "Try again" else "Show more", color = C.fg, style = WolfskinTokens.Type.button, textAlign = TextAlign.Center) + Text(if (retry) "More titles couldn’t load" else "Only titles already shown came back", color = C.muted, + style = G.tailDescription, textAlign = TextAlign.Center) + } +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInFixture.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInFixture.kt new file mode 100644 index 00000000..50608f0b --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInFixture.kt @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.design + +import org.json.JSONObject + +/** Debug assets and native screenshots consume the same projection of the frozen prototype. */ +fun readComingInFixture(json: String, poster: (String) -> Any?): List { + val items = JSONObject(json).getJSONArray("items") + return List(items.length()) { index -> + val item = items.getJSONObject(index) + val scores = item.getJSONArray("ratings") + ComingInRequest( + id = item.getString("id"), mediaType = item.getString("mediaType"), tmdbId = item.getLong("tmdbId"), + title = item.getString("title"), requesterKey = item.getString("requesterKey"), requesterName = item.getString("requesterName"), + actorKey = item.getString("actorKey"), actorName = item.getString("actorName"), stage = ComingInStage.valueOf(item.getString("stage")), + poster = poster(item.getString("poster")), + ratings = List(scores.length()) { scoreIndex -> + val score = scores.getJSONObject(scoreIndex) + ComingInRating(score.getString("mark"), score.getDouble("score"), score.getBoolean("percentage")) + }, + ) + } +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInLineBox.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInLineBox.kt new file mode 100644 index 00000000..e7ae86a9 --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInLineBox.kt @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.design + +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.FirstBaseline +import androidx.compose.ui.layout.layout +import androidx.compose.ui.unit.Dp + +/** A CSS line box has its own height and baseline, independent of native leading rounding. */ +internal fun Modifier.comingInLineBox(height: Dp, baseline: Dp): Modifier = layout { measurable, constraints -> + val text = measurable.measure(constraints.copy(minHeight = 0)) + val baselineOffset = baseline.roundToPx() - text[FirstBaseline] + layout(text.width, height.roundToPx()) { + text.placeRelative(0, baselineOffset) + } +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInRatings.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInRatings.kt new file mode 100644 index 00000000..bd990e32 --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInRatings.kt @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.design + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.foundation.background +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.ContentScale +import androidx.tv.material3.Text +import java.util.Locale +import org.justinvisser.wolfskin.design.WolfskinTokens.Requests as T + +/** Percentages remain whole; ten-point scores use the television's locale. */ +fun formatComingInRating(rating: ComingInRating, locale: Locale = Locale.getDefault()): String = + if (rating.percentage) "${rating.score.toInt()}%" else String.format(locale, "%.1f", rating.score) + +/** This row always displays the full supplier set rather than the card plate's shortened prefix. */ +@Composable +fun ComingInRatings(ratings: List, color: Color) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(T.ratingsGap)) { + ratings.forEach { rating -> + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(T.ratingsEntryGap)) { + val mark = when (rating.mark) { + "fresh" -> R.drawable.coming_in_rating_fresh + "tomato" -> R.drawable.coming_in_rating_fresh + "popcorn" -> R.drawable.coming_in_rating_upright + "rotten" -> R.drawable.coming_in_rating_rotten + "upright" -> R.drawable.coming_in_rating_upright + "spilled" -> R.drawable.coming_in_rating_spilled + "imdb" -> R.drawable.coming_in_rating_imdb + else -> R.drawable.coming_in_rating_tmdb + } + val wide = rating.mark == "imdb" || rating.mark == "tmdb" + Image(painterResource(mark), contentDescription = rating.mark, + modifier = Modifier.height(T.readinessGlyph).width(T.readinessGlyph * if (wide) T.ratingsWordmarkRatio else 1f) + .clip(ratingMarkShape).background(WolfskinTokens.Color.surface3) + .graphicsLayer { alpha = if (rating.mark == "tomato" || rating.mark == "popcorn") WolfskinTokens.Library.unknownCriticOpacity else 1f }, + contentScale = ContentScale.Fit) + Text(formatComingInRating(rating), style = ComingInTypography.rating, color = color) + } + } + } +} + +private val ratingMarkShape = RoundedCornerShape(WolfskinTokens.Radius.thumb) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInSheet.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInSheet.kt new file mode 100644 index 00000000..d02b4835 --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInSheet.kt @@ -0,0 +1,188 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.design + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.style.TextOverflow +import androidx.tv.material3.Text +import coil3.compose.AsyncImage +import coil3.compose.AsyncImagePainter +import androidx.compose.runtime.mutableStateOf +import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C +import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L +import org.justinvisser.wolfskin.design.WolfskinTokens.Requests as T + +/** The overlay intercepts pointer input as well as the shell's virtual D-pad focus. */ +@Composable +fun ComingInSheet(shell: ShellState, modifier: Modifier = Modifier) { + val state = shell.requests + val data = state.snapshot + val ratingsReader = LocalScreenRatings.current + LaunchedEffect(state, state.focusedId, data.signedIn, data.status) { + if (ratingsReader == null) state.awaitFocusedRatings() + else if (data.signedIn) state.focused?.let { item -> + ratingsReader.read(item.tmdbId, item.mediaType, null, null, cachedOnly = data.status == ComingInStatus.Unavailable)?.let { values -> + state.updateRatings(item.id, values.map { rating -> + val mapped = rating.homeRating() + ComingInRating(mapped.mark, mapped.score, mapped.percentage) + }) + } + } + } + Box(modifier.fillMaxSize().background(C.sheetScrim).clickable { shell.closeRequests() }.testTag("coming-in-scrim")) + Column(modifier.fillMaxHeight().width(L.sheetWidth) + .offset(x = WolfskinTokens.Shell.stageWidth - L.sheetWidth) + .drawWithCache { + val panel = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply { + color = C.surface.toArgb() + setShadowLayer(T.shadowBlur.toPx(), T.shadowOffset.toPx(), 0f, T.shadowColor.toArgb()) + } + onDrawBehind { + drawIntoCanvas { it.nativeCanvas.drawRect(0f, 0f, size.width, size.height, panel) } + } + }.pointerInput(Unit) { + awaitPointerEventScope { + while (true) awaitPointerEvent(PointerEventPass.Final).changes.forEach { it.consume() } + } + } + .padding(horizontal = T.sheetPaddingX, vertical = T.sheetPaddingY) + .testTag("coming-in"), verticalArrangement = Arrangement.spacedBy(T.sheetGap)) { + Text("Coming in", Modifier.comingInLineBox(T.sheetTitleLineHeight, T.sheetTitleBaseline), style = ComingInTypography.title, color = C.fg) + when { + data.status == ComingInStatus.Loading && data.items.isEmpty() -> { + LazyColumn(verticalArrangement = Arrangement.spacedBy(T.listGap)) { + items(3, key = { "request-skeleton-$it" }, contentType = { "skeleton" }) { ComingInSkeleton() } + } + SheetButton("Close") { shell.closeRequests() } + } + data.status == ComingInStatus.Unavailable && data.items.isEmpty() -> { + Column(Modifier.fillMaxWidth().background(C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.panel)) + .padding(horizontal = T.receiptPaddingX, vertical = T.receiptPaddingY), + verticalArrangement = Arrangement.spacedBy(T.receiptGap)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(T.rowPaddingY)) { + Glyph("alert", C.warn, Modifier.size(T.receiptGlyph)) + Text("Requests unavailable", style = ComingInTypography.stateTitle, color = C.warn) + } + Text("Conductarr didn’t answer.", style = ComingInTypography.body, color = C.muted) + } + SheetButton("Try again", primary = true) { shell.retryRequests() } + } + data.items.isEmpty() -> Text("Nothing coming in.", Modifier.padding(top = T.emptyMargin), style = ComingInTypography.body, color = C.muted) + else -> { + if (data.status == ComingInStatus.Unavailable) Text("Conductarr didn’t answer.", style = ComingInTypography.caption, color = C.warn) + data.savedAt?.takeIf { data.status == ComingInStatus.Unavailable }?.let { SavedPill(it) } + if (data.partial) Text("Some requests may be missing: Conductarr answered only in part.", style = ComingInTypography.caption, color = C.warn) + val start = state.windowStart + val visibleRows = remember(data, start, state.windowSize) { + data.ordered.subList(start, (start + state.windowSize).coerceAtMost(data.ordered.size)) + } + LazyColumn(Modifier.clipToBounds().then(if (data.status == ComingInStatus.Unavailable) Modifier.weight(1f, fill = false) else Modifier), verticalArrangement = Arrangement.spacedBy(T.listGap)) { + itemsIndexed(visibleRows, + key = { _, item -> item.id }, contentType = { _, _ -> "request" }) { _, item -> + ComingInRow(item, shell.person, !state.recoveryFocused && item.id == state.focusedId, state.expanded && item.id == state.focusedId, + state.ratingsVisible && data.signedIn && item.id == state.focusedId, + onFocus = { state.focus(item.id) }, onClick = { state.focus(item.id); shell.activateRequest() }) + } + } + if (data.status == ComingInStatus.Unavailable) SheetButton("Try again", primary = true, focused = state.recoveryFocused) { shell.retryRequests() } + } + } + } +} + +@Composable +private fun SheetButton(label: String, primary: Boolean = false, focused: Boolean = true, onClick: () -> Unit) { + Box(Modifier.padding(top = T.buttonMargin)) { PickerButton(label, focused, primary, onClick = onClick) } +} + +@Composable +private fun ComingInSkeleton() { + Row(Modifier.fillMaxWidth().padding(horizontal = T.rowPaddingX, vertical = T.rowPaddingY), + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(T.rowGap)) { + Box(Modifier.size(L.thumbWidth, L.thumbHeight).background(C.surface3, RoundedCornerShape(WolfskinTokens.Radius.thumb))) + Column(verticalArrangement = Arrangement.spacedBy(T.skeletonGap)) { + Box(Modifier.size(T.skeletonTitleWidth, T.skeletonTitleHeight).background(C.surface, RoundedCornerShape(WolfskinTokens.Shell.skeletonRadius))) + Box(Modifier.size(T.skeletonSubtitleWidth, T.skeletonSubtitleHeight).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.card))) + } + } +} + +/** Thumbnail slots do not resize when the text or the readiness block grows. */ +@OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class, ExperimentalLayoutApi::class) +@Composable +fun ComingInRow(item: ComingInRequest, viewer: ShellPerson, focused: Boolean, expanded: Boolean, + showRatings: Boolean, onFocus: () -> Unit, onClick: () -> Unit) { + val foreground = if (focused) C.onFocus else C.fg + val secondary = if (focused) C.onFocus else C.muted + val context = LocalContext.current + val request = remember(item.poster, context) { + item.poster?.let { WolfskinImages.request(context, it, (L.thumbWidth.value * 2).toInt(), (L.thumbHeight.value * 2).toInt(), true) } + } + val posterReady = remember(request) { mutableStateOf(false) } + Row(Modifier.fillMaxWidth().focusScale(focused, WolfskinTokens.Focus.scaleRow) + .background(if (focused) C.fg else Color.Transparent, RoundedCornerShape(WolfskinTokens.Radius.panel)) + .pointerInput(item.id) { + awaitPointerEventScope { + while (true) if (awaitPointerEvent().type == PointerEventType.Enter) onFocus() + } + }.clickable(onClick = onClick) + .padding(horizontal = T.rowPaddingX, vertical = T.rowPaddingY).testTag("coming-row-${item.id}"), + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(T.rowGap)) { + AsyncImage(model = request, imageLoader = WolfskinImages.loader(context), contentDescription = null, + modifier = Modifier.size(L.thumbWidth, L.thumbHeight).clip(RoundedCornerShape(WolfskinTokens.Radius.thumb)) + .background(if (posterReady.value) Color.Transparent else C.surface3), + onState = { posterReady.value = it is AsyncImagePainter.State.Success }, + contentScale = androidx.compose.ui.layout.ContentScale.Crop) + Column(Modifier.weight(1f)) { + Text(item.title, Modifier.comingInLineBox(T.titleLineHeight, T.titleBaseline), + style = ComingInTypography.listTitle, color = foreground, maxLines = 1, overflow = TextOverflow.Ellipsis) + val actor = item.actor(viewer) + Text("for ${item.recipient(viewer)}" + (actor?.let { " · by $it" } ?: ""), Modifier.comingInLineBox(T.factsLineHeight, T.factsBaseline), + style = ComingInTypography.caption, color = secondary) + FlowRow(Modifier.padding(top = T.stageMargin).heightIn(min = T.stageLineHeight), horizontalArrangement = Arrangement.spacedBy(T.readinessGlyph)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(T.stageGap)) { + Glyph(item.stage.glyph, secondary, Modifier.size(T.stageGlyph)) + Text(item.stage.label, Modifier.comingInLineBox(T.stageLabelLineHeight, T.stageBaseline), style = ComingInTypography.label, color = secondary) + } + if (showRatings) ComingInRatings(item.ratings, foreground) + } + if (expanded) FlowRow(Modifier.padding(top = T.readinessMargin), + horizontalArrangement = Arrangement.spacedBy(T.readinessGapX), verticalArrangement = Arrangement.spacedBy(T.readinessGapY)) { + item.readiness.forEachIndexed { index, word -> + Row { + val glyph = when (word) { "Done", "Files present", "Recorded play", "Another library" -> "check"; "Failed", "Check needed", "Needs attention" -> "alert"; "Not yet", "Unknown", "Not checked yet", "Not in library", "Removed", "Not out yet" -> null; else -> "clock" } + if (glyph != null) Glyph(glyph, secondary, Modifier.size(T.readinessGlyph).alignBy { it.measuredHeight }) + Text("${READINESS_NAMES[index]} · $word", Modifier.alignByBaseline(), style = ComingInTypography.readiness, color = secondary) + } + } + } + if (expanded) item.summary?.let { summary -> + Text(summary, Modifier.padding(top = T.readinessMargin), style = ComingInTypography.readiness, color = secondary) + } + } + if (focused && !expanded) Glyph("right", foreground.copy(alpha = T.chevronAlpha), Modifier.size(T.chevron)) + } +} + +private val READINESS_NAMES = listOf("Request", "Download", "Library", "Playback") diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInState.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInState.kt new file mode 100644 index 00000000..feddf7b8 --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInState.kt @@ -0,0 +1,143 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.design + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue + +import kotlinx.coroutines.delay +/** History states distinguish acquisition work from library evidence and playback health. */ +enum class ComingInStage(val label: String, val rank: Int, val glyph: String, val readiness: List) { + Failed("Failed", 0, "alert", listOf("Done", "Failed", "Not yet", "Not yet")), + FileHealth("File health needs attention", 0, "alert", listOf("Done", "Files present", "See details", "Needs attention")), + CheckNeeded("Check needed", 0, "alert", listOf("Done", "Files present", "Check needed", "Not checked yet")), + Downloading("Downloading", 1, "download", listOf("Done", "Downloading", "Not yet", "Not yet")), + AlmostReady("Almost ready", 1, "download", listOf("Done", "Done", "Being added", "Not yet")), + Checking("Checking", 2, "clock", listOf("Checking", "Unknown", "Not yet", "Not yet")), + Searching("Searching", 2, "clock", listOf("Done", "Searching", "Not yet", "Not yet")), + Waiting("Waiting", 3, "clock", listOf("Waiting", "Not yet", "Not yet", "Not yet")), + WaitingForFile("Waiting for a file", 3, "clock", listOf("Done", "Waiting for a file", "Not yet", "Not checked yet")), + NotOutYet("Not out yet", 3, "clock", listOf("Done", "Not out yet", "Not yet", "Not checked yet")), + HeldElsewhere("In another library", 4, "check", listOf("Done", "Files present", "Another library", "Not checked yet")), + Removed("Removed", 4, "clock", listOf("Done", "Removed", "Not in library", "Not checked yet")), + NotInLibrary("Not in any library", 4, "clock", listOf("Done", "Not observed", "Not in library", "Not checked yet")), + Ready("Ready", 4, "check", listOf("Done", "Done", "Done", "Not checked yet")), +} + +/** Ratings have already passed the supplier's observed-score and vote checks. */ +data class ComingInRating(val mark: String, val score: Double, val percentage: Boolean = false) + +/** Requests carry authenticated actor evidence, never the current viewer as a substitute. */ +data class ComingInRequest( + val id: String, + val mediaType: String, + val tmdbId: Long, + val title: String, + val requesterKey: String, + val requesterName: String, + val actorKey: String?, + val actorName: String?, + val stage: ComingInStage, + val poster: Any? = null, + val ratings: List = emptyList(), + val summary: String? = null, + val readiness: List = stage.readiness, +) { + val ratingKey = "$mediaType:$tmdbId" + fun recipient(viewer: ShellPerson): String = if (requesterKey == viewer.key) "you" else requesterName + fun actor(viewer: ShellPerson): String? = when { + actorKey == "shared" -> "Shared" + actorKey != null && actorKey == viewer.key -> "you" + else -> actorName?.takeIf { it.isNotBlank() } + } +} + +enum class ComingInStatus { Ready, Loading, Unavailable } + +/** The provider retains saved rows when refresh fails; an unavailable empty result is not empty success. */ +data class ComingInSnapshot( + val items: List = emptyList(), + val status: ComingInStatus = ComingInStatus.Unavailable, + val partial: Boolean = false, + val signedIn: Boolean = false, + val savedAt: String? = null, +) { + val ordered = items.sortedBy { it.stage.rank } + val requestCount: Int? get() = if (status == ComingInStatus.Ready || items.isNotEmpty()) items.size else null + val hasUnsettledRequests: Boolean get() = items.any { + when (it.stage) { + ComingInStage.Ready, ComingInStage.Removed, ComingInStage.NotInLibrary, ComingInStage.HeldElsewhere, ComingInStage.NotOutYet -> false + else -> true + } + } +} + +/** A bounded window and stable request id preserve focus across refreshes and title round trips. */ +class ComingInState { + var snapshot by mutableStateOf(ComingInSnapshot()) + private set + var focusedId by mutableStateOf(null) + private set + var expanded by mutableStateOf(false) + private set + var ratingsVisible by mutableStateOf(false) + private set + var recoveryFocused by mutableStateOf(false) + private set + private val cachedRatings = mutableSetOf() + val index: Int get() = snapshot.ordered.indexOfFirst { it.id == focusedId }.coerceAtLeast(0) + val focused: ComingInRequest? get() = snapshot.ordered.getOrNull(index) + val windowSize: Int get() = if (expanded) 4 else 5 + val windowStart: Int get() = (index - 2).coerceIn(0, (snapshot.ordered.size - windowSize).coerceAtLeast(0)) + + fun update(value: ComingInSnapshot) { + snapshot = if (value.status != ComingInStatus.Ready && value.signedIn && value.items.isEmpty() && snapshot.signedIn) { + snapshot.copy(status = value.status) + } else value + if (!snapshot.signedIn) cachedRatings.clear() + if (snapshot.ordered.none { it.id == focusedId }) focus(snapshot.ordered.firstOrNull()?.id) + ratingsVisible = snapshot.signedIn && focused?.ratingKey in cachedRatings + if (snapshot.status == ComingInStatus.Ready) recoveryFocused = false + } + + fun focus(id: String?) { + focusedId = id + recoveryFocused = false + expanded = false + ratingsVisible = snapshot.signedIn && focused?.ratingKey in cachedRatings + } + + fun updateRatings(id: String, values: List) { + snapshot = snapshot.copy(items = snapshot.items.map { if (it.id == id) it.copy(ratings = values) else it }) + snapshot.items.firstOrNull { it.id == id }?.let { cachedRatings.add(it.ratingKey) } + ratingsVisible = snapshot.signedIn && focused?.ratingKey in cachedRatings + } + + /** Coroutine cancellation and the captured request id prevent stale focus reads from painting. */ + suspend fun awaitFocusedRatings() { + val item = focused ?: return + if (!snapshot.signedIn || snapshot.status != ComingInStatus.Ready || item.ratings.isEmpty() || ratingsVisible) return + delay(WolfskinTokens.Motion.ratingDwell.toLong()) + if (focusedId != item.id || !snapshot.signedIn || snapshot.status != ComingInStatus.Ready) return + cachedRatings.add(item.ratingKey) + ratingsVisible = true + } + + /** Return true only when a row requests a title; every direction remains trapped in the sheet. */ + fun key(key: ShellKey): Boolean { + if (recoveryFocused) { + if (key == ShellKey.Up) recoveryFocused = false + return false + } + when (key) { + ShellKey.Up -> focus(snapshot.ordered.getOrNull((index - 1).coerceAtLeast(0))?.id) + ShellKey.Down -> if (snapshot.status == ComingInStatus.Unavailable && index == snapshot.ordered.lastIndex) recoveryFocused = true + else focus(snapshot.ordered.getOrNull((index + 1).coerceAtMost(snapshot.ordered.lastIndex))?.id) + ShellKey.Right -> if (focused != null) expanded = true + ShellKey.Left -> expanded = false + ShellKey.Ok -> return focused != null + else -> Unit + } + return false + } +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInTypography.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInTypography.kt new file mode 100644 index 00000000..a8f3b3e0 --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInTypography.kt @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.design + +import androidx.compose.ui.text.style.LineHeightStyle +import androidx.compose.ui.text.font.FontWeight + +/** CSS line boxes retain their leading even on single lines; trimming changes sheet geometry. */ +internal object ComingInTypography { + private val lineBox = LineHeightStyle(LineHeightStyle.Alignment.Center, LineHeightStyle.Trim.None) + val title = WolfskinTokens.Type.sheetTitle.copy(lineHeightStyle = lineBox) + val listTitle = WolfskinTokens.Type.listTitle.copy(lineHeightStyle = lineBox) + val body = WolfskinTokens.Type.body.copy(lineHeightStyle = lineBox) + val label = WolfskinTokens.Type.label.copy(lineHeightStyle = lineBox) + val caption = WolfskinTokens.Type.caption.copy(lineHeightStyle = lineBox) + val stateTitle = WolfskinTokens.Type.stateTitle.copy(lineHeightStyle = lineBox, fontWeight = FontWeight.Bold) + val readiness = caption.copy(fontWeight = FontWeight.Bold) + val rating = WolfskinTokens.Type.label.copy(fontFeatureSettings = "tnum") +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/CssLineBox.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/CssLineBox.kt new file mode 100644 index 00000000..4f1d281a --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/CssLineBox.kt @@ -0,0 +1,68 @@ +package org.justinvisser.wolfskin.design + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.FirstBaseline +import androidx.compose.ui.layout.LastBaseline +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.LayoutModifier +import androidx.compose.ui.layout.Measurable +import androidx.compose.ui.layout.MeasureScope +import androidx.compose.ui.layout.ParentDataModifier +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit +import kotlin.math.roundToInt + +internal data class CssLineBox(val lineHeight: TextUnit, val top: Dp, val bottom: Dp) : LayoutModifier, ParentDataModifier { + override fun Density.modifyParentData(parentData: Any?): Any = parentData ?: this@CssLineBox + fun Density.exactHeight(height: Int): Float { + val margins = top.toPx() + bottom.toPx() + return ((height - margins) / lineHeight.toPx()).roundToInt().coerceAtLeast(1) * lineHeight.toPx() + margins + } + override fun MeasureScope.measure(measurable: Measurable, constraints: Constraints) = with(measurable.measure(constraints.copy(minHeight = 0))) { + val line = lineHeight.toPx() + val lines = ((this[LastBaseline] - this[FirstBaseline]) / line).roundToInt().coerceAtLeast(0) + 1 + val textHeight = lines * line + val offset = top.toPx() + (textHeight - height) / 2f + layout(width, (textHeight + top.toPx() + bottom.toPx()).roundToInt()) { + placeRelative(0, offset.roundToInt()) + } + } +} + +private class CssColumnHeight : ParentDataModifier { + var pixels = 0f + override fun Density.modifyParentData(parentData: Any?): Any = parentData ?: this@CssColumnHeight +} + +/** Fractional line heights accumulate before rounding, rather than adding a rounded pixel per row. */ +@Composable +internal fun CssColumn(modifier: Modifier = Modifier, gap: Dp = WolfskinTokens.Shell.zero, content: @Composable () -> Unit) { + val exact = remember { CssColumnHeight() } + Layout(content, modifier.then(exact)) { children, constraints -> + val measured = Array(children.size) { children[it].measure(constraints.copy(minWidth = 0, minHeight = 0)) } + fun height(index: Int): Float = when (val data = children[index].parentData) { + is CssLineBox -> with(data) { exactHeight(measured[index].height) } + is CssColumnHeight -> data.pixels + else -> measured[index].height.toFloat() + } + val space = gap.toPx() + var total = 0f + var width = constraints.minWidth + for (index in measured.indices) { + total += height(index) + if (index == measured.lastIndex) 0f else space + width = maxOf(width, measured[index].width) + } + exact.pixels = total + layout(width.coerceIn(constraints.minWidth, constraints.maxWidth), total.roundToInt().coerceIn(constraints.minHeight, constraints.maxHeight)) { + var y = 0f + for (index in measured.indices) { + measured[index].placeRelative(0, y.roundToInt()) + y += height(index) + space + } + } + } +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverArtwork.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverArtwork.kt new file mode 100644 index 00000000..77e26655 --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverArtwork.kt @@ -0,0 +1,100 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.design + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.ui.Alignment +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.runtime.Composable +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.blur +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.dropShadow +import androidx.compose.ui.graphics.shadow.Shadow +import androidx.compose.ui.unit.DpOffset +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.ColorMatrix +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.painter.BitmapPainter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import coil3.compose.AsyncImage +import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C +import org.justinvisser.wolfskin.design.WolfskinTokens.Discover as D +import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L + +/** Native captures provide decoded local art; neither posters nor heroes perform image HTTP reads. */ +val LocalDiscoverArt = compositionLocalOf<((String) -> ImageBitmap?)?> { null } + +/** Decode requests use the actual art slot, with RGB565 only for upright posters. */ +@Composable +fun DiscoverArtwork(path: String?, modifier: Modifier, poster: Boolean = false, dim: Boolean = false, onError: () -> Unit = {}) { + if (path == null) return + val bitmap = LocalDiscoverArt.current?.invoke(path) + val painter = remember(bitmap) { bitmap?.let(::BitmapPainter) } + val filter = remember(dim) { if (dim) ColorFilter.colorMatrix(ColorMatrix().apply { + setToSaturation(D.ambientSaturation) + this *= ColorMatrix().apply { setToScale(D.ambientBrightness, D.ambientBrightness, D.ambientBrightness, 1f) } + }) else null } + val context = LocalContext.current + val request = remember(context, path, poster, dim) { + val upright = poster || dim + WolfskinImages.request(context, path, + if (upright) WolfskinTokens.Image.heroPosterRequestWidth else WolfskinTokens.Image.heroRequestWidth, + if (upright) WolfskinTokens.Image.heroPosterRequestHeight else WolfskinTokens.Image.heroRequestHeight, upright) + } + if (painter != null) Image(painter, null, modifier, contentScale = ContentScale.Crop, colorFilter = filter) + else AsyncImage( + model = request, + imageLoader = WolfskinImages.loader(context), + contentDescription = null, modifier = modifier, contentScale = ContentScale.Crop, colorFilter = filter, + onError = { onError() }, + ) +} + +/** Poster-only titles retain the one-sheet over a blurred wash rather than stretching the poster. */ +@Composable +fun DiscoverBackdrop(title: DiscoverTitle, detail: Boolean = false, modifier: Modifier = Modifier) { + val failed = remember(title.poster, title.backdrop) { androidx.compose.runtime.mutableStateOf(false) } + Box(modifier.size(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight).clipToBounds().background(C.surface3)) { + if (failed.value || (title.poster == null && title.backdrop == null)) HomeAmbient(Modifier.fillMaxSize().testTag("discover-missing-art")) + else if (title.posterOnly) { + DiscoverArtwork(title.poster, Modifier.offset(-D.ambientInset, -D.ambientInset) + .wrapContentSize(Alignment.TopStart, unbounded = true) + .size(WolfskinTokens.Shell.stageWidth + D.ambientInset * 2, WolfskinTokens.Shell.stageHeight + D.ambientInset * 2) + .graphicsLayer { scaleX = D.ambientScale; scaleY = D.ambientScale }.blur(D.ambientBlur), dim = true, onError = { failed.value = true }) + DiscoverArtwork(title.poster, Modifier.offset( + WolfskinTokens.Shell.stageWidth - D.heroSheetRight - if (detail) L.heroSheetWidth else D.heroSheetWidth, + if (detail) L.navBand else D.heroSheetTop, + ).size(if (detail) L.heroSheetWidth else D.heroSheetWidth, if (detail) L.heroSheetHeight else D.heroSheetHeight) + .dropShadow(RoundedCornerShape(WolfskinTokens.Radius.heroSheet), Shadow( + radius = WolfskinTokens.Lift.heroBlur, color = Color.Black.copy(alpha = WolfskinTokens.Lift.heroOpacity), + offset = DpOffset(WolfskinTokens.Shell.zero, WolfskinTokens.Lift.heroY))) + .clip(RoundedCornerShape(WolfskinTokens.Radius.heroSheet)), poster = true, onError = { failed.value = true }) + } else DiscoverArtwork(title.backdrop ?: title.poster, Modifier.fillMaxSize(), onError = { failed.value = true }) + Box(Modifier.fillMaxSize().drawWithCache { + val scrim = Brush.horizontalGradient(0f to C.bg.copy(alpha = .92f), .42f to C.bg.copy(alpha = .55f), .70f to C.bg.copy(alpha = 0f), endX = size.width) + onDrawBehind { drawRect(scrim) } + }) + Box(Modifier.fillMaxSize().background(Brush.verticalGradient(0f to C.bg.copy(alpha = 0f), .52f to C.bg.copy(alpha = 0f), .74f to C.bg.copy(alpha = .6f), 1f to C.bg))) + } +} + +/** Missing art is deterministic and quiet; it does not imply a provider read succeeded. */ +fun discoverFallback(title: String): Brush { + val hue = title.fold(0) { hash, char -> (hash * 31 + char.code) % 360 }.toFloat() + return Brush.linearGradient(listOf(Color.hsl(hue, .22f, .22f), Color.hsl(hue, .26f, .10f))) +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverCardProjection.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverCardProjection.kt new file mode 100644 index 00000000..488d90d3 --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverCardProjection.kt @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.design + +/** Discover metadata projects into the same poster and grid contract as Libraries. */ +internal fun DiscoverTitle.libraryCard(state: DiscoverState): LibraryTitle { + val receipts = state.requests[key].orEmpty() + val held = state.shell.person.key in heldBy + val availability = when { + held -> "In your library" + receipts.isNotEmpty() -> { + val recipients = receipts.joinToString(", ") { request -> + if (request.requester == state.shell.person.key) "you" + else state.shell.people.firstOrNull { it.key == request.requester }?.name ?: request.requester.orEmpty() + } + "Requested for $recipients · ${receipts.first().stage}" + } + heldBy.isNotEmpty() -> "In ${heldBy.joinToString(" and ") { key -> state.shell.people.firstOrNull { it.key == key }?.name ?: key }}’s library" + else -> "" + } + return LibraryTitle(id = key, title = title, type = type, year = year, + runtimeSeconds = runtimeSeconds?.toLong(), seasons = seasonCount, poster = poster, + ratings = state.ratingsFor(this).map { LibraryRating(it.source, it.score, it.label) }, availability = availability, + badge = when { + held -> "held" + receipts.isEmpty() -> null + receipts.all { it.stage == "Failed" } -> "failed" + else -> "requested" + }) +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverFixture.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverFixture.kt new file mode 100644 index 00000000..2136e5ed --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverFixture.kt @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.design + +import org.json.JSONArray +import org.json.JSONObject + +/** Projects supplied prototype data; this class never loads a bundled fixture or contacts a server. */ +class DiscoverFixture(json: String, private val artPath: (String) -> String = { it }) { + private val profiles = JSONObject(json).getJSONArray("profiles").objects() + private val holdings = profiles.associate { profile -> + profile.getString("key") to listOf("movies", "shows", "anime").flatMap { profile.getJSONArray(it).objects() } + .map { identity(it) }.toSet() + } + private val pool = profiles.flatMap { it.getJSONArray("discover").objects() }.distinctBy(::identity).map(::title) + val services = listOf("Netflix", "Prime Video", "Disney+", "HBO Max", "Apple TV+", "Videoland", "Hulu", "NPO Start", "Crunchyroll") + .mapIndexed { index, name -> DiscoverService(index.toLong(), name, discoverServiceHue(name)) } + + fun snapshot(person: String): DiscoverSnapshot { + val profile = profiles.first { it.getString("key") == person } + val picks = profile.getJSONArray("discover").objects().map(::title) + val played = listOf("movies", "shows", "anime").flatMap { profile.getJSONArray(it).objects() } + .filter { it.optBoolean("played") } + val groups = linkedMapOf>>() + picks.sortedBy { if (it.type == "movie") 0 else 1 }.forEach { pick -> + played.map { it to normalizedGenres(it.getJSONArray("genres").strings()).intersect(normalizedGenres(pick.genres)).size } + .filter { it.second > 0 }.sortedByDescending { it.second }.take(2).forEach { (seed, _) -> + groups.getOrPut(identity(seed)) { seed.getString("title") to mutableListOf() }.second.add(pick) + } + } + val lanes = services.mapIndexed { index, service -> + val source = rotate(pool, index * 7).sortedByDescending { it.year } + DiscoverLane("new-$index", "New on ${service.name}", source.take(12), fullTitles = source) + } + listOf(DiscoverLane("trending", "Trending", pool.take(20), pool.size, fullTitles = pool)) + + listOf("Comedy", "Animation", "Science Fiction", "Horror", "Romance").map { genre -> + val items = pool.filter { genre in normalizedGenres(it.genres) && (genre !in setOf("Science Fiction", "Horror", "Romance") || it.type == "movie") } + DiscoverLane("genre-$genre", genre, items.take(20), items.size, fullTitles = items) + } + return DiscoverSnapshot(picks, groups.map { (key, group) -> DiscoverSeed(key, group.first, group.second.toList(), watched = true, added = false) }, + services, lanes, status = DiscoverStatus.Ready, reusesFixtureLanes = true) + } + + fun service(service: DiscoverService, type: String): List { + val base = rotate(pool.filter { it.type == type }, service.id.toInt() * 5) + val noun = if (type == "movie") "movies" else "series" + val genres = base.flatMap { it.genres }.groupingBy { it }.eachCount().entries.sortedByDescending { it.value }.take(3) + return listOf( + DiscoverLane("sv-pop", "Popular $noun", base.drop(1).take(20)), + DiscoverLane("sv-new", "New $noun", base.sortedByDescending { it.year }.take(20)), + DiscoverLane("sv-top", "Top rated $noun", rotate(base, 11).take(20)), + ) + genres.map { genre -> DiscoverLane("sv-g-${genre.key}", "${genre.key} $noun", base.filter { genre.key in it.genres }.take(20)) } + } + + fun find(name: String): DiscoverTitle = (pool + profiles.flatMap { p -> listOf("movies", "shows", "anime").flatMap { p.getJSONArray(it).objects() }.map(::title) }).first { it.title == name } + + private fun title(item: JSONObject): DiscoverTitle = DiscoverTitle( + tmdb = item.optLong("tmdbId", item.getString("id").hashCode().toLong().let { if (it < 0) -it else it }), + type = item.getString("type"), title = item.getString("title"), year = item.nullableInt("year"), + genres = item.getJSONArray("genres").strings(), overview = item.optString("overview"), + poster = item.nullableString("poster")?.let(artPath), backdrop = item.nullableString("backdrop")?.let(artPath), + posterOnly = item.optJSONObject("artwork")?.optString("backdropKind") == "poster_crop", + runtimeSeconds = item.nullableInt("runtimeSeconds"), seasonCount = item.nullableInt("seasonCount"), + ratings = ratings(item.optJSONObject("ratings")), heldBy = holdings.filterValues { identity(item) in it }.keys, + ) + + private fun ratings(json: JSONObject?): List = buildList { + if (json == null) return@buildList + for ((field, source, label) in listOf(Triple("rtCritics", "rtCritics", "rtCriticsLabel"), Triple("rtAudience", "rtAudience", "rtAudienceLabel"), Triple("imdb", "imdb", ""))) { + if (!json.isNull(field)) add(DiscoverRating(source, json.getDouble(field), json.nullableString(label))) + } + if (isEmpty() && !json.isNull("tmdb") && json.optInt("tmdbVotes") > 0) add(DiscoverRating("tmdb", json.getDouble("tmdb"))) + } + + private fun identity(json: JSONObject): String = "${json.getString("type")}:${json.optLong("tmdbId", json.getString("id").hashCode().toLong().let { if (it < 0) -it else it })}" + private fun rotate(items: List, offset: Int): List = if (items.isEmpty()) items else items.drop(offset % items.size) + items.take(offset % items.size) + private fun normalizedGenres(genres: List): Set = genres.flatMap { + when (it) { "Sci-Fi & Fantasy" -> listOf("Science Fiction", "Fantasy"); "Action & Adventure" -> listOf("Action", "Adventure"); "Sci-Fi" -> listOf("Science Fiction"); else -> listOf(it) } + }.toSet() +} + +private fun JSONArray.objects(): List = List(length()) { getJSONObject(it) } +private fun JSONArray.strings(): List = List(length()) { getString(it) } +private fun JSONObject.nullableString(key: String): String? = if (isNull(key)) null else optString(key).takeIf { it.isNotEmpty() } +private fun JSONObject.nullableInt(key: String): Int? = if (isNull(key)) null else getInt(key) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverLaneProjection.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverLaneProjection.kt new file mode 100644 index 00000000..9558b735 --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverLaneProjection.kt @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.design + +import kotlin.math.ceil + +/** Dropped seed groups must not suppress titles from the later catalogue lanes. */ +internal fun projectDiscoverLanes(snapshot: DiscoverSnapshot, heroes: List, filter: String, hidden: Map): List { + fun matches(item: DiscoverTitle) = filter == "all" || item.type == filter + val seen = heroes.mapTo(mutableSetOf()) { it.key } + val result = mutableListOf() + fun take(items: List, limit: Int) = items.filter { matches(it) && it.key !in hidden && it.key !in seen }.take(limit).filter { seen.add(it.key) } + if (snapshot.status == DiscoverStatus.Ready) { + val groups = snapshot.seeds.asSequence().filter { it.watched && it.title.isNotBlank() } + .map { seed -> seed.copy(items = seed.items.filter { matches(it) && it.key !in hidden }) } + .filter { it.items.isNotEmpty() }.take(3).toList() + for ((type, name) in listOf("movie" to "Movies", "tv" to "Series")) { + val all = snapshot.picks.filter { it.type == type && matches(it) && it.key !in hidden } + val count = all.count { it.key !in seen } + val items = take(all, minOf(12, ceil(count.toDouble() / (groups.size + 1)).toInt())) + if (items.isNotEmpty()) result += DiscoverLane("pk-$type", "$name for you", items, all.size, pick = true, fullTitles = all) + } + val size = if (groups.isEmpty()) 0 else minOf(8, ceil(snapshot.picks.count { matches(it) && it.key !in seen && it.key !in hidden }.toDouble() / groups.size).toInt()) + groups.forEach { seed -> + val items = take(seed.items, size) + if (items.size >= 3) result += DiscoverLane("pk-w-${seed.key}", "Because you watched ${seed.title}", items, seed.items.size, pick = true, fullTitles = seed.items) + } + } + val displayed = heroes.mapTo(mutableSetOf()) { it.key } + result.forEach { lane -> lane.titles.forEach { displayed.add(it.key) } } + if (snapshot.services.isNotEmpty()) result += DiscoverLane("svc", "Services", emptyList()) + snapshot.lanes.forEach { lane -> + val source = lane.fullTitles.filter { matches(it) && it.key !in displayed && it.key !in hidden } + val items = source.take(if (lane.id.startsWith("new-")) 12 else WolfskinTokens.Behaviour.rowLimit.toInt()) + val paged = lane.hasMore + val tail = when { + lane.tail == DiscoverTail.Retry || lane.tail == DiscoverTail.ShowMore -> lane.tail + paged && items.isEmpty() -> DiscoverTail.ShowMore + paged || (!lane.id.startsWith("new-") && source.size > items.size) -> DiscoverTail.SeeAll + else -> DiscoverTail.None + } + if (items.isNotEmpty() || lane.pending || tail == DiscoverTail.Retry || tail == DiscoverTail.ShowMore) { + result += lane.copy(titles = items, fullTitles = source, total = if (paged) lane.total else if (lane.id.startsWith("new-")) items.size else source.size, tail = tail) + if (!snapshot.reusesFixtureLanes) items.forEach { displayed.add(it.key) } + } + } + val hiddenItems = (snapshot.hidden + snapshot.picks).distinctBy { it.key }.filter { it.key in hidden && matches(it) } + if (hiddenItems.isNotEmpty()) result += DiscoverLane("hidden", "Hidden picks", hiddenItems, hidden = true) + return result +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverModel.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverModel.kt new file mode 100644 index 00000000..6c6f2ff3 --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverModel.kt @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.design + +/** Catalogue identity is stable across lanes, filters, requests and dismissals. */ +data class DiscoverTitle( + val tmdb: Long, + val type: String, + val title: String, + val year: Int? = null, + val genres: List = emptyList(), + val overview: String = "", + val poster: String? = null, + val backdrop: String? = null, + val posterOnly: Boolean = false, + val runtimeSeconds: Int? = null, + val seasonCount: Int? = null, + val seasons: List? = null, + val ratings: List = emptyList(), + val heldBy: Set = emptySet(), + val score: Double = 0.0, +) { + val key: String get() = "$type:$tmdb" + val typeWord: String get() = if (type == "movie") "Movie" else "Series" +} + +/** Observed source marks keep labels separate from score values. */ +data class DiscoverRating(val source: String, val score: Double, val label: String? = null) + +/** A typed seed identity and its contribution evidence retain wire order without inventing recency. */ +data class DiscoverSeed(val key: String, val title: String, val items: List, val watched: Boolean, val added: Boolean) + +/** Services carry their catalogue id and a text wordmark, never third-party logos. */ +data class DiscoverService(val id: Long, val name: String, val hue: Float) + +/** Locked service accents are shared by fixture and live catalogue providers. */ +fun discoverServiceHue(name: String): Float = when (name) { + "Netflix" -> 355f + "Prime Video", "Amazon Prime Video" -> 200f + "Disney+", "Disney Plus" -> 230f + "HBO Max", "Max" -> 265f + "Videoland" -> 20f + "Hulu" -> 140f + "NPO Start" -> 25f + "Crunchyroll" -> 30f + else -> 0f +} + +enum class DiscoverStatus { Ready, NeedsSignIn, Unavailable, Loading, NoSeeds, PicksUnavailable } +enum class DiscoverTail { None, SeeAll, Retry, ShowMore } + +/** Provider data is immutable; screen focus never mutates source ordering. */ +data class DiscoverSnapshot( + val picks: List = emptyList(), + val seeds: List = emptyList(), + val services: List = emptyList(), + val lanes: List = emptyList(), + val hidden: List = emptyList(), + val status: DiscoverStatus = DiscoverStatus.NeedsSignIn, + val savedAt: String? = null, + val reusesFixtureLanes: Boolean = false, + val reachable: Boolean = true, + val requestsAllowed: Boolean = true, +) + +/** Lane identity survives lazy recycling and provider refreshes. */ +data class DiscoverLane( + val id: String, + val title: String, + val titles: List, + val total: Int = titles.size, + val tail: DiscoverTail = if (total > titles.size) DiscoverTail.SeeAll else DiscoverTail.None, + val pick: Boolean = false, + val hidden: Boolean = false, + val fullTitles: List = titles, + val pending: Boolean = false, + val hasMore: Boolean = false, +) + +/** Caller identity is deliberately absent: the authenticated client supplies the actor. */ +data class DiscoverRequest(val title: DiscoverTitle, val requester: String, val profile: String, val fromRecommendation: Boolean = true) + +/** Request outcomes distinguish uncertainty from an acknowledged receipt. */ +data class DiscoverRequestResult( + val phase: RequestPhase, + val actor: String? = null, + val stage: String = "Waiting", + val requester: String? = null, +) + +enum class RequestPhase { Choose, LoadingRecipients, RecipientsUnavailable, Sending, Receipt, NotConfirmed, Pending, Refused } + +/** Production adapters use the generated Conductarr client; fixture adapters cannot send HTTP. */ +interface DiscoverOperations { + suspend fun request(intent: DiscoverRequest): DiscoverRequestResult + suspend fun check(intent: DiscoverRequest): DiscoverRequestResult + suspend fun hide(profile: String, title: DiscoverTitle): Boolean + suspend fun restore(profile: String, title: DiscoverTitle): Boolean + suspend fun refresh(): DiscoverSnapshot +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverOverlay.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverOverlay.kt new file mode 100644 index 00000000..df050bc3 --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverOverlay.kt @@ -0,0 +1,192 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.design + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.dropShadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.shadow.Shadow +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.layout.FirstBaseline +import androidx.compose.ui.layout.Layout +import androidx.tv.material3.Text +import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C +import org.justinvisser.wolfskin.design.WolfskinTokens.Discover as D +import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L + +/** Sheets draw after the shell nav: their scrim consumes pointers and focus stays inside. */ +@Composable +fun DiscoverOverlay(state: DiscoverState) { + val overlay = state.overlay + if (overlay != null && !state.shell.pickerVisible) { + Box(Modifier.fillMaxSize().background(C.sheetScrim).clickable(onClick = {}).testTag("discover-overlay")) { + Column(Modifier.align(Alignment.CenterEnd).width(L.sheetWidth).fillMaxSize() + .dropShadow(RectangleShape, Shadow(radius = WolfskinTokens.Lift.sheetBlur, + color = Color.Black.copy(alpha = WolfskinTokens.Lift.sheetOpacity), + offset = DpOffset(WolfskinTokens.Lift.sheetX, WolfskinTokens.Shell.zero))) + .background(C.surface).padding(D.sheetPaddingX, D.sheetPaddingY), verticalArrangement = Arrangement.spacedBy(D.sheetGap)) { + when (overlay) { + is DiscoverOverlayState.Request -> RequestSheet(state, overlay.state) + is DiscoverOverlayState.Hidden -> { + ReceiptPanel("Hidden from your picks", "${overlay.title.title} leaves every row of ${state.shell.person.name}’s picks. It waits under Hidden picks at the end of Discover.") + Row(Modifier.padding(top = D.sheetButtonTop), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) { + DiscoverButton("Undo", overlay.focus == 0) { state.restore(overlay.title, showToast = false) } + DiscoverButton("Done", overlay.focus == 1, primary = false) { overlay.focus = 1; state.key(ShellKey.Ok) } + } + } + is DiscoverOverlayState.Restore -> { + Text(overlay.title.title, Modifier.discoverLineBox(DiscoverType.sheetTitle), color = C.fg, style = DiscoverType.sheetTitle) + Text("Hidden from ${state.shell.person.name}’s picks.", + Modifier.offset(y = D.sheetSubTop).padding(bottom = D.sheetSubGap + D.sheetSubTop).discoverLineBox(DiscoverType.body), + color = C.muted, style = DiscoverType.body) + Row(Modifier.padding(top = D.sheetButtonTop), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) { + DiscoverButton("Show again", overlay.focus == 0) { state.restore(overlay.title, showToast = true) } + DiscoverButton("Back", overlay.focus == 1, primary = false) { state.overlay = null } + } + } + } + } + } + } + state.toast?.let { RecoveryToast(it) } +} + +@Composable +private fun RequestSheet(state: DiscoverState, sheet: RequestSheetState) { + Text(if (sheet.held) "Add for someone" else "Request", Modifier.exactLineBox(DiscoverType.sheetTitle), color = C.fg, style = DiscoverType.sheetTitle) + Text("${sheet.title.title} · ${sheet.title.typeWord}${sheet.title.year?.let { " · $it" }.orEmpty()}", + Modifier.exactLineBox(DiscoverType.body, top = D.sheetSubTop, bottom = D.sheetSubGap), color = C.muted, style = DiscoverType.body) + if (state.isSignedIn) SheetRatings(sheet.title.ratings) + when (sheet.phase) { + RequestPhase.Choose, RequestPhase.Sending -> RequestChoices(state, sheet) + RequestPhase.LoadingRecipients -> { + Text("For", Modifier.padding(top = D.sheetLabelTop), color = C.dim, style = DiscoverType.label) + repeat(3) { + Row(Modifier.fillMaxWidth().height(L.sheetRow).background(C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.listRow)).padding(horizontal = D.sheetRowPadding), + horizontalArrangement = Arrangement.spacedBy(D.sheetPersonGap), verticalAlignment = Alignment.CenterVertically) { + Box(Modifier.size(D.sheetAvatar).background(C.surface, CircleShape)) + Box(Modifier.width(D.skeletonNameWidth).height(D.skeletonNameHeight).background(C.surface, RoundedCornerShape(WolfskinTokens.Shell.skeletonRadius))) + } + } + Box(Modifier.padding(top = D.sheetButtonTop)) { + DiscoverButton("Close", true, primary = false) { state.overlay = null } + } + } + else -> RequestOutcome(state, sheet) + } +} + +/** The inline ratings baseline includes the first mark; the inherited text strut keeps its descent. */ +@Composable +private fun SheetRatings(ratings: List) { + if (ratings.isEmpty()) return + Layout(content = { + DiscoverRatings(ratings) + Text(" ", color = C.muted, style = DiscoverType.body) + }) { children, constraints -> + val row = children[0].measure(constraints.copy(minHeight = 0)) + val strut = children[1].measure(constraints.copy(minWidth = 0, minHeight = 0)) + val mark = DiscoverType.ratings.fontSize.toPx() + val baseline = (row.height + mark) / 2f + val descent = maxOf(row.height - baseline, (strut.height - strut[FirstBaseline]).toFloat()) + val height = maxOf(baseline, strut[FirstBaseline].toFloat()) + descent + val top = D.sheetRatingTop.roundToPx() + val bottom = D.sheetRatingBottom.roundToPx() + layout(row.width, kotlin.math.round(height).toInt() + top + bottom) { + row.placeRelative(0, top) + } + } +} + +@Composable +private fun RequestChoices(state: DiscoverState, sheet: RequestSheetState) { + Text("For", Modifier.padding(top = D.sheetLabelTop), color = C.dim, style = DiscoverType.label) + sheet.people.forEachIndexed { index, person -> + val focused = sheet.focus == index + val disabled = person.key in sheet.title.heldBy + Row(Modifier.fillMaxWidth().height(L.sheetRow).alpha(if (disabled) .5f else 1f) + .focusScale(focused, WolfskinTokens.Focus.scaleRow) + .background(if (focused) C.fg else C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.listRow)) + .clickable(enabled = !disabled && sheet.phase == RequestPhase.Choose) { sheet.selected = index; sheet.focus = sheet.buttonIndex } + .padding(horizontal = D.sheetRowPadding), horizontalArrangement = Arrangement.spacedBy(D.sheetPersonGap), verticalAlignment = Alignment.CenterVertically) { + Avatar(person, D.sheetAvatar, WolfskinTokens.Type.avatarLetterSheetRow) + Text(person.name + if (person.key == sheet.viewer.key) " (you)" else "", Modifier.weight(1f), color = if (focused) C.onFocus else C.fg, style = DiscoverType.listTitle) + if (disabled) Text("In library", color = (if (focused) C.onFocus else C.fg).copy(alpha = D.reasonOpacity), style = DiscoverType.reason) + else Box(Modifier.size(D.sheetRadio).background(if (sheet.selected == index) C.accent else C.listRowRest.copy(alpha = 0f), CircleShape) + .border(if (sheet.selected != index || focused) D.sheetRadioRing else WolfskinTokens.Shell.zero, if (focused) C.onFocus.copy(alpha = .5f) else C.fg.copy(alpha = .35f), CircleShape), contentAlignment = Alignment.Center) { + if (sheet.selected == index) Box(Modifier.size(D.sheetRadioDot).background(C.onFocus, CircleShape)) + } + } + } + if (sheet.blocked) Text("Season details are unknown right now, so this series can’t be requested yet. Nothing was sent.", color = C.warn, style = DiscoverType.caption) + else if (sheet.title.type == "tv" && !sheet.held) Text("Whole series: every listed season. Other seasons may download too.", color = C.dim, style = DiscoverType.caption) + Box(Modifier.padding(top = D.sheetButtonTop)) { + DiscoverButton(if (sheet.blocked) "Close" else sheet.buttonLabel, sheet.focus == sheet.buttonIndex, glyph = if (sheet.blocked) null else "plus", primary = !sheet.blocked) { sheet.focus = sheet.buttonIndex; state.key(ShellKey.Ok) } + } +} + +@Composable +private fun RequestOutcome(state: DiscoverState, sheet: RequestSheetState) { + val phase = sheet.phase + val name = sheet.selectedPerson.name + val actor = sheet.result?.actor + val text = when (phase) { + RequestPhase.Receipt -> "for ${if (sheet.selectedPerson.key == state.shell.person.key) "you" else name}" + actor?.let { " · by $it" }.orEmpty() + RequestPhase.NotConfirmed -> "The request service didn’t answer. Nothing is assumed filed." + RequestPhase.Pending -> "Seerr hasn’t confirmed it yet. Checking again looks first, so nothing is filed twice." + RequestPhase.Refused -> "$name can’t receive requests from this TV." + else -> "Conductarr didn’t say who this TV may request for. Nothing was sent." + } + val heading = when (phase) { + RequestPhase.Receipt -> if (sheet.held) "Adding to $name’s library" else "Requested" + RequestPhase.NotConfirmed -> "Not confirmed" + RequestPhase.Pending -> "Request unresolved" + RequestPhase.Refused -> "Refused" + else -> "Can’t read who can receive requests" + } + ReceiptPanel(heading, text, if (phase == RequestPhase.Receipt) C.fg else if (phase == RequestPhase.Refused) C.fail else C.warn, + if (phase == RequestPhase.Receipt) sheet.result?.stage ?: "Waiting" else null) + if (phase == RequestPhase.Receipt) Text("Follow it under Requests in Settings.", color = C.dim, style = DiscoverType.caption) + Row(Modifier.padding(top = D.sheetButtonTop), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) { + if (phase in setOf(RequestPhase.NotConfirmed, RequestPhase.Pending, RequestPhase.RecipientsUnavailable)) { + DiscoverButton(if (phase == RequestPhase.RecipientsUnavailable) "Try again" else "Check again", sheet.focus == sheet.buttonIndex) { sheet.focus = sheet.buttonIndex; state.key(ShellKey.Ok) } + DiscoverButton("Close", sheet.focus != sheet.buttonIndex, primary = false) { state.overlay = null } + } else DiscoverButton(if (phase == RequestPhase.Receipt) "Done" else "Close", true) { state.overlay = null } + } +} + +@Composable +private fun ReceiptPanel(title: String, text: String, color: androidx.compose.ui.graphics.Color = C.fg, stage: String? = null) { + Column(Modifier.fillMaxWidth().background(C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.panel)).padding(WolfskinTokens.Shell.panelPaddingX, WolfskinTokens.Shell.panelPaddingY), verticalArrangement = Arrangement.spacedBy(WolfskinTokens.Shell.panelGap)) { + Row(horizontalArrangement = Arrangement.spacedBy(L.badgeInset), verticalAlignment = Alignment.CenterVertically) { + Glyph(if (title == "Requested" || title == "Hidden from your picks" || title.startsWith("Adding")) "check" else if (title == "Request unresolved") "clock" else "alert", color, Modifier.size(WolfskinTokens.Shell.panelIcon)) + Text(title, color = color, style = DiscoverType.stateTitle) + } + Text(text, color = C.muted, style = DiscoverType.body) + if (stage != null) Row(horizontalArrangement = Arrangement.spacedBy(D.stageGap), verticalAlignment = Alignment.CenterVertically) { + val stageColor = when (stage) { "Ready" -> C.ok; "Failed" -> C.fail; "Downloading", "Almost" -> C.accent; else -> C.muted } + Glyph("clock", stageColor, Modifier.size(L.buttonIcon)) + Text(stage, color = stageColor, style = DiscoverType.label) + } + } +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverRatings.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverRatings.kt new file mode 100644 index 00000000..62a55b57 --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverRatings.kt @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.design + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.Alignment +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.ColorMatrix +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.tv.material3.Text +import java.util.Locale +import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C + +/** Localized score formatting never infers a tomato label from its percentage. */ +fun formatDiscoverRating(rating: DiscoverRating, locale: Locale): String = + if (rating.source.startsWith("rt")) "${rating.score.toInt()}%" else String.format(locale, "%.1f", rating.score) + +/** Heroes, title destinations and sheets show every observed score without a dwell or truncation. */ +@Composable +fun DiscoverRatings(ratings: List, modifier: Modifier = Modifier) { + if (ratings.isEmpty()) return + val locale = LocalConfiguration.current.locales[0] + val labels = remember(ratings, locale) { ratings.map { formatDiscoverRating(it, locale) } } + val style = DiscoverType.ratings + val markSize = with(LocalDensity.current) { style.fontSize.toDp() } + val desaturated = remember { ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }) } + Row(modifier, horizontalArrangement = Arrangement.spacedBy(markSize * .7142857f), verticalAlignment = Alignment.CenterVertically) { + ratings.forEachIndexed { index, rating -> + val resource = when (rating.source) { + "rtCritics" -> if (rating.label == "Rotten") R.drawable.ic_rotten_tomatoes_rotten else R.drawable.ic_rotten_tomatoes_fresh + "rtAudience" -> if (rating.label == "Spilled") R.drawable.ic_rt_spilled else R.drawable.ic_rt_upright + "imdb" -> R.drawable.ic_rating_imdb + else -> R.drawable.ic_rating_tmdb + } + val muted = rating.source == "rtCritics" && rating.label != "Rotten" && rating.label != "Fresh" && rating.label != "Certified Fresh" + val wordmark = rating.source == "imdb" || rating.source == "tmdb" + Row(horizontalArrangement = Arrangement.spacedBy(markSize * .2857143f), verticalAlignment = Alignment.CenterVertically) { + Image(painterResource(resource), null, Modifier.height(markSize).width(markSize * if (wordmark) 1.8f else 1f), + alpha = if (muted) .55f else 1f, colorFilter = if (muted) desaturated else null) + Text(labels[index], color = C.fg, style = style, maxLines = 1) + } + } + } +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverScreen.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverScreen.kt new file mode 100644 index 00000000..fcc891ff --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverScreen.kt @@ -0,0 +1,206 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.design + +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.style.TextOverflow +import androidx.tv.material3.Text +import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C +import org.justinvisser.wolfskin.design.WolfskinTokens.Discover as D +import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L + +/** Discover uses the shell's real scroller, Back ladder and per-person focus memory. */ +@Composable +fun DiscoverScreen(state: DiscoverState, modifier: Modifier = Modifier) { + val shell = state.shell + val focus = remember(state) { object : ShellContentFocus by state {} } + DisposableEffect(focus) { + shell.contentFocus = focus + onDispose { if (shell.contentFocus === focus) { shell.contentFocus = null; shell.contentOverlayVisible = false } } + } + LaunchedEffect(shell.route, state.snapshot.savedAt) { shell.view.savedAt = state.snapshot.savedAt } + LaunchedEffect(state.toast) { if (state.toast != null) { kotlinx.coroutines.delay(WolfskinTokens.Motion.toast.toLong()); state.toast = null } } + val focusedTitle = if (state.collection != null) null else state.focusedLane?.titles?.getOrNull(state.column) + val ratingsReader = LocalScreenRatings.current + LaunchedEffect(focusedTitle?.key, state.contentFocused, state.snapshot.savedAt, state.hero?.key, state.title?.key, state.isSignedIn) { + state.plateKey = null + coroutineScope { + if (state.isSignedIn && ratingsReader != null) launch { + val prominent = state.title ?: state.hero + if (prominent != null) ratingsReader.read(prominent.tmdb, prominent.type, null, null, cachedOnly = state.snapshot.savedAt != null)?.let { state.updateRatings(prominent, it) } + } + if (state.title == null && state.contentFocused && focusedTitle != null && (state.snapshot.savedAt == null || focusedTitle.key in state.observedRatings || ratingsReader != null)) launch { + if (focusedTitle.key !in state.observedRatings) kotlinx.coroutines.delay(WolfskinTokens.Motion.ratingDwell.toLong()) + if (state.isSignedIn && ratingsReader != null) ratingsReader.read(focusedTitle.tmdb, focusedTitle.type, null, null, cachedOnly = state.snapshot.savedAt != null)?.let { state.updateRatings(focusedTitle, it) } + state.observedRatings.add(focusedTitle.key); state.plateKey = focusedTitle.key + } + } + } + if (state.title != null) { DiscoverTitleDestination(state, modifier); return } + state.collection?.let { + LibraryScreen(shell, it, modifier, ratingsSignedIn = state.isSignedIn, offlineArt = LocalDiscoverArt.current, registerFocus = false) + return + } + if (state.snapshot.status == DiscoverStatus.Loading) { + val loading = remember(state) { LibraryGridState("Discover", emptyList(), catalogue = true).apply { status = LibraryStatus.Loading } } + LibraryScreen(shell, loading, modifier, offlineArt = LocalDiscoverArt.current, registerFocus = false) + return + } + val density = LocalDensity.current + val list = shell.view.listState + val note = state.snapshot.status in setOf(DiscoverStatus.NoSeeds, DiscoverStatus.PicksUnavailable) + val heights = remember(state.hasHero, state.lanes, note, state.hasCatalogue) { + buildList { + add(if (state.hasHero) L.row_phero else L.row_lhead) + add(if (state.hasCatalogue) L.row_chips else WolfskinTokens.Shell.zero) + if (!state.hasCatalogue) add(L.row_state) else { + if (note) add(L.row_note) + state.lanes.forEach { lane -> add(D.shelfHeadingBand + (if (lane.id == "svc") WolfskinTokens.Card.entryH else WolfskinTokens.Card.posterH + WolfskinTokens.Card.posterCaptionBand) + L.rowGapAfter) } + } + } + } + LaunchedEffect(state.focusedRow, shell.navFocused, heights) { + if (!shell.navFocused) { + val row = state.focusedRow + val physical = row + if (row >= state.firstShelf && note) 1 else 0 + var top = WolfskinTokens.Shell.zero + for (index in 0 until physical.coerceAtMost(heights.size)) top += heights[index] + val fits = top < D.anchorFitTop && top + heights[physical.coerceAtMost(heights.lastIndex)] <= WolfskinTokens.Shell.stageHeight - D.anchorBottomClearance + val target = if (row <= 1 || fits) 0f else with(density) { (top - L.pinnedLine).toPx() } + var current = WolfskinTokens.Shell.zero + for (index in 0 until list.firstVisibleItemIndex.coerceAtMost(heights.size)) current += heights[index] + animateFrameScroll(list, target - with(density) { current.toPx() } - list.firstVisibleItemScrollOffset) + } + } + Box(modifier.fillMaxSize().testTag("discover-screen")) { + if (state.hasHero && state.service == null && list.firstVisibleItemIndex == 0) state.hero?.let { DiscoverBackdrop(it, modifier = Modifier.graphicsLayer { translationY = -list.firstVisibleItemScrollOffset.toFloat() }) } + LazyColumn(state = list, contentPadding = PaddingValues(bottom = (WolfskinTokens.Shell.stageHeight - L.pinnedLine - heights.last()).coerceAtLeast(D.frameBottomMin)), modifier = Modifier.fillMaxSize()) { + item(key = "discover-header", contentType = if (state.hasHero && state.service == null) "hero" else "header") { + if (state.hasHero && state.service == null) DiscoverHero(state) else LibraryHeader(state.shell.person, state.service?.name ?: "Discover", if (state.service != null) "General interest" else "") + } + item(key = "discover-chips", contentType = "chips") { + if (state.snapshot.status !in setOf(DiscoverStatus.NeedsSignIn, DiscoverStatus.Unavailable, DiscoverStatus.Loading)) DiscoverChips(state) + } + if (state.snapshot.status in setOf(DiscoverStatus.NeedsSignIn, DiscoverStatus.Unavailable)) item(key = "discover-state", contentType = "state") { DiscoverStatePanel(state) } + else { + if (state.snapshot.status in setOf(DiscoverStatus.NoSeeds, DiscoverStatus.PicksUnavailable)) item(key = "discover-note", contentType = "note") { + Box(Modifier.height(L.row_note).fillMaxWidth()) { + Row(Modifier.offset(L.safeSide, D.noteTop), horizontalArrangement = Arrangement.spacedBy(D.noteGap), verticalAlignment = Alignment.CenterVertically) { + val color = if (state.snapshot.status == DiscoverStatus.NoSeeds) C.muted else C.warn + Glyph(if (state.snapshot.status == DiscoverStatus.NoSeeds) "eye" else "alert", color, Modifier.size(D.noteIcon)) + Text(if (state.snapshot.status == DiscoverStatus.NoSeeds) "Watch something to get picks for ${shell.person.name}." else "Picks unavailable right now.", color = color, style = DiscoverType.body) + } + } + } + itemsIndexed(state.lanes, key = { _, lane -> lane.id }, contentType = { _, _ -> "shelf" }) { index, lane -> DiscoverShelf(state, lane, index + state.firstShelf) } + } + } + } +} + +@Composable +private fun DiscoverHero(state: DiscoverState) { + val title = state.hero ?: return + Column(Modifier.fillMaxWidth().height(L.row_phero).padding(start = L.safeSide, bottom = L.heroCopyBottom), verticalArrangement = Arrangement.Bottom) { + Row(horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.posterGap), verticalAlignment = Alignment.CenterVertically) { + Glyph("seerr", C.accentText, Modifier.size(L.buttonIcon)) + Text(if (state.snapshot.status == DiscoverStatus.Ready) "For you" else "Trending", color = C.accentText, style = DiscoverType.label) + } + Spacer(Modifier.height(D.heroKickerGap)) + Text(title.title, Modifier.width(L.heroCopyWidth).testTag("discover-hero-title-box").discoverLineBox(DiscoverType.hero), color = C.fg, style = DiscoverType.hero, maxLines = 2, overflow = TextOverflow.Ellipsis) + Spacer(Modifier.height(D.heroTitleGap)) + Row(Modifier.height(D.heroFactsHeight), horizontalArrangement = Arrangement.spacedBy(D.heroFactsGap), verticalAlignment = Alignment.CenterVertically) { + Text("${title.typeWord}${title.year?.let { " · $it" }.orEmpty()}", color = C.muted, style = DiscoverType.body) + if (title.genres.isNotEmpty()) Text(title.genres.take(2).joinToString(", "), color = C.muted, style = DiscoverType.body) + if (state.isSignedIn) DiscoverRatings(title.ratings) + if (state.shell.person.key in title.heldBy) Text("In your library", color = C.accentText, style = DiscoverType.body) + } + Spacer(Modifier.height(D.heroMetaGap)) + Text(title.overview, Modifier.width(L.heroOverviewMax), color = C.muted, style = DiscoverType.body, maxLines = 2, overflow = TextOverflow.Ellipsis) + Spacer(Modifier.height(D.heroOverviewGap)) + Row(verticalAlignment = Alignment.CenterVertically) { + DiscoverButton("Details", state.contentFocused && state.focusedRow == 0, "about") { state.openTitle(title) } + Row(Modifier.padding(start = D.dotMargin), horizontalArrangement = Arrangement.spacedBy(D.dotGap), verticalAlignment = Alignment.CenterVertically) { + Glyph("left", C.fg.copy(alpha = if (state.contentFocused && state.focusedRow == 0) 1f else 0f), Modifier.size(D.dotChevron)) + state.heroes.forEachIndexed { index, _ -> Box(Modifier.size(if (index == state.slide) D.dotActive else D.dotSize, D.dotSize).background(if (index == state.slide) C.fg else C.fg.copy(alpha = .3f), CircleShape)) } + Glyph("right", C.fg.copy(alpha = if (state.contentFocused && state.focusedRow == 0) 1f else 0f), Modifier.size(D.dotChevron)) + } + } + } +} + +@Composable +private fun DiscoverChips(state: DiscoverState) { + Row(Modifier.fillMaxWidth().height(L.row_chips).padding(start = L.safeSide, top = D.chipTop), horizontalArrangement = Arrangement.spacedBy(D.chipGap)) { + (if (state.service == null) listOf("all" to "All", "movie" to "Movies", "tv" to "Series") else listOf("movie" to "Movies", "tv" to "Series")).forEachIndexed { index, (value, label) -> + val focused = state.contentFocused && state.focusedRow == 1 && state.column == index + Box(Modifier.height(D.chipHeight).focusScale(focused, WolfskinTokens.Focus.scaleControl) + .background(if (focused) C.fg else if (state.filter == value) C.accentSoft else C.chipRest, RoundedCornerShape(WolfskinTokens.Radius.chip)) + .border(if (!focused && state.filter == value) WolfskinTokens.Focus.ringCardRing / 2 else WolfskinTokens.Shell.zero, if (!focused && state.filter == value) C.accent else C.chipRest.copy(alpha = 0f), RoundedCornerShape(WolfskinTokens.Radius.chip)) + .clickable { state.focus(1, index); state.key(ShellKey.Ok) }.padding(horizontal = D.chipPadding), contentAlignment = Alignment.Center) { + Text(label, color = if (focused) C.onFocus else if (state.filter == value) C.accentText else C.fg, style = DiscoverType.label) + } + } + } +} + +@Composable +private fun DiscoverStatePanel(state: DiscoverState) { + val signIn = state.snapshot.status == DiscoverStatus.NeedsSignIn + Box(Modifier.fillMaxWidth().height(L.row_state)) { + Column(Modifier.padding(start = L.safeSide, top = D.stateTop).wrapContentHeight(Alignment.Top, unbounded = true).width(L.stateWidth) + .background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.panel)) + .border(WolfskinTokens.Library.borderWidth, C.border, RoundedCornerShape(WolfskinTokens.Radius.panel)) + .padding(WolfskinTokens.Library.borderWidth).padding(D.statePaddingX, D.statePaddingY)) { + Row(horizontalArrangement = Arrangement.spacedBy(L.badgeInset), verticalAlignment = Alignment.CenterVertically) { + Glyph(if (signIn) "profile" else "alert", if (signIn) C.accentText else C.warn, Modifier.size(L.buttonIcon)) + Text(if (signIn) "Discover needs sign-in" else "Discover is resting", color = C.fg, style = DiscoverType.stateTitle) + } + Text(if (signIn) "Sign this TV in to Conductarr to see picks and request titles. Your library plays as usual." else "Conductarr can’t be reached. Your library still plays.", Modifier.padding(top = L.navBlobAvatarPad, bottom = D.stateTextGap), color = C.muted, style = DiscoverType.body) + DiscoverButton("Try again", state.contentFocused, primary = signIn) { state.refresh() } + } + } +} + +/** Hero and sheet buttons reuse the shell's scale and outside-ring primitives. */ +@Composable +fun DiscoverButton(label: String, focused: Boolean, glyph: String? = null, primary: Boolean = true, onClick: () -> Unit) { + Row(Modifier.height(L.buttonHeight).focusScale(focused, WolfskinTokens.Focus.scaleControl) + .focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringControlRing, FocusLift.Button) + .background(if (primary) C.fg else if (focused) C.buttonSecondaryFocused else C.buttonSecondary, RoundedCornerShape(WolfskinTokens.Radius.button)) + .clickable(onClick = onClick).padding(horizontal = L.buttonPaddingX), horizontalArrangement = Arrangement.spacedBy(L.badgeInset), verticalAlignment = Alignment.CenterVertically) { + if (glyph != null) Glyph(glyph, if (primary) C.onFocus else C.fg, Modifier.size(L.buttonIcon)) + Text(label, color = if (primary) C.onFocus else C.fg, style = DiscoverType.button, maxLines = 1) + } +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverShelf.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverShelf.kt new file mode 100644 index 00000000..e6ded43c --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverShelf.kt @@ -0,0 +1,114 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.design + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.zIndex +import androidx.tv.material3.Text +import org.justinvisser.wolfskin.design.WolfskinTokens.Card as K +import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C +import org.justinvisser.wolfskin.design.WolfskinTokens.Discover as D +import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L + +/** Every shelf is lazy and keyed; the focused caption is outside the scaled art slot. */ +@Composable +fun DiscoverShelf(state: DiscoverState, lane: DiscoverLane, row: Int) { + val services = lane.id == "svc" + val height = if (services) K.entryH else K.posterH + val cards = rememberLazyListState() + val count = if (services) state.snapshot.services.size else lane.titles.size + if (lane.tail == DiscoverTail.None) 0 else 1 + val column = (state.shell.view.cards[lane.id] ?: 0).coerceIn(0, (count - 1).coerceAtLeast(0)) + val active = state.contentFocused && state.focusedRow == row + val slots = K.posterSlots + val first = state.shell.view.cards["first:${lane.id}"] ?: 0 + val density = LocalDensity.current + LaunchedEffect(column, count, active) { + val target = shelfFirst(column, first, slots, count) + state.shell.view.cards["first:${lane.id}"] = target + cards.scrollShelfTo(target, with(density) { K.posterPitch.toPx() }) + } + Box(Modifier.fillMaxWidth().height(D.shelfHeadingBand + height + (if (services) WolfskinTokens.Shell.zero else K.posterCaptionBand) + L.rowGapAfter).testTag("discover-lane-${lane.id}")) { + Text(lane.title, Modifier.offset(L.safeSide), color = C.fg, style = DiscoverType.rowHeading, maxLines = 1) + LazyRow(state = cards, userScrollEnabled = false, + contentPadding = PaddingValues(start = L.safeSide, end = WolfskinTokens.Shell.stageWidth - L.safeSide - (K.posterW * slots + K.posterGap * (slots - 1)), top = K.posterGap, bottom = K.posterGap), + horizontalArrangement = Arrangement.spacedBy(K.posterGap), + modifier = Modifier.offset(y = D.shelfHeadingBand - K.posterGap).height(height + K.posterGap * 2)) { + if (services) itemsIndexed(state.snapshot.services, key = { _, service -> "service:${service.id}" }, contentType = { _, _ -> "entry" }) { index, service -> + Box(Modifier.size(K.entryW, K.entryH).focusScale(active && index == column, WolfskinTokens.Focus.scaleCard) + .focusRing(active && index == column, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardRing, FocusLift.Card) + .background(Brush.linearGradient(listOf(Color.hsl(service.hue, .30f, .26f), Color.hsl(service.hue, .30f, .12f))), RoundedCornerShape(WolfskinTokens.Radius.card)) + .border(WolfskinTokens.Library.borderWidth, C.border.copy(alpha = D.entryBorderOpacity), RoundedCornerShape(WolfskinTokens.Radius.card)) + .clickable { state.focus(row, index); state.openService(service) }, contentAlignment = Alignment.BottomStart) { + Text(service.name, Modifier.padding(start = D.entryPaddingX, bottom = D.entryPaddingY), style = DiscoverType.entry, color = C.fg) + } + } else if (lane.pending) { + items(slots, key = { "pending:$it" }) { + Box(Modifier.size(K.posterW, K.posterH).background(C.surface3, RoundedCornerShape(WolfskinTokens.Radius.card))) + } + } else { + itemsIndexed(lane.titles, key = { _, item -> item.key }, contentType = { _, _ -> "poster" }) { index, item -> + DiscoverPoster(state, item, active && index == column, Modifier.clickable { + state.focus(row, index) + if (lane.hidden) state.overlay = DiscoverOverlayState.Restore(item) else state.openTitle(item) + }) + } + if (lane.tail != DiscoverTail.None) item(key = "tail:${lane.id}", contentType = if (lane.tail == DiscoverTail.SeeAll) "seeAll" else "tail") { + DiscoverTailCard(lane, active && column == lane.titles.size) { state.focus(row, lane.titles.size); state.key(ShellKey.Ok) } + } + } + } + if (!services && active && !lane.pending) { + val item = lane.titles.getOrNull(column) + Caption( + item?.title ?: if (lane.tail == DiscoverTail.SeeAll) "See all ${lane.title}" else if (lane.tail == DiscoverTail.Retry) "Try again" else "Show more", + if (item != null) buildDiscoverCaptionFacts(item, state, lane.hidden) else if (lane.tail == DiscoverTail.SeeAll) "${lane.total} titles" else if (lane.tail == DiscoverTail.Retry) "${lane.title} stopped loading" else "The last read only repeated titles", + true, Modifier.offset(L.safeSide + K.posterPitch * (column - first), D.shelfHeadingBand + K.posterH + L.captionOffset).width(WolfskinTokens.Library.captionWidth), + accentFirstFact = lane.hidden, + accentLastFact = item != null && (item.heldBy.isNotEmpty() || state.requests[item.key].orEmpty().isNotEmpty()), + ) + } + } +} + +/** Only the focused poster reads the ratings plate; cached observations paint immediately. */ +@Composable +fun DiscoverPoster(state: DiscoverState, title: DiscoverTitle, focused: Boolean, modifier: Modifier = Modifier) { + val receipts = state.requests[title.key] + val card = remember(title, receipts, state.ratingsFor(title), state.shell.person.key) { title.libraryCard(state) } + LibraryPoster(card, focused, focused && state.plateKey == title.key && state.isSignedIn, true, + modifier.zIndex(if (focused) 1f else 0f), offlineArt = LocalDiscoverArt.current) +} + +@Composable +internal fun DiscoverTailCard(lane: DiscoverLane, focused: Boolean, onClick: () -> Unit) { + if (lane.tail == DiscoverTail.SeeAll) LibrarySeeAllCard(lane.total, focused, onClick) + else CatalogueRecoveryCard(lane.tail == DiscoverTail.Retry, focused, onClick) +} + +internal fun buildDiscoverCaptionFacts(title: DiscoverTitle, state: DiscoverState, hidden: Boolean): String { + val card = title.libraryCard(state) + return (if (hidden) "Hidden from your picks · " else "") + card.facts(true) +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverState.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverState.kt new file mode 100644 index 00000000..f0e2dabb --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverState.kt @@ -0,0 +1,421 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.design + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** One person owns a Discover view; the shell retains its root list and card memory. */ +class DiscoverState( + val shell: ShellState, + initial: DiscoverSnapshot, + private val operations: DiscoverOperations, + private val scope: CoroutineScope, + private val onPush: (String) -> Unit = {}, + private val onPop: () -> Unit = {}, + private val serviceLanes: (DiscoverService, String) -> List = { _, _ -> emptyList() }, + requesters: List = shell.people, + private val onMore: (suspend (String) -> List?)? = null, + private val nowMillis: () -> Long = System::currentTimeMillis, +) : ShellContentFocus { + var snapshot by mutableStateOf(initial) + private set + private var requesters = requesters + + fun updateRequesters(people: List) { + if (requesters == people) return + requesters = people + requestSheets.values.forEach { it.updatePeople(people) } + (overlay as? DiscoverOverlayState.Request)?.state?.let { current -> + if (requestSheets.values.none { it === current }) current.updatePeople(people) + } + } + private var savedTime = initial.savedAt ?: formatSavedTime() + private var serviceFilter by mutableStateOf("movie") + var filter: String + get() = if (service != null) serviceFilter else DISCOVER_FILTERS[shell.rememberedView("discover").cards["filter"] ?: 0] + set(value) { + if (service != null) serviceFilter = value + else shell.rememberedView("discover").cards["filter"] = DISCOVER_FILTERS.indexOf(value) + } + var slide: Int + get() = (shell.rememberedView("discover").cards["slide"] ?: 0).coerceIn(0, (heroes.size - 1).coerceAtLeast(0)) + set(value) { shell.rememberedView("discover").cards["slide"] = value } + var title by mutableStateOf(null) + var service by mutableStateOf(null) + var collection by mutableStateOf(null) + var titleButton by mutableStateOf(0) + private var activeOverlay by mutableStateOf(null) + var overlay: DiscoverOverlayState? + get() = activeOverlay + set(value) { activeOverlay = value; shell.contentOverlayVisible = value != null } + var toast by mutableStateOf(null) + val observedRatings = mutableSetOf() + var plateKey by mutableStateOf(null) + val requests = mutableStateMapOf>() + private val requestSheets = mutableMapOf, RequestSheetState>() + private val hidden = mutableStateMapOf().apply { initial.hidden.forEach { put(it.key, it) } } + private val ratingValues = mutableStateMapOf>() + private lateinit var collectionCards: LinkedHashMap + private var revision by mutableStateOf(0) + private var cachedRevision = -1 + private var cachedFilter = "" + private var cachedService: DiscoverService? = null + private var cachedLanes = emptyList() + private var cachedHeroesRevision = -1 + private var cachedHeroesFilter = "" + private var cachedHeroes = emptyList() + private val dismissalMutex = Mutex() + private val dismissalVersions = mutableMapOf() + private val pendingDismissals = mutableMapOf() + private var rootFilter = "all" + + fun updateSnapshot(value: DiscoverSnapshot) { + snapshot = value + if (value.savedAt == null && value.status in setOf(DiscoverStatus.Ready, DiscoverStatus.NoSeeds, DiscoverStatus.PicksUnavailable)) savedTime = formatSavedTime() + hidden.clear() + value.hidden.forEach { hidden[it.key] = it } + pendingDismissals.forEach { (key, item) -> if (item == null) hidden.remove(key) else hidden[key] = item } + revision++ + } + + fun invalidateLanes() { revision++ } + fun updateLanes(value: List) { snapshot = snapshot.copy(lanes = value); revision++ } + + private suspend fun loadLane(id: String) { + if (id == "offline-recovery") { reload(); return } + val loaded = onMore?.invoke(id) + if (loaded != null) updateLanes(loaded) else reload() + } + + fun updateTitle(value: DiscoverTitle) { + if (title?.key == value.key) title = value + } + + fun ratingsFor(item: DiscoverTitle): List = ratingValues[item.key] ?: item.ratings + + fun updateRatings(item: DiscoverTitle, values: List) { + val ratings = values.map { DiscoverRating(it.source, it.score, it.label) } + ratingValues[item.key] = ratings + fun replace(source: List) = source.map { if (it.key == item.key) it.copy(ratings = ratings) else it } + snapshot = snapshot.copy(picks = replace(snapshot.picks), + lanes = snapshot.lanes.map { it.copy(titles = replace(it.titles), fullTitles = replace(it.fullTitles)) }, + seeds = snapshot.seeds.map { it.copy(items = replace(it.items)) }) + title?.takeIf { it.key == item.key }?.let { title = it.copy(ratings = ratings) } + revision++ + } + + override val hasContent: Boolean get() = snapshot.status != DiscoverStatus.Loading + val isSignedIn: Boolean get() = snapshot.status != DiscoverStatus.NeedsSignIn + val hasCatalogue: Boolean get() = when (snapshot.status) { + DiscoverStatus.Ready, DiscoverStatus.NoSeeds, DiscoverStatus.PicksUnavailable -> true + else -> false + } + val canRequest: Boolean get() = hasCatalogue && snapshot.savedAt == null && snapshot.reachable && snapshot.requestsAllowed + val hasHero: Boolean get() = service == null && collection == null && heroes.isNotEmpty() && hasCatalogue + val heroes: List get() { + if (cachedHeroesRevision != revision || cachedHeroesFilter != filter) { + val source = if (snapshot.status == DiscoverStatus.Ready) snapshot.picks else snapshot.lanes.firstOrNull { it.id == "trending" }?.titles.orEmpty() + cachedHeroes = source.asSequence().filter { it.key !in hidden && matches(it) } + .sortedWith(compareByDescending { it.score }.thenBy { if (it.type == "movie") 0 else 1 }) + .take(if (snapshot.status == DiscoverStatus.Ready) 5 else 1).toList() + cachedHeroesRevision = revision; cachedHeroesFilter = filter + } + return cachedHeroes + } + val hero: DiscoverTitle? get() = heroes.getOrNull(slide) + val lanes: List get() { + if (cachedRevision != revision || cachedFilter != filter || cachedService != service) { + cachedLanes = buildLanes(); cachedRevision = revision; cachedFilter = filter; cachedService = service + } + return cachedLanes + } + val firstShelf: Int get() = 2 + val rowCount: Int get() = firstShelf + lanes.size + val focusedRow: Int get() = shell.view.row.coerceIn(0, (rowCount - 1).coerceAtLeast(0)) + val focusedLane: DiscoverLane? get() = lanes.getOrNull(focusedRow - firstShelf) + val column: Int get() = shell.view.cards[rowKey].orZero().coerceAtMost(maxColumn()) + val rowKey: String get() = when (focusedRow) { 0 -> "phero"; 1 -> "dchips"; else -> focusedLane?.id.orEmpty() } + val isCard: Boolean get() = title == null && focusedRow >= firstShelf && focusedLane != null && focusedLane?.pending != true + val contentFocused: Boolean get() = !shell.navFocused && !shell.pickerVisible && overlay == null + override val cardKey: String? get() = when { + overlay != null || title != null -> null + collection != null -> collection?.cardKey + isCard -> "$rowKey:$column" + else -> null + } + + fun focus(row: Int, column: Int = 0) { + shell.navFocused = false; shell.view.row = row + shell.view.cards[rowKey] = column.coerceIn(0, maxColumn()) + } + + private fun moveRow(direction: Int) { + val target = focusedRow + direction + if (target < if (hasHero) 0 else 1) { shell.focusNav(shell.currentTab); return } + if (target >= rowCount) return + val key = when (target) { 0 -> "phero"; 1 -> "dchips"; else -> lanes[target - firstShelf].id } + val nearest = if (focusedRow >= firstShelf && target >= firstShelf) + column - shell.view.cards["first:$rowKey"].orZero() + shell.view.cards["first:$key"].orZero() + else 0 + focus(target, shell.view.cards[key] ?: nearest.coerceAtLeast(0)) + } + + fun openTitle(item: DiscoverTitle) { + title = item; titleButton = 0 + if (isSignedIn && item.ratings.isNotEmpty()) observedRatings.add(item.key) + onPush("title/${android.net.Uri.encode(item.key)}") + } + fun openCollection(lane: DiscoverLane) { + var titles = lane.fullTitles.associateBy { it.key } + collectionCards = LinkedHashMap(titles.size).apply { + titles.values.forEach { put(it.key, it.libraryCard(this@DiscoverState)) } + } + lateinit var grid: LibraryGridState + grid = LibraryGridState(lane.title, collectionCards.values.filter { it.id !in hidden }, catalogue = true, observedRatings = observedRatings, + onTitle = { openTitle(titles.getValue(it.id)) }, onDiscover = { collection = null; onPop() }, onRecover = { + scope.launch { + loadLane(lane.id) + if (collection !== grid) return@launch + grid.savedAt = snapshot.savedAt + val fresh = lanes.firstOrNull { it.id == lane.id } + if (canRequest && fresh != null) { + titles = fresh.fullTitles.associateBy { it.key } + collectionCards = LinkedHashMap(titles.size).apply { + titles.values.forEach { put(it.key, it.libraryCard(this@DiscoverState)) } + } + grid.tail = when (fresh.tail) { DiscoverTail.Retry -> GridTail.Retry; DiscoverTail.ShowMore -> GridTail.ShowMore; else -> GridTail.None } + updateCollectionCards() + } else grid.tail = GridTail.Retry + } + }).apply { + savedAt = snapshot.savedAt + tail = when (lane.tail) { DiscoverTail.Retry -> GridTail.Retry; DiscoverTail.ShowMore -> GridTail.ShowMore; else -> GridTail.None } + } + if (onMore != null && lane.hasMore) grid.pagination = LibraryPagination(grid) { + loadLane(lane.id) + val fresh = lanes.firstOrNull { it.id == lane.id } ?: error("Catalogue lane unavailable") + if (fresh.tail == DiscoverTail.Retry) error("Catalogue read unavailable") + titles = fresh.fullTitles.associateBy { it.key } + collectionCards = LinkedHashMap(titles.size).apply { + titles.values.forEach { put(it.key, it.libraryCard(this@DiscoverState)) } + } + LibraryPage(collectionCards.values.filter { it.id !in hidden }, fresh.hasMore) + } + collection = grid + onPush("discover-see-all/${lane.id}") + } + + private fun updateCollectionCards(changed: DiscoverTitle? = null) { + val grid = collection ?: return + val focused = title?.key ?: grid.focusedTitle?.id + if (changed != null && changed.key in collectionCards) collectionCards[changed.key] = changed.libraryCard(this) + grid.source = collectionCards.values.filter { it.id !in hidden } + val restored = grid.items.indexOfFirst { it.id == focused } + grid.index = if (restored >= 0) restored else grid.index.coerceIn(0, + if (grid.tail == GridTail.None) grid.items.lastIndex.coerceAtLeast(0) else grid.tailIndex) + } + fun isHidden(item: DiscoverTitle): Boolean = item.key in hidden + fun isPick(item: DiscoverTitle): Boolean = snapshot.picks.any { it.key == item.key } || isHidden(item) + + fun openService(item: DiscoverService) { + rootFilter = filter; service = item; filter = "movie"; revision++ + onPush("discover-service/${item.id}") + focus(firstShelf) + } + + fun enterRoute(route: String) { + if (route == "discover") { title = null; collection = null; if (service != null) filter = rootFilter; service = null } + else if (route.startsWith("discover-service") || route.startsWith("discover-see-all")) title = null + } + + /** Card hold/release handling belongs to the shell, including the shared catalogue grid. */ + override fun key(key: ShellKey): Boolean { + if (overlay != null) { overlayKey(key); return true } + if (shell.route != "discover" && !shell.route.startsWith("discover-") && !shell.route.startsWith("title/")) return false + if (snapshot.status == DiscoverStatus.Loading) { shell.focusNav(shell.currentTab); return true } + if (title == null) collection?.let { + if (key == ShellKey.Back) { collection = null; onPop(); return true } + return it.key(key) + } + if (key == ShellKey.Back) { + if (title != null) { title = null; onPop() } + else if (service != null) { service = null; filter = rootFilter; revision++; onPop() } + else shell.focusNav(shell.currentTab) + return true + } + if (title != null) { titleKey(key); return true } + if (!hasCatalogue) { + if (key == ShellKey.Up) shell.focusNav(shell.currentTab) else if (key == ShellKey.Ok) refresh() + return true + } + when (key) { + ShellKey.Up -> moveRow(-1) + ShellKey.Down -> moveRow(1) + ShellKey.Left, ShellKey.Right -> { + val direction = if (key == ShellKey.Left) -1 else 1 + if (focusedRow == 0) slide = (slide + direction).coerceIn(0, (heroes.size - 1).coerceAtLeast(0)) + else shell.view.cards[rowKey] = (column + direction).coerceIn(0, maxColumn()) + } + ShellKey.Ok -> when { + isCard -> activateCard() + focusedRow == 0 -> hero?.let(::openTitle) + focusedRow == 1 -> { filter = (if (service == null) DISCOVER_FILTERS else listOf("movie", "tv"))[column]; if (service == null) slide = 0; revision++ } + } + } + return true + } + + private fun maxColumn(): Int = when (focusedRow) { + 0 -> heroes.lastIndex + 1 -> if (service == null) 2 else 1 + else -> if (focusedLane?.id == "svc") snapshot.services.lastIndex else focusedLane?.let { it.titles.size - 1 + if (it.tail != DiscoverTail.None) 1 else 0 } ?: 0 + }.coerceAtLeast(0) + + private fun activateCard() { + val lane = focusedLane ?: return + if (lane.id == "svc") { snapshot.services.getOrNull(column)?.let(::openService); return } + val item = lane.titles.getOrNull(column) + if (item == null) { + if (lane.tail in setOf(DiscoverTail.Retry, DiscoverTail.ShowMore)) scope.launch { loadLane(lane.id) } + else openCollection(lane) + } else if (lane.hidden) overlay = DiscoverOverlayState.Restore(item) + else openTitle(item) + } + + private fun titleKey(key: ShellKey) { + if (!canRequest) { + if (key == ShellKey.Up) shell.focusNav(shell.currentTab) else if (key == ShellKey.Ok) refresh() + return + } + when (key) { + ShellKey.Up -> shell.focusNav(shell.currentTab) + ShellKey.Left -> titleButton = (titleButton - 1).coerceAtLeast(0) + ShellKey.Right -> titleButton = (titleButton + 1).coerceAtMost(if (title?.let(::isPick) == true) 2 else 1) + ShellKey.Ok -> when (titleButton) { + 0 -> if (requests[requireNotNull(title).key]?.any { it.requester == shell.person.key } == true) shell.requestsVisible = true else openRequest(false) + 1 -> openRequest(true) + 2 -> title?.let { if (it.key in hidden) restore(it, showToast = true) else hide(it) } + } + else -> Unit + } + } + + fun openRequest(other: Boolean, item: DiscoverTitle = requireNotNull(title), phase: RequestPhase = RequestPhase.Choose) { + if (!canRequest) { refresh(); return } + val choice = RequestSheetState(item, shell.person, requesters, other, phase) + val previous = requestSheets[item.key to choice.selectedPerson.key] + overlay = DiscoverOverlayState.Request( + if (phase == RequestPhase.Choose && previous?.phase in setOf(RequestPhase.Sending, RequestPhase.NotConfirmed, RequestPhase.Pending)) requireNotNull(previous) else choice, + ) + } + + private fun overlayKey(key: ShellKey) { + when (val sheet = overlay) { + is DiscoverOverlayState.Request -> sheet.state.key(key, ::send, { overlay = null }) { + scope.launch { + reload() + sheet.state.phase = if (canRequest) RequestPhase.Choose else RequestPhase.RecipientsUnavailable + sheet.state.focus = sheet.state.buttonIndex + } + } + is DiscoverOverlayState.Hidden -> when (key) { + ShellKey.Left, ShellKey.Up -> sheet.focus = 0 + ShellKey.Right, ShellKey.Down -> sheet.focus = 1 + ShellKey.Back -> overlay = null + ShellKey.Ok -> if (sheet.focus == 0) restore(sheet.title, showToast = false) else { overlay = null; title = null; onPop() } + } + is DiscoverOverlayState.Restore -> when (key) { + ShellKey.Left, ShellKey.Up -> sheet.focus = 0 + ShellKey.Right, ShellKey.Down -> sheet.focus = 1 + ShellKey.Back -> overlay = null + ShellKey.Ok -> if (sheet.focus == 0) restore(sheet.title, showToast = true) else overlay = null + } + null -> Unit + } + } + + private fun send(sheet: RequestSheetState, check: Boolean) { + if (!canRequest || (!check && !sheet.canSubmit)) return + if (!check) { + val previous = requestSheets[sheet.title.key to sheet.selectedPerson.key] + if (previous?.phase in setOf(RequestPhase.Sending, RequestPhase.NotConfirmed, RequestPhase.Pending)) { + overlay = DiscoverOverlayState.Request(requireNotNull(previous)) + return + } + } + val intent = DiscoverRequest(sheet.title, sheet.selectedPerson.key, sheet.viewer.key, isPick(sheet.title)) + requestSheets[sheet.title.key to sheet.selectedPerson.key] = sheet + sheet.phase = RequestPhase.Sending + scope.launch { + val result = (if (check) operations.check(intent) else operations.request(intent)).copy(requester = intent.requester) + sheet.result = result; sheet.phase = result.phase; sheet.focus = sheet.buttonIndex + if (result.phase == RequestPhase.Receipt) { + requests[sheet.title.key] = buildList { + requests[sheet.title.key].orEmpty().forEach { if (it.requester != result.requester) add(it) } + add(result) + } + updateCollectionCards(sheet.title) + } + } + } + + fun hide(item: DiscoverTitle) { + if (!canRequest) { refresh(); return } + hidden[item.key] = item; revision++; overlay = DiscoverOverlayState.Hidden(item) + updateCollectionCards() + val profile = shell.person.key + val version = (dismissalVersions[item.key] ?: 0) + 1 + dismissalVersions[item.key] = version + pendingDismissals[item.key] = item + scope.launch { + val success = dismissalMutex.withLock { operations.hide(profile, item) } + if (dismissalVersions[item.key] == version) pendingDismissals.remove(item.key) + if (!success && dismissalVersions[item.key] == version) { hidden.remove(item.key); revision++; updateCollectionCards(); overlay = null; toast = "Couldn’t hide this pick. Try again." } + } + } + + fun restore(item: DiscoverTitle, showToast: Boolean) { + if (!canRequest) { refresh(); return } + hidden.remove(item.key); revision++; overlay = null; toast = if (showToast) "Back in your picks." else null + updateCollectionCards() + val profile = shell.person.key + val version = (dismissalVersions[item.key] ?: 0) + 1 + dismissalVersions[item.key] = version + pendingDismissals[item.key] = null + scope.launch { + val success = dismissalMutex.withLock { operations.restore(profile, item) } + if (dismissalVersions[item.key] == version) pendingDismissals.remove(item.key) + if (!success && dismissalVersions[item.key] == version) { hidden[item.key] = item; revision++; updateCollectionCards(); toast = "Couldn’t restore this pick. Try again." } + } + } + + fun refresh() { scope.launch { reload() } } + + private suspend fun reload() { + val refreshed = operations.refresh() + val failed = refreshed.status == DiscoverStatus.Unavailable + snapshot = if (failed && hasCatalogue) snapshot.copy(savedAt = savedTime) else refreshed + if (!failed && hasCatalogue) savedTime = formatSavedTime() + revision++ + toast = if (failed) RecoveryMessage.StillUnreachable else null + } + private fun matches(item: DiscoverTitle): Boolean = filter == "all" || item.type == filter + private fun formatSavedTime(): String = java.time.Instant.ofEpochMilli(nowMillis()).atZone(java.time.ZoneId.systemDefault()).format(java.time.format.DateTimeFormatter.ofPattern("HH:mm")) + + private fun buildLanes(): List { + val lanes = service?.let { + serviceLanes(it, filter).filter { lane -> lane.titles.isNotEmpty() } + } ?: projectDiscoverLanes(snapshot, heroes, filter, hidden) + return if (snapshot.savedAt != null) lanes + DiscoverLane("offline-recovery", "More picks", emptyList(), tail = DiscoverTail.Retry) else lanes + } +} + +private fun Int?.orZero(): Int = this ?: 0 + +private val DISCOVER_FILTERS = listOf("all", "movie", "tv") diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverTitleDestination.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverTitleDestination.kt new file mode 100644 index 00000000..81247975 --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverTitleDestination.kt @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.design + +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.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.tv.material3.Text +import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C +import org.justinvisser.wolfskin.design.WolfskinTokens.Discover as D +import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L + +/** Explicit destination stub exposes only the Discover-owned request and pick actions. */ +@Composable +fun DiscoverTitleDestination(state: DiscoverState, modifier: Modifier = Modifier) { + val title = state.title ?: return + val focused = state.contentFocused + Box(modifier.fillMaxSize().testTag("discover-title-destination-stub")) { + DiscoverBackdrop(title, detail = true) + Column(Modifier.offset(L.safeSide, L.detailCopyTop).width(L.detailCopyWidth)) { + Text(title.title, color = C.fg, style = WolfskinTokens.Type.displayDetail, maxLines = 2) + Spacer(Modifier.height(D.heroTitleGap)) + Text("${title.typeWord}${title.year?.let { " · $it" }.orEmpty()} · ${title.genres.take(2).joinToString(", ")}", color = C.muted, style = WolfskinTokens.Type.body) + Spacer(Modifier.height(D.heroMetaGap)) + if (state.isSignedIn) DiscoverRatings(title.ratings) + Spacer(Modifier.height(D.heroMetaGap)) + Text(title.overview, color = C.muted, style = WolfskinTokens.Type.body, maxLines = 3) + Spacer(Modifier.height(D.heroOverviewGap)) + Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) { + if (!state.canRequest) DiscoverButton("Try again", focused) { state.refresh() } else { + val receipt = state.requests[title.key]?.firstOrNull { it.requester == state.shell.person.key } + DiscoverButton(if (receipt == null) "Request" else "Requested · ${receipt.stage}", focused && state.titleButton == 0, glyph = if (receipt == null) "plus" else "clock") { + state.titleButton = 0; state.key(ShellKey.Ok) + } + DiscoverButton(if (state.shell.person.key in title.heldBy) "Add for someone" else "For someone else", focused && state.titleButton == 1, glyph = "profile", primary = false) { state.openRequest(true) } + if (state.isPick(title)) DiscoverButton(if (state.isHidden(title)) "Show again" else "Not interested", focused && state.titleButton == 2, glyph = if (state.isHidden(title)) "undo" else "hide", primary = false) { state.titleButton = 2; state.key(ShellKey.Ok) } + } + } + } + } +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverType.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverType.kt new file mode 100644 index 00000000..45df9669 --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverType.kt @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.design + +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.FirstBaseline +import androidx.compose.ui.layout.LastBaseline +import androidx.compose.ui.layout.layout +import androidx.compose.ui.text.TextStyle +import kotlin.math.roundToInt +import androidx.compose.ui.text.PlatformTextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.LineHeightStyle + +/** CSS line boxes survive native first/last-line trimming only within Discover's text stacks. */ +internal object DiscoverType { + private val lineBox = LineHeightStyle(LineHeightStyle.Alignment.Center, LineHeightStyle.Trim.None) + private val platform = PlatformTextStyle(includeFontPadding = false) + val hero = WolfskinTokens.Type.displayHero.copy(lineHeightStyle = lineBox, platformStyle = platform) + val body = WolfskinTokens.Type.body.copy(lineHeightStyle = lineBox, platformStyle = platform) + val label = WolfskinTokens.Type.label.copy(lineHeightStyle = lineBox, platformStyle = platform) + val button = WolfskinTokens.Type.button.copy(lineHeightStyle = lineBox, platformStyle = platform) + val sheetTitle = WolfskinTokens.Type.sheetTitle.copy(lineHeightStyle = lineBox, platformStyle = platform) + val stateTitle = WolfskinTokens.Type.stateTitle.copy(fontWeight = FontWeight.Bold, lineHeightStyle = lineBox, platformStyle = platform) + val listTitle = WolfskinTokens.Type.listTitle.copy(lineHeightStyle = lineBox, platformStyle = platform) + val caption = WolfskinTokens.Type.caption.copy(lineHeightStyle = lineBox, platformStyle = platform) + val reason = WolfskinTokens.Type.caption.copy(fontWeight = FontWeight.Medium, lineHeightStyle = lineBox, platformStyle = platform) + val rowHeading = WolfskinTokens.Type.rowHeading.copy(lineHeightStyle = lineBox, platformStyle = platform) + val entry = WolfskinTokens.Discover.entryText.copy(lineHeightStyle = lineBox, platformStyle = platform) + val ratings = WolfskinTokens.Type.ratingPlate.copy(fontFeatureSettings = "tnum", lineHeightStyle = lineBox, platformStyle = platform) +} + +/** Paragraph overflow must not expand the CSS block height. */ +internal fun Modifier.discoverLineBox(style: TextStyle): Modifier = layout { measurable, constraints -> + val paragraph = measurable.measure(constraints) + val lineHeight = style.lineHeight.toPx() + val lines = ((paragraph[LastBaseline] - paragraph[FirstBaseline]) / lineHeight).roundToInt() + 1 + val height = (lines * lineHeight).roundToInt().coerceIn(constraints.minHeight, constraints.maxHeight) + layout(paragraph.width, height) { + paragraph.placeRelative(0, (height - paragraph.height) / 2) + } +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Focus.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Focus.kt new file mode 100644 index 00000000..39e23ea1 --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Focus.kt @@ -0,0 +1,78 @@ +package org.justinvisser.wolfskin.design + +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.remember +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.ui.draw.dropShadow +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.unit.Dp + +/** Only transforms animate: focus never changes measured geometry. */ +@Composable +fun Modifier.focusScale(focused: Boolean, scale: Float): Modifier { + val animated by animateFloatAsState( + if (focused) scale else 1f, + tween(WolfskinTokens.Motion.fast, easing = CubicBezierEasing(.25f, .1f, .25f, 1f)), + label = "focus-scale", + ) + return graphicsLayer { scaleX = animated; scaleY = animated } +} + +/** Lift shadows remain specific to cards and buttons. */ +enum class FocusLift { None, Card, Button } + +/** Every focus ring straddles the surface edge above content, without a background gap. */ +@Composable +fun Modifier.focusRing(focused: Boolean, radius: Dp, width: Dp, lift: FocusLift = FocusLift.None): Modifier { + val ringAlpha by animateFloatAsState(if (focused) 1f else 0f, + tween(WolfskinTokens.Motion.fast, easing = CubicBezierEasing(.25f, .1f, .25f, 1f)), label = "focus-ring") + val shadow = if (lift == FocusLift.None) this else { + val shape = remember(radius) { RoundedCornerShape(radius) } + val density = LocalDensity.current + val blur = with(density) { (if (lift == FocusLift.Card) WolfskinTokens.Lift.cardBlur else WolfskinTokens.Lift.buttonBlur).toPx() } + val shift = with(density) { (if (lift == FocusLift.Card) WolfskinTokens.Lift.cardY else WolfskinTokens.Lift.buttonY).toPx() } + dropShadow(shape) { + this.radius = blur + offset = Offset(0f, shift) + this.color = Color.Black + alpha = ringAlpha * if (lift == FocusLift.Card) WolfskinTokens.Lift.cardOpacity else WolfskinTokens.Lift.buttonOpacity + } + } + return shadow.drawWithCache { + val stroke = Stroke(width.toPx()) + val corner = CornerRadius(radius.toPx()) + onDrawWithContent { + drawContent() + if (ringAlpha > 0f) { + drawRoundRect(WolfskinTokens.Color.accent.copy(alpha = ringAlpha), + cornerRadius = corner, style = stroke) + } + } + } +} + +/** Pointing changes the same logical focus as the D-pad without activating an action. */ +@Composable +fun Modifier.focusOnPointerEnter(onEnter: () -> Unit): Modifier { + val action by rememberUpdatedState(onEnter) + val interactions = remember { MutableInteractionSource() } + val hovered by interactions.collectIsHoveredAsState() + LaunchedEffect(hovered) { if (hovered) action() } + return hoverable(interactions) +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/FrameScroll.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/FrameScroll.kt new file mode 100644 index 00000000..18b83e5f --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/FrameScroll.kt @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.design + +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.animation.core.animate +import androidx.compose.animation.core.tween +import androidx.compose.foundation.lazy.LazyListState + +/** Every frame follows focus with the same bounded scroll tween. */ +internal suspend fun animateFrameScroll(list: LazyListState, distance: Float) { + if (distance == 0f) return + list.scroll { + var previous = 0f + animate(0f, distance, animationSpec = tween(WolfskinTokens.Motion.scroll, + easing = CubicBezierEasing(.33f, 1f, .68f, 1f))) { value, _ -> + scrollBy(value - previous) + previous = value + } + } +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Glyph.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Glyph.kt new file mode 100644 index 00000000..5a9227d3 --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Glyph.kt @@ -0,0 +1,43 @@ +package org.justinvisser.wolfskin.design + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.tv.material3.Icon + +/** Shared glyph geometry comes from the locked prototype, never a platform icon font. */ +@Composable +fun Glyph(name: String, color: Color, modifier: Modifier = Modifier) { + val resource = when (name) { + "gear" -> R.drawable.wolfskin_gear + "requests" -> R.drawable.wolfskin_tray + "interface" -> R.drawable.wolfskin_grid + "play", "playback" -> R.drawable.wolfskin_play + "tv", "nextup" -> R.drawable.wolfskin_tv + "users", "profile" -> R.drawable.wolfskin_users + "about" -> R.drawable.wolfskin_info + "sparkle", "seerr" -> R.drawable.wolfskin_sparkle + "advanced" -> R.drawable.wolfskin_sort + "alert" -> R.drawable.wolfskin_alert + "wifi" -> R.drawable.wolfskin_wifi + "clock" -> R.drawable.wolfskin_clock + "plus" -> R.drawable.wolfskin_plus + "check" -> R.drawable.wolfskin_check + "hide" -> R.drawable.wolfskin_hide + "undo" -> R.drawable.wolfskin_undo + "left" -> R.drawable.wolfskin_left + "download" -> R.drawable.wolfskin_download + "film" -> R.drawable.wolfskin_film + "eye" -> R.drawable.wolfskin_eye + "down" -> R.drawable.wolfskin_down + "restart" -> R.drawable.title_restart + "version" -> R.drawable.title_version + "tracks" -> R.drawable.title_tracks + "eyeoff" -> R.drawable.title_eyeoff + "trailer" -> R.drawable.title_trailer + "more" -> R.drawable.title_more + else -> R.drawable.wolfskin_right + } + Icon(painterResource(resource), contentDescription = null, modifier = modifier, tint = color) +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeAmbient.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeAmbient.kt new file mode 100644 index 00000000..7f6b300a --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeAmbient.kt @@ -0,0 +1,32 @@ +package org.justinvisser.wolfskin.design + +import android.graphics.Matrix +import android.graphics.RadialGradient +import android.graphics.Shader +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.ShaderBrush +import androidx.compose.ui.graphics.toArgb + +/** Empty hero art keeps the locked elliptical ambient wash rather than flashing a black frame. */ +@Composable +fun HomeAmbient(modifier: Modifier = Modifier) { + val brush = remember { + object : ShaderBrush() { + override fun createShader(size: Size): Shader { + val centerX = size.width * .7f + val centerY = size.height * .2f + val radiusX = size.width * 1.2f + val radiusY = size.height * .9f + return RadialGradient(centerX, centerY, radiusX, intArrayOf(WolfskinTokens.Color.surface3.toArgb(), WolfskinTokens.Color.bg.toArgb()), null, Shader.TileMode.CLAMP).apply { + setLocalMatrix(Matrix().apply { setScale(1f, radiusY / radiusX, centerX, centerY) }) + } + } + } + } + Box(modifier.background(brush)) +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeFocus.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeFocus.kt new file mode 100644 index 00000000..1078d8db --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeFocus.kt @@ -0,0 +1,111 @@ +package org.justinvisser.wolfskin.design + +import kotlin.math.abs + +/** Home navigation mutates the shell's per-person view, including each row's remembered column. */ +class HomeFocus( + private val shell: ShellState, + private val rows: List, + private val onAction: (HomeAction) -> Unit, +) : ShellContentFocus { + private val view = shell.rememberedView("home") + override val hasContent: Boolean get() = rows.any { it.focusable } + override val cardKey: String? get() = row?.let { if (it.kind == HomeRowKind.Shelf) it.cards.getOrNull(column)?.id ?: it.id else null } + val row: HomeRow? get() = rows.getOrNull(view.row) + val column: Int get() = row?.let { view.cards[it.id] ?: 0 } ?: 0 + + fun focus(index: Int, column: Int) { + shell.navFocused = false + view.row = index + view.home.focusedKey = rows[index].id + val target = rows[index] + val bounded = column.coerceIn(0, (target.count - 1).coerceAtLeast(0)) + view.cards[target.id] = bounded + if (target.kind == HomeRowKind.Shelf) view.home.firstCards[target.id] = target.adjustedFirst(bounded, view.home.firstCards[target.id] ?: 0) + } + + override fun key(key: ShellKey): Boolean { + val current = row ?: return false + if (key == ShellKey.Back) return false + if (key == ShellKey.Ok) { activate(current); return true } + if (reenterVisible()) return true + when (key) { + ShellKey.Left -> focus(view.row, (column - 1).coerceAtLeast(0)) + ShellKey.Right -> focus(view.row, (column + 1).coerceAtMost((current.count - 1).coerceAtLeast(0))) + ShellKey.Up, ShellKey.Down -> { + val direction = if (key == ShellKey.Up) -1 else 1 + var next = view.row + direction + while (next in rows.indices && !rows[next].focusable) next += direction + if (next in rows.indices) { + val target = rows[next] + val nearest = nearest(current, target) + focus(next, view.cards[target.id] ?: nearest) + } else if (direction < 0) { + view.row = rows.indexOfFirst { it.focusable }.coerceAtLeast(0) + view.home.resetScroll++ + shell.navFocused = true + shell.navIndex = shell.currentTab + } + } + } + return true + } + + private fun reenterVisible(): Boolean { + val layout = view.listState.layoutInfo + if (layout.totalItemsCount == 0 || view.home.positionedKey != row?.id || view.listState.isScrollInProgress) return false + val density = layout.viewportSize.width / WolfskinTokens.Shell.stageWidth.value + val current = layout.visibleItemsInfo.firstOrNull { it.index == view.row } + if (current != null && current.offset + current.size >= WolfskinTokens.Layout.navSolidAfter.value * density && + current.offset <= layout.viewportSize.height - WolfskinTokens.Layout.rowHeadingBand.value * 2 * density) return false + val first = layout.visibleItemsInfo.firstOrNull { + rows[it.index].focusable && it.offset + it.size > WolfskinTokens.Layout.rowHeadingBand.value * density && + it.offset < layout.viewportSize.height - WolfskinTokens.Layout.row_endcta.value * density + } ?: return false + focus(first.index, view.cards[rows[first.index].id] ?: 0) + return true + } + + private fun activate(row: HomeRow) { + when (row.kind) { + HomeRowKind.Hero -> row.cards.firstOrNull()?.let { title -> + onAction(if (column == 0 && row.heroKind != "pick" && row.watchReady) HomeAction.Play(title) else HomeAction.Details(title)) + } + HomeRowKind.Shelf -> if (column < row.cards.size) onAction(HomeAction.Details(row.cards[column])) else onAction(HomeAction.SeeAll(row)) + HomeRowKind.State -> onAction(HomeAction.Retry) + HomeRowKind.Explore -> onAction(HomeAction.Discover) + else -> Unit + } + } + + private fun nearest(from: HomeRow, to: HomeRow): Int { + if (to.kind != HomeRowKind.Shelf) return (column * pitch(from) / pitch(to)).toInt() + val fromFirst = if (from.kind == HomeRowKind.Shelf) view.home.firstCards[from.id] ?: from.adjustedFirst(column, 0) else 0 + val x = (column - fromFirst) * pitch(from) + from.cardWidth.value / 2 + val first = (view.home.firstCards[to.id] ?: 0).coerceIn(0, (to.count - to.focusSlots).coerceAtLeast(0)) + val last = minOf(to.count - 1, first + to.focusSlots - 1) + var best = first + var distance = Float.POSITIVE_INFINITY + for (index in first..last) { + val candidate = abs((index - first) * pitch(to) + to.cardWidth.value / 2 - x) + if (candidate < distance) { best = index; distance = candidate } + } + return best + } + + private fun pitch(row: HomeRow): Float = when (row.cardKind) { + HomeCardKind.Poster -> WolfskinTokens.Card.posterPitch.value + HomeCardKind.Tile -> WolfskinTokens.Card.tilePitch.value + HomeCardKind.Wide -> WolfskinTokens.Card.widePitch.value + } +} + +internal val HomeRow.focusSlots: Int get() = when (cardKind) { + HomeCardKind.Poster -> 6 + HomeCardKind.Tile -> 4 + HomeCardKind.Wide -> 3 +} + +/** Keep one card behind focus, but do not shift again while focus stays in the visible middle. */ +internal fun HomeRow.adjustedFirst(column: Int, previous: Int): Int = + shelfFirst(column, previous, focusSlots, count) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeHeader.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeHeader.kt new file mode 100644 index 00000000..8aad42d4 --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeHeader.kt @@ -0,0 +1,93 @@ +package org.justinvisser.wolfskin.design + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.tv.material3.Text +import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C +import org.justinvisser.wolfskin.design.WolfskinTokens.Home as H +import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L +import org.justinvisser.wolfskin.design.HomeType as T + +/** The hero occupies one row while its backdrop extends beneath the first shelves. */ +@Composable +fun HomeHero(row: HomeRow, focused: Boolean, column: Int, ratings: List, onButton: (Int) -> Unit, onHover: (Int) -> Unit) { + Box(Modifier.fillMaxWidth().height(L.row_hero)) { + if (!row.skeleton) { + val title = row.cards.first() + val artModifier = Modifier.wrapContentSize(Alignment.TopStart, unbounded = true).requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight) + TitleArtwork(title.title, title.type, title.poster, WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight, artModifier, backdrop = title.backdrop, posterOnly = title.posterOnly, landscape = true, hero = true) + Box(artModifier.background(Brush.verticalGradient(0f to C.bg.copy(alpha = 0f), .52f to C.bg.copy(alpha = 0f), .74f to C.bg.copy(alpha = .6f), 1f to C.bg))) + Box(artModifier.background(Brush.horizontalGradient(0f to C.bg.copy(alpha = .92f), .42f to C.bg.copy(alpha = .55f), .70f to C.bg.copy(alpha = 0f)))) + Column(Modifier.align(Alignment.BottomStart).padding(start = L.safeSide, bottom = L.heroCopyBottom).width(L.heroCopyWidth)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(H.kickerGap)) { + Glyph(if (row.heroKind == "pick") "seerr" else "playback", C.accentText, Modifier.size(L.buttonIcon * (28f / 30f))) + Text(when (row.heroKind) { "resume" -> "Continue watching"; "next" -> "Up next"; "new" -> "New in your library"; else -> "For you" }, modifier = Modifier.exactLineBox(T.label), color = C.accentText, style = T.label) + } + Spacer(Modifier.height(H.kickerBottom)) + Text(title.displayTitle, modifier = Modifier.exactLineBox(T.displayHero), color = C.fg, style = T.displayHero, maxLines = 2, overflow = TextOverflow.Ellipsis) + Spacer(Modifier.height(H.titleBottom)) + Row(Modifier.heightIn(min = H.factsHeight), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(H.factsGap)) { + Text(if (title.isEpisode) title.subtitle else title.year?.toString().orEmpty(), modifier = Modifier.exactLineBox(T.body), color = C.muted, style = T.body, maxLines = 1) + if (row.heroKind == "resume" || title.runtimeSeconds != null) Text(if (row.heroKind == "resume") title.remaining else title.runtime, modifier = Modifier.exactLineBox(T.body), color = C.muted, style = T.body, maxLines = 1) + HomeRatingsRow(ratings) + } + Spacer(Modifier.height(H.factsBottom)) + Text(title.overview, color = C.muted, style = T.body, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.width(L.heroOverviewMax).exactLineBox(T.body)) + Spacer(Modifier.height(H.overviewBottom)) + Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) { + val detailsOnly = row.heroKind == "pick" || !row.watchReady + val primary = if (detailsOnly) "Details" else (if (row.heroKind == "resume") "Resume" else "Play") + if (title.isEpisode) " ${title.episodeLabel}" else "" + PickerButton(primary, focused && column == 0, true, if (detailsOnly) "about" else "playback", onFocus = { onHover(0) }, textStyle = T.button) { onButton(0) } + if (!detailsOnly) PickerButton("Details", focused && column == 1, false, "about", onFocus = { onHover(1) }, textStyle = T.button) { onButton(1) } + } + } + } else { + HomeAmbient(Modifier.wrapContentSize(Alignment.TopStart, unbounded = true).requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) + Column(Modifier.align(Alignment.BottomStart).padding(start = L.safeSide, bottom = L.heroCopyBottom)) { + Box(Modifier.size(H.heroSkeletonWidth, H.heroSkeletonHeight).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.card))) + Spacer(Modifier.height(H.heroSkeletonGap)) + Box(Modifier.size(H.heroSkeletonFactsWidth, H.heroSkeletonFactsHeight).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.cardSheet))) + Spacer(Modifier.height(H.heroSkeletonButtonGap)) + Box(Modifier.size(H.heroSkeletonButtonWidth, L.buttonHeight).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.button))) + } + } + } +} + +/** Discover continues in the same scroller, under the active person's identity. */ +@Composable +fun HomeSection(person: ShellPerson, savedAt: String?) { + Box(Modifier.fillMaxWidth().height(L.row_section)) { + Row(Modifier.padding(start = L.safeSide, top = H.sectionTop), horizontalArrangement = Arrangement.spacedBy(H.sectionGap), verticalAlignment = Alignment.CenterVertically) { + Text("Discover", color = C.fg, style = T.sectionTitle) + Avatar(person, H.sectionAvatar, T.avatarLetterSectionHeader) + Text("Picks for you first, then what’s out there", color = C.muted, style = T.body) + savedAt?.let { SavedPill(it) } + } + } +} + +/** Failures keep a focusable recovery action without covering working library shelves. */ +@Composable +fun HomeRecovery(row: HomeRow, focused: Boolean, retry: () -> Unit, onHover: () -> Unit) { + Box(Modifier.fillMaxWidth().height(L.row_state)) { + Column(Modifier.offset(L.safeSide, H.stateTop).width(L.stateWidth).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.panel)).border(H.hairline, C.border, RoundedCornerShape(WolfskinTokens.Radius.panel)).padding(horizontal = H.statePaddingX + H.hairline, vertical = H.statePaddingY + H.hairline)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(H.plateTop)) { + Glyph(if (row.calm) "profile" else "alert", if (row.calm) C.accentText else C.warn, Modifier.size(H.stateIcon)) + Text(row.title, modifier = Modifier.exactLineBox(T.stateTitle), color = C.fg, style = T.stateTitle.copy(fontWeight = FontWeight.Bold)) + } + Spacer(Modifier.height(H.stateGap)) + Text(row.text, modifier = Modifier.exactLineBox(T.body), color = C.muted, style = T.body) + Spacer(Modifier.height(H.stateTextBottom)) + PickerButton("Try again", focused, row.calm, onFocus = onHover, textStyle = T.button, onClick = retry) + } + } +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeModel.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeModel.kt new file mode 100644 index 00000000..81fbd5eb --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeModel.kt @@ -0,0 +1,93 @@ +package org.justinvisser.wolfskin.design + +import androidx.compose.runtime.Immutable +import java.util.Locale +import kotlin.math.roundToInt + +/** A screen-owned media projection, independent of Jellyfin and catalogue DTOs. */ +@Immutable +data class HomeTitle( + val id: String, + val title: String, + val type: String, + val year: Int? = null, + val runtimeSeconds: Int? = null, + val seasons: Int? = null, + val overview: String = "", + val poster: String? = null, + val backdrop: String? = null, + val posterOnly: Boolean = false, + val seriesTitle: String? = null, + val season: Int? = null, + val episode: Int? = null, + val resumeSeconds: Double = 0.0, + val played: Boolean = false, + val addedAt: String = "", + val genres: List = emptyList(), + val ratings: List = emptyList(), + val held: Boolean = false, + val otherOwner: String? = null, + val tmdbId: Int? = null, +) { + val isEpisode: Boolean get() = type == "episode" + val displayTitle: String get() = seriesTitle ?: title + val episodeLabel: String get() = "S$season E$episode" + val runtime: String get() { + val minutes = ((runtimeSeconds ?: 0) / 60.0).roundToInt() + return if (minutes >= 60) "${minutes / 60}h ${minutes % 60}m" else "${minutes}m" + } + val remaining: String get() = "${(((runtimeSeconds ?: 0) - resumeSeconds) / 60).roundToInt().coerceAtLeast(1)} min left" + val subtitle: String get() = if (isEpisode) "$episodeLabel · $title" else "${if (type == "movie") "Movie" else "Series"} · ${year ?: ""}" + val facts: String get() = listOfNotNull(year?.toString(), if (type == "movie") runtimeSeconds?.let { runtime } ?: "Movie" else seasons?.let { "$it ${if (it == 1) "season" else "seasons"}" } ?: "Series").joinToString(" · ") +} + +/** Only observed, range-checked scores reach this model; formatting follows the TV locale. */ +@Immutable +data class HomeRating(val mark: String, val score: Double, val percentage: Boolean = false, val episode: Boolean = false) { + fun format(locale: Locale = Locale.getDefault()): String = if (percentage) "${score.toInt()}%" else String.format(locale, "%.1f", score) +} + +enum class HomeCardKind { Poster, Tile, Wide } +enum class HomeRowKind { Hero, Shelf, Section, State, Note, Explore } +enum class HomeAvailability { Ready, Loading, SignedOut, Unavailable, Saved } + +/** Rows retain stable keys while a refresh replaces their immutable contents. */ +@Immutable +data class HomeRow( + val id: String, + val kind: HomeRowKind, + val title: String = "", + val cards: List = emptyList(), + val cardKind: HomeCardKind = HomeCardKind.Poster, + val catalogue: Boolean = false, + val total: Int = cards.size, + val target: String? = null, + val text: String = "", + val heroKind: String = "next", + val skeleton: Boolean = false, + val calm: Boolean = false, + val allCards: List = cards, + val watchReady: Boolean = true, +) { + val hasSeeAll: Boolean get() = total > cards.size + val count: Int get() = when (kind) { HomeRowKind.Hero -> if (heroKind == "pick" || !watchReady) 1 else 2; HomeRowKind.Shelf -> cards.size + if (hasSeeAll) 1 else 0; HomeRowKind.State, HomeRowKind.Explore -> 1; else -> 0 } + val focusable: Boolean get() = !skeleton && count > 0 +} + +/** One snapshot paints immediately; source status never discards a saved view. */ +@Immutable +data class HomeSnapshot( + val rows: List, + val conductarr: HomeAvailability = HomeAvailability.SignedOut, + val jellyfinAvailable: Boolean = true, + val savedAt: String? = null, +) + +/** Home emits integration intents, never owns the upstream player or later-slice pages. */ +sealed interface HomeAction { + data class Play(val title: HomeTitle) : HomeAction + data class Details(val title: HomeTitle) : HomeAction + data class SeeAll(val row: HomeRow) : HomeAction + data object Discover : HomeAction + data object Retry : HomeAction +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeRatingCache.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeRatingCache.kt new file mode 100644 index 00000000..d36d0f85 --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeRatingCache.kt @@ -0,0 +1,50 @@ +package org.justinvisser.wolfskin.design + +/** The screen's ratings cache uses an injected clock and never caches failed reads. */ +class HomeRatingCache(private val clock: () -> Long = System::currentTimeMillis) { + private data class Entry(val ratings: List, val readAt: Long) + private val entries = linkedMapOf() + private val seasons = linkedMapOf>() + + fun read(title: HomeTitle): List? { + val season = seasonKey(title) + val bucket = season?.let { seasons[it] } + val entry = if (season != null) bucket?.get(title.episode) else entries[key(title)] + if (entry == null) return null + if (clock() - entry.readAt >= lifetimeMillis) { + if (season == null) entries.remove(key(title)) else { + bucket?.remove(title.episode) + if (bucket.isNullOrEmpty()) seasons.remove(season) + } + return null + } + return entry.ratings + } + + fun save(title: HomeTitle, ratings: List) { + val entry = Entry(ratings, clock()) + val season = seasonKey(title) + if (season != null) { + val bucket = seasons.remove(season) ?: mutableMapOf() + bucket[requireNotNull(title.episode)] = entry + seasons[season] = bucket + while (seasons.size > capacity) seasons.remove(seasons.keys.first()) + } else { + val key = key(title) + entries.remove(key) + entries[key] = entry + while (entries.size > capacity) entries.remove(entries.keys.first()) + } + } + + private fun key(title: HomeTitle): String = if (title.isEpisode) "episode:${title.id}" else "${title.type}:${title.tmdbId ?: title.id}" + + private fun seasonKey(title: HomeTitle): String? = + if (title.isEpisode && title.tmdbId != null && title.season != null && title.episode != null) + "${title.tmdbId}:${title.season}" else null + + companion object { + private const val capacity = 300 + private val lifetimeMillis = (WolfskinTokens.Behaviour.cacheRatingsHours * 60 * 60 * 1000).toLong() + } +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeRatings.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeRatings.kt new file mode 100644 index 00000000..669ea07c --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeRatings.kt @@ -0,0 +1,92 @@ +package org.justinvisser.wolfskin.design + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.ColorMatrix +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.unit.TextUnit +import androidx.tv.material3.Text +import java.util.Locale +import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C +import org.justinvisser.wolfskin.design.WolfskinTokens.Home as H +import org.justinvisser.wolfskin.design.HomeType as T + +/** Formatting is measured before fitting, so decimal commas obey the same one-line rule. */ +@Composable +fun HomeRatingsRow(ratings: List, modifier: Modifier = Modifier, fontSize: TextUnit = T.ratingPlate.fontSize, plate: Boolean = false) { + val density = LocalDensity.current + val size = with(density) { fontSize.toDp() } + val entryGap = if (plate) size * .64f else H.ratingGap + val markGap = if (plate) size * .29f else H.ratingMarkGap + Row(modifier, horizontalArrangement = Arrangement.spacedBy(entryGap), verticalAlignment = Alignment.CenterVertically) { + ratings.forEach { rating -> + Row(horizontalArrangement = Arrangement.spacedBy(markGap), verticalAlignment = Alignment.CenterVertically) { + val resource = when (rating.mark) { + "rotten" -> R.drawable.ic_rotten_tomatoes_rotten + "upright" -> R.drawable.ic_rt_upright + "popcorn" -> R.drawable.ic_rt_upright + "spilled" -> R.drawable.ic_rt_spilled + "imdb" -> R.drawable.ic_rating_imdb + "tmdb" -> R.drawable.ic_rating_tmdb + else -> R.drawable.ic_rotten_tomatoes_fresh + } + val muted = rating.mark == "tomato" || rating.mark == "popcorn" + Image(painterResource(resource), null, Modifier.size(size * if (rating.mark == "imdb" || rating.mark == "tmdb") 1.8f else 1f, size).graphicsLayer { alpha = if (muted) .55f else 1f }, colorFilter = if (muted) ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }) else null) + if (rating.episode) Text("Ep", color = C.muted, style = T.ratingPlate.copy(fontSize = fontSize, lineHeight = fontSize * 1.2f, fontWeight = T.label.fontWeight), maxLines = 1) + Text(rating.format(), color = C.fg, style = T.ratingPlate.copy(fontSize = fontSize, lineHeight = fontSize * 1.2f, fontFeatureSettings = "tnum"), maxLines = 1, softWrap = false) + } + } + } +} + +/** The plate is a prefix of the full ratings set; its first entry is never discarded. */ +@Composable +fun HomeRatingPlate(ratings: List, visible: Boolean, modifier: Modifier = Modifier) { + val alpha by animateFloatAsState(if (visible && ratings.isNotEmpty()) 1f else 0f, tween(WolfskinTokens.Motion.base), label = "rating-plate") + if (ratings.isEmpty()) return + val measurer = rememberTextMeasurer() + val density = LocalDensity.current + val locale = Locale.getDefault() + BoxWithConstraints(modifier.graphicsLayer { this.alpha = alpha }.background(Brush.verticalGradient(0f to C.bg.copy(alpha = .9f), .52f to C.bg.copy(alpha = .72f), 1f to C.bg.copy(alpha = 0f))).padding(start = H.plateSide, end = H.plateSide, top = H.plateTop, bottom = H.plateFade)) { + val available = with(density) { maxWidth.toPx() } + val fitted = remember(ratings, available, density, locale) { + val base = T.ratingPlate.fontSize + val floor = T.caption.fontSize + fun width(size: TextUnit, entries: List): Float { + val em = with(density) { size.toPx() } + return entries.sumOf { rating -> + val mark = em * if (rating.mark == "imdb" || rating.mark == "tmdb") 1.8f else 1f + val number = measurer.measure(rating.format(locale), T.ratingPlate.copy(fontSize = size)).size.width + val episode = if (rating.episode) measurer.measure("Ep", T.ratingPlate.copy(fontSize = size, fontWeight = T.label.fontWeight)).size.width + em * .29f else 0f + (mark + em * .29f + number + episode).toDouble() + }.toFloat() + (entries.size - 1).coerceAtLeast(0) * em * .64f + } + var size = base + val initialWidth = width(base, ratings) + if (initialWidth > available) size = base * (available / initialWidth).coerceAtLeast(floor.value / base.value) + val step = (base.value - floor.value) / 8 + while (size > floor && width(size, ratings) > available) size = base * ((size.value - step).coerceAtLeast(floor.value) / base.value) + var count = ratings.size + while (count > 1 && width(size, ratings.take(count)) > available) count-- + size to ratings.take(count) + } + HomeRatingsRow(fitted.second, fontSize = fitted.first, plate = true) + } +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeRows.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeRows.kt new file mode 100644 index 00000000..4c7f8beb --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeRows.kt @@ -0,0 +1,55 @@ +package org.justinvisser.wolfskin.design + +/** Source reads preserve their order; the hero is removed by stable item identity. */ +data class HomeLibrary( + val resume: List = emptyList(), + val nextUp: List = emptyList(), + val newest: List = emptyList(), + val destinations: Map> = emptyMap(), + val destinationTotals: Map = emptyMap(), +) + +/** Merge the two sources into the single Home frame without inventing playable items. */ +fun buildHomeRows( + library: HomeLibrary, + continuation: List, + libraryAvailable: Boolean = true, + loading: Boolean = false, +): List = buildList { + if (loading) { + add(HomeRow("hero-sk", HomeRowKind.Hero, skeleton = true)) + listOf(HomeCardKind.Tile, HomeCardKind.Tile, HomeCardKind.Poster, HomeCardKind.Poster).forEachIndexed { index, kind -> + add(HomeRow("sk$index", HomeRowKind.Shelf, cardKind = kind, skeleton = true)) + } + return@buildList + } + val choice = when { + library.resume.isNotEmpty() -> "resume" to library.resume.first() + library.nextUp.isNotEmpty() -> "next" to library.nextUp.first() + library.newest.isNotEmpty() -> "new" to library.newest.first() + else -> continuation.firstNotNullOfOrNull { it.cards.firstOrNull() }?.let { "pick" to it } + } + val hero = choice?.second + if (hero != null) add(HomeRow("hero", HomeRowKind.Hero, cards = listOf(hero), heroKind = choice.first)) + if (!libraryAvailable) { + add(HomeRow("jf-down", HomeRowKind.State, "Your library can’t be reached", text = "Jellyfin isn’t answering, so nothing can play right now.")) + } else { + listOf(Triple("cw", "Continue watching", library.resume), Triple("nu", "Next up", library.nextUp), Triple("new", "New in your library", library.newest)).forEach { (id, title, source) -> + val cards = source.filter { it.id != hero?.id } + if (cards.isNotEmpty()) add(HomeRow(id, HomeRowKind.Shelf, title, cards, if (id == "cw") HomeCardKind.Wide else HomeCardKind.Tile)) + } + listOf("movies", "shows", "anime").forEach { destination -> + val titles = library.destinations[destination].orEmpty().sortedWith(compareBy { it.played }.thenByDescending { it.addedAt }) + if (titles.isNotEmpty()) add(HomeRow("lib-$destination", HomeRowKind.Shelf, if (destination == "anime") "Anime" else "Your $destination", titles.take(WolfskinTokens.Behaviour.rowLimit.toInt()), total = library.destinationTotals[destination] ?: titles.size, target = destination)) + } + } + add(HomeRow("disc-h", HomeRowKind.Section, "Discover")) + addAll(continuation) +} + +/** Authentication failure is distinct from an unreachable signed-in catalogue. */ +fun homeDiscoverState(status: HomeAvailability): HomeRow = if (status == HomeAvailability.SignedOut) { + HomeRow("signin", HomeRowKind.State, "Discover needs sign-in", text = "Sign this TV in to Conductarr to see picks and request titles. Your library plays as usual.", calm = true) +} else { + HomeRow("cond-down", HomeRowKind.State, "Discover is resting", text = "Conductarr can’t be reached. Your library still plays.") +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeScreen.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeScreen.kt new file mode 100644 index 00000000..10090be2 --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeScreen.kt @@ -0,0 +1,128 @@ +package org.justinvisser.wolfskin.design + +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.animation.core.tween +import androidx.compose.foundation.gestures.animateScrollBy +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription +import androidx.tv.material3.Text +import kotlinx.coroutines.delay +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C +import org.justinvisser.wolfskin.design.WolfskinTokens.Home as H +import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L + +/** One collector owns the focused card dwell; cached scores appear on re-entry without another read. */ +@Composable +fun HomeScreen( + shell: ShellState, + snapshot: HomeSnapshot, + modifier: Modifier = Modifier, + onAction: (HomeAction) -> Unit, +) { + val view = shell.rememberedView("home") + LaunchedEffect(view, snapshot.savedAt) { view.savedAt = snapshot.savedAt } + val action by rememberUpdatedState(onAction) + val focus = remember(shell, snapshot.rows, view) { HomeFocus(shell, snapshot.rows) { action(it) } } + DisposableEffect(shell, focus) { + shell.contentFocus = focus + onDispose { if (shell.contentFocus === focus) shell.contentFocus = null } + } + LaunchedEffect(snapshot.rows, view) { + snapshot.rows.forEach { item -> + view.cards[item.id]?.let { view.cards[item.id] = it.coerceIn(0, (item.count - 1).coerceAtLeast(0)) } + } + val remembered = snapshot.rows.indexOfFirst { it.id == view.home.focusedKey && it.focusable } + if (remembered >= 0) view.row = remembered + if (snapshot.rows.none { it.focusable }) { + if (view.home.focusedKey == null && !shell.navFocused) view.home.loadingEntryRevision = shell.inputRevision + shell.navFocused = true + shell.navIndex = shell.currentTab + } else { + if (view.home.loadingEntryRevision == shell.inputRevision) focus.focus(snapshot.rows.indexOfFirst { it.focusable }, 0) + view.home.loadingEntryRevision = null + if (snapshot.rows.getOrNull(view.row)?.focusable != true) view.row = snapshot.rows.indexOfFirst { it.focusable } + } + view.home.focusedKey = snapshot.rows.getOrNull(view.row)?.id + } + val density = LocalDensity.current + val heights = remember(snapshot.rows) { snapshot.rows.map { rowHeight(it) } } + val topInset = if (snapshot.rows.firstOrNull()?.kind == HomeRowKind.Hero) WolfskinTokens.Shell.zero else L.pinnedLine + val context = LocalContext.current + LaunchedEffect(view.row, snapshot.rows) { + snapshot.rows.drop(view.row + 1).firstOrNull { it.kind == HomeRowKind.Shelf && !it.skeleton }?.let { WolfskinImages.prefetch(context, it) } + } + LaunchedEffect(view, view.row, view.home.resetScroll, heights) { + val key = snapshot.rows.getOrNull(view.row)?.id + if (view.home.positionedKey == key && view.home.positionedReset == view.home.resetScroll) return@LaunchedEffect + val topRequested = shell.navFocused && view.home.resetScroll > 0 && view.home.positionedReset != view.home.resetScroll + val target = if (topRequested) 0f else with(density) { + (heights.take(view.row).fold(topInset) { total, height -> total + height } - if (snapshot.rows.getOrNull(view.row)?.kind == HomeRowKind.Hero) WolfskinTokens.Shell.zero else L.pinnedLine).toPx() + }.coerceAtLeast(0f) + val current = with(density) { heights.take(view.listState.firstVisibleItemIndex).fold(WolfskinTokens.Shell.zero) { total, height -> total + height }.toPx() } + view.listState.firstVisibleItemScrollOffset + view.listState.animateScrollBy(target - current, tween(WolfskinTokens.Motion.scroll, easing = CubicBezierEasing(.33f, 1f, .68f, 1f))) + view.home.positionedKey = key + view.home.positionedReset = view.home.resetScroll + } + val cache = view.home.ratings + val ratingsReader = LocalScreenRatings.current + val row = snapshot.rows.getOrNull(view.row) + val title = row?.cards?.getOrNull(focus.column) + val hero = snapshot.rows.firstOrNull { it.kind == HomeRowKind.Hero }?.cards?.firstOrNull() + var heroRatings by remember(hero?.id) { mutableStateOf(hero?.let(cache::read).orEmpty()) } + var focusedRatings by remember(title?.id, shell.navFocused) { mutableStateOf(title?.let(cache::read).orEmpty()) } + LaunchedEffect(title?.id, hero?.id, shell.navFocused, shell.pickerVisible, snapshot.conductarr) { + val available = snapshot.conductarr == HomeAvailability.Ready || snapshot.conductarr == HomeAvailability.Saved + coroutineScope { + if (hero != null && available) launch { + val values = if (ratingsReader == null) hero.ratings else hero.tmdbId?.let { ratingsReader.read(it.toLong(), hero.type, hero.season, hero.episode, cachedOnly = snapshot.conductarr == HomeAvailability.Saved)?.map(LibraryRating::homeRating) } + if (values != null) { heroRatings = values; cache.save(hero, values) } + } + if (title != null && row?.kind != HomeRowKind.Hero && !shell.navFocused && !shell.pickerVisible && available) launch { + val saved = cache.read(title) + if (saved == null) delay(WolfskinTokens.Motion.ratingDwell.toLong()) + val values = saved ?: if (ratingsReader == null) title.ratings else title.tmdbId?.let { ratingsReader.read(it.toLong(), title.type, title.season, title.episode, cachedOnly = snapshot.conductarr == HomeAvailability.Saved)?.map(LibraryRating::homeRating) } + if (values != null) { focusedRatings = values; cache.save(title, values) } + } + } + } + val showRatings = snapshot.conductarr != HomeAvailability.SignedOut + val trailingSpace = maxOf(H.frameBottom, WolfskinTokens.Shell.stageHeight - L.pinnedLine - (heights.lastOrNull() ?: WolfskinTokens.Shell.zero)) + LazyColumn(state = view.listState, modifier = modifier.testTag("wolfskin-home").semantics { stateDescription = "${row?.id}:${focus.column}" }, contentPadding = PaddingValues(top = topInset, bottom = trailingSpace)) { + itemsIndexed(snapshot.rows, key = { _, item -> item.id }, contentType = { _, item -> item.kind.name.lowercase() }) { index, item -> + val selected = index == view.row && !shell.navFocused + fun activate(column: Int) { focus.focus(index, column); focus.key(ShellKey.Ok) } + when (item.kind) { + HomeRowKind.Hero -> HomeHero(item, selected, focus.column, if (showRatings) heroRatings else emptyList(), ::activate) { focus.focus(index, it) } + HomeRowKind.Shelf -> HomeShelf(item, view.home, selected, view.cards[item.id] ?: 0, if (showRatings && selected) focusedRatings else emptyList(), ::activate) { focus.focus(index, it) } + HomeRowKind.Section -> HomeSection(shell.person, snapshot.savedAt) + HomeRowKind.State -> HomeRecovery(item, selected, { activate(0) }) { focus.focus(index, 0) } + HomeRowKind.Note -> Row(Modifier.fillMaxWidth().height(L.row_note).padding(start = L.safeSide, top = H.noteTop), horizontalArrangement = Arrangement.spacedBy(H.noteGap)) { + Glyph(if (item.calm) "eye" else "alert", if (item.calm) C.muted else C.warn, Modifier.size(L.buttonIcon)) + Text(item.text, color = if (item.calm) C.muted else C.warn, style = HomeType.body) + } + HomeRowKind.Explore -> Box(Modifier.fillMaxWidth().height(L.row_endcta).padding(start = L.safeSide, top = H.endTop)) { + PickerButton("Explore more in Discover", selected, true, "seerr", onFocus = { focus.focus(index, 0) }, textStyle = HomeType.button) { activate(0) } + } + } + } + } +} + +private fun rowHeight(row: HomeRow) = when (row.kind) { + HomeRowKind.Hero -> L.row_hero + HomeRowKind.Shelf -> L.rowHeadingBand + row.cardHeight + (if (row.cardKind == HomeCardKind.Poster) WolfskinTokens.Card.posterCaptionBand else WolfskinTokens.Shell.zero) + L.rowGapAfter + HomeRowKind.Section -> L.row_section + HomeRowKind.State -> L.row_state + HomeRowKind.Note -> L.row_note + HomeRowKind.Explore -> L.row_endcta +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeShelf.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeShelf.kt new file mode 100644 index 00000000..069851f1 --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeShelf.kt @@ -0,0 +1,101 @@ +package org.justinvisser.wolfskin.design + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.zIndex +import androidx.tv.material3.Text +import org.justinvisser.wolfskin.design.WolfskinTokens.Card as K +import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C +import org.justinvisser.wolfskin.design.WolfskinTokens.Home as H +import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L +import org.justinvisser.wolfskin.design.HomeType as T + +/** Every lazy row and card uses a stable source identity and an explicit content type. */ +@Composable +fun HomeShelf(row: HomeRow, memory: HomeViewMemory, focused: Boolean, column: Int, ratings: List, onCard: (Int) -> Unit, onHover: (Int) -> Unit) { + val width = row.cardWidth + val height = row.cardHeight + val caption = if (row.cardKind == HomeCardKind.Poster) K.posterCaptionBand else WolfskinTokens.Shell.zero + val first = row.adjustedFirst(column, memory.firstCards[row.id] ?: 0) + SideEffect { if (memory.firstCards[row.id] != first) memory.firstCards[row.id] = first } + val track = remember(memory, row.id) { memory.tracks.getOrPut(row.id) { LazyListState(first) } } + val density = LocalDensity.current + var positioned by remember(track) { mutableStateOf(false) } + LaunchedEffect(track, first, focused, width, density) { + if (!positioned) track.scrollToItem(first) + else track.scrollShelfTo(first, with(density) { (width + K.posterGap).toPx() }) + positioned = true + } + Box(Modifier.fillMaxWidth().height(L.rowHeadingBand + height + caption + L.rowGapAfter)) { + if (!row.skeleton) Text(row.title, color = C.fg, style = T.rowHeading, maxLines = 1, modifier = Modifier.offset(x = L.safeSide)) + val cards = if (row.skeleton) List(if (row.cardKind == HomeCardKind.Poster) 8 else 5) { null } else row.cards + if (row.hasSeeAll) listOf(null) else emptyList() + LazyRow(state = track, modifier = Modifier.fillMaxWidth().offset(y = L.rowHeadingBand - L.rowGapAfter).height(height + L.rowGapAfter * 2), contentPadding = PaddingValues(horizontal = L.safeSide, vertical = L.rowGapAfter), horizontalArrangement = Arrangement.spacedBy(K.posterGap), userScrollEnabled = !row.skeleton) { + itemsIndexed(cards, key = { index, title -> title?.id ?: "${row.id}:tail:$index" }, contentType = { _, title -> if (row.skeleton) "skeleton" else if (title == null) "seeAll" else row.cardKind.name.lowercase() }) { index, title -> + val selected = focused && column == index + val cardModifier = Modifier.zIndex(if (selected) 2f else 0f).testTag("home-card:${row.id}:$index") + .then(if (!row.skeleton) Modifier.focusOnPointerEnter { onHover(index) } else Modifier) + if (title == null && !row.skeleton) { + LibrarySeeAllCard(row.total, selected, { onCard(index) }, cardModifier, width, height, T.listTitle, T.label) + } else TitleCard(selected, cardModifier.then(if (!row.skeleton) Modifier.clickable { onCard(index) } else Modifier), width, height) { + if (title == null) Box(Modifier.fillMaxSize().background(C.surface)) else { + TitleArtwork(title.title, title.type, title.poster, width, height, Modifier.fillMaxSize(), backdrop = title.backdrop, posterOnly = title.posterOnly, landscape = row.cardKind != HomeCardKind.Poster) + if (row.cardKind != HomeCardKind.Poster) TileOverlay(title) + if (row.catalogue && title.held) Box(Modifier.align(Alignment.BottomEnd).padding(L.badgeInset).size(L.badge).background(C.ok, CircleShape), contentAlignment = Alignment.Center) { Text("✓", color = C.onFocus, style = T.button) } + if (title.resumeSeconds > 0 && title.runtimeSeconds != null) Box(Modifier.align(Alignment.BottomCenter).padding(horizontal = H.progressInset).padding(bottom = if (row.cardKind == HomeCardKind.Poster) H.posterProgressBottom else H.progressBottom).fillMaxWidth().height(L.progressBar).clip(RoundedCornerShape(WolfskinTokens.Radius.progress)).background(C.progressTrack)) { + Box(Modifier.fillMaxHeight().fillMaxWidth((title.resumeSeconds / title.runtimeSeconds).toFloat().coerceIn(0f, 1f)).background(C.accent)) + } + HomeRatingPlate(if (selected) ratings else emptyList(), selected, Modifier.fillMaxWidth()) + } + } + } + } + if (row.cardKind == HomeCardKind.Poster && focused && !row.skeleton) { + val title = row.cards.getOrNull(column) + val availability = when { + title == null -> null + row.catalogue -> if (title.held) "In your library" else title.otherOwner?.let { "In $it’s library" } + title.played -> "Watched" + title.resumeSeconds > 0 -> title.remaining + else -> null + } + val facts = remember(title, row.total, availability) { + (title?.facts ?: "${row.total} titles").replace(" · ", " · ") + availability?.let { " · $it" }.orEmpty() + } + Caption(title?.title ?: "See all ${row.title}", facts, true, Modifier.offset(L.safeSide + (width + K.posterGap) * (column - first), L.rowHeadingBand + height + L.captionOffset).width(H.captionWidth), accentLastFact = availability != null, titleStyle = T.button, factsStyle = T.caption, titleOverflow = TextOverflow.Ellipsis) + } + } +} + +@Composable +private fun TileOverlay(title: HomeTitle) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.BottomStart) { + Column(Modifier.fillMaxWidth().background(Brush.verticalGradient(listOf(C.bg.copy(alpha = 0f), C.bg.copy(alpha = .92f)))).padding(start = H.cardPadding, end = H.cardPadding, top = H.cardOverlayTop, bottom = H.cardOverlayBottom)) { + Text(title.displayTitle, modifier = Modifier.exactLineBox(T.button), color = C.fg, style = T.button, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(title.subtitle, modifier = Modifier.exactLineBox(T.caption), color = C.muted, style = T.caption, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } +} + +val HomeRow.cardWidth: Dp get() = when (cardKind) { HomeCardKind.Poster -> K.posterW; HomeCardKind.Tile -> K.tileW; HomeCardKind.Wide -> K.wideW } +val HomeRow.cardHeight: Dp get() = when (cardKind) { HomeCardKind.Poster -> K.posterH; HomeCardKind.Tile -> K.tileH; HomeCardKind.Wide -> K.wideH } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeType.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeType.kt new file mode 100644 index 00000000..f54faeaa --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeType.kt @@ -0,0 +1,24 @@ +package org.justinvisser.wolfskin.design + +import androidx.compose.ui.text.PlatformTextStyle +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.LineHeightStyle + +/** Home keeps full CSS line boxes without moving retained shell or library typography. */ +object HomeType { + private fun fullBox(style: TextStyle): TextStyle = style.copy( + platformStyle = PlatformTextStyle(includeFontPadding = false), + lineHeightStyle = LineHeightStyle(LineHeightStyle.Alignment.Center, LineHeightStyle.Trim.None, LineHeightStyle.Mode.Tight), + ) + val body = fullBox(WolfskinTokens.Type.body) + val button = fullBox(WolfskinTokens.Type.button) + val caption = fullBox(WolfskinTokens.Type.caption) + val label = fullBox(WolfskinTokens.Type.label) + val displayHero = fullBox(WolfskinTokens.Type.displayHero) + val stateTitle = fullBox(WolfskinTokens.Type.stateTitle) + val sectionTitle = fullBox(WolfskinTokens.Type.sectionTitle) + val rowHeading = WolfskinTokens.Type.rowHeading + val ratingPlate = WolfskinTokens.Type.ratingPlate + val listTitle = fullBox(WolfskinTokens.Type.listTitle) + val avatarLetterSectionHeader = WolfskinTokens.Type.avatarLetterSectionHeader +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeViewMemory.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeViewMemory.kt new file mode 100644 index 00000000..65f379ad --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeViewMemory.kt @@ -0,0 +1,19 @@ +package org.justinvisser.wolfskin.design + +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.setValue + +/** Focus-follow runs on a new row, never on re-entry to an already-positioned saved viewport. */ +class HomeViewMemory { + val ratings = HomeRatingCache() + val firstCards = mutableStateMapOf() + val tracks = mutableMapOf() + var loadingEntryRevision: Long? = null + var focusedKey: String? = null + var positionedKey: String? = null + var resetScroll by mutableIntStateOf(0) + var positionedReset = -1 +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Identity.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Identity.kt new file mode 100644 index 00000000..266cbded --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Identity.kt @@ -0,0 +1,39 @@ +package org.justinvisser.wolfskin.design + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.Dp +import androidx.tv.material3.Text + +/** Fixed household colours never depend on preference or avatar presets. */ +fun personColor(key: String): Color = when (key.lowercase()) { + "justin" -> WolfskinTokens.PersonColor.justin + "mindel" -> WolfskinTokens.PersonColor.mindel + "shared" -> WolfskinTokens.PersonColor.shared + else -> WolfskinTokens.Color.accent +} + +/** The same mark anchors the picker and the persistent navigation. */ +@Composable +fun Wordmark(modifier: Modifier = Modifier) { + Row(modifier) { + Text("Wolf", color = WolfskinTokens.Color.fg, style = WolfskinTokens.Type.wordmark) + Text("skin", color = WolfskinTokens.Color.accent, style = WolfskinTokens.Type.wordmark) + } +} + +/** Initials are drawn over opaque person colour at the exact slot size. */ +@Composable +fun Avatar(person: ShellPerson, diameter: Dp, textStyle: TextStyle, modifier: Modifier = Modifier, letterColor: Color = WolfskinTokens.Color.onFocus) { + Box(modifier.size(diameter).background(personColor(person.key), CircleShape), contentAlignment = Alignment.Center) { + Text(person.initial, color = letterColor, style = textStyle) + } +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryFixture.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryFixture.kt new file mode 100644 index 00000000..24f680c3 --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryFixture.kt @@ -0,0 +1,56 @@ +package org.justinvisser.wolfskin.design + +import org.json.JSONArray +import org.json.JSONObject + +/** The locked fixture is supplied by debug assets or JVM resources, never a release fallback. */ +class LibraryFixture(json: String, private val posterPrefix: String) { + private val document = JSONObject(json) + private val profiles = document.getJSONArray("profiles") + private val people = (0 until profiles.length()).associate { profiles.getJSONObject(it).let { person -> person.getString("key") to person } } + private val holders = buildMap> { + people.forEach { (key, person) -> + for (destination in listOf("movies", "shows", "anime")) { + val library = person.optJSONArray(destination) ?: continue + for (index in 0 until library.length()) { + val item = library.getJSONObject(index) + getOrPut("${item.optString("type")}:${item.optString("tmdbId")}") { mutableListOf() }.add(key.replaceFirstChar { it.uppercase() }) + } + } + } + } + + fun library(person: String, destination: String): List = readTitles(people.getValue(person).optJSONArray(destination) ?: JSONArray()) + fun picks(person: String, type: String): List = readTitles(people.getValue(person).getJSONArray("discover")).filter { it.type == type } + fun trending(): List = readTitles(document.getJSONObject("seeAll").getJSONArray("trending")) + fun duplicatePage(): List = readTitles(document.getJSONObject("seeAll").getJSONArray("duplicate")) + + private fun readTitles(array: JSONArray): List = List(array.length()) { index -> + val item = array.getJSONObject(index) + val type = item.getString("type") + val ratings = item.optJSONObject("ratings") + val owners = holders["$type:${item.optString("tmdbId")}"].orEmpty() + val mine = item.optString("availability") == "in_library" + LibraryTitle( + id = item.getString("id"), title = item.getString("title"), type = type, + year = item.optInt("year").takeIf { it > 0 }, runtimeSeconds = item.optLong("runtimeSeconds").takeIf { it > 0 }, + seasons = item.optInt("seasonCount").takeIf { it > 0 }, + poster = item.optString("poster").takeIf { it.isNotBlank() && it != "null" }?.let { posterPrefix + it }, + played = item.optBoolean("played"), progress = item.optDouble("progress", 0.0).toFloat(), + addedAt = item.optString("addedAt"), ratings = buildList { + fun append(source: String, field: String, label: String? = null) { + val score = ratings?.optDouble(field, Double.NaN) ?: Double.NaN + val maximum = if (source.startsWith("rt")) 100 else 10 + if (score.isFinite() && score in 0.0..maximum.toDouble()) add(LibraryRating(source, score, label)) + } + append("rtCritics", "rtCritics", ratings?.optString("rtCriticsLabel")) + append("rtAudience", "rtAudience", ratings?.optString("rtAudienceLabel")) + if (type == "movie" && (ratings?.optInt("imdbVotes", 0) ?: 0) > 0) append("imdb", "imdb") + if (isEmpty() && (ratings?.optInt("tmdbVotes", 0) ?: 0) > 0) append("tmdb", "tmdb") + }, + availability = if (mine) "In your library" else if (owners.isNotEmpty()) "In ${owners.joinToString(" and ")}’s library" else "", + badge = if (mine) "held" else null, + tmdbId = item.optString("tmdbId"), + ) + } +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryGridState.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryGridState.kt new file mode 100644 index 00000000..2b6bcc1a --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryGridState.kt @@ -0,0 +1,174 @@ +package org.justinvisser.wolfskin.design + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.runtime.derivedStateOf +import java.util.Locale +import androidx.compose.runtime.Immutable + +private val leadingArticle = Regex("^(the|a|an)\\s+", RegexOption.IGNORE_CASE) + +/** Art-only card data shared without per-card adapter allocations. */ +@Immutable +interface PosterContent { + val id: String + val posterTitle: String + val posterIsMovie: Boolean + val poster: String? + val ratings: List + val progress: Float + val badge: String? +} + +/** Immutable title facts cross the provider boundary without SDK objects. */ +data class LibraryTitle( + override val id: String, + val title: String, + val type: String, + val year: Int? = null, + val runtimeSeconds: Long? = null, + val seasons: Int? = null, + override val poster: String? = null, + val played: Boolean = false, + override val progress: Float = 0f, + val addedAt: String = "", + override val ratings: List = emptyList(), + val availability: String = "", + override val badge: String? = null, + val tmdbId: String? = null, +) : PosterContent { + override val posterTitle: String get() = title + override val posterIsMovie: Boolean get() = type == "movie" + val sortName: String = title.replace(leadingArticle, "").lowercase(Locale.ROOT) + val catalogueKey: String = "$type:${tmdbId ?: id}" + val letter: Char = sortName.firstOrNull()?.uppercaseChar()?.takeIf { it in 'A'..'Z' } ?: '#' + val leadScore: Double get() = ratings.firstOrNull()?.let { if (it.source.startsWith("rt")) it.score else it.score * 10 } ?: -1.0 + + fun facts(catalogue: Boolean): String = buildList { + year?.let { add(it.toString()) } + add(if (type == "movie") runtimeSeconds?.let { val minutes = (it + 30) / 60; if (minutes >= 60) "${minutes / 60}h ${minutes % 60}m" else "${minutes}m" } ?: "Movie" else seasons?.let { "$it season${if (it == 1) "" else "s"}" } ?: "Series") + if (catalogue) { if (availability.isNotEmpty()) add(availability) } + else if (played) add("Watched") + else if (progress > 0f && runtimeSeconds != null) add("${((runtimeSeconds * (1 - progress) + 30) / 60).toInt().coerceAtLeast(1)} min left") + }.joinToString(" · ") +} + +/** Ordered rating entries are formatted after the active TV locale is known. */ +@Immutable +data class LibraryRating(val source: String, val score: Double, val label: String? = null, val episode: Boolean = false) { + fun formatted(locale: Locale = Locale.getDefault()): String = if (source.startsWith("rt")) "${score.toInt()}%" else String.format(locale, "%.1f", score) +} + +enum class LibraryStatus { Ready, Loading, Unavailable } +enum class GridTail { None, Retry, ShowMore } + +/** A grid retains its exact card, toolbar choices and rail return point with the shell view. */ +class LibraryGridState( + val title: String, + items: List, + val catalogue: Boolean = false, + val onTitle: (LibraryTitle) -> Unit = {}, + val onRecover: () -> Unit = {}, + val onDiscover: () -> Unit = {}, + val onReadAhead: (Int) -> Unit = {}, + val observedRatings: MutableSet = mutableSetOf(), +) : ShellContentFocus { + var source by mutableStateOf(items) + var status by mutableStateOf(LibraryStatus.Ready) + var savedAt by mutableStateOf(null) + var sort by mutableStateOf(0) + var unwatched by mutableStateOf(false) + var mediaType by mutableStateOf("all") + var tail by mutableStateOf(GridTail.None) + var pagination: LibraryPagination? = null + var pageRevision by mutableStateOf(0) + var refreshRevision by mutableStateOf(0) + var pointerReentry: (() -> Int?)? = null + var index by mutableStateOf(0) + var toolbar by mutableStateOf(false) + var tool by mutableStateOf(0) + var inRail by mutableStateOf(false) + var railLetter by mutableStateOf('#') + private var railReturn = 0 + val columns: Int get() = WolfskinTokens.Behaviour.gridColumns.toInt() - if (railVisible) 1 else 0 + val mixed: Boolean get() = catalogue && source.any { it.type == "movie" } && source.any { it.type == "tv" } + val hasToolbar: Boolean get() = !catalogue || mixed + val items: List by derivedStateOf { + val filtered = source.filter { (!unwatched || !it.played) && (mediaType == "all" || it.type == mediaType) } + if (catalogue) filtered else when (sort) { + 1 -> filtered.sortedWith(compareByDescending { it.addedAt }.thenBy { it.sortName }) + 2 -> filtered.sortedWith(compareByDescending { it.leadScore }.thenBy { it.sortName }) + else -> filtered.sortedBy { it.sortName } + } + } + val railVisible: Boolean get() = !catalogue && sort == 0 && items.size >= WolfskinTokens.Behaviour.letterRailMin && status == LibraryStatus.Ready + val tailIndex: Int get() = ((items.size + columns - 1) / columns) * columns + override val hasContent: Boolean get() = status != LibraryStatus.Loading + override val cardKey: String? get() = if (toolbar || inRail || status != LibraryStatus.Ready || items.isEmpty()) null else items.getOrNull(index)?.id ?: "grid-tail" + val focusedTitle: LibraryTitle? get() = if (toolbar || inRail) null else items.getOrNull(index) + + override fun key(key: ShellKey): Boolean { + if (inRail) return railKey(key) + if (key == ShellKey.Back) return false + if (key == ShellKey.Up || key == ShellKey.Down || key == ShellKey.Left || key == ShellKey.Right) { + val reentry = pointerReentry + pointerReentry = null + reentry?.invoke()?.let { index = it; toolbar = false; return true } + } + if (status == LibraryStatus.Loading) return false + if (status == LibraryStatus.Unavailable || (items.isEmpty() && !toolbar)) { + when (key) { + ShellKey.Up -> if (status == LibraryStatus.Ready && hasToolbar) toolbar = true else return false + ShellKey.Ok -> if (status == LibraryStatus.Unavailable) onRecover() else if (unwatched) { unwatched = false; index = 0 } else onDiscover() + else -> Unit + } + return true + } + if (toolbar) return toolbarKey(key) + val currentItems = items + when (key) { + ShellKey.Up -> if (index >= columns) index -= columns else if (hasToolbar) toolbar = true else return false + ShellKey.Down -> when { + index / columns < currentItems.lastIndex / columns -> index = (index + columns).coerceAtMost(currentItems.lastIndex) + tail != GridTail.None && index < tailIndex -> index = tailIndex + } + ShellKey.Left -> if (index % columns > 0) index-- + ShellKey.Right -> if (index % columns < columns - 1 && index < currentItems.lastIndex) index++ else if (railVisible) { inRail = true; railReturn = index; railLetter = currentItems[index].letter } + ShellKey.Ok -> currentItems.getOrNull(index)?.let(onTitle) ?: if (pagination != null) pageRevision++ else onRecover() + } + onReadAhead(index) + return true + } + + private fun toolbarKey(key: ShellKey): Boolean { + val count = if (catalogue) 3 else 2 + when (key) { + ShellKey.Up -> return false + ShellKey.Down -> { toolbar = false; index = index.coerceIn(0, items.lastIndex.coerceAtLeast(0)) } + ShellKey.Left -> tool = (tool - 1).coerceAtLeast(0) + ShellKey.Right -> tool = (tool + 1).coerceAtMost(count - 1) + ShellKey.Ok -> { + if (catalogue) mediaType = listOf("all", "movie", "tv")[tool] + else if (tool == 0) sort = (sort + 1) % 3 else unwatched = !unwatched + index = 0 + } + else -> Unit + } + return true + } + + private fun railKey(key: ShellKey): Boolean { + val letters = items.map { it.letter }.distinct() + when (key) { + ShellKey.Up, ShellKey.Down -> { + val next = (letters.indexOf(railLetter) + if (key == ShellKey.Up) -1 else 1).coerceIn(0, letters.lastIndex) + railLetter = letters[next] + } + ShellKey.Left, ShellKey.Ok -> { index = items.indexOfFirst { it.letter == railLetter }; inRail = false } + ShellKey.Back -> { index = railReturn; inRail = false } + else -> Unit + } + return true + } +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryPagination.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryPagination.kt new file mode 100644 index 00000000..dc25fbed --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryPagination.kt @@ -0,0 +1,36 @@ +package org.justinvisser.wolfskin.design + +import kotlinx.coroutines.CancellationException + +/** Provider pages are merged by title identity; transport pagination is never displayed. */ +data class LibraryPage(val items: List, val hasMore: Boolean) + +/** Duplicate-only reads pause until Show more; failures pause until Try again. */ +class LibraryPagination(private val grid: LibraryGridState, private val load: suspend (Int) -> LibraryPage) { + private var nextPage = 2 + private var hasMore = true + private var reading = false + + suspend fun readAhead(force: Boolean = false) { + if (reading || !hasMore || !force && (grid.tail != GridTail.None || grid.index < grid.items.size - WolfskinTokens.Behaviour.nextPageCards)) return + reading = true + try { + val page = load(nextPage) + val known = grid.source.mapTo(mutableSetOf()) { it.catalogueKey } + val additional = page.items.filter { known.add(it.catalogueKey) } + nextPage++ + hasMore = page.hasMore + if (additional.isEmpty() && page.items.isNotEmpty() && hasMore) grid.tail = GridTail.ShowMore + else { + grid.source = grid.source + additional + grid.tail = GridTail.None + grid.index = grid.index.coerceAtMost(grid.items.lastIndex.coerceAtLeast(0)) + } + } catch (exception: Exception) { + if (exception is CancellationException) throw exception + grid.tail = GridTail.Retry + } finally { + reading = false + } + } +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryPoster.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryPoster.kt new file mode 100644 index 00000000..41e1e053 --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryPoster.kt @@ -0,0 +1,62 @@ +package org.justinvisser.wolfskin.design + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import coil3.compose.AsyncImage +import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C +import org.justinvisser.wolfskin.design.WolfskinTokens.Library as L + +/** Poster slots use exact requests and share the shell's draw-only focus treatment. */ +@Composable +fun LibraryPoster( + title: PosterContent, + focused: Boolean, + ratingsVisible: Boolean, + catalogue: Boolean, + modifier: Modifier = Modifier, + offlineArt: ((String) -> ImageBitmap?)? = null, +) { + val poster = title.poster + TitleCard(focused, modifier.testTag("poster-${title.id}")) { + val bitmap = remember(poster, offlineArt) { poster?.let { offlineArt?.invoke(it) } } + if (bitmap != null) Image(bitmap, title.posterTitle, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) + else if (poster != null && offlineArt == null) { + var ready by remember(poster) { mutableStateOf(false) } + if (!ready) MediaArtworkFallback(title.posterTitle, title.posterIsMovie) + val context = LocalContext.current + val request = remember(poster, context) { WolfskinImages.request(context, poster, WolfskinTokens.Image.posterRequestWidth, WolfskinTokens.Image.posterRequestHeight, true) } + AsyncImage(request, title.posterTitle, imageLoader = WolfskinImages.loader(context), modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop, + onSuccess = { ready = true }, onError = { ready = false }) + } else MediaArtworkFallback(title.posterTitle, title.posterIsMovie) + if (title.ratings.isNotEmpty()) LibraryRatingPlate(title.ratings, visible = ratingsVisible) + val progress = title.progress + if (progress > 0f && progress < 1f) { + Box(Modifier.align(Alignment.BottomCenter).padding(horizontal = WolfskinTokens.Title.cardProgressInset, vertical = WolfskinTokens.Layout.navItemGap) + .fillMaxWidth().height(WolfskinTokens.Layout.progressBar).testTag("poster-progress-${title.id}").background(C.progressTrack, CircleShape)) { + Box(Modifier.fillMaxHeight().fillMaxWidth(progress).background(C.accent, CircleShape)) + } + } + if (catalogue && title.badge != null) { + val held = title.badge == "held" + val color = if (title.badge == "failed") C.fail else C.accent + Box(Modifier.align(Alignment.BottomEnd).padding(WolfskinTokens.Layout.badgeInset) + .size(WolfskinTokens.Layout.badge).background(if (held) C.ok else C.badgeRest, CircleShape) + .then(if (held) Modifier else Modifier.border(L.badgeRing, color, CircleShape)), contentAlignment = Alignment.Center) { + Glyph(if (held) "check" else if (title.badge == "failed") "alert" else "clock", + if (held) C.onFocus else color, Modifier.size(L.badgeIcon)) + } + } + } +} + + diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryScreen.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryScreen.kt new file mode 100644 index 00000000..fa339d43 --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryScreen.kt @@ -0,0 +1,272 @@ +package org.justinvisser.wolfskin.design + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withStyle +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.input.pointer.* +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.zIndex +import androidx.tv.material3.Text +import kotlinx.coroutines.delay +import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C +import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L +import org.justinvisser.wolfskin.design.WolfskinTokens.Library as G +import org.justinvisser.wolfskin.design.WolfskinTokens.Card as K + +/** One lazy frame owns headers, toolbar, continuous poster rows and recoverable tails. */ +@Composable +fun LibraryScreen( + shell: ShellState, + grid: LibraryGridState, + modifier: Modifier = Modifier, + ratingsSignedIn: Boolean = false, + cachedRatings: Set = emptySet(), + ratingsReachable: Boolean = true, + offlineArt: ((String) -> ImageBitmap?)? = null, + view: RememberedView = shell.view, + registerFocus: Boolean = true, +) { + val density = LocalDensity.current + val context = LocalContext.current + val active = !shell.navFocused && !shell.pickerVisible + val items = grid.items + val focused = grid.focusedTitle + val count = if (grid.catalogue) items.size else grid.source.size + var plateKey by remember { mutableStateOf(null) } + val ratingsReader = LocalScreenRatings.current + DisposableEffect(shell, grid, registerFocus) { + if (registerFocus) shell.contentFocus = grid + onDispose { if (registerFocus && shell.contentFocus === grid) shell.contentFocus = null } + } + LaunchedEffect(grid) { + var revision = grid.pageRevision + snapshotFlow { Triple(grid.index, grid.source.size, grid.pageRevision) }.collect { (_, _, nextRevision) -> + grid.pagination?.readAhead(force = nextRevision != revision) + revision = nextRevision + } + } + LaunchedEffect(grid.index / grid.columns, items, offlineArt) { + if (offlineArt == null) { + val start = (grid.index / grid.columns + 1) * grid.columns + for (index in start until (start + grid.columns).coerceAtMost(items.size)) { + items[index].poster?.let { WolfskinImages.loader(context).enqueue(WolfskinImages.request(context, it, (K.posterW.value * 2).toInt(), (K.posterH.value * 2).toInt(), true)) } + } + } + } + LaunchedEffect(focused?.id, active, ratingsSignedIn, ratingsReachable) { + plateKey = null + if (active && ratingsSignedIn && focused != null && (ratingsReachable || focused.id in cachedRatings || focused.id in grid.observedRatings)) { + if (focused.id !in cachedRatings && focused.id !in grid.observedRatings) delay(WolfskinTokens.Motion.ratingDwell.toLong()) + if (ratingsReader != null) { + val tmdb = focused.tmdbId?.toLongOrNull() ?: focused.id.substringAfter(':', "").toLongOrNull() + val ratings = tmdb?.let { ratingsReader.read(it, focused.type, null, null) } + if (ratings != null) grid.source = grid.source.map { if (it.id == focused.id) it.copy(ratings = ratings) else it } + } + if (focused.ratings.isNotEmpty()) grid.observedRatings.add(focused.id) + plateKey = focused.id + } + } + LaunchedEffect(grid.savedAt) { view.savedAt = grid.savedAt } + LaunchedEffect(grid.status) { + if (grid.status == LibraryStatus.Loading && !shell.navFocused) shell.focusNav(shell.currentTab) + } + val headerCount = if (grid.hasToolbar && grid.status != LibraryStatus.Loading && grid.status != LibraryStatus.Unavailable) 2 else 1 + val rowHeight = K.posterH + K.posterCaptionBand + L.gridRowGap + val focusedRow = (if (grid.inRail) items.indexOfFirst { it.letter == grid.railLetter } else grid.index).coerceAtLeast(0) / grid.columns + LaunchedEffect(grid.index, grid.columns, focusedRow, headerCount, rowHeight, density, + grid.toolbar, grid.inRail, grid.railLetter, shell.navFocused, view) { + if (grid.toolbar || focusedRow == 0 && active) { + animateLibraryScroll(view, headerCount, 0f, density) + } else if (focusedRow > 0) { + val target = with(density) { (L.row_lhead + (if (headerCount == 2) L.row_chipsGrid else WolfskinTokens.Shell.zero) + rowHeight * focusedRow - L.pinnedLine).toPx() } + animateLibraryScroll(view, headerCount, target, density) + } + } + Box(modifier.semantics { stateDescription = "${grid.title}:${if (grid.toolbar) "tool-${grid.tool}" else if (grid.inRail) "rail-${grid.railLetter}" else focused?.id ?: "recovery"}" }.testTag("library-grid")) { + LazyColumn(state = view.listState, modifier = Modifier.fillMaxSize().pointerInput(grid, headerCount) { + awaitPointerEventScope { + while (true) { + if (awaitPointerEvent().type == PointerEventType.Scroll) grid.pointerReentry = { + val visible = view.listState.layoutInfo.visibleItemsInfo + if (visible.none { it.index == headerCount + grid.index / grid.columns }) { + visible.firstOrNull { it.index >= headerCount }?.let { ((it.index - headerCount) * grid.columns).coerceAtMost(grid.items.lastIndex) } + } else null + } + } + } + }, contentPadding = PaddingValues(bottom = WolfskinTokens.Shell.stageHeight - L.pinnedLine - rowHeight)) { + item(key = "header", contentType = "header") { + LibraryHeader(shell.person, grid.title, + if (grid.status != LibraryStatus.Ready) "" else "$count ${if (grid.catalogue) "titles" else if (grid.title == "Movies") if (count == 1) "film" else "films" else "series"}", + grid.savedAt) + } + if (headerCount == 2) item(key = "toolbar", contentType = "chips") { LibraryToolbar(grid, active) } + when (grid.status) { + LibraryStatus.Loading -> items(3, key = { "skeleton-$it" }, contentType = { "grid" }) { + Box(Modifier.fillMaxWidth().height(rowHeight)) { + Row(Modifier.padding(start = L.safeSide), horizontalArrangement = Arrangement.spacedBy(K.posterGap)) { + repeat(grid.columns) { Box(Modifier.size(K.posterW, K.posterH).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.card))) } + } + } + } + LibraryStatus.Unavailable -> item(key = "unavailable", contentType = "state") { LibraryRecovery(grid, active) } + LibraryStatus.Ready -> if (items.isEmpty()) item(key = "empty", contentType = "state") { LibraryRecovery(grid, active && !grid.toolbar) } + else { + items((items.size + grid.columns - 1) / grid.columns, key = { row -> + val title = items[row * grid.columns] + "grid-${if (grid.catalogue) title.catalogueKey else title.id}" + }, contentType = { "grid" }) { row -> + Box(Modifier.fillMaxWidth().height(rowHeight)) { + Row(Modifier.padding(start = L.safeSide), horizontalArrangement = Arrangement.spacedBy(K.posterGap)) { + for (column in 0 until grid.columns) { + val index = row * grid.columns + column + val title = items.getOrNull(index) ?: break + key(if (grid.catalogue) title.catalogueKey else title.id) { + val isFocused = active && !grid.toolbar && !grid.inRail && grid.index == index + LibraryPoster(title, isFocused, plateKey == title.id && isFocused, grid.catalogue, + Modifier.zIndex(if (isFocused) 1f else 0f).pointerInput(index, grid) { + awaitPointerEventScope { + while (true) { + val event = awaitPointerEvent() + if ((event.type == PointerEventType.Move || event.type == PointerEventType.Enter) && event.changes.any { it.type == PointerType.Mouse }) { + grid.index = index; grid.toolbar = false; shell.navFocused = false + } + } + } + }.clickable { grid.index = index; grid.toolbar = false; shell.navFocused = false; grid.onTitle(title) }, offlineArt) + } + } + } + if (active && !grid.toolbar && !grid.inRail && grid.index / grid.columns == row) { + val title = items.getOrNull(grid.index) + if (title != null) Caption(title.title, title.facts(grid.catalogue), true, + Modifier.offset(L.safeSide + K.posterPitch * (grid.index % grid.columns), K.posterH + L.captionOffset).width(G.captionWidth), + accentLastFact = if (grid.catalogue) title.availability.isNotEmpty() else title.played || title.progress > 0f) + } + } + } + if (grid.tail != GridTail.None) item(key = "tail", contentType = "grid") { + LibraryTail(grid, active && grid.index == grid.tailIndex) + } + } + } + } + if (grid.railVisible) LibraryRail(grid) + } +} + +/** Library, catalogue and service headers share one baseline and person treatment. */ +@Composable +fun LibraryHeader(person: ShellPerson, title: String, subtitle: String = "", savedAt: String? = null) { + Box(Modifier.fillMaxWidth().height(L.row_lhead)) { + Row(Modifier.offset(L.safeSide, L.navBand), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Shell.settingsHeaderGap), verticalAlignment = Alignment.CenterVertically) { + Avatar(person, WolfskinTokens.Shell.settingsHeaderAvatar, WolfskinTokens.Type.avatarLetterLibHeader, letterColor = C.muted) + Text(title, modifier = Modifier.alignByBaseline().exactLineBox(WolfskinTokens.Type.screenTitle), color = C.fg, style = WolfskinTokens.Type.screenTitle) + if (subtitle.isNotEmpty()) Text(subtitle, modifier = Modifier.alignByBaseline(), color = C.muted, style = G.headerSubtitle) + savedAt?.let { SavedPill(it) } + } + } +} + +@Composable +private fun LibraryToolbar(grid: LibraryGridState, active: Boolean) { + Row(Modifier.fillMaxWidth().height(L.row_chipsGrid).padding(start = L.safeSide, top = G.chipTop), horizontalArrangement = Arrangement.spacedBy(G.chipGap)) { + val labels = if (grid.catalogue) listOf("All", "Movies", "Series") else listOf("Sort ${listOf("A–Z", "Recently added", "Rating")[grid.sort]}", if (grid.unwatched) "Unwatched" else "All") + labels.forEachIndexed { index, label -> + val focused = active && grid.toolbar && grid.tool == index + val selected = if (grid.catalogue) listOf("all", "movie", "tv")[index] == grid.mediaType else index == 1 && grid.unwatched + Row(Modifier.height(G.chipHeight).focusScale(focused, WolfskinTokens.Focus.scaleControl) + .background(if (focused) C.fg else if (selected) C.accentSoft else C.chipRest, CircleShape) + .then(if (selected && !focused) Modifier.border(G.borderWidth, C.accent, CircleShape) else Modifier) + .clickable { grid.toolbar = true; grid.tool = index; grid.key(ShellKey.Ok) } + .padding(horizontal = G.chipPadding), horizontalArrangement = Arrangement.spacedBy(G.chipContentGap), verticalAlignment = Alignment.CenterVertically) { + val color = if (focused) C.onFocus else if (selected) C.accentText else C.fg + if (!grid.catalogue) Glyph(if (index == 0) "advanced" else "eye", color, Modifier.size(G.chipIcon)) + if (!grid.catalogue && index == 0) Text(buildAnnotatedString { + withStyle(SpanStyle(color = color.copy(alpha = G.chipLabelOpacity), fontWeight = FontWeight.Medium)) { append("Sort") } + append(" ${listOf("A–Z", "Recently added", "Rating")[grid.sort]}") + }, color = color, style = WolfskinTokens.Type.label) + else Text(label, color = color, style = WolfskinTokens.Type.label) + } + } + } +} + +@Composable +private fun LibraryRecovery(grid: LibraryGridState, focused: Boolean) { + val lineBoxes = androidx.compose.ui.text.style.LineHeightStyle( + androidx.compose.ui.text.style.LineHeightStyle.Alignment.Center, + androidx.compose.ui.text.style.LineHeightStyle.Trim.None, + ) + val unavailable = grid.status == LibraryStatus.Unavailable + val title = if (unavailable) "Couldn’t reach your library" else if (grid.unwatched) "Nothing unwatched in ${grid.title}" else "Nothing in ${grid.title} yet" + val text = if (unavailable) "Jellyfin isn’t answering. Nothing here is lost; try again in a moment." else if (grid.unwatched) "Everything here has been watched." else "Titles you or others request for you land here." + val button = if (unavailable) "Try again" else if (grid.unwatched) "Show all" else "Open Discover" + Box(Modifier.fillMaxWidth().height(L.row_state).padding(start = L.safeSide, top = G.panelTop)) { + Column(Modifier.width(L.stateWidth).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.panel)) + .border(G.borderWidth, C.border, RoundedCornerShape(WolfskinTokens.Radius.panel)).padding(G.borderWidth) + .padding(horizontal = G.panelPaddingX, vertical = G.panelPaddingY)) { + Row(horizontalArrangement = Arrangement.spacedBy(G.panelIconGap), verticalAlignment = Alignment.CenterVertically) { + Glyph(if (unavailable) "alert" else if (grid.title == "Movies") "film" else "nextup", if (unavailable) C.warn else C.accentText, Modifier.size(WolfskinTokens.Shell.panelIcon)) + Text(title, color = C.fg, style = WolfskinTokens.Type.stateTitle.copy(fontWeight = FontWeight.Bold, lineHeightStyle = lineBoxes)) + } + Spacer(Modifier.height(G.panelTitleGap)) + Text(text, color = C.muted, style = WolfskinTokens.Type.body.copy(lineHeightStyle = lineBoxes)) + Spacer(Modifier.height(G.panelTextGap)) + PickerButton(button, focused, !unavailable) { grid.key(ShellKey.Ok) } + } + } +} + +@Composable +private fun LibraryTail(grid: LibraryGridState, focused: Boolean) { + val retry = grid.tail == GridTail.Retry + Box(Modifier.fillMaxWidth().height(K.posterH + K.posterCaptionBand + L.gridRowGap)) { + CatalogueRecoveryCard(retry, focused, { grid.index = grid.tailIndex; grid.key(ShellKey.Ok) }, Modifier.offset(L.safeSide)) + Caption(if (retry) "Try again" else "Show more", if (retry) "${grid.title} stopped loading" else "The last read only repeated titles", focused, + Modifier.offset(L.safeSide, K.posterH + L.captionOffset).width(G.captionWidth)) + } +} + +@Composable +private fun BoxScope.LibraryRail(grid: LibraryGridState) { + Column(Modifier.align(Alignment.TopEnd).padding(end = L.safeSide, top = L.navBand).width(G.railWidth)) { + val letters = remember(grid.items) { grid.items.map { it.letter }.toSet() } + for (letter in "#ABCDEFGHIJKLMNOPQRSTUVWXYZ") { + val current = if (grid.inRail) grid.railLetter == letter else grid.focusedTitle?.letter == letter + Box(Modifier.width(G.railWidth).height(if (current && grid.inRail) G.railFocusHeight else G.railLetterHeight) + .background(if (current && grid.inRail) C.fg else androidx.compose.ui.graphics.Color.Transparent, CircleShape) + .clickable(enabled = letter in letters) { grid.railLetter = letter; grid.inRail = true; grid.key(ShellKey.Ok) }, contentAlignment = Alignment.Center) { + Text(letter.toString(), color = if (current) if (grid.inRail) C.onFocus else C.accent else if (letter in letters) C.muted else C.railEmpty, + style = if (current && grid.inRail) G.railFocusedLetter else G.railLetter) + } + } + } +} + +private suspend fun animateLibraryScroll(view: RememberedView, headerCount: Int, target: Float, density: androidx.compose.ui.unit.Density) { + val rowHeight = K.posterH + K.posterCaptionBand + L.gridRowGap + val first = view.listState.firstVisibleItemIndex + val current = with(density) { + (if (first == 0) WolfskinTokens.Shell.zero else L.row_lhead + + if (first < headerCount) WolfskinTokens.Shell.zero else + (if (headerCount == 2) L.row_chipsGrid else WolfskinTokens.Shell.zero) + rowHeight * (first - headerCount)).toPx() + } + view.listState.firstVisibleItemScrollOffset + animateFrameScroll(view.listState, target - current) +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibrarySeeAllCard.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibrarySeeAllCard.kt new file mode 100644 index 00000000..0dabee9b --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibrarySeeAllCard.kt @@ -0,0 +1,30 @@ +package org.justinvisser.wolfskin.design + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.Dp +import androidx.tv.material3.Text + +/** Shelves expose the full underlying list without displaying transport pagination. */ +@Composable +fun LibrarySeeAllCard(count: Int, focused: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier, + width: Dp = WolfskinTokens.Card.posterW, height: Dp = WolfskinTokens.Card.posterH, + titleStyle: TextStyle = WolfskinTokens.Type.listTitle, countStyle: TextStyle = WolfskinTokens.Type.label) { + val shape = RoundedCornerShape(WolfskinTokens.Radius.card) + TitleCard(focused, modifier.clickable(onClick = onClick), width, height) { + Column(Modifier.fillMaxSize().background(WolfskinTokens.Color.surface, shape) + .border(WolfskinTokens.Library.borderWidth, WolfskinTokens.Color.border, shape), + verticalArrangement = Arrangement.spacedBy(WolfskinTokens.Library.seeAllGap, Alignment.CenterVertically), horizontalAlignment = Alignment.CenterHorizontally) { + Glyph("interface", WolfskinTokens.Color.accent, Modifier.size(WolfskinTokens.Layout.badge)) + Text("See all", modifier = Modifier.exactLineBox(titleStyle), color = WolfskinTokens.Color.fg, style = titleStyle) + Text(count.toString(), modifier = Modifier.exactLineBox(countStyle), color = WolfskinTokens.Color.muted, style = countStyle) + } + } +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/NavBar.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/NavBar.kt new file mode 100644 index 00000000..f88d4458 --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/NavBar.kt @@ -0,0 +1,118 @@ +package org.justinvisser.wolfskin.design + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.animation.core.tween +import androidx.compose.animation.core.spring +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.tv.material3.Text +import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C +import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L +import org.justinvisser.wolfskin.design.WolfskinTokens.Shell as S +import org.justinvisser.wolfskin.design.WolfskinTokens.SavedIndicator as V + +/** Blob bounds retain their measurement while their draw coordinates spring between targets. */ +data class NavBox(val left: Dp, val top: Dp, val width: Dp, val height: Dp) + +fun navBoxes(tabs: List): List { + var left = L.safeSide + S.wordmarkWidth + L.wordmarkGap + L.navItemGap + return tabs.map { tab -> + val width = when (tab) { + "home" -> S.navHomeWidth + "discover" -> S.navDiscoverWidth + "movies" -> S.navMoviesWidth + "shows" -> S.navShowsWidth + else -> S.navAnimeWidth + } + NavBox(left, S.navItemTop, width, L.navItemHeight).also { left += width + L.navItemGap } + } + NavBox(S.stageWidth - L.safeSide - L.navAvatar, S.navItemTop, L.navAvatar, L.navAvatar) +} + +/** There is one blob, including when focus wraps around the nav avatar. */ +@Composable +fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier) { + val boxes = remember(state.person.hasAnime) { navBoxes(state.tabs) } + val labelFade = remember { tween(S.navLabelFade, easing = CubicBezierEasing(0f, 0f, .58f, 1f)) } + val index = if (state.navFocused) state.navIndex else state.currentTab + val box = boxes[index] + val avatarFocused = state.navFocused && index == state.tabs.size + val pad = if (avatarFocused) L.navBlobAvatarPad else S.zero + val damping = WolfskinTokens.Motion.navBlobDampingRatio + val stiffness = WolfskinTokens.Motion.navBlobStiffness + val x by animateFloatAsState((box.left - pad).value, spring(damping, stiffness), label = "blob-x") + val y by animateFloatAsState((box.top - pad).value, spring(damping, stiffness), label = "blob-y") + val width by animateFloatAsState((box.width + pad * 2).value, spring(damping, stiffness), label = "blob-width") + val height by animateFloatAsState((box.height + pad * 2).value, spring(damping, stiffness), label = "blob-height") + val color by animateColorAsState(if (state.navFocused) C.fg else C.navBlobRest, spring(damping, stiffness), label = "blob-color") + val density = LocalDensity.current.density + val band = remember { + Brush.verticalGradient(listOf(C.bg.copy(alpha = S.navBandOpacity), C.bg.copy(alpha = 0f))) + } + Box(modifier.fillMaxWidth().height(L.navBand)) { + Canvas(Modifier.matchParentSize()) { + if (scrolled) drawRect(C.bg) else drawRect(band) + drawRoundRect(color, Offset(x * density, y * density), Size(width * density, height * density), CornerRadius(height * density / 2)) + } + Wordmark(Modifier.offset(L.safeSide, S.wordmarkTop)) + state.tabs.forEachIndexed { itemIndex, tab -> + val item = boxes[itemIndex] + val focused = state.navFocused && state.navIndex == itemIndex + val labelColor by animateColorAsState( + if (focused) C.onFocus else if (itemIndex == state.currentTab) C.fg else C.muted, + animationSpec = labelFade, + label = "nav-label-$tab", + ) + Box(Modifier.offset(item.left, item.top).size(item.width, item.height) + .focusProperties { canFocus = false } + .clickable { + state.focusNav(itemIndex); state.key(ShellKey.Ok) + }, contentAlignment = Alignment.Center) { + Text(tab.replaceFirstChar { it.uppercase() }, style = WolfskinTokens.Type.nav, color = labelColor) + if (itemIndex == state.currentTab && !focused) { + Box(Modifier.align(Alignment.BottomCenter).offset(y = -L.navUnderline).size(item.width - L.navItemPaddingX * 2, L.navUnderline).background(C.accent)) + } + } + } + state.navSavedAt?.let { time -> + Row(Modifier.align(Alignment.TopEnd) + .offset(x = -(L.safeSide + L.navAvatar + L.navItemGap), y = S.navItemTop + (L.navAvatar - V.height) / 2) + .height(V.height).border(V.border, C.warn.copy(alpha = V.borderOpacity), RoundedCornerShape(WolfskinTokens.Radius.button)) + .padding(horizontal = V.side + V.border), horizontalArrangement = Arrangement.spacedBy(V.gap), verticalAlignment = Alignment.CenterVertically) { + Glyph("wifi", C.warn, Modifier.size(V.icon)) + Text("Saved $time", color = C.warn, style = WolfskinTokens.Type.caption) + } + } + val avatar = boxes.last() + Avatar(state.person, L.navAvatar, WolfskinTokens.Type.avatarLetterNav, + Modifier.offset(avatar.left, avatar.top) + .focusRing(avatarFocused, L.navAvatar / 2, WolfskinTokens.Focus.ringAvatarRing) + .focusProperties { canFocus = false } + .clickable { state.focusNav(state.tabs.size); state.key(ShellKey.Ok) }) + } +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PersonPage.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PersonPage.kt new file mode 100644 index 00000000..ea95f24b --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PersonPage.kt @@ -0,0 +1,158 @@ +package org.justinvisser.wolfskin.design + +import androidx.compose.foundation.background +import androidx.compose.foundation.focusable +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.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.CircleShape +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.foundation.gestures.animateScrollBy +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.input.key.KeyEventType +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription +import androidx.tv.material3.Text +import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C +import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L +import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D + +/** Filmography retains a real credit key and requests more credits before focus reaches the tail. */ +class PersonState(initialPerson: TitlePerson, initialCredits: List, private val onAction: (TitleAction) -> Unit, private val onReadMore: () -> Unit = {}) { + var person by mutableStateOf(initialPerson) + private set + var credits by mutableStateOf(initialCredits) + var savedAt by mutableStateOf(null) + var toast by mutableStateOf(null) + fun update(actor: TitlePerson, titles: List, saved: String? = null) { + person = actor; credits = titles; savedAt = saved + index = index.coerceAtMost((credits.size - 1).coerceAtLeast(0)) + } + var index by mutableStateOf(0) + private var armed: String? = null + + /** Pointer activation and remote key-up open the same selected credit. */ + fun click(position: Int) { index = position; onAction(TitleAction.OpenTitle(credits[index])) } + + /** The first grid row exits upward into the existing shell nav. */ + fun key(key: ShellKey, down: Boolean, repeat: Int, shell: ShellState): Boolean { + if (credits.isEmpty()) { + if (down && key == ShellKey.Back) onAction(TitleAction.Pop) + else if (down && key == ShellKey.Up) shell.focusNav(shell.currentTab) + return true + } + val columns = WolfskinTokens.Behaviour.gridColumns.toInt() + if (key == ShellKey.Ok) { + if (repeat > 0) return true + if (down) armed = credits[index].id else { if (armed == credits[index].id) click(index); armed = null } + return true + } + if (!down) return true + armed = null + when (key) { + ShellKey.Back -> onAction(TitleAction.Pop) + ShellKey.Up -> if (index < columns) shell.focusNav(shell.currentTab) else index -= columns + ShellKey.Down -> if (index / columns < credits.lastIndex / columns) index = (index + columns).coerceAtMost(credits.lastIndex) + ShellKey.Left -> if (index % columns > 0) index-- + ShellKey.Right -> if (index % columns < columns - 1 && index < credits.lastIndex) index++ + else -> Unit + } + if (index >= credits.size - WolfskinTokens.Behaviour.nextPageCards.toInt()) onReadMore() + return true + } +} + +/** Portrait, biography and the full continuous grid use the shell's remembered frame. */ +@Composable +fun PersonSurface(state: PersonState, shell: ShellState, modifier: Modifier = Modifier) { + val view = shell.view + LaunchedEffect(view, state.savedAt) { view.savedAt = state.savedAt } + val owner = androidx.compose.runtime.remember(shell) { { shell.navFocused = false } } + LaunchedEffect(state) { shell.navFocused = false } + Box(modifier.fillMaxSize().background(C.bg)) { + androidx.compose.runtime.CompositionLocalProvider(LocalTitleFocusOwner provides owner) { + ShellSurface(shell, handlesContentKeys = false) { PersonPage(state, shell, it) } + } + if (!shell.pickerVisible) TitleNotice(state.toast, Modifier.align(Alignment.TopEnd)) { state.toast = null } + } +} + +@Composable +private fun PersonPage(state: PersonState, shell: ShellState, modifier: Modifier = Modifier) { + val density = LocalDensity.current + val list = shell.view.listState + var wheelPending by remember { mutableStateOf(false) } + val columns = WolfskinTokens.Behaviour.gridColumns.toInt() + LaunchedEffect(state.index / columns, shell.navFocused) { + val target = if (shell.navFocused || state.index < columns) 0f + else with(density) { (L.row_phead + L.row_gh + D.gridHeight * (state.index / columns) - L.pinnedLine).toPx() } + val first = list.firstVisibleItemIndex + val before = when (first) { + 0 -> WolfskinTokens.Shell.zero + 1 -> L.row_phead + else -> L.row_phead + L.row_gh + D.gridHeight * (first - 2) + } + val current = with(density) { before.toPx() } + list.firstVisibleItemScrollOffset + if (target != current) list.animateScrollBy(target - current, titleFrameScrollSpec) + } + Box(modifier.testTag("person-page").semantics { stateDescription = "${state.person.id}:${state.index}" } + .titleWheel { wheelPending = true; shell.navFocused = false } + .onPreviewKeyEvent { event -> + val key = readShellKey(event) + if (key != null && key != ShellKey.Back && event.type == KeyEventType.KeyDown && wheelPending) { + val visible = list.layoutInfo.visibleItemsInfo.firstOrNull { it.index >= 2 && it.key != "end-padding" && it.offset + it.size > with(density) { L.pinnedLine.roundToPx() } } + state.index = ((visible?.index?.minus(2) ?: 0) * columns).coerceAtMost((state.credits.size - 1).coerceAtLeast(0)) + wheelPending = false + if (key != ShellKey.Ok) return@onPreviewKeyEvent true + } + key?.let { state.key(it, event.type == KeyEventType.KeyDown, event.nativeKeyEvent.repeatCount, shell) } ?: false + }.focusable()) { + LazyColumn(state = list, modifier = Modifier.fillMaxSize()) { + item(key = "person-header", contentType = "header") { + Box(Modifier.height(L.row_phead)) { + Row(Modifier.offset(L.safeSide, D.personTop), horizontalArrangement = Arrangement.spacedBy(D.personGap), verticalAlignment = Alignment.CenterVertically) { + PersonPortrait(state.person, Modifier.size(L.personPortrait).clip(CircleShape), true) + Column(Modifier.width(L.contentWidth - L.personPortrait - D.personGap)) { + Text(state.person.name, color = C.fg, style = TitleTextStyles.screenTitle, modifier = Modifier.padding(bottom = D.personNameBottom)) + Text("Acting · ${state.credits.size} credit${if (state.credits.size == 1) "" else "s"} among this household’s titles", color = C.muted, style = TitleTextStyles.body) + if (state.person.biography.isNotBlank()) Text(state.person.biography, color = C.dim, style = TitleTextStyles.caption, maxLines = 3) + } + } + } + } + item(key = "filmography", contentType = "header") { + Text("Filmography", color = C.fg, style = TitleTextStyles.rowHeading, modifier = Modifier.height(L.row_gh).padding(start = L.safeSide, top = D.gridHeadingTop)) + } + itemsIndexed(state.credits.chunked(columns), key = { _, row -> row.first().id }, contentType = { _, _ -> "grid" }) { rowIndex, row -> + Row(Modifier.height(D.gridHeight).padding(start = L.safeSide), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.posterGap)) { + row.forEachIndexed { column, item -> + val index = rowIndex * columns + column + TitlePoster(item, index == state.index && !shell.navFocused, Modifier.width(WolfskinTokens.Card.posterW), catalogue = true, onFocus = { state.index = index }) { state.click(index) } + } + } + } + item(key = "end-padding", contentType = "spacer") { Spacer(Modifier.height(WolfskinTokens.Shell.stageHeight - L.pinnedLine - D.gridHeight)) } + } + } +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/RecoveryToast.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/RecoveryToast.kt new file mode 100644 index 00000000..3e8dd23c --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/RecoveryToast.kt @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.design + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.dropShadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.shadow.Shadow +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.DpOffset +import androidx.tv.material3.Text +import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C +import org.justinvisser.wolfskin.design.WolfskinTokens.Home as H +import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L + +/** Recovery messages remain transient and never take focus from the action that failed. */ +@Composable +fun RecoveryToast(message: String, modifier: Modifier = Modifier) { + Box(modifier.fillMaxSize()) { + val shape = androidx.compose.runtime.remember { RoundedCornerShape(WolfskinTokens.Radius.panel) } + Row(Modifier.align(Alignment.TopEnd).offset(-L.safeSide, H.toastTop) + .widthIn(max = H.toastWidth) + .dropShadow(shape, Shadow(radius = H.toastShadowBlur, color = Color.Black.copy(alpha = H.toastShadowOpacity), + offset = DpOffset(WolfskinTokens.Shell.zero, H.toastShadowY))) + .background(C.surface2, shape) + .testTag("recovery-toast") + .padding(horizontal = H.toastPadding, vertical = H.toastPaddingY), + horizontalArrangement = Arrangement.spacedBy(H.toastGap), verticalAlignment = Alignment.CenterVertically) { + Glyph("alert", C.warn, Modifier.size(L.buttonIcon)) + Text(message, Modifier.weight(1f, fill = false).exactLineBox(HomeType.body), color = C.fg, style = HomeType.body) + } + } +} + +/** Shared wording is independent of transport details and never exposes exception text. */ +object RecoveryMessage { + const val StillUnreachable = "Still can’t reach it. The last saved view stays on screen." + const val PlaybackUnavailable = "Can’t reach Jellyfin to play. Check the network and try again." +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/RequestSheetState.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/RequestSheetState.kt new file mode 100644 index 00000000..4ab9cc13 --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/RequestSheetState.kt @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.design + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue + +/** Overlay focus is independent of the screen below and never reaches the navigation bar. */ +sealed interface DiscoverOverlayState { + class Request(val state: RequestSheetState) : DiscoverOverlayState + class Hidden(val title: DiscoverTitle) : DiscoverOverlayState { var focus by mutableStateOf(0) } + class Restore(val title: DiscoverTitle) : DiscoverOverlayState { var focus by mutableStateOf(0) } +} + +/** Request selection cannot supply an actor and cannot submit an unknown-season series. */ +class RequestSheetState( + val title: DiscoverTitle, + val viewer: ShellPerson, + people: List, + other: Boolean, + initialPhase: RequestPhase = RequestPhase.Choose, +) { + var people by mutableStateOf(listOf(viewer) + people.filter { it.key != viewer.key }) + private set + val held = viewer.key in title.heldBy + val blocked = title.type == "tv" && !held && title.seasons.isNullOrEmpty() + val buttonIndex: Int get() = people.size + var selected by mutableStateOf(if (other || held) this.people.indexOfFirst { it.key != viewer.key && it.key !in title.heldBy }.coerceAtLeast(0) else 0) + var phase by mutableStateOf(initialPhase) + var focus by mutableStateOf(if (initialPhase != RequestPhase.Choose || blocked || !other) buttonIndex else selected) + var result by mutableStateOf(null) + val selectedPerson: ShellPerson get() = people[selected] + val canSubmit: Boolean get() = phase == RequestPhase.Choose && !blocked && selectedPerson.key !in title.heldBy + val buttonLabel: String get() = when { + held -> "Add to ${selectedPerson.name}’s library" + selectedPerson.key == viewer.key -> "Request" + else -> "Request for ${selectedPerson.name}" + } + + fun updatePeople(value: List) { + val selectedKey = selectedPerson.key + val focusedKey = people.getOrNull(focus)?.key + val footerOffset = focus - buttonIndex + people = listOf(viewer) + value.filter { it.key != viewer.key } + selected = people.indexOfFirst { it.key == selectedKey }.coerceAtLeast(0) + focus = if (footerOffset >= 0) buttonIndex + footerOffset else people.indexOfFirst { it.key == focusedKey }.coerceAtLeast(0) + } + + fun key(key: ShellKey, send: (RequestSheetState, Boolean) -> Unit, close: () -> Unit, retryRecipients: () -> Unit) { + if (key == ShellKey.Back) { close(); return } + when (phase) { + RequestPhase.Choose -> { + val order = people.indices.filter { people[it].key !in title.heldBy } + buttonIndex + val index = order.indexOf(focus) + when (key) { + ShellKey.Up -> focus = order[(index - 1).coerceAtLeast(0)] + ShellKey.Down -> focus = order[(index + 1).coerceAtMost(order.lastIndex)] + ShellKey.Ok -> if (focus == buttonIndex) { if (blocked) close() else if (canSubmit) send(this, false) } + else { selected = focus; focus = buttonIndex } + else -> Unit + } + } + RequestPhase.Sending -> Unit + RequestPhase.LoadingRecipients -> if (key == ShellKey.Ok) close() + RequestPhase.Receipt, RequestPhase.Refused -> if (key == ShellKey.Ok) close() + RequestPhase.NotConfirmed, RequestPhase.Pending, RequestPhase.RecipientsUnavailable -> { + when (key) { + ShellKey.Left, ShellKey.Up -> focus = buttonIndex + ShellKey.Right, ShellKey.Down -> focus = buttonIndex + 1 + ShellKey.Ok -> if (focus != buttonIndex) close() else if (phase == RequestPhase.RecipientsUnavailable) retryRecipients() else send(this, true) + else -> Unit + } + } + } + } +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/SavedPill.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/SavedPill.kt new file mode 100644 index 00000000..48cea4fa --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/SavedPill.kt @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.design + +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.tv.material3.Text +import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C +import org.justinvisser.wolfskin.design.WolfskinTokens.Home as H +import org.justinvisser.wolfskin.design.WolfskinTokens.Shell as S + +/** Headers identify a retained copy without becoming a separate focus target. */ +@Composable +fun SavedPill(time: String, modifier: Modifier = Modifier) { + Box(modifier.height(S.settingsPillHeight) + .border(H.hairline, C.warn.copy(alpha = .45f), RoundedCornerShape(WolfskinTokens.Radius.pill)) + .padding(horizontal = S.settingsPillPadding + H.hairline), contentAlignment = Alignment.Center) { + Text("Saved $time", color = C.warn, style = HomeType.caption.copy(fontWeight = HomeType.label.fontWeight)) + } +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ScreenRatings.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ScreenRatings.kt new file mode 100644 index 00000000..275ac228 --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ScreenRatings.kt @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.design + +import androidx.compose.runtime.compositionLocalOf + +/** One focused-item collector reads through this seam; null keeps deterministic fixture ratings. */ +interface ScreenRatings { + suspend fun read(tmdb: Long, type: String, season: Int?, episode: Int?, cachedOnly: Boolean = false): List? +} + +val LocalScreenRatings = compositionLocalOf { null } + +/** Convert supplier marks without inferring a Rotten Tomatoes label from a percentage. */ +fun LibraryRating.homeRating(): HomeRating = HomeRating( + mark = when (label) { "Fresh", "Certified Fresh" -> "fresh"; "Rotten" -> "rotten"; "Upright" -> "upright"; "Spilled" -> "spilled"; else -> when (source) { "rtCritics" -> "tomato"; "rtAudience" -> "popcorn"; else -> source } }, + score = score, percentage = source.startsWith("rt"), episode = episode, +) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/SettingsList.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/SettingsList.kt new file mode 100644 index 00000000..d206ccc4 --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/SettingsList.kt @@ -0,0 +1,128 @@ +package org.justinvisser.wolfskin.design + +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.animation.core.tween +import androidx.compose.foundation.gestures.animateScrollBy +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.tv.material3.Text +import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C +import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L +import org.justinvisser.wolfskin.design.WolfskinTokens.Shell as S + +/** Settings routes identify real upstream destinations rather than stand-in panels. */ +data class SettingsGroup(val key: String, val title: String, val description: String, val wolfskin: Boolean = false) + +val SETTINGS_GROUPS = listOf( + SettingsGroup("requests", "Requests", "Coming in: what the household asked for, and how far each is"), + SettingsGroup("interface", "Interface", "Sign in automatically · Remember selected tabs · Theme music · Subtitle style · Application theme · Screensaver"), + SettingsGroup("playback", "Playback", "Skip forward · Skip back · Skip back when resuming"), + SettingsGroup("nextup", "Next up", "Show next up timing · Auto play next up · Delay · Passout protection"), + SettingsGroup("profile", "User profile settings", "Profile protection · Customize home page (Wolfskin presets: Default, Compact, Series thumbs, Episode thumbnails) · Navigation drawer items · Language", true), + SettingsGroup("about", "About", "Installed version · Update (Wolfskin releases on Forgejo) · Wolfskin about", true), + SettingsGroup("seerr", "Seerr integration", "Add, change or remove the Seerr server this TV asks for Discover and ratings", true), + SettingsGroup("advanced", "Advanced settings", "Ratings display · Backdrop style · Playback backend (ExoPlayer, MPV) · Max resolution and bitrate · Skip segments · Logs and image cache"), +) + +/** The count describes returned request history, not an inferred queue of active downloads. */ +@Composable +fun SettingsList(state: ShellState, modifier: Modifier = Modifier, requestCount: Int? = null) { + val list = state.view.listState + val density = LocalDensity.current + LaunchedEffect(state.settingsRow, state.navFocused) { + if (state.navFocused) return@LaunchedEffect + val y = L.row_lhead + L.row_srow * state.settingsRow + val target = if (y >= L.windowAhead) y - L.pinnedLine else S.zero + val current = if (list.firstVisibleItemIndex == 0) S.zero else L.row_lhead + L.row_srow * (list.firstVisibleItemIndex - 1) + val distance = with(density) { (target - current).toPx() } - list.firstVisibleItemScrollOffset + list.animateScrollBy(distance, tween(WolfskinTokens.Motion.scroll, easing = CubicBezierEasing(.33f, 1f, .68f, 1f))) + } + LazyColumn(modifier.fillMaxSize(), state = list, + contentPadding = PaddingValues(bottom = S.stageHeight - L.pinnedLine - L.row_srow)) { + item(key = "settings-header") { + Row(Modifier.height(L.row_lhead).padding(start = L.safeSide, top = S.settingsHeaderTop), + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(S.settingsHeaderGap)) { + Avatar(state.person, S.settingsHeaderAvatar, WolfskinTokens.Type.avatarLetterLibHeader, letterColor = C.muted) + Text("Settings", Modifier.alignByBaseline(), style = WolfskinTokens.Type.screenTitle, color = C.fg) + Text("Requests and Wholphin settings on this TV · ${state.person.name}", Modifier.alignByBaseline(), style = WolfskinTokens.Type.nav.copy(fontWeight = FontWeight.Normal), color = C.muted) + } + } + itemsIndexed(SETTINGS_GROUPS, key = { _, group -> group.key }) { index, group -> + Box(Modifier.height(L.row_srow)) { + SettingsRow(group, !state.navFocused && !state.requestsVisible && index == state.settingsRow, + if (group.key == "requests") requestCount else null, + Modifier.offset(L.safeSide, S.settingsRowTop)) { + state.navFocused = false; state.settingsRow = index; state.key(ShellKey.Ok) + } + } + } + } +} + +/** The inverse focus fill has no focus ring and never changes row layout. */ +@Composable +fun SettingsRow(group: SettingsGroup, focused: Boolean, requestCount: Int?, modifier: Modifier = Modifier, onClick: () -> Unit) { + val foreground = if (focused) C.onFocus else C.fg + Row(modifier.size(L.settingsRowWidth, L.settingsRowHeight).focusScale(focused, WolfskinTokens.Focus.scaleRow) + .background(if (focused) C.fg else C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.listRow)) + .clickable(onClick = onClick).padding(horizontal = S.settingsRowPadding), + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(S.settingsRowGap)) { + Glyph(group.key, if (focused) C.onFocus else C.accentText, Modifier.size(S.settingsIcon)) + Column(Modifier.weight(1f)) { + val count = requestCount?.let { " · $it ${if (it == 1) "request" else "requests"}" } ?: "" + Text(group.title + count, Modifier.exactLineBox(WolfskinTokens.Type.listTitle), color = foreground, style = WolfskinTokens.Type.listTitle, maxLines = 1) + Text(group.description, Modifier.exactLineBox(WolfskinTokens.Type.caption), color = if (focused) C.onFocus else C.muted, style = WolfskinTokens.Type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + if (group.wolfskin) SettingsPill("Wolfskin", focused) + SettingsPill(if (group.key == "requests") "Wolfskin screen" else "upstream screen", focused) + Glyph("right", if (focused) C.onFocus else C.accentText, Modifier.size(S.settingsChevron)) + } +} + +@Composable +private fun SettingsPill(label: String, focused: Boolean) { + val foreground = if (focused) C.onFocus else C.dim + val border = if (focused) S.settingsPillFocusedBorder else S.settingsPillBorder + Box(Modifier.height(S.settingsPillHeight).drawWithCache { + val stroke = S.settingsPillStroke.toPx() + val inset = Offset(stroke / 2, stroke / 2) + val bounds = Size(size.width - stroke, size.height - stroke) + val outline = Stroke(stroke, pathEffect = PathEffect.dashPathEffect( + floatArrayOf(stroke * S.settingsPillDashRatio, stroke * S.settingsPillDashGapRatio), + )) + val radius = CornerRadius(WolfskinTokens.Radius.pill.toPx()) + onDrawBehind { + drawRoundRect(border, topLeft = inset, size = bounds, cornerRadius = radius, style = outline) + } + }.padding(horizontal = S.settingsPillPadding + S.settingsPillStroke), contentAlignment = Alignment.Center) { + Text(label, color = foreground, style = WolfskinTokens.Type.caption.copy(fontWeight = WolfskinTokens.Type.label.fontWeight)) + } +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShelfScroll.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShelfScroll.kt new file mode 100644 index 00000000..fc84ed7f --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShelfScroll.kt @@ -0,0 +1,22 @@ +package org.justinvisser.wolfskin.design + +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.animation.core.tween +import androidx.compose.foundation.gestures.animateScrollBy +import androidx.compose.foundation.lazy.LazyListState + +/** Preserve one lookahead card and clamp short shelves and row ends to their real contents. */ +internal fun shelfFirst(column: Int, previous: Int, slots: Int, count: Int): Int { + var first = previous + if (column > first + slots - 2) first = column - (slots - 2) + if (column < first + 1) first = (column - 1).coerceAtLeast(0) + return first.coerceIn(0, (count - slots).coerceAtLeast(0)) +} + +/** Reconcile the actual track on every focus transition, including interrupted or restored offsets. */ +internal suspend fun LazyListState.scrollShelfTo(first: Int, pitch: Float) { + val distance = (first - firstVisibleItemIndex) * pitch - firstVisibleItemScrollOffset + if (distance != 0f) animateScrollBy(distance, tween(WolfskinTokens.Motion.base, + easing = CubicBezierEasing(.25f, .1f, .25f, 1f))) + if (firstVisibleItemIndex != first || firstVisibleItemScrollOffset != 0) scrollToItem(first) +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellState.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellState.kt new file mode 100644 index 00000000..c770a3ab --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellState.kt @@ -0,0 +1,324 @@ +package org.justinvisser.wolfskin.design + +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue + +/** Household identity data contains no credentials or server objects. */ +data class ShellPerson(val key: String, val name: String, val initial: String, val hasAnime: Boolean, val jellyfinUserId: String? = null) + +/** The shell owns keys; the integration owns navigation and authenticated user changes. */ +sealed interface ShellAction { + data class Destination(val route: String, val preservePushed: Boolean = false) : ShellAction + data class Person(val person: ShellPerson) : ShellAction + data class HandOff(val group: String) : ShellAction + data object Pop : ShellAction + data object RetryPeople : ShellAction + data object RetryRequests : ShellAction + data class RequestTitle(val request: ComingInRequest) : ShellAction +} + +/** D-pad inputs are shared by Android events and deterministic JVM replays. */ +enum class ShellKey { Up, Down, Left, Right, Ok, Back } +enum class PeopleStatus { Ready, Loading, Unavailable } + +/** Screens participate in the shell's hold guard and Back ladder without a second key adapter. */ +interface ShellContentFocus { + val hasContent: Boolean get() = true + val cardKey: String? + fun key(key: ShellKey): Boolean +} + +/** A root destination retains focus, horizontal tracks and its actual Compose scroller. */ +class RememberedView { + val listState = LazyListState() + var row by mutableStateOf(0) + val cards = mutableStateMapOf() + var savedAt by mutableStateOf(null) + val home by lazy { HomeViewMemory() } + var library: LibraryGridState? = null +} + +/** Focus and Back transitions are independent of drawing and server availability. */ +class ShellState( + people: List, + initialPerson: String, + private val onAction: (ShellAction) -> Unit = {}, +) { + var people by mutableStateOf(people) + private set + + /** Refresh household data without losing valid nav focus or this person's view memory. */ + fun updatePeople(value: List) { + val focusedTab = tabs.getOrNull(navIndex) + val avatarFocused = navIndex == tabs.size + val selected = value.firstOrNull { it.jellyfinUserId != null && it.jellyfinUserId == person.jellyfinUserId } + ?: value.firstOrNull { it.key == person.key } + people = value + if (selected != null) person = selected + pickerIndex = people.indexOf(person).coerceAtLeast(0) + val removedRoot = rootRoute !in tabs + if (removedRoot) { + if (route == rootRoute) route = "home" + rootRoute = "home" + } + preparePersonMemory() + navIndex = if (avatarFocused) tabs.size else tabs.indexOf(focusedTab).takeIf { it >= 0 } ?: currentTab + if (removedRoot) onAction(ShellAction.Destination("home", preservePushed = true)) + } + var person by mutableStateOf(people.firstOrNull { it.key == initialPerson } ?: people.first()) + private set + var route by mutableStateOf("home") + private set + var pickerVisible by mutableStateOf(true) + var pickerLaunch by mutableStateOf(true) + var pickerIndex by mutableStateOf(people.indexOf(person)) + var gearFocused by mutableStateOf(false) + var navFocused by mutableStateOf(false) + var navIndex by mutableStateOf(0) + var peopleStatus by mutableStateOf(PeopleStatus.Ready) + var recoveryIndex by mutableStateOf(0) + var requestsVisible by mutableStateOf(false) + var toast by mutableStateOf(null) + private val personRequests = mutableMapOf() + val requests: ComingInState get() = personRequests.getValue(person.key) + private var requestReturnRoute: String? = null + var handOff by mutableStateOf(null) + var settingsRow by mutableStateOf(0) + var contentOverlayVisible by mutableStateOf(false) + private var rootRoute by mutableStateOf("home") + private var pickerReturnRoute = "home" + private val views = mutableStateMapOf, RememberedView>() + private var armedCard: String? = null + var contentFocus: ShellContentFocus? = null + var inputRevision = 0L + private set + + init { preparePersonMemory() } + + private fun preparePersonMemory() { + personRequests.getOrPut(person.key) { ComingInState() } + tabs.forEach(::retainView) + retainView(route) + } + + private fun retainView(destination: String) { + views.getOrPut(person.key to destination) { RememberedView() } + } + val tabs: List get() = if (person.hasAnime) ANIME_TABS else BASE_TABS + val currentTab: Int get() = tabs.indexOf(rootRoute).coerceAtLeast(0) + val view: RememberedView get() = rememberedView(route) + val navSavedAt: String? get() = if (requestsVisible && requests.snapshot.status == ComingInStatus.Unavailable) requests.snapshot.savedAt ?: view.savedAt else view.savedAt + + /** Reads never register defaults: snapshotFlow evaluates accessors in a read-only snapshot. */ + fun rememberedView(destination: String): RememberedView = + views[person.key to destination] ?: RememberedView() + + /** External intents and upstream pops remain authoritative over the screen. */ + fun enterDestination(destination: String) { + retainView(destination) + route = destination + if (destination in tabs) rootRoute = destination + handOff = null + if (requestReturnRoute == destination) { + requestsVisible = true + requestReturnRoute = null + } + } + + /** Pointer navigation uses the same transitions as D-pad selection. */ + fun focusNav(index: Int) { inputRevision++; navIndex = index; navFocused = true } + fun focusPerson(index: Int) { inputRevision++; pickerIndex = index; gearFocused = false } + + /** Repeats are consumed, and a row activation is cancelled when focus moves. */ + fun key(key: ShellKey, down: Boolean = true, repeat: Int = 0, card: String? = if (!pickerVisible && !navFocused && !requestsVisible && !contentOverlayVisible) contentFocus?.cardKey else null): Boolean { + if (down) inputRevision++ + if (key == ShellKey.Ok && repeat > 0) return true + if (!pickerVisible && !requestsVisible && contentOverlayVisible) { + if (down) contentFocus?.key(key) + return true + } + if (key == ShellKey.Ok) { + if (card != null) { + if (down) armedCard = card else { + if (armedCard == card) activate() + armedCard = null + } + return true + } + if (!down) return true + } else { + if (!down) return true + armedCard = null + } + if (requestsVisible) { + when { + key == ShellKey.Back -> closeRequests() + key == ShellKey.Ok && requests.snapshot.status == ComingInStatus.Unavailable && (requests.snapshot.items.isEmpty() || requests.recoveryFocused) -> retryRequests() + key == ShellKey.Ok && requests.snapshot.status == ComingInStatus.Loading && requests.snapshot.items.isEmpty() -> closeRequests() + requests.key(key) -> activateRequest() + } + return true + } + if (pickerVisible) pickerKey(key) else if (key == ShellKey.Back) back() else if (navFocused) navKey(key) else contentKey(key) + return true + } + + private fun pickerKey(key: ShellKey) { + if (peopleStatus != PeopleStatus.Ready) { + when (key) { + ShellKey.Left -> recoveryIndex = 0 + ShellKey.Right -> if (peopleStatus == PeopleStatus.Unavailable) recoveryIndex = 1 + ShellKey.Back -> closePicker() + ShellKey.Ok -> if (peopleStatus == PeopleStatus.Unavailable && recoveryIndex == 0) onAction(ShellAction.RetryPeople) else choosePerson(person) + else -> Unit + } + } else if (gearFocused) { + when (key) { + ShellKey.Up -> gearFocused = false + ShellKey.Ok -> { + pickerVisible = false + navFocused = false + settingsRow = 0 + retainView("settings") + route = "settings" + onAction(ShellAction.Destination(route)) + } + ShellKey.Back -> closePicker() + else -> Unit + } + } else { + when (key) { + ShellKey.Left -> pickerIndex = (pickerIndex - 1).coerceAtLeast(0) + ShellKey.Right -> pickerIndex = (pickerIndex + 1).coerceAtMost(people.lastIndex) + ShellKey.Down -> if (pickerIndex == people.lastIndex) gearFocused = true + ShellKey.Ok -> choosePerson(people[pickerIndex]) + ShellKey.Back -> closePicker() + else -> Unit + } + } + } + + private fun choosePerson(selected: ShellPerson) { + person = selected + pickerVisible = false + pickerLaunch = false + route = "home" + navFocused = false + navIndex = 0 + rootRoute = "home" + requestReturnRoute = null + requestsVisible = false + preparePersonMemory() + views[person.key to route] = RememberedView() + onAction(ShellAction.Person(selected)) + } + + private fun closePicker() { + if (!pickerLaunch) { pickerVisible = false; navFocused = true; navIndex = tabs.size } + } + + private fun navKey(key: ShellKey) { + when (key) { + ShellKey.Left -> navIndex = (navIndex + tabs.size) % (tabs.size + 1) + ShellKey.Right -> navIndex = (navIndex + 1) % (tabs.size + 1) + ShellKey.Down -> if (contentFocus?.hasContent != false) navFocused = false + ShellKey.Ok -> activate() + else -> Unit + } + } + + private fun contentKey(key: ShellKey) { + if (contentFocus?.key(key) == true) return + when (key) { + ShellKey.Up -> if (route == "settings" && settingsRow > 0) settingsRow-- else { navFocused = true; navIndex = currentTab } + ShellKey.Down -> if (route == "settings") settingsRow = (settingsRow + 1).coerceAtMost(SETTINGS_GROUPS.lastIndex) + ShellKey.Ok -> activate() + else -> Unit + } + } + + private fun activate() { + if (navFocused) { + if (navIndex == tabs.size) { + pickerReturnRoute = route + pickerVisible = true + pickerLaunch = false + pickerIndex = people.indexOf(person) + gearFocused = false + } else { + val target = tabs[navIndex] + requestReturnRoute = null + retainView(target) + if (target == route) views[person.key to target] = RememberedView() + route = target + rootRoute = target + navFocused = false + onAction(ShellAction.Destination(target)) + } + } else if (route == "settings") { + val group = SETTINGS_GROUPS[settingsRow].key + if (group == "requests") openRequests() else { + handOff = group + onAction(ShellAction.HandOff(group)) + } + } + else contentFocus?.key(ShellKey.Ok) + } + + /** Requested buttons may name an existing request without changing its authenticated actor. */ + fun openRequests(requestId: String? = null) { + requests.focus(requestId ?: requests.snapshot.ordered.firstOrNull()?.id) + requestsVisible = true + navFocused = false + } + + fun closeRequests() { requestsVisible = false } + fun retryRequests() { onAction(ShellAction.RetryRequests) } + + fun activateRequest() { + val request = requests.focused ?: return + requestReturnRoute = route + requestsVisible = false + onAction(ShellAction.RequestTitle(request)) + } + + /** Back never discards a root view, including the card below a focused nav. */ + fun back() { + if (requestsVisible) { closeRequests(); return } + val returnRoute = requestReturnRoute + if (returnRoute != null && route != returnRoute) { + route = returnRoute + requestsVisible = true + requestReturnRoute = null + onAction(ShellAction.Pop) + return + } + if (!pickerVisible && !navFocused && contentFocus?.key(ShellKey.Back) == true) return + if (route == "settings") { + route = pickerReturnRoute + onAction(ShellAction.Pop) + pickerVisible = true + pickerLaunch = false + gearFocused = true + pickerIndex = people.lastIndex + } else if (route !in tabs) { + onAction(ShellAction.Pop) + } else if (!navFocused) { + navFocused = true + navIndex = currentTab + } else if (route != "home") { + route = "home" + rootRoute = "home" + navIndex = 0 + onAction(ShellAction.Destination(route)) + } + } + + companion object { + private val BASE_TABS = listOf("home", "discover", "movies", "shows") + private val ANIME_TABS = BASE_TABS + "anime" + } +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellSurface.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellSurface.kt new file mode 100644 index 00000000..603942b2 --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellSurface.kt @@ -0,0 +1,111 @@ +package org.justinvisser.wolfskin.design + +import androidx.compose.foundation.background +import androidx.compose.foundation.focusable +import androidx.compose.foundation.focusGroup +import androidx.compose.ui.focus.focusRestorer +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.offset +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEvent +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription +import androidx.tv.material3.Text +import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C +import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L + +/** Screens and the shell interpret the same Android directional and activation keys. */ +fun readShellKey(event: KeyEvent): ShellKey? = when (event.key) { + Key.DirectionUp -> ShellKey.Up + Key.DirectionDown -> ShellKey.Down + Key.DirectionLeft -> ShellKey.Left + Key.DirectionRight -> ShellKey.Right + Key.DirectionCenter, Key.Enter, Key.NumPadEnter -> ShellKey.Ok + Key.Back, Key.Escape -> ShellKey.Back + else -> null +} + +/** Map Android key events once, preserving repeat count and key-up semantics. */ +fun ShellState.handleKey(event: KeyEvent): Boolean { + val key = readShellKey(event) ?: return false + return this.key(key, event.type == KeyEventType.KeyDown, event.nativeKeyEvent.repeatCount) +} + +/** Content and nav share a single focus policy; upstream full-screen pages bypass this surface. */ +@Composable +fun ShellSurface( + state: ShellState, + modifier: Modifier = Modifier, + handlesContentKeys: Boolean = true, + settingsRequestCount: Int? = null, + overlay: @Composable () -> Unit = {}, + content: @Composable (Modifier) -> Unit = { ShellPlaceholder(state.route) }, +) { + val focus = remember { FocusRequester() } + val contentFocus = remember { FocusRequester() } + val focusManager = LocalFocusManager.current + LaunchedEffect(state.toast) { + val message = state.toast + if (message != null) { + kotlinx.coroutines.delay(WolfskinTokens.Motion.toast.toLong()) + if (state.toast == message) state.toast = null + } + } + LaunchedEffect(state.pickerVisible, state.navFocused, handlesContentKeys) { + if (state.pickerVisible || state.navFocused || handlesContentKeys) focus.requestFocus() + else contentFocus.requestFocus() + } + Box(modifier.fillMaxSize().background(if (handlesContentKeys || state.pickerVisible) C.bg else Color.Transparent).testTag("wolfskin-shell") + .semantics { stateDescription = "${state.person.key}:${state.route}:${if (state.pickerVisible) "picker" else if (state.navFocused) "nav-${state.navIndex}" else "content-${state.settingsRow}"}" } + .focusRequester(focus) + .onPreviewKeyEvent { + if (state.pickerVisible || state.navFocused || state.requestsVisible || handlesContentKeys) state.handleKey(it) else false + } + .onKeyEvent { + if (it.key == Key.DirectionUp && it.type == KeyEventType.KeyDown) { + if (!focusManager.moveFocus(FocusDirection.Up)) { + state.navFocused = true + state.navIndex = state.currentTab + } + true + } else false + }.focusable()) { + if (state.pickerVisible) { + WhosWatching(state) + } else { + if (state.route == "settings") SettingsList(state, requestCount = settingsRequestCount ?: state.requests.snapshot.requestCount) else content(Modifier.fillMaxSize().focusRequester(contentFocus).focusRestorer().focusGroup()) + NavBar(state, state.view.listState.firstVisibleItemIndex > 0 || state.view.listState.firstVisibleItemScrollOffset > L.navSolidAfter.value * 2) + if (state.requestsVisible) ComingInSheet(state) + overlay() + } + state.toast?.let { RecoveryToast(it) } + } +} + +/** Future screens are explicitly out of scope, never simulated library or title data. */ +@Composable +fun ShellPlaceholder(route: String, modifier: Modifier = Modifier) { + Column(modifier.offset(L.safeSide, L.detailCopyTop), verticalArrangement = Arrangement.spacedBy(L.buttonGap)) { + Text(route.replaceFirstChar { it.uppercase() }, color = C.fg, style = WolfskinTokens.Type.screenTitle) + Text("Wolfskin screen — not built in this slice", color = C.accentText, style = WolfskinTokens.Type.body) + Text("Home and Library remain available through the Wolfskin screens switch in Advanced settings.", color = C.muted, style = WolfskinTokens.Type.body) + } +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/SourceRatings.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/SourceRatings.kt new file mode 100644 index 00000000..018e4a4b --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/SourceRatings.kt @@ -0,0 +1,107 @@ +package org.justinvisser.wolfskin.design + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.getValue +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.ColorMatrix +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.TextUnitType +import androidx.tv.material3.Text +import java.util.Locale +import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C +import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D +import org.justinvisser.wolfskin.design.WolfskinTokens.Library as P + +/** A full facts-line set never shrinks or waits for focused-card dwell. */ +@Composable +fun TitleRatings(ratings: List, modifier: Modifier = Modifier, style: TextStyle = WolfskinTokens.Type.ratingPlate, plate: Boolean = false) { + if (ratings.isEmpty()) return + val density = LocalDensity.current + val markHeight = with(density) { style.fontSize.toDp() } + val lineHeight = style.fontSize * (WolfskinTokens.Type.ratingPlate.lineHeight.value / WolfskinTokens.Type.ratingPlate.fontSize.value) + val textStyle = if (style.lineHeight == lineHeight) style else style.copy(lineHeight = lineHeight) + val numberStyle = remember(textStyle) { textStyle.copy(fontFeatureSettings = "tnum", fontWeight = FontWeight.Bold) } + val gap = if (plate) with(density) { (style.fontSize * P.plateGapEm).toDp() } else D.ratingGap + val markGap = if (plate) with(density) { (style.fontSize * P.markGapEm).toDp() } else D.ratingMarkGap + Row(modifier, horizontalArrangement = Arrangement.spacedBy(gap), verticalAlignment = Alignment.CenterVertically) { + ratings.forEach { rating -> + Row(horizontalArrangement = Arrangement.spacedBy(markGap), verticalAlignment = Alignment.CenterVertically) { + val resource = when (rating.source) { + "rtCritics" -> if (rating.label == "Rotten") R.drawable.ic_rotten_tomatoes_rotten else R.drawable.ic_rotten_tomatoes_fresh + "rtAudience" -> if (rating.label == "Spilled") R.drawable.ic_rt_spilled else R.drawable.ic_rt_upright + "imdb" -> R.drawable.ic_rating_imdb + else -> R.drawable.ic_rating_tmdb + } + val unknownCritic = rating.source == "rtCritics" && rating.label != "Fresh" && rating.label != "Certified Fresh" && rating.label != "Rotten" + val desaturated = if (unknownCritic) remember { ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }) } else null + Image(painterResource(resource), rating.source, Modifier.size(markHeight * if (rating.source == "imdb" || rating.source == "tmdb") P.wordmarkRatio else 1f, markHeight), + alpha = if (unknownCritic) P.unknownCriticOpacity else 1f, colorFilter = desaturated) + if (rating.episode) Text("Ep", color = C.muted, style = textStyle.copy(fontWeight = WolfskinTokens.Type.label.fontWeight), modifier = Modifier.exactLineBox(textStyle)) + Text(rating.formatted(), color = C.fg, style = numberStyle, modifier = Modifier.exactLineBox(numberStyle), maxLines = 1) + } + } + } +} + +/** The plate keeps a prefix of the title set, shrinking to the caption floor before dropping its tail. */ +@Composable +fun LibraryRatingPlate(ratings: List, modifier: Modifier = Modifier, visible: Boolean = true) { + if (ratings.isEmpty()) return + val measurer = rememberTextMeasurer() + val density = LocalDensity.current + val alpha by animateFloatAsState(if (visible) 1f else 0f, tween(WolfskinTokens.Motion.base), label = "rating-plate") + BoxWithConstraints(modifier.fillMaxWidth().graphicsLayer { this.alpha = alpha } + .background(Brush.verticalGradient(0f to C.bg.copy(alpha = P.plateOpacity), P.plateMidStop to C.bg.copy(alpha = P.plateMidOpacity), 1f to C.bg.copy(alpha = 0f))) + .testTag("rating-plate").padding(horizontal = P.plateSide)) { + val width = with(density) { maxWidth.toPx() } + val fit = remember(ratings, width, density, Locale.getDefault()) { + var size = WolfskinTokens.Type.ratingPlate.fontSize.value + var count = ratings.size + val numbers = ratings.map { it.formatted() } + fun measured(): Float { + val fontSize = TextUnit(size, TextUnitType.Sp) + val style = WolfskinTokens.Type.ratingPlate.copy(fontSize = fontSize, fontFeatureSettings = "tnum") + val markGap = with(density) { (fontSize * P.markGapEm).toPx() } + val entryGap = with(density) { (fontSize * P.plateGapEm).toPx() } + var total = 0f + for (index in 0 until count) { + val rating = ratings[index] + total += measurer.measure(numbers[index], style, maxLines = 1).size.width + total += with(density) { fontSize.toPx() } * if (rating.source == "imdb" || rating.source == "tmdb") P.wordmarkRatio else 1f + total += markGap + if (rating.episode) total += measurer.measure("Ep", style.copy(fontWeight = WolfskinTokens.Type.label.fontWeight), maxLines = 1).size.width + markGap + if (index > 0) total += entryGap + } + return total + } + while (size > P.plateFloor.fontSize.value && measured() > width) size = (size - P.plateStep).coerceAtLeast(P.plateFloor.fontSize.value) + while (count > 1 && measured() > width) count-- + count to size + } + val fontSize = TextUnit(fit.second, TextUnitType.Sp) + val style = WolfskinTokens.Type.ratingPlate.copy(fontSize = fontSize, + lineHeight = WolfskinTokens.Type.ratingPlate.lineHeight * (fit.second / WolfskinTokens.Type.ratingPlate.fontSize.value)) + TitleRatings(ratings.take(fit.first), modifier = Modifier.exactLineBox(style, top = P.plateTop, bottom = P.plateBottom + P.plateInlineDescent), style = style, plate = true) + } +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TextLineBox.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TextLineBox.kt new file mode 100644 index 00000000..b21c4dcb --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TextLineBox.kt @@ -0,0 +1,10 @@ +package org.justinvisser.wolfskin.design + +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit + +/** Explicit CSS leading and margins share one fractional layout engine across Wolfskin screens. */ +fun Modifier.exactLineBox(style: TextStyle, top: Dp = WolfskinTokens.Shell.zero, bottom: Dp = WolfskinTokens.Shell.zero): Modifier = + if (style.lineHeight == TextUnit.Unspecified) this else then(CssLineBox(style.lineHeight, top, bottom)) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleArtwork.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleArtwork.kt new file mode 100644 index 00000000..cc2696d6 --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleArtwork.kt @@ -0,0 +1,176 @@ +package org.justinvisser.wolfskin.design + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.blur +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.draw.dropShadow +import androidx.compose.ui.graphics.shadow.Shadow +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.layout +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.ColorMatrix +import androidx.compose.ui.graphics.ShaderBrush +import androidx.compose.ui.graphics.toArgb +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.tv.material3.Text +import coil3.compose.AsyncImage +import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C +import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L +import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D + +/** Local decoded fixture art keeps screenshot tests deterministic and completely offline. */ +val LocalTitleBitmaps = compositionLocalOf> { emptyMap() } + +/** Decode at the slot size, with no crossfade or oversized poster allocations. */ +@Composable +fun TitleArtwork(path: String?, width: Int, height: Int, modifier: Modifier = Modifier, poster: Boolean = false, colorFilter: ColorFilter? = null, onError: () -> Unit = {}) { + val bitmap = LocalTitleBitmaps.current[path] + if (bitmap != null) Image(bitmap, null, modifier, contentScale = ContentScale.Crop, colorFilter = colorFilter) + else if (path != null) { + val context = LocalContext.current + val request = remember(context, path, width, height, poster) { + WolfskinImages.request(context, path, width, height, poster) + } + AsyncImage(model = request, imageLoader = WolfskinImages.loader(context), + contentDescription = null, modifier = modifier, contentScale = ContentScale.Crop, colorFilter = colorFilter, onError = { onError() }) + } +} + +/** Initials follow the same title-hashed hue as the locked art fallback. */ +@Composable +fun PersonPortrait(person: TitlePerson, modifier: Modifier = Modifier, large: Boolean = false) { + val initials = remember(person.name) { person.name.split(Regex("\\s+")).filter { it.isNotEmpty() }.take(2).joinToString("") { it.take(1) }.uppercase() } + val size = if (large) (L.personPortrait.value * 2).toInt() else WolfskinTokens.Image.castRequestWidth + Box(modifier.titleFallbackBackground(person.name, D.personFallbackGradient), contentAlignment = Alignment.Center) { + Text(initials, color = D.initialsColor, style = if (large) D.personInitialSize else D.initialSize, textAlign = TextAlign.Center) + if (person.portrait != null) TitleArtwork(person.portrait, size, size, Modifier.fillMaxSize()) + } +} + +/** The original gradient is parsed once so stops stay derived from the canonical token. */ +@Composable +fun TitleBackdrop(item: TitleItem, modifier: Modifier = Modifier) { + val horizontal = remember { + val stops = Regex("rgb\\(7 7 10 / ([.\\d]+)\\) (\\d+)%").findAll(WolfskinTokens.Gradient.detailScrim).take(3) + .map { it.groupValues[2].toFloat() / 100 to C.bg.copy(alpha = it.groupValues[1].toFloat()) }.toList() + Brush.horizontalGradient(*stops.toTypedArray()) + } + val vertical = remember { + val token = WolfskinTokens.Gradient.detailScrim.substringAfter("linear-gradient(0deg,") + val stops = mutableListOf(1f to C.bg) + Regex("rgb\\(7 7 10 / ([.\\d]+)\\) (\\d+)%").findAll(token).forEach { + stops.add(1f - it.groupValues[2].toFloat() / 100 to C.bg.copy(alpha = it.groupValues[1].toFloat())) + } + Brush.verticalGradient(*stops.sortedBy { it.first }.toTypedArray()) + } + Box(modifier.titleAmbientBackground()) { + if (item.backdrop != null && !item.isPosterBackdrop) { + TitleArtwork(item.backdrop, WolfskinTokens.Image.heroRequestWidth, WolfskinTokens.Image.heroRequestHeight, Modifier.fillMaxSize()) + } else if (item.poster != null) { + val filter = remember { + val token = WolfskinTokens.Gradient.heroAmbientFilter + Regex("blur\\(([\\d.]+)px\\).*saturate\\(([\\d.]+)\\).*brightness\\(([\\d.]+)\\)").find(token)!!.groupValues + } + val colorFilter = remember { + val matrix = ColorMatrix().apply { setToSaturation(filter[2].toFloat()) } + for (index in 0 until 15) matrix.values[index] *= filter[3].toFloat() + ColorFilter.colorMatrix(matrix) + } + TitleArtwork(item.poster, WolfskinTokens.Image.heroPosterRequestWidth, WolfskinTokens.Image.heroPosterRequestHeight, + Modifier.fillMaxSize().artOverscan(D.ambientInset).graphicsLayer { scaleX = D.ambientScale; scaleY = D.ambientScale } + .blur(Dp(filter[1].toFloat() / 2)), colorFilter = colorFilter) + val posterShape = remember { RoundedCornerShape(WolfskinTokens.Radius.heroSheet) } + val posterShadow = remember { Shadow(radius = D.posterShadowRadius, offset = DpOffset.Zero.copy(y = D.posterShadowY), color = Color.Black.copy(alpha = D.posterShadowOpacity)) } + TitleArtwork(item.poster, (L.heroSheetWidth.value * 2).toInt(), (L.heroSheetHeight.value * 2).toInt(), + Modifier.offset(WolfskinTokens.Shell.stageWidth - L.heroSheetWidth - D.heroSheetRight, D.heroSheetTop) + .size(L.heroSheetWidth, L.heroSheetHeight).dropShadow(posterShape, posterShadow).clip(posterShape), poster = true) + } + Box(Modifier.fillMaxSize().background(horizontal)) + Box(Modifier.fillMaxSize().background(vertical)) + } +} + +/** Library, related and person posters share the exact locked missing-art treatment. */ +@Composable +internal fun MediaArtworkFallback(name: String, movie: Boolean, showTitle: Boolean = true) { + Box(Modifier.fillMaxSize().titleFallbackBackground(name, D.cardFallbackGradient)) { + Glyph(if (movie) "film" else "tv", D.fallbackGlyphColor, Modifier.offset(D.fallbackInset, D.fallbackInset).size(D.fallbackGlyph)) + if (showTitle) Text(name, color = C.fg, style = D.fallbackTitle, maxLines = 4, overflow = TextOverflow.Ellipsis, + modifier = Modifier.align(Alignment.BottomStart).padding(D.fallbackInset)) + } +} + +/** Episode tiles use their backdrop, or the prototype's blurred poster and small one-sheet. */ +@Composable +fun TitleTileArtwork(item: TitleItem) { + Box(Modifier.fillMaxSize()) { + MediaArtworkFallback(item.seriesName ?: item.name, item.kind == "movie", showTitle = false) + if (item.isPosterBackdrop && item.poster != null) { + val filter = remember { ColorFilter.colorMatrix(ColorMatrix().apply { + for (index in 0 until 15) values[index] *= D.tileAmbientBrightness + }) } + TitleArtwork(item.poster, WolfskinTokens.Image.episodeRequestWidth, WolfskinTokens.Image.episodeRequestHeight, Modifier.fillMaxSize().artOverscan(D.tileAmbientInset).blur(D.tileAmbientBlur), colorFilter = filter) + val height = D.episodeCardHeight - D.tileSheetInset * 2 + val shape = remember { RoundedCornerShape(D.tileSheetRadius) } + val shadow = remember { Shadow(radius = D.tileShadowRadius, offset = DpOffset.Zero.copy(y = D.tileShadowY), color = Color.Black.copy(alpha = D.tileShadowOpacity)) } + TitleArtwork(item.poster, (height.value * 2 * 2 / 3).toInt(), (height.value * 2).toInt(), + Modifier.offset(D.tileSheetLeft, D.tileSheetInset).size(height * (2f / 3), height).dropShadow(shape, shadow).clip(shape), poster = true) + } else TitleArtwork(item.backdrop, WolfskinTokens.Image.episodeRequestWidth, WolfskinTokens.Image.episodeRequestHeight, Modifier.fillMaxSize()) + } +} + +private fun Modifier.artOverscan(inset: Dp): Modifier = layout { measurable, constraints -> + val pixels = inset.roundToPx() + val width = constraints.maxWidth + pixels * 2 + val height = constraints.maxHeight + pixels * 2 + val placeable = measurable.measure(constraints.copy(minWidth = width, maxWidth = width, minHeight = height, maxHeight = height)) + layout(constraints.maxWidth, constraints.maxHeight) { placeable.place(-pixels, -pixels) } +} + +private fun Modifier.titleFallbackBackground(name: String, token: String): Modifier = drawWithCache { + var hue = 0 + name.forEach { hue = (hue * 31 + it.code) % 360 } + val values = Regex("([\\d.]+)%").findAll(token).map { it.groupValues[1].toFloat() / 100 }.toList() + val angle = Math.toRadians(Regex("(\\d+)deg").find(token)!!.groupValues[1].toDouble()) + val dx = kotlin.math.sin(angle).toFloat() + val dy = -kotlin.math.cos(angle).toFloat() + val half = (kotlin.math.abs(size.width * dx) + kotlin.math.abs(size.height * dy)) / 2 + val vector = Offset(dx * half, dy * half) + val middle = Offset(size.width / 2, size.height / 2) + val brush = Brush.linearGradient(listOf(Color.hsl(hue.toFloat(), values[0], values[1]), Color.hsl(hue.toFloat(), values[2], values[3])), + start = middle - vector, end = middle + vector) + onDrawBehind { drawRect(brush) } +} + +private fun Modifier.titleAmbientBackground(): Modifier = drawWithCache { + val values = Regex("([\\d.]+)%").findAll(D.heroAmbientBackground).map { it.groupValues[1].toFloat() / 100 }.toList() + val shader = android.graphics.RadialGradient(0f, 0f, 1f, intArrayOf(C.surface3.toArgb(), C.bg.toArgb()), null, android.graphics.Shader.TileMode.CLAMP) + shader.setLocalMatrix(android.graphics.Matrix().apply { + setScale(size.width * values[0], size.height * values[1]) + postTranslate(size.width * values[2], size.height * values[3]) + }) + val brush = ShaderBrush(shader) + onDrawBehind { drawRect(brush) } +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleCard.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleCard.kt new file mode 100644 index 00000000..fd581f12 --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleCard.kt @@ -0,0 +1,23 @@ +package org.justinvisser.wolfskin.design + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.Dp + +/** Shelf, grid and continuation cards share one fixed slot and draw-only focus surface. */ +@Composable +fun TitleCard(focused: Boolean, modifier: Modifier = Modifier, width: Dp = WolfskinTokens.Card.posterW, + height: Dp = WolfskinTokens.Card.posterH, content: @Composable BoxScope.() -> Unit) { + val shape = RoundedCornerShape(WolfskinTokens.Radius.card) + Box(modifier.size(width, height).focusScale(focused, WolfskinTokens.Focus.scaleCard) + .focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardRing, FocusLift.Card)) { + Box(Modifier.fillMaxSize().clip(shape).background(WolfskinTokens.Color.surface2), content = content) + } +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleControl.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleControl.kt new file mode 100644 index 00000000..ee5054c5 --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleControl.kt @@ -0,0 +1,59 @@ +package org.justinvisser.wolfskin.design + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics +import androidx.tv.material3.Text +import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C +import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L +import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D + +/** Compact title controls reserve captions beneath the whole row, not inside an expanding button. */ +@Composable +fun TitleControl(option: TitleOption, focused: Boolean, modifier: Modifier = Modifier, onFocus: (() -> Unit)? = null, overArtwork: Boolean = true, onClick: () -> Unit) { + val focusOwner = LocalTitleFocusOwner.current + val background = if (option.primary) C.fg else if (overArtwork) { + if (focused) C.titleButtonSecondaryFocused else C.titleButtonSecondary + } else if (focused) C.buttonSecondaryFocused else C.buttonSecondary + val foreground = if (option.primary) C.onFocus else C.fg + val shape = RoundedCornerShape(WolfskinTokens.Radius.button) + val dimensions = if (option.icon) Modifier.size(L.iconButton) else Modifier.height(L.buttonHeight).padding(horizontal = L.buttonPaddingX) + Row(modifier.testTag("title-option-${option.id}").titlePointerFocus(onFocus).focusScale(focused, WolfskinTokens.Focus.scaleControl) + .semantics { if (option.icon) contentDescription = option.label; role = Role.Button; selected = option.on } + .focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringControlRing, FocusLift.Button) + .background(background, shape) + .then(if (option.on && !focused) Modifier.border(D.radioBorder, C.accent, shape) else Modifier) + .clickable { focusOwner?.invoke(); onClick() }.then(dimensions), + horizontalArrangement = Arrangement.spacedBy(D.iconGap, Alignment.CenterHorizontally), verticalAlignment = Alignment.CenterVertically) { + if (option.glyph.isNotEmpty()) Glyph(option.glyph, foreground, Modifier.size(if (option.icon) D.iconSize else L.buttonIcon)) + if (!option.icon) Text(option.label, color = foreground, style = WolfskinTokens.Type.button, maxLines = 1) + } +} + + +/** A radio remains visibly selected independently of the inverse focused row. */ +@Composable +fun TitleRadio(selected: Boolean, focused: Boolean) { + val color = if (focused) C.onFocus else C.fg + Box(Modifier.size(D.radioSize).background(if (selected) C.accent else androidx.compose.ui.graphics.Color.Transparent, CircleShape) + .then(if (focused || !selected) Modifier.border(D.radioBorder, color.copy(alpha = if (focused) .5f else .35f), CircleShape) else Modifier), contentAlignment = Alignment.Center) { + if (selected) Box(Modifier.size(D.radioDot).background(C.onFocus, CircleShape)) + } +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleHeader.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleHeader.kt new file mode 100644 index 00000000..b5d35264 --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleHeader.kt @@ -0,0 +1,122 @@ +package org.justinvisser.wolfskin.design + +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.boundsInRoot +import androidx.compose.ui.platform.testTag +import androidx.compose.foundation.background +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.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.text.style.TextOverflow +import androidx.tv.material3.Text +import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C +import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L +import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D + +/** Film, series and episode share one copy block and one fixed options-caption band. */ +@Composable +fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifier) { + if (state.data.status == TitleReadStatus.Loading) { TitleHeaderSkeleton(modifier); return } + val item = state.data.item + val episode = when { + state.row == "episodes" -> state.data.episodes.getOrNull(state.column) + state.data.watchTarget.kind == "episode" -> state.data.watchTarget + else -> state.data.playTarget?.takeIf { it.kind == "episode" } + } + val type = if (item.kind == "movie") "Movie" else "Series" + CssColumn(modifier.offset(L.safeSide, L.detailCopyTop + D.lineLift).width(L.detailCopyWidth)) { + Row(Modifier.exactLineBox(TitleTextStyles.dLine, bottom = D.lineGap), horizontalArrangement = Arrangement.spacedBy(L.navItemGap), verticalAlignment = Alignment.CenterVertically) { + Glyph(if (item.held) "check" else "sparkle", C.accentText, Modifier.size(D.lineIcon)) + Text(buildString { + append("$type · ${if (item.held) "In your library" else "Not in your library"}") + if (item.held && state.watched) append(" · Watched") + if (!item.held && item.otherOwners.isNotEmpty()) append(" · In ${item.otherOwners.joinToString(" and ")}’s library") + if (state.data.hidden) append(" · Hidden from your picks") + }, color = C.accentText, style = TitleTextStyles.dLine) + } + Text(item.name, color = C.fg, style = TitleTextStyles.displayDetail, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.exactLineBox(TitleTextStyles.displayDetail, bottom = D.titleBottom).testTag("title-heading")) + Row(Modifier.padding(bottom = D.factsBottom).height(D.factsHeight).testTag("title-facts"), horizontalArrangement = Arrangement.spacedBy(D.factsGap), verticalAlignment = Alignment.CenterVertically) { + item.year?.let { Text(it.toString(), color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body)) } + if (item.kind == "movie" && item.runtimeSeconds > 0) Text(item.runtime, color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body)) + if (item.kind == "tv" && item.seasons > 0) Text("${item.seasons} season${if (item.seasons == 1) "" else "s"}", color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body)) + if (state.data.connection != TitleConnection.SignedOut) TitleRatings(item.ratings) + if (item.genres.isNotEmpty()) Text(item.genres.take(3).joinToString(", "), color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body)) + } + state.data.requestedStage?.let { stage -> + val color = when (stage) { "Ready" -> C.ok; "Failed" -> C.fail; "Downloading", "Almost ready" -> C.accent; else -> C.muted } + Row(Modifier.exactLineBox(TitleTextStyles.dLine, bottom = D.lineGap), horizontalArrangement = Arrangement.spacedBy(L.navItemGap), verticalAlignment = Alignment.CenterVertically) { + Glyph(titleStageGlyph(stage), color, Modifier.size(D.lineIcon)) + Text(stage, color = color, style = TitleTextStyles.label) + Text("for ${state.data.requestFor ?: "you"}" + state.data.requestActor?.let { " · by $it" }.orEmpty(), color = C.muted, style = TitleTextStyles.dLine) + } + } + Text((episode ?: item).overview, color = C.muted, style = TitleTextStyles.body, maxLines = 3, overflow = TextOverflow.Ellipsis, modifier = Modifier.exactLineBox(TitleTextStyles.body, bottom = D.overviewGap).testTag("title-overview")) + if (!item.held && state.data.connection != TitleConnection.SignedIn) { + Text(if (state.data.connection == TitleConnection.SignedOut) "Requests need this TV to be signed in to Conductarr." else "Requests need Conductarr, and it can’t be reached right now.", + color = C.warn, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body, bottom = D.overviewGap)) + } + state.data.playTarget?.let { target -> + Row(Modifier.exactLineBox(TitleTextStyles.progress, top = D.progressLift, bottom = D.progressBottom).testTag("title-progress"), horizontalArrangement = Arrangement.spacedBy(D.progressGap), verticalAlignment = Alignment.CenterVertically) { + if (target.resumeSeconds > 0) { + Box(Modifier.size(D.progressWidth, L.progressBar).background(C.progressTrack, RoundedCornerShape(WolfskinTokens.Radius.progress))) { + val fraction = if (target.runtimeSeconds > 0) (target.resumeSeconds.toFloat() / target.runtimeSeconds).coerceIn(0f, 1f) else 0f + Box(Modifier.fillMaxWidth(fraction).fillMaxHeight().background(C.accent)) + } + Text((if (target.kind == "episode") "${target.episodeLabel} · " else "") + "${((target.runtimeSeconds - target.resumeSeconds + 30) / 60).coerceAtLeast(1)} min left", color = C.muted, style = TitleTextStyles.progress) + } else if (target.kind == "episode") Text("Up next: ${target.episodeLabel} · ${target.name}", color = C.muted, style = TitleTextStyles.progress) + if (state.data.connection != TitleConnection.SignedOut) TitleRatings(target.ratings, style = TitleTextStyles.progress) + } + } + Row(Modifier.wrapContentWidth(Alignment.Start, unbounded = true).testTag("title-actions"), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) { + state.options.forEachIndexed { index, option -> TitleControl(option, active && state.column == index, + Modifier.onGloballyPositioned { state.recordCenter("header", index, it.boundsInRoot().center.x) }, + onFocus = { state.focus("header", index) }) { state.click("header", index) } } + } + if (state.options.any { it.icon }) { + val option = state.options.getOrNull(state.column)?.takeIf { active && it.icon } + Text(buildAnnotatedString { + if (option != null) { + withStyle(SpanStyle(color = C.fg, fontWeight = WolfskinTokens.Type.label.fontWeight)) { append(option.label) } + if (option.note.isNotEmpty()) append(" · ${option.note}") + } + }, color = C.muted, style = D.iconCaption, modifier = Modifier.padding(top = D.captionTop).height(L.iconCaptionLine)) + } + } +} + +/** A no-copy load has no focusable content and never draws a spinner. */ +@Composable +fun TitleHeaderSkeleton(modifier: Modifier = Modifier) { + Column(modifier.offset(L.safeSide, L.detailCopyTop)) { + Box(Modifier.size(D.skeletonKickerWidth, D.skeletonKickerHeight).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.cardSheet))) + Spacer(Modifier.height(D.skeletonTitleGap)) + Box(Modifier.size(D.skeletonTitleWidth, D.skeletonTitleHeight).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.card))) + Spacer(Modifier.height(D.skeletonFactsGap)) + Box(Modifier.size(D.skeletonFactsWidth, D.skeletonKickerHeight).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.cardSheet))) + Spacer(Modifier.height(D.skeletonOverviewGap)) + Box(Modifier.size(D.skeletonOverviewWidth, D.skeletonOverviewHeight).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.card))) + Spacer(Modifier.height(D.overviewGap)) + Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) { + Box(Modifier.size(D.skeletonPlayWidth, L.buttonHeight).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.button))) + repeat(2) { Box(Modifier.size(L.iconButton).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.button))) } + } + } +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleModel.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleModel.kt new file mode 100644 index 00000000..4630ce75 --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleModel.kt @@ -0,0 +1,116 @@ +package org.justinvisser.wolfskin.design + +import androidx.compose.runtime.Immutable + +/** Screen data is independent of Jellyfin and Conductarr wire objects. */ +@Immutable +data class TitleItem( + override val id: String, + val name: String, + val kind: String, + val year: Int? = null, + val runtimeSeconds: Long = 0, + val seasons: Int = 0, + val overview: String = "", + val genres: List = emptyList(), + override val poster: String? = null, + val backdrop: String? = null, + val held: Boolean = false, + val watched: Boolean = false, + val resumeSeconds: Long = 0, + val seriesId: String? = null, + val seriesName: String? = null, + val season: Int = 0, + val episode: Int = 0, + override val ratings: List = emptyList(), + val character: String? = null, + val otherOwners: List = emptyList(), + val isPosterBackdrop: Boolean = false, + val tmdbId: Long? = null, +) : PosterContent { + override val posterTitle: String get() = name + override val posterIsMovie: Boolean get() = kind == "movie" + override val progress: Float get() = if (runtimeSeconds > 0) resumeSeconds.toFloat() / runtimeSeconds else 0f + override val badge: String? get() = if (held) "held" else null + val episodeLabel: String get() = "S$season E$episode" + val runtime: String get() { + val minutes = (runtimeSeconds + 30) / 60 + return if (minutes >= 60) "${minutes / 60}h ${minutes % 60}m" else "${minutes}m" + } +} + +/** A cast identity can open a performer page without borrowing a title's identity. */ +@Immutable +data class TitlePerson(val id: String, val name: String, val character: String = "", val portrait: String? = null, val biography: String = "") + +/** Stream indexes remain the server's indexes, including non-contiguous track lists. */ +@Immutable +data class TitleTrack(val index: Int, val label: String, val isDefault: Boolean = false) + +/** Playback options describe the actual play target, not a season or recommendation stub. */ +@Immutable +data class TitleVersion( + val id: String, + val name: String, + val video: String, + val bitrate: String, + val size: String, + val container: String, + val audio: List, + val subtitles: List, +) + +enum class TitleReadStatus { Ready, Loading, Unavailable } +enum class TitleConnection { SignedIn, SignedOut, Offline } +enum class TitleSheet { Version, Tracks, MediaInfo, ConfirmWatched, HiddenReceipt } + +/** A title read includes related titles and the selected season; reads happen outside composition. */ +@Immutable +data class TitlePageData( + val item: TitleItem, + val playTarget: TitleItem? = null, + val watchTarget: TitleItem = item, + val versions: List = emptyList(), + val selectedVersion: String? = null, + val selectedAudio: Int? = null, + val selectedSubtitle: Int = -1, + val trailerUrl: String? = null, + val cast: List = emptyList(), + val castStatus: TitleReadStatus = TitleReadStatus.Ready, + val related: List = emptyList(), + val episodes: List = emptyList(), + val status: TitleReadStatus = TitleReadStatus.Ready, + val connection: TitleConnection = TitleConnection.SignedOut, + val requestedStage: String? = null, + val requestActor: String? = null, + val hidden: Boolean = false, + val pick: Boolean = false, + val savedAt: String? = null, + val seasonNumbers: List = List(item.seasons) { it + 1 }, + val selectedSeason: Int? = null, + val episodesStatus: TitleReadStatus = TitleReadStatus.Ready, + val requestsAllowed: Boolean = true, + val requestFor: String? = null, + val watchReady: Boolean = true, +) + +/** Effects are handled by the app's authenticated facade and navigation layer. */ +sealed interface TitleAction { + data class Play(val item: TitleItem, val restart: Boolean = false) : TitleAction + data class Version(val item: TitleItem, val version: TitleVersion) : TitleAction + data class Track(val item: TitleItem, val version: TitleVersion, val audio: Int?, val subtitle: Int) : TitleAction + data class Watched(val item: TitleItem, val watched: Boolean) : TitleAction + data class Trailer(val url: String) : TitleAction + data class Person(val person: TitlePerson) : TitleAction + data class OpenTitle(val item: TitleItem, val episodeSelection: Boolean = false) : TitleAction + data class Season(val number: Int) : TitleAction + data class Request(val item: TitleItem, val forSomeoneElse: Boolean) : TitleAction + data class Hide(val item: TitleItem, val hidden: Boolean) : TitleAction + data class Requests(val item: TitleItem) : TitleAction + data object Retry : TitleAction + data object Pop : TitleAction +} + +/** The options row distinguishes compact icons from labelled primary and secondary pills. */ +@Immutable +data class TitleOption(val id: String, val label: String, val glyph: String, val note: String = "", val primary: Boolean = false, val icon: Boolean = true, val on: Boolean = false) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleOptionsSheet.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleOptionsSheet.kt new file mode 100644 index 00000000..3a87dcdd --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleOptionsSheet.kt @@ -0,0 +1,170 @@ +package org.justinvisser.wolfskin.design + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusable +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.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription +import androidx.tv.material3.Text +import androidx.compose.ui.draw.dropShadow +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.shadow.Shadow +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.layout.layout +import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C +import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L +import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D + +/** Sheets sit above navigation and consume pointer and key input across the entire scrim. */ +@Composable +fun TitleOptionsSheet(state: TitleState, shell: ShellState) { + val focus = remember { FocusRequester() } + LaunchedEffect(state.sheet) { focus.requestFocus() } + val shadow = remember { Shadow(radius = D.sheetShadowRadius, offset = DpOffset.Zero.copy(x = D.sheetShadowX), color = Color.Black.copy(alpha = D.sheetShadowOpacity)) } + Box(Modifier.fillMaxSize().background(C.sheetScrim).clickable { } + .testTag("title-sheet").semantics { stateDescription = "${state.sheet}:${state.sheetIndex}" } + .focusRequester(focus).onPreviewKeyEvent { event -> + val key = readShellKey(event) + key != null && state.key(key, event.type == KeyEventType.KeyDown, event.nativeKeyEvent.repeatCount, shell) + }.focusable()) { + CssColumn(Modifier.align(Alignment.TopEnd).width(L.sheetWidth).fillMaxHeight().dropShadow(RectangleShape, shadow).background(C.surface) + .verticalScroll(rememberScrollState()).padding(horizontal = D.sheetPaddingSide, vertical = D.sheetPaddingTop), + gap = if (state.sheet == TitleSheet.Tracks) D.trackGap else if (state.sheet == TitleSheet.MediaInfo) D.infoGap else D.sheetGap) { + when (state.sheet) { + TitleSheet.Version -> VersionChoices(state) + TitleSheet.Tracks -> TrackChoices(state) + TitleSheet.MediaInfo -> MediaInformation(state) + TitleSheet.ConfirmWatched -> WatchedConfirmation(state) + TitleSheet.HiddenReceipt -> HiddenReceipt(state) + null -> Unit + } + } + } +} + +@Composable +private fun VersionChoices(state: TitleState) { + Text("Version", color = C.fg, style = TitleTextStyles.sheetTitle, modifier = Modifier.exactLineBox(TitleTextStyles.sheetTitle)) + SheetSubtitle("${state.target?.name ?: state.data.item.name} · ${state.data.versions.size} files") + state.data.versions.forEachIndexed { index, version -> + PlaybackChoice(version.name, listOf(version.video, version.bitrate, version.size).filter { it.isNotBlank() }.joinToString(" · "), version == state.version, state.sheetIndex == index, { state.sheetIndex = index }) { state.clickSheet(index) } + } + Text("Remembered for ${state.person.name} on this title.", color = C.dim, style = TitleTextStyles.caption, modifier = Modifier.exactLineBox(TitleTextStyles.caption)) +} + +@Composable +private fun TrackChoices(state: TitleState) { + val version = state.version ?: return + Text("Audio and subtitles", color = C.fg, style = TitleTextStyles.sheetTitle, modifier = Modifier.exactLineBox(TitleTextStyles.sheetTitle)) + SheetSubtitle("${state.target?.let { if (it.kind == "episode") "${it.seriesName} · ${it.episodeLabel}" else it.name } ?: state.data.item.name} · ${version.name}") + Text("Audio", color = C.dim, style = TitleTextStyles.label, modifier = Modifier.exactLineBox(TitleTextStyles.label, top = D.sheetLabelTop)) + version.audio.forEachIndexed { index, track -> + PlaybackChoice(track.label, null, track.index == state.audio?.index, state.sheetIndex == index, { state.sheetIndex = index }) { state.clickSheet(index) } + } + Text("Subtitles", color = C.dim, style = TitleTextStyles.label, modifier = Modifier.exactLineBox(TitleTextStyles.label, top = D.sheetLabelTop)) + val start = version.audio.size + PlaybackChoice("Off", null, state.subtitleIndex == -1, state.sheetIndex == start, { state.sheetIndex = start }) { state.clickSheet(start) } + version.subtitles.forEachIndexed { index, track -> + val position = start + index + 1 + PlaybackChoice(track.label, null, track.index == state.subtitleIndex, state.sheetIndex == position, { state.sheetIndex = position }) { state.clickSheet(position) } + } + val done = start + version.subtitles.size + 1 + TitleControl(TitleOption("done", "Done", "", primary = true, icon = false), state.sheetIndex == done, Modifier.padding(top = D.sheetButtonTop).sheetFocused(state.sheetIndex == done), onFocus = { state.sheetIndex = done }) { state.clickSheet(done) } +} + +@Composable +private fun PlaybackChoice(label: String, why: String?, selected: Boolean, focused: Boolean, onFocus: () -> Unit, onClick: () -> Unit) { + val foreground = if (focused) C.onFocus else C.fg + val sizing = if (why == null) Modifier.height(D.trackHeight) else Modifier.padding(vertical = D.versionRowVertical) + Row(Modifier.fillMaxWidth().sheetFocused(focused).titlePointerFocus(onFocus).focusScale(focused, WolfskinTokens.Focus.scaleRow) + .background(if (focused) C.fg else C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.listRow)) + .clickable(onClick = onClick).then(sizing).padding(horizontal = D.sheetRowSide), + horizontalArrangement = Arrangement.spacedBy(D.personGap), verticalAlignment = Alignment.CenterVertically) { + CssColumn(Modifier.weight(1f)) { + Text(label, color = foreground, style = TitleTextStyles.listTitle, modifier = Modifier.exactLineBox(TitleTextStyles.listTitle)) + if (why != null) Text(why, color = foreground.copy(alpha = .8f), style = TitleTextStyles.caption, modifier = Modifier.exactLineBox(TitleTextStyles.caption)) + } + TitleRadio(selected, focused) + } +} + +@Composable +private fun MediaInformation(state: TitleState) { + val version = state.version ?: return + Text("Media info", color = C.fg, style = TitleTextStyles.sheetTitle, modifier = Modifier.exactLineBox(TitleTextStyles.sheetTitle)) + SheetSubtitle(state.target?.let { if (it.kind == "episode") "${it.seriesName} · ${it.episodeLabel}" else it.name } ?: state.data.item.name) + MediaInfoSection("General", listOf("Container" to version.container, "Size" to version.size.ifEmpty { "not known" }, "Bitrate" to version.bitrate, "Runtime" to (state.target?.runtime ?: "not known"))) + MediaInfoSection("Video", listOf("Stream" to version.video)) + MediaInfoSection("Audio", version.audio.mapIndexed { index, track -> "Track ${index + 1}" to (track.label + if (track.isDefault) " (default)" else "") }) + MediaInfoSection("Subtitles", version.subtitles.mapIndexed { index, track -> "Track ${index + 1}" to track.label }) + TitleControl(TitleOption("close", "Close", "", primary = true, icon = false), true, Modifier.padding(top = D.sheetButtonTop).sheetFocused(true), onFocus = { state.sheetIndex = 0 }) { state.clickSheet(0) } +} + +@Composable +private fun MediaInfoSection(label: String, values: List>) { + Text(label, color = C.dim, style = TitleTextStyles.label, modifier = Modifier.exactLineBox(TitleTextStyles.label, top = D.sheetLabelTop)) + CssColumn(gap = D.infoRowGap) { values.forEach { (name, value) -> + Row(Modifier.fillMaxWidth().exactLineBox(TitleTextStyles.caption), horizontalArrangement = Arrangement.spacedBy(D.infoColumnGap)) { + Text(name, color = C.dim, style = TitleTextStyles.infoKey, modifier = Modifier.width(D.infoLabelWidth)) + Text(value, color = C.fg, style = TitleTextStyles.caption) + } + } } +} + +@Composable +private fun WatchedConfirmation(state: TitleState) { + val verb = if (state.watched) "unwatched" else "watched" + Text("Mark the whole series as $verb?", color = C.fg, style = TitleTextStyles.sheetTitle, modifier = Modifier.exactLineBox(TitleTextStyles.sheetTitle)) + SheetSubtitle("Every episode of ${state.data.item.name} changes for ${state.person.name}.") + Row(Modifier.padding(top = D.sheetButtonTop), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) { + TitleControl(TitleOption("confirm", "Mark as $verb", "", primary = true, icon = false), state.sheetIndex == 0, Modifier.sheetFocused(state.sheetIndex == 0), onFocus = { state.sheetIndex = 0 }) { state.clickSheet(0) } + TitleControl(TitleOption("cancel", "Cancel", "", icon = false), state.sheetIndex == 1, Modifier.sheetFocused(state.sheetIndex == 1), onFocus = { state.sheetIndex = 1 }, overArtwork = false) { state.clickSheet(1) } + } +} + +@Composable +private fun SheetSubtitle(text: String) { + Text(text, color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body, top = D.sheetSubLift, bottom = D.sheetSubBottom)) +} + +@Composable +private fun HiddenReceipt(state: TitleState) { + Column(Modifier.fillMaxWidth().background(C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.panel)).padding(horizontal = D.receiptSide, vertical = D.receiptTop), + verticalArrangement = Arrangement.spacedBy(D.receiptGap)) { + Row(horizontalArrangement = Arrangement.spacedBy(D.iconGap), verticalAlignment = Alignment.CenterVertically) { + Glyph("check", C.fg, Modifier.size(D.receiptIcon)) + Text("Hidden from your picks", color = C.fg, style = TitleTextStyles.receiptTitle) + } + Text("${state.data.item.name} leaves every row of ${state.person.name}’s picks. It waits under Hidden picks at the end of Discover.", color = C.muted, style = TitleTextStyles.body) + } + Row(Modifier.padding(top = D.sheetButtonTop), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) { + TitleControl(TitleOption("undo", "Undo", "", primary = true, icon = false), state.sheetIndex == 0, Modifier.sheetFocused(state.sheetIndex == 0), onFocus = { state.sheetIndex = 0 }) { state.clickSheet(0) } + TitleControl(TitleOption("done", "Done", "", icon = false), state.sheetIndex == 1, Modifier.sheetFocused(state.sheetIndex == 1), onFocus = { state.sheetIndex = 1 }, overArtwork = false) { state.clickSheet(1) } + } +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePage.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePage.kt new file mode 100644 index 00000000..c683c8e7 --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePage.kt @@ -0,0 +1,223 @@ +package org.justinvisser.wolfskin.design + +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.boundsInRoot +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusable +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.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import kotlinx.coroutines.delay +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.foundation.gestures.animateScrollBy +import androidx.compose.animation.core.tween +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.Modifier +import androidx.compose.ui.Alignment +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.unit.Dp +import androidx.tv.material3.Text +import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C +import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L +import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D + +/** The shared shell remains authoritative; title sheets overlay it and trap all input. */ +@Composable +fun TitleSurface(state: TitleState, shell: ShellState, modifier: Modifier = Modifier) { + val view = shell.view + LaunchedEffect(view, state.data.savedAt) { view.savedAt = state.data.savedAt } + Box(modifier.fillMaxSize().onPreviewKeyEvent { event -> + // No content target exists during an initial load; nav Down must not enter a skeleton. + if (state.data.status != TitleReadStatus.Loading || shell.pickerVisible || shell.requestsVisible) false + else when (readShellKey(event)) { + ShellKey.Down -> true + ShellKey.Back -> { + if (event.type == KeyEventType.KeyDown && event.nativeKeyEvent.repeatCount == 0) state.back(shell) + true + } + else -> false + } + }) { + val focusOwner = remember(shell) { { shell.navFocused = false } } + androidx.compose.runtime.CompositionLocalProvider(LocalTitleFocusOwner provides focusOwner) { + ShellSurface(shell, handlesContentKeys = false) { TitlePage(state, shell, it) } + } + if (!shell.pickerVisible && state.sheet != null) TitleOptionsSheet(state, shell) + if (!shell.pickerVisible) TitleNotice(state.toast, Modifier.align(Alignment.TopEnd)) { state.toast = null } + } +} + +/** Playback and person reads share one calm, timed notice above the persistent nav. */ +@Composable +internal fun TitleNotice(message: String?, modifier: Modifier = Modifier, onDismiss: () -> Unit) { + LaunchedEffect(message) { + if (message != null) { delay(WolfskinTokens.Motion.toast.toLong()); onDismiss() } + } + if (message != null) RecoveryToast(message, modifier) +} + +/** One keyed vertical frame renders the exact header, season, cast and related order. */ +@Composable +fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifier) { + val density = LocalDensity.current + val list = shell.view.listState + var wheelPending by remember { mutableStateOf(false) } + val focus = remember { FocusRequester() } + var measuredHeaderHeight by remember(state.data.item.id) { mutableStateOf(L.row_dhead) } + val compactSeries = state.data.status == TitleReadStatus.Ready && state.data.item.held && + state.data.item.kind == "tv" && state.data.item.seasons > 0 + val headerHeight = if (compactSeries) measuredHeaderHeight else L.row_dhead + LaunchedEffect(state.sheet, shell.navFocused, shell.pickerVisible) { + if (state.sheet == null && !shell.navFocused && !shell.pickerVisible) focus.requestFocus() + } + val displayedRows = remember(state.data) { + buildList { + add("header") + if (state.data.status != TitleReadStatus.Ready) return@buildList + if (state.data.item.held && state.data.item.kind == "tv" && state.data.item.seasons > 0) { + add("seasons") + if (state.data.episodesStatus == TitleReadStatus.Loading || state.data.episodes.isNotEmpty()) add("episodes") + } + if (state.data.castStatus == TitleReadStatus.Loading || state.data.cast.isNotEmpty()) add("cast") + if (state.data.related.isNotEmpty()) add("related") + } + } + val rowOffsets = remember(displayedRows, density, headerHeight) { + FloatArray(displayedRows.size + 1).apply { + displayedRows.forEachIndexed { index, row -> this[index + 1] = this[index] + with(density) { titleRowHeight(row, headerHeight).toPx() } } + } + } + val position by remember(list, rowOffsets) { derivedStateOf { + rowOffsets[list.firstVisibleItemIndex.coerceAtMost(rowOffsets.lastIndex)] + list.firstVisibleItemScrollOffset + } } + LaunchedEffect(state.row, shell.navFocused, state.data.status, displayedRows, rowOffsets) { + if (state.data.status == TitleReadStatus.Loading) shell.focusNav(shell.currentTab) + val index = if (shell.navFocused) 0 else displayedRows.indexOf(state.row).coerceAtLeast(0) + val y = rowOffsets[index] + val height = with(density) { titleRowHeight(state.row, headerHeight).toPx() } + val target = if (index == 0 || (y < with(density) { L.windowAhead.toPx() } && y + height <= with(density) { (WolfskinTokens.Shell.stageHeight - L.safeTopPicker).toPx() })) 0f + else y - with(density) { L.pinnedLine.toPx() } + if (target != position) list.animateScrollBy(target - position, titleFrameScrollSpec) + } + val dim by remember(rowOffsets, list) { derivedStateOf { (position / D.dimScrollPx).coerceAtMost(D.dimMaximum) } } + val lastVisible by remember(list) { derivedStateOf { list.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1 } } + val nextArtRow = remember(displayedRows, lastVisible) { if (lastVisible < 0) null else displayedRows.drop(lastVisible + 1).firstOrNull { it == "cast" || it == "episodes" || it == "related" } } + TitleRowPrefetch(state.data, nextArtRow) + Box(modifier.focusRequester(focus).testTag("title-page").semantics { stateDescription = "${state.data.item.id}:${state.row}:${state.column}:${state.sheet}" } + .titleWheel { if (state.data.status == TitleReadStatus.Ready) { wheelPending = true; shell.navFocused = false } } + .onPreviewKeyEvent { event -> + val key = readShellKey(event) + if (key != null && key != ShellKey.Back && event.type == KeyEventType.KeyDown && wheelPending) { + val visible = list.layoutInfo.visibleItemsInfo.firstOrNull { + it.key in state.rows && it.offset + it.size > with(density) { L.pinnedLine.roundToPx() } + }?.key as? String ?: "header" + state.focus(visible, state.columns[visible] ?: 0) + wheelPending = false + if (key != ShellKey.Ok) return@onPreviewKeyEvent true + } + key != null && state.key(key, event.type == KeyEventType.KeyDown, event.nativeKeyEvent.repeatCount, shell) + }.focusable()) { + // This remains visible behind the scrolled rows; it is not an off-screen header image. + TitleBackdrop(state.data.item, Modifier.fillMaxSize()) + Box(Modifier.fillMaxSize().graphicsLayer { alpha = dim }.background(C.bg)) + if (state.data.status == TitleReadStatus.Unavailable) TitleReadError(state) + else LazyColumn(state = list, modifier = Modifier.fillMaxSize()) { + displayedRows.forEach { id -> item(key = id, contentType = if (id == "header") "header" else if (id == "seasons") "chips" else "shelf") { + val active = state.row == id && !shell.navFocused && state.sheet == null + when (id) { + "header" -> Box(Modifier.height(headerHeight)) { + TitleHeader(state, active, if (compactSeries) Modifier.onSizeChanged { size -> + measuredHeaderHeight = L.detailCopyTop + D.lineLift + with(density) { size.height.toDp() } + D.seriesHeaderGap + } else Modifier) + } + "seasons" -> TitleSeasons(state, active) + "episodes" -> TitleMediaShelf(state, true, active) + "cast" -> TitleCast(state, active) + "related" -> TitleMediaShelf(state, false, active) + } + } } + item(key = "end-padding", contentType = "spacer") { + Spacer(Modifier.height((WolfskinTokens.Shell.stageHeight - L.pinnedLine - titleRowHeight(displayedRows.last(), headerHeight)).coerceAtLeast(D.minimumEndPadding))) + } + } + } +} + +private fun titleRowHeight(row: String, headerHeight: Dp): Dp = when (row) { + "header" -> headerHeight + "seasons" -> L.row_chips + "episodes" -> L.rowHeadingBand + D.episodeCardHeight + L.rowGapAfter + "cast" -> D.castHeight + else -> L.rowHeadingBand + WolfskinTokens.Card.posterH + WolfskinTokens.Card.posterCaptionBand + L.rowGapAfter +} + +@Composable +private fun TitleSeasons(state: TitleState, active: Boolean) { + val focusOwner = LocalTitleFocusOwner.current + val list = androidx.compose.foundation.lazy.rememberLazyListState() + val visibleChip = if (active) state.column else state.data.seasonNumbers.indexOf(state.season) + LaunchedEffect(visibleChip, state.data.seasonNumbers) { + if (visibleChip >= 0) list.animateScrollToItem(visibleChip) + } + androidx.compose.foundation.lazy.LazyRow(Modifier.height(L.row_chips).padding(top = D.chipTop), state = list, contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = L.safeSide), horizontalArrangement = Arrangement.spacedBy(D.chipGap)) { + items(state.data.seasonNumbers.size, key = { "season-${state.data.seasonNumbers[it]}" }, contentType = { "chip" }) { index -> + val number = state.data.seasonNumbers[index] + val focused = active && state.column == index + val selected = state.season == number + val shape = RoundedCornerShape(WolfskinTokens.Radius.chip) + Box(Modifier.height(D.chipHeight).titlePointerFocus { state.focus("seasons", index) }.focusScale(focused, WolfskinTokens.Focus.scaleControl) + .onGloballyPositioned { state.recordCenter("seasons", index, it.boundsInRoot().center.x) } + .background(if (focused) C.fg else if (selected) C.accentSoft else C.chipRest, shape) + .then(if (selected && !focused) Modifier.border(D.chipBorder, C.accent, shape) else Modifier) + .clickable { focusOwner?.invoke(); state.click("seasons", index) }.padding(horizontal = D.chipPadding), contentAlignment = Alignment.Center) { + Text("Season $number", color = if (focused) C.onFocus else if (selected) C.accentText else C.fg, style = WolfskinTokens.Type.label) + } + } + } +} + +@Composable +private fun TitleReadError(state: TitleState) { + Column(Modifier.offset(L.safeSide, D.errorTop).width(L.stateWidth) + .background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.panel)) + .border(WolfskinTokens.SavedIndicator.border, C.border, RoundedCornerShape(WolfskinTokens.Radius.panel)) + .padding(WolfskinTokens.SavedIndicator.border) + .padding(horizontal = D.errorSide, vertical = D.errorVertical)) { + Row(Modifier.padding(bottom = D.errorHeadingBottom), horizontalArrangement = Arrangement.spacedBy(D.iconGap), verticalAlignment = Alignment.CenterVertically) { + Glyph("alert", C.warn, Modifier.size(D.receiptIcon)) + Text(if (state.data.item.kind == "person") "This person can’t be read" else "This title can’t be read", color = C.fg, style = TitleTextStyles.errorTitle) + } + Text("${if (state.data.connection == TitleConnection.SignedOut) "Jellyfin" else "Conductarr"} didn’t answer, and this TV has no saved copy of the page yet.", color = C.muted, style = TitleTextStyles.body, modifier = Modifier.padding(bottom = D.errorBodyBottom)) + Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) { state.options.forEachIndexed { index, option -> TitleControl(option, state.column == index, onFocus = { state.focus("header", index) }, overArtwork = false) { state.click("header", index) } } } + } +} + +internal val titleFrameScrollSpec = tween(WolfskinTokens.Motion.scroll, easing = CubicBezierEasing(.33f, 1f, .68f, 1f)) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePointer.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePointer.kt new file mode 100644 index 00000000..ab763b8a --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePointer.kt @@ -0,0 +1,41 @@ +package org.justinvisser.wolfskin.design + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.pointerInput + +/** Pointing at owned content exits nav focus through the same shell state as a remote Down. */ +val LocalTitleFocusOwner = compositionLocalOf<(() -> Unit)?> { null } + +/** Hover changes logical focus without activating a button or arming an OK press. */ +@Composable +fun Modifier.titlePointerFocus(onFocus: (() -> Unit)?): Modifier { + if (onFocus == null) return this + val latest by rememberUpdatedState(onFocus) + val owner = LocalTitleFocusOwner.current + return pointerInput(owner) { + awaitPointerEventScope { + while (true) { + val event = awaitPointerEvent() + if (event.type == PointerEventType.Enter) { owner?.invoke(); latest() } + } + } + } +} + +/** Observe wheel input without consuming the frame's native scroll gesture. */ +@Composable +fun Modifier.titleWheel(onScroll: () -> Unit): Modifier { + val latest by rememberUpdatedState(onScroll) + return pointerInput(Unit) { + awaitPointerEventScope { + while (true) { + if (awaitPointerEvent().type == PointerEventType.Scroll) latest() + } + } + } +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePrefetch.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePrefetch.kt new file mode 100644 index 00000000..d4a956fc --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePrefetch.kt @@ -0,0 +1,32 @@ +package org.justinvisser.wolfskin.design + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.ui.platform.LocalContext +import coil3.request.Disposable + +/** Read the next hidden art row into the bounded loader, cancelling work when the window changes. */ +@Composable +internal fun TitleRowPrefetch(data: TitlePageData, row: String?) { + val context = LocalContext.current + if (row == null || data.status != TitleReadStatus.Ready) return + if (LocalTitleBitmaps.current.isNotEmpty()) return + val loader = WolfskinImages.loader(context) + DisposableEffect(loader, context, row, data.cast, data.episodes, data.related) { + val requests = ArrayList() + fun queue(path: String?, width: Int, height: Int, poster: Boolean = false) { + if (path != null) requests.add(loader.enqueue(WolfskinImages.request(context, path, width, height, poster))) + } + when (row) { + "cast" -> for (index in 0 until minOf(data.cast.size, WolfskinTokens.Card.personSlots)) + queue(data.cast[index].portrait, WolfskinTokens.Image.castRequestWidth, WolfskinTokens.Image.castRequestHeight) + "episodes" -> for (index in 0 until minOf(data.episodes.size, WolfskinTokens.Card.tileSlots)) { + val item = data.episodes[index] + queue(if (item.isPosterBackdrop) item.poster else item.backdrop, WolfskinTokens.Image.episodeRequestWidth, WolfskinTokens.Image.episodeRequestHeight) + } + "related" -> for (index in 0 until minOf(data.related.size, WolfskinTokens.Card.posterSlots)) + queue(data.related[index].poster, WolfskinTokens.Image.posterRequestWidth, WolfskinTokens.Image.posterRequestHeight, true) + } + onDispose { requests.forEach { it.dispose() } } + } +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleSheetFocus.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleSheetFocus.kt new file mode 100644 index 00000000..d6c46049 --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleSheetFocus.kt @@ -0,0 +1,16 @@ +package org.justinvisser.wolfskin.design + +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.foundation.relocation.bringIntoViewRequester +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier + +/** Logical remote focus must reveal its row even though Android focus stays on the modal owner. */ +@Composable +internal fun Modifier.sheetFocused(focused: Boolean): Modifier { + val request = remember { BringIntoViewRequester() } + LaunchedEffect(focused) { if (focused) request.bringIntoView() } + return bringIntoViewRequester(request) +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleShelf.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleShelf.kt new file mode 100644 index 00000000..6d7752ae --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleShelf.kt @@ -0,0 +1,166 @@ +package org.justinvisser.wolfskin.design + +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.core.tween +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.boundsInRoot +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.zIndex +import androidx.tv.material3.Text +import kotlin.math.roundToInt +import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C +import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L +import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D + +/** Cast occupies the first shelf after films, and follows season episodes on series. */ +@Composable +fun TitleCast(state: TitleState, active: Boolean) { + val track = rememberLazyListState() + val focusOwner = LocalTitleFocusOwner.current + val pitch = with(LocalDensity.current) { WolfskinTokens.Card.personPitch.toPx() } + LaunchedEffect(state.column, active, state.data.cast.size) { + if (active) track.keepColumnInWindow(state.column, WolfskinTokens.Card.personSlots, pitch, state.data.cast.size) + } + Box(Modifier.height(D.castHeight)) { + Text("Cast", color = C.fg, style = WolfskinTokens.Type.rowHeading, modifier = Modifier.padding(start = L.safeSide).height(L.rowHeadingBand)) + LazyRow(state = track, modifier = Modifier.fillMaxWidth().offset(y = L.rowHeadingBand - L.rowGapAfter).height(WolfskinTokens.Card.personH + L.rowGapAfter * 2), + contentPadding = PaddingValues(horizontal = L.safeSide, vertical = L.rowGapAfter), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.personGap)) { + if (state.data.castStatus == TitleReadStatus.Loading) { + items(WolfskinTokens.Card.personSlots, key = { "cast-skeleton-$it" }, contentType = { "skeleton" }) { + Box(Modifier.size(L.castCircle).background(C.surface, CircleShape)) + } + } else itemsIndexed(state.data.cast, key = { _, person -> person.id }, contentType = { _, _ -> "person" }) { index, person -> + val focused = active && state.column == index + Box(Modifier.size(WolfskinTokens.Card.personW, WolfskinTokens.Card.personH).zIndex(if (focused) 1f else 0f) + .onGloballyPositioned { state.recordCenter("cast", index, it.boundsInRoot().center.x) } + .titlePointerFocus { state.focus("cast", index) }.focusScale(focused, WolfskinTokens.Focus.scaleCard).clickable { focusOwner?.invoke(); state.click("cast", index) }.testTag("title-cast-${person.id}")) { + PersonPortrait(person, Modifier.size(L.castCircle).focusRing(focused, L.castCircle / 2, WolfskinTokens.Focus.ringCardRing, FocusLift.Card).clip(CircleShape)) + Column(Modifier.offset(x = -D.castNameSide, y = D.castNameTop).wrapContentWidth(Alignment.Start, unbounded = true).width(L.castCircle + D.castNameSide * 2), horizontalAlignment = Alignment.CenterHorizontally) { + Text(person.name, color = C.fg, style = TitleTextStyles.castName, modifier = Modifier.exactLineBox(TitleTextStyles.castName), maxLines = 1, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center) + Text(person.character, color = C.muted, style = TitleTextStyles.caption, modifier = Modifier.exactLineBox(TitleTextStyles.caption), maxLines = 1, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center) + } + } + } + } + } +} + +/** Episode and related shelves are lazy, keyed and keep their focused card above adjacent art. */ +@Composable +fun TitleMediaShelf(state: TitleState, episodes: Boolean, active: Boolean) { + val titles = if (episodes) state.data.episodes else state.data.related + val id = if (episodes) "episodes" else "related" + val width = if (episodes) WolfskinTokens.Card.tileW else WolfskinTokens.Card.posterW + val height = if (episodes) D.episodeCardHeight else WolfskinTokens.Card.posterH + val track = rememberLazyListState() + val visible = if (episodes) WolfskinTokens.Card.tileSlots else WolfskinTokens.Card.posterSlots + val pitch = with(LocalDensity.current) { (if (episodes) WolfskinTokens.Card.tilePitch else WolfskinTokens.Card.posterPitch).toPx() } + LaunchedEffect(state.column, active, titles.size) { + if (active) track.keepColumnInWindow(state.column, visible, pitch, titles.size) + } + Box(Modifier.height(L.rowHeadingBand + height + L.rowGapAfter + if (episodes) WolfskinTokens.Shell.zero else WolfskinTokens.Card.posterCaptionBand)) { + Text(if (episodes) "Season ${state.season}" else "More like this", color = C.fg, style = WolfskinTokens.Type.rowHeading, modifier = Modifier.padding(start = L.safeSide).height(L.rowHeadingBand)) + LazyRow(state = track, modifier = Modifier.fillMaxWidth().offset(y = L.rowHeadingBand - L.rowGapAfter) + .height(height + (if (episodes) WolfskinTokens.Shell.zero else WolfskinTokens.Card.posterCaptionBand) + L.rowGapAfter * 2), + contentPadding = PaddingValues(horizontal = L.safeSide, vertical = L.rowGapAfter), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.posterGap)) { + if (episodes && state.data.episodesStatus == TitleReadStatus.Loading) { + items(visible, key = { "episode-skeleton-$it" }, contentType = { "skeleton" }) { + Box(Modifier.size(width, height).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.card))) + } + } else itemsIndexed(titles, key = { _, item -> item.id }, contentType = { _, _ -> if (episodes) "tile" else "poster" }) { index, item -> + TitlePoster(item, active && state.column == index, Modifier.width(width) + .onGloballyPositioned { state.recordCenter(id, index, it.boundsInRoot().center.x) }, + episodes, state.data.connection != TitleConnection.SignedOut, onFocus = { state.focus(id, index) }) { state.click(id, index) } + } + } + } +} + +/** Posters draw art at rest, with facts only in the reserved focused-caption band. */ +@Composable +fun TitlePoster(item: TitleItem, focused: Boolean, modifier: Modifier = Modifier, tile: Boolean = false, ratingsVisible: Boolean = true, catalogue: Boolean = false, onFocus: (() -> Unit)? = null, onClick: () -> Unit) { + val focusOwner = LocalTitleFocusOwner.current + Column(modifier.zIndex(if (focused) 1f else 0f)) { + if (!tile) { + val bitmaps = LocalTitleBitmaps.current + val offlineArt = androidx.compose.runtime.remember(bitmaps) { if (bitmaps.isEmpty()) null else { path: String -> bitmaps[path] } } + LibraryPoster(item, focused, focused && ratingsVisible, catalogue = catalogue, + modifier = Modifier.titlePointerFocus(onFocus).clickable { focusOwner?.invoke(); onClick() }, offlineArt = offlineArt) + } else Box(Modifier.size(WolfskinTokens.Card.tileW, D.episodeCardHeight).titlePointerFocus(onFocus).focusScale(focused, WolfskinTokens.Focus.scaleCard) + .focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardRing, FocusLift.Card) + .clip(RoundedCornerShape(WolfskinTokens.Radius.card)).background(C.surface2).clickable { focusOwner?.invoke(); onClick() }) { + TitleTileArtwork(item) + Column(Modifier.align(Alignment.BottomStart).fillMaxWidth().background(Brush.verticalGradient(listOf(C.bg.copy(alpha = 0f), C.bg.copy(alpha = D.cardOverlayOpacity)))) + .padding(start = D.tileOverlaySide, end = D.tileOverlaySide, top = D.tileOverlayTop, bottom = D.tileOverlayBottom)) { + Text(item.seriesName ?: item.name, color = C.fg, style = WolfskinTokens.Type.button, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text("${item.episodeLabel} · ${item.name}", color = C.muted, style = WolfskinTokens.Type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis) + if (focused && item.overview.isNotBlank()) Text(item.overview, color = C.muted, style = WolfskinTokens.Type.caption, maxLines = 2, overflow = TextOverflow.Ellipsis) + } + val progress = item.progress + if (progress > 0f && progress < 1f) { + Box(Modifier.align(Alignment.BottomCenter).padding(start = D.cardProgressInset, end = D.cardProgressInset, bottom = D.tileProgressBottom) + .fillMaxWidth().height(L.progressBar).clip(RoundedCornerShape(WolfskinTokens.Radius.progress)).background(C.progressTrack)) { + Box(Modifier.fillMaxWidth(progress).fillMaxHeight().background(C.accent)) + } + } + androidx.compose.animation.AnimatedVisibility(focused && ratingsVisible, enter = fadeIn(tween(WolfskinTokens.Motion.base)), exit = fadeOut(tween(WolfskinTokens.Motion.base))) { + LibraryRatingPlate(item.ratings, Modifier.fillMaxWidth()) + } + } + if (!tile) { + val availability = androidx.compose.runtime.remember(item, catalogue) { + if (catalogue) { + if (item.held) "In your library" else item.otherOwners.takeIf { it.isNotEmpty() }?.let { "In ${it.joinToString(" and ")}’s library" } + } else if (item.watched) "Watched" + else if (item.resumeSeconds > 0) "${((item.runtimeSeconds - item.resumeSeconds + 30) / 60).coerceAtLeast(1)} min left" + else null + } + val facts = androidx.compose.runtime.remember(item, availability) { + listOfNotNull(item.character?.takeIf { it.isNotBlank() }?.let { "as $it" }, item.year?.toString(), + if (item.kind == "movie") { if (item.runtimeSeconds > 0) item.runtime else "Movie" } else if (item.seasons > 0) "${item.seasons} season${if (item.seasons == 1) "" else "s"}" else "Series", + availability).joinToString(" · ") + } + Box(Modifier.height(WolfskinTokens.Card.posterCaptionBand)) { + Caption(item.name, facts, focused, Modifier.offset(y = L.captionOffset).wrapContentWidth(Alignment.Start, unbounded = true).width(D.captionWidth), + accentLastFact = availability != null, accentFirstFact = !item.character.isNullOrBlank()) + } + } + } +} + +/** Apply the same lookahead and settled-offset rule to cast, episodes and related shelves. */ +private suspend fun LazyListState.keepColumnInWindow(column: Int, slots: Int, pitch: Float, count: Int) { + val previous = firstVisibleItemIndex + (firstVisibleItemScrollOffset / pitch).roundToInt() + scrollShelfTo(shelfFirst(column, previous, slots, count), pitch) +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleSlotArtwork.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleSlotArtwork.kt new file mode 100644 index 00000000..154c44a3 --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleSlotArtwork.kt @@ -0,0 +1,60 @@ +package org.justinvisser.wolfskin.design + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.blur +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.ColorMatrix +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.unit.Dp +import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C +import org.justinvisser.wolfskin.design.WolfskinTokens.Home as H + +/** Slot dimensions adapt to the shared exact-pixel artwork request, never another image loader. */ +@Composable +private fun SlotImage(source: String, width: Dp, height: Dp, modifier: Modifier = Modifier, poster: Boolean = false, colorFilter: ColorFilter? = null, onError: () -> Unit = {}) { + TitleArtwork(source, (width.value * 2).toInt(), (height.value * 2).toInt(), modifier, poster, colorFilter, onError) +} + +/** Poster-only landscapes retain the upright one-sheet above the same dimmed image wash. */ +@Composable +fun TitleArtwork(title: String, type: String, poster: String?, width: Dp, height: Dp, modifier: Modifier = Modifier, + backdrop: String? = null, posterOnly: Boolean = false, landscape: Boolean = false, hero: Boolean = false) { + val source = if (landscape) backdrop ?: poster else poster + val failed = remember(source) { androidx.compose.runtime.mutableStateOf(false) } + val missing = source == null || failed.value + Box(modifier.background(C.surface2)) { + if (!missing) { + if (landscape && (posterOnly || backdrop == null) && poster != null) { + val wash = remember(hero) { + val matrix = ColorMatrix().apply { setToSaturation(if (hero) 1.2f else 1f) } + val brightness = if (hero) .55f else .5f + for (index in 0 until 15) matrix.values[index] *= brightness + ColorFilter.colorMatrix(matrix) + } + val inset = if (hero) H.heroWashInset else H.cardWashInset + val washModifier = Modifier.wrapContentSize(Alignment.Center, unbounded = true) + .requiredSize(width + inset * 2, height + inset * 2) + .blur(if (hero) H.heroBlur else H.cardBlur) + .graphicsLayer { if (hero) { scaleX = 1.1f; scaleY = 1.1f } } + SlotImage(poster, if (hero) WolfskinTokens.Layout.heroSheetWidth else width, if (hero) WolfskinTokens.Layout.heroSheetHeight else height, washModifier, colorFilter = wash, onError = { failed.value = true }) + val sheetHeight = if (hero) WolfskinTokens.Layout.heroSheetHeight else height - H.cardSheetInset + val sheetWidth = if (hero) WolfskinTokens.Layout.heroSheetWidth else sheetHeight * (2f / 3f) + val sheetModifier = if (hero) Modifier.align(Alignment.TopEnd).offset(-H.heroSheetRight, H.heroSheetTop) else Modifier.offset(H.cardSheetLeft, H.cardSheetTop) + SlotImage(poster, sheetWidth, sheetHeight, sheetModifier.size(sheetWidth, sheetHeight).clip(RoundedCornerShape(if (hero) WolfskinTokens.Radius.heroSheet else WolfskinTokens.Radius.cardSheet)), poster = true, onError = { failed.value = true }) + } else SlotImage(source, width, height, Modifier.fillMaxSize(), poster = !landscape, onError = { failed.value = true }) + } else if (!hero) MediaArtworkFallback(title, type == "movie", showTitle = !landscape) + else HomeAmbient(Modifier.fillMaxSize()) + } +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleState.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleState.kt new file mode 100644 index 00000000..d8a6d560 --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleState.kt @@ -0,0 +1,295 @@ +package org.justinvisser.wolfskin.design + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue + +/** One focus controller drives the drawn screen and Android key-event replays. */ +class TitleState( + initial: TitlePageData, + val person: ShellPerson, + private val onAction: (TitleAction) -> Unit, +) { + var data by mutableStateOf(initial) + private set + var row by mutableStateOf("header") + var column by mutableStateOf(0) + val columns = mutableStateMapOf() + private val centers = HashMap>() + fun recordCenter(row: String, index: Int, x: Float) { centers.getOrPut(row) { HashMap() }[index] = x } + fun showSeasonLoading() { if (data.episodesStatus != TitleReadStatus.Loading) data = data.copy(episodesStatus = TitleReadStatus.Loading) } + fun restoreSeason() { season = data.selectedSeason ?: season; data = data.copy(episodesStatus = TitleReadStatus.Ready) } + var sheet by mutableStateOf(null) + private set + var sheetIndex by mutableStateOf(0) + var season by mutableStateOf(initial.selectedSeason ?: initial.playTarget?.season?.takeIf { it in initial.seasonNumbers } ?: initial.seasonNumbers.firstOrNull() ?: 1) + var versionId by mutableStateOf(initial.selectedVersion) + var audioIndex by mutableStateOf(initial.selectedAudio) + var subtitleIndex by mutableStateOf(initial.selectedSubtitle) + var watched by mutableStateOf(initial.watchTarget.watched) + var toast by mutableStateOf(null) + private var armedCard: String? = null + private var seasonChosen = false + val requestedSeason: Int? get() = season.takeIf { seasonChosen } + private var okHeld = false + val target: TitleItem? get() = data.playTarget ?: data.item.takeIf { it.kind == "movie" && it.held } + val version: TitleVersion? get() = data.versions.firstOrNull { it.id == versionId } ?: data.versions.firstOrNull() + val audio: TitleTrack? get() = version?.audio?.firstOrNull { it.index == audioIndex } ?: version?.audio?.firstOrNull { it.isDefault } ?: version?.audio?.firstOrNull() + val subtitle: TitleTrack? get() = version?.subtitles?.firstOrNull { it.index == subtitleIndex } + val rows: List by derivedStateOf { buildList { + add("header") + if (data.status != TitleReadStatus.Ready) return@buildList + if (data.item.held && data.item.kind == "tv" && data.item.seasons > 0) { + add("seasons") + if (data.episodesStatus == TitleReadStatus.Ready && data.episodes.isNotEmpty()) add("episodes") + } + if (data.castStatus == TitleReadStatus.Ready && data.cast.isNotEmpty()) add("cast") + if (data.related.isNotEmpty()) add("related") + } } + val options: List by derivedStateOf { titleOptions(this) } + + /** Refresh keeps the selected row and file when they still exist. */ + fun update(value: TitlePageData) { + val wasLoading = data.status == TitleReadStatus.Loading + val pendingWatched = watched != data.watchTarget.watched + val pendingVersion = versionId != data.selectedVersion + val pendingAudio = audioIndex != data.selectedAudio + val pendingSubtitle = subtitleIndex != data.selectedSubtitle + data = value + if (wasLoading || (!seasonChosen && !columns.containsKey("seasons")) || season !in value.seasonNumbers) season = value.selectedSeason ?: value.playTarget?.season?.takeIf { it in value.seasonNumbers } ?: value.seasonNumbers.firstOrNull() ?: 1 + if (!pendingWatched) watched = value.watchTarget.watched + if (!pendingVersion) versionId = value.selectedVersion + if (!pendingAudio) audioIndex = value.selectedAudio + if (!pendingSubtitle) subtitleIndex = value.selectedSubtitle + if (row !in rows) { row = "header"; column = 0 } + column = column.coerceAtMost(rowSize(row).minus(1).coerceAtLeast(0)) + } + + /** Pointer focus and D-pad use the same row memory. */ + fun focus(targetRow: String, targetColumn: Int) { + columns[row] = column + row = targetRow + column = targetColumn + columns[row] = column + } + /** Pointer clicks activate the same state transition as a completed remote press. */ + fun click(targetRow: String, targetColumn: Int) { + focus(targetRow, targetColumn) + activate() + } + + + /** Keep the last confirmed selections separate from immediate, optimistic UI feedback. */ + fun confirmPlayback(action: TitleAction) { + data = when (action) { + is TitleAction.Version -> data.copy(selectedVersion = action.version.id, selectedAudio = null, selectedSubtitle = -1) + is TitleAction.Track -> data.copy(selectedVersion = action.version.id, selectedAudio = action.audio, selectedSubtitle = action.subtitle) + is TitleAction.Watched -> data.copy(watchTarget = data.watchTarget.copy(watched = action.watched)) + else -> data + } + } + + /** A failed older write cannot undo a newer selection still waiting to save. */ + fun rejectPlayback(action: TitleAction) { + when (action) { + is TitleAction.Version -> if (versionId == action.version.id) { + versionId = data.selectedVersion; audioIndex = data.selectedAudio; subtitleIndex = data.selectedSubtitle + } + is TitleAction.Track -> if (audioIndex == action.audio && subtitleIndex == action.subtitle) { + audioIndex = data.selectedAudio; subtitleIndex = data.selectedSubtitle + } + is TitleAction.Watched -> if (watched == action.watched) watched = data.watchTarget.watched + else -> Unit + } + } + + /** Key-up can only activate the same card that armed the first key-down. */ + fun key(key: ShellKey, down: Boolean, repeat: Int, shell: ShellState): Boolean { + if (key == ShellKey.Ok) { + if (!down) { + okHeld = false + val card = "$row:$column" + if (armedCard == card && sheet == null && !shell.navFocused) activate() + armedCard = null + return true + } + if (repeat > 0 || okHeld) return true + okHeld = true + if (sheet == null && (row == "cast" || row == "related" || row == "episodes")) { + armedCard = "$row:$column" + return true + } + } else { + if (!down) return true + armedCard = null + } + if (sheet != null) { sheetKey(key); return true } + when (key) { + ShellKey.Back -> back(shell) + ShellKey.Up -> if (row == "header") { shell.focusNav(shell.currentTab) } else moveRow(-1) + ShellKey.Down -> moveRow(1) + ShellKey.Left -> focus(row, (column - 1).coerceAtLeast(0)) + ShellKey.Right -> focus(row, (column + 1).coerceAtMost((rowSize(row) - 1).coerceAtLeast(0))) + ShellKey.Ok -> activate() + } + return true + } + + /** Sheets and shell overlays close first; a pushed title/person pops from content or nav. */ + fun back(shell: ShellState) { + when { + sheet != null -> sheet = null + shell.pickerVisible || shell.requestsVisible -> shell.key(ShellKey.Back) + else -> onAction(TitleAction.Pop) + } + } + + /** Pointer selection stays inside the modal's row order. */ + fun clickSheet(index: Int) { sheetIndex = index; sheetKey(ShellKey.Ok) } + + private fun moveRow(delta: Int) { + val index = (rows.indexOf(row) + delta).coerceIn(0, rows.lastIndex) + val next = rows[index] + val x = centers[row]?.get(column) + val nearest = if (x != null) centers[next]?.entries?.minByOrNull { kotlin.math.abs(it.value - x) }?.key else null + val selected = if (next == "seasons") data.seasonNumbers.indexOf(season).takeIf { it >= 0 } else null + focus(next, (columns[next] ?: selected ?: nearest ?: column).coerceAtMost((rowSize(next) - 1).coerceAtLeast(0))) + } + + private fun rowSize(id: String): Int = when (id) { + "header" -> options.size + "seasons" -> data.seasonNumbers.size + "episodes" -> data.episodes.size + "cast" -> data.cast.size + "related" -> data.related.size + else -> 0 + } + + private fun activate() { + when (row) { + "cast" -> data.cast.getOrNull(column)?.let { onAction(TitleAction.Person(it)) } + "related" -> data.related.getOrNull(column)?.let { onAction(TitleAction.OpenTitle(it)) } + "episodes" -> data.episodes.getOrNull(column)?.let { onAction(TitleAction.OpenTitle(it, episodeSelection = true)) } + "seasons" -> { seasonChosen = true; season = data.seasonNumbers[column]; onAction(TitleAction.Season(season)) } + "header" -> options.getOrNull(column)?.let { activateOption(it.id) } + } + } + + private fun activateOption(id: String) { + when (id) { + "play", "restart" -> if (data.watchReady) target?.let { onAction(TitleAction.Play(it, id == "restart")) } + "version" -> { sheetIndex = data.versions.indexOf(version).coerceAtLeast(0); sheet = TitleSheet.Version } + "tracks" -> { sheetIndex = 0; sheet = TitleSheet.Tracks } + "more" -> { sheetIndex = 0; sheet = TitleSheet.MediaInfo } + "watched" -> if (data.watchTarget.kind == "tv") { sheetIndex = 0; sheet = TitleSheet.ConfirmWatched } else toggleWatched() + "trailer" -> data.trailerUrl?.let { onAction(TitleAction.Trailer(it)) } + "seasons" -> focus("seasons", data.seasonNumbers.indexOf(season).coerceAtLeast(0)) + "retry" -> onAction(TitleAction.Retry) + "back" -> onAction(TitleAction.Pop) + "request", "other", "add" -> onAction(TitleAction.Request(data.item, id != "request")) + "status" -> onAction(TitleAction.Requests(data.item)) + "hide", "unhide" -> { + val hidden = id == "hide" + data = data.copy(hidden = hidden) + onAction(TitleAction.Hide(data.item, hidden)) + if (hidden) { sheetIndex = 0; sheet = TitleSheet.HiddenReceipt } else toast = "Back in your picks." + } + } + } + + private fun toggleWatched() { + watched = !watched + onAction(TitleAction.Watched(data.watchTarget, watched)) + } + + private fun sheetKey(key: ShellKey) { + if (key == ShellKey.Back) { sheet = null; return } + val current = version + val size = when (sheet) { + TitleSheet.Version -> data.versions.size + TitleSheet.Tracks -> (current?.audio?.size ?: 0) + (current?.subtitles?.size ?: 0) + 2 + TitleSheet.ConfirmWatched, TitleSheet.HiddenReceipt -> 2 + else -> 1 + } + when (key) { + ShellKey.Up, ShellKey.Left -> sheetIndex = (sheetIndex - 1).coerceAtLeast(0) + ShellKey.Down, ShellKey.Right -> sheetIndex = (sheetIndex + 1).coerceAtMost(size - 1) + ShellKey.Ok -> when (sheet) { + TitleSheet.Version -> { + val selected = data.versions[sheetIndex] + versionId = selected.id + audioIndex = null + subtitleIndex = -1 + target?.let { onAction(TitleAction.Version(it, selected)) } + sheet = null + } + TitleSheet.Tracks -> if (current != null) selectTrack(current) + TitleSheet.ConfirmWatched -> { if (sheetIndex == 0) toggleWatched(); sheet = null } + TitleSheet.HiddenReceipt -> { + sheet = null + if (sheetIndex == 0) { + data = data.copy(hidden = false) + onAction(TitleAction.Hide(data.item, false)) + } else onAction(TitleAction.Pop) + } + else -> sheet = null + } + } + } + + private fun selectTrack(current: TitleVersion) { + when { + sheetIndex < current.audio.size -> audioIndex = current.audio[sheetIndex].index + sheetIndex == current.audio.size -> subtitleIndex = -1 + sheetIndex <= current.audio.size + current.subtitles.size -> subtitleIndex = current.subtitles[sheetIndex - current.audio.size - 1].index + else -> { sheet = null; return } + } + target?.let { onAction(TitleAction.Track(it, current, audio?.index, subtitleIndex)) } + } +} + +/** Button order follows the file, resume, ownership and authentication facts, never synthetic availability. */ +fun titleOptions(state: TitleState): List = buildList { + val data = state.data + val item = data.item + val target = state.target + if (data.status != TitleReadStatus.Ready) { + if (data.status == TitleReadStatus.Unavailable) { + add(TitleOption("retry", "Try again", "", primary = true, icon = false)) + add(TitleOption("back", "Back", "", icon = false)) + } + return@buildList + } + if (item.held) { + if (!data.watchReady) add(TitleOption("retry", "Try again", "wifi", primary = true, icon = false)) + if (target != null && data.watchReady) { + val verb = if (target.resumeSeconds > 0) "Resume" else "Play" + add(TitleOption("play", verb + if (target.kind == "episode") " ${target.episodeLabel}" else "", "play", primary = true, icon = false)) + if (target.resumeSeconds > 0) add(TitleOption("restart", "Restart", "restart", "play from the beginning")) + if (data.versions.size > 1) add(TitleOption("version", "Version", "version", state.version?.name.orEmpty())) + add(TitleOption("tracks", "Audio and subtitles", "tracks", "${state.audio?.label.orEmpty()} · subtitles ${state.subtitle?.label ?: "off"}")) + } + val series = data.watchTarget.kind == "tv" + add(TitleOption("watched", "Mark ${if (series) "series " else ""}as ${if (state.watched) "unwatched" else "watched"}", if (state.watched) "eye" else "eyeoff", if (data.watchTarget.kind == "episode") data.watchTarget.episodeLabel else "", on = state.watched)) + if (data.trailerUrl != null) add(TitleOption("trailer", "Play trailer", "trailer")) + if (target != null) add(TitleOption("more", "More", "more", "Media info")) + if (item.kind == "tv" && item.seasons > 0) add(TitleOption("seasons", "Seasons", "tv", icon = false)) + if (data.connection == TitleConnection.SignedIn && data.requestsAllowed) add(TitleOption("add", "Add for someone", "users", icon = false)) + } else if (data.connection != TitleConnection.SignedIn || !data.requestsAllowed) { + add(TitleOption("retry", "Try again", "wifi", primary = true, icon = false)) + } else { + add(TitleOption(if (data.requestedStage == null) "request" else "status", data.requestedStage?.let { "Requested · $it" } ?: "Request", data.requestedStage?.let(::titleStageGlyph) ?: "plus", primary = true, icon = false)) + add(TitleOption("other", "For someone else", "users", icon = false)) + if (data.hidden) add(TitleOption("unhide", "Show again", "undo", icon = false)) + else if (data.pick) add(TitleOption("hide", "Not interested", "eyeoff", icon = false)) + } +} + +internal fun titleStageGlyph(stage: String): String = when (stage) { + "Ready" -> "check" + "Failed" -> "alert" + "Downloading", "Almost ready" -> "download" + else -> "clock" +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleTextStyles.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleTextStyles.kt new file mode 100644 index 00000000..69465238 --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleTextStyles.kt @@ -0,0 +1,25 @@ +package org.justinvisser.wolfskin.design + +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.LineHeightStyle + +/** Explicit styles retain CSS leading without changing shared nav or library typography. */ +internal object TitleTextStyles { + private val lineBoxes = LineHeightStyle(LineHeightStyle.Alignment.Center, LineHeightStyle.Trim.None) + private fun TextStyle.withLineBoxes() = copy(lineHeightStyle = lineBoxes) + + val displayDetail = WolfskinTokens.Type.displayDetail.withLineBoxes() + val dLine = WolfskinTokens.Title.dLine.withLineBoxes() + val progress = WolfskinTokens.Title.dLine.withLineBoxes() + val castName = WolfskinTokens.Title.castName.withLineBoxes() + val body = WolfskinTokens.Type.body.withLineBoxes() + val caption = WolfskinTokens.Type.caption.withLineBoxes() + val label = WolfskinTokens.Type.label.withLineBoxes() + val sheetTitle = WolfskinTokens.Type.sheetTitle.withLineBoxes() + val listTitle = WolfskinTokens.Type.listTitle.withLineBoxes() + val screenTitle = WolfskinTokens.Type.screenTitle.withLineBoxes() + val rowHeading = WolfskinTokens.Type.rowHeading.withLineBoxes() + val errorTitle = WolfskinTokens.Title.errorTitle.withLineBoxes() + val receiptTitle = WolfskinTokens.Title.receiptTitle.withLineBoxes() + val infoKey = WolfskinTokens.Title.infoKey.withLineBoxes() +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WhosWatching.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WhosWatching.kt new file mode 100644 index 00000000..224469bc --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WhosWatching.kt @@ -0,0 +1,117 @@ +package org.justinvisser.wolfskin.design + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +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.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.requiredWidth +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.tv.material3.Text +import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C +import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L +import org.justinvisser.wolfskin.design.WolfskinTokens.Shell as S + +/** The picker covers the nav and exposes Settings only below the final person. */ +@Composable +fun WhosWatching(state: ShellState, modifier: Modifier = Modifier) { + Box(modifier.fillMaxSize().background(C.bg)) { + Wordmark(Modifier.offset(L.safeSide, L.safeTopPicker)) + if (state.peopleStatus == PeopleStatus.Ready) PeoplePicker(state) else RecoveryPicker(state) + } +} + +@Composable +private fun PeoplePicker(state: ShellState) { + Box(Modifier.fillMaxWidth().offset(y = S.pickerTitleTop), contentAlignment = Alignment.TopCenter) { + Text("Who’s watching?", color = C.fg, style = WolfskinTokens.Type.screenTitle) + } + Row(Modifier.fillMaxWidth().offset(y = S.pickerPeopleTop), horizontalArrangement = Arrangement.spacedBy(L.pickerGap, Alignment.CenterHorizontally)) { + state.people.forEachIndexed { index, person -> + val focused = !state.gearFocused && state.pickerIndex == index + Column(Modifier.width(L.pickerAvatar).focusScale(focused, WolfskinTokens.Focus.scaleCard).clickable { + state.focusPerson(index); state.key(ShellKey.Ok) + }, horizontalAlignment = Alignment.CenterHorizontally) { + Avatar(person, L.pickerAvatar, WolfskinTokens.Type.avatarLetterPicker, + Modifier.focusRing(focused, L.pickerAvatar / 2, WolfskinTokens.Focus.ringPickerRing)) + Spacer(Modifier.height(S.pickerNameGap)) + Text(person.name, color = if (focused) C.fg else C.muted, style = WolfskinTokens.Type.pickerName) + } + } + } + val left = (S.stageWidth - L.gear) / 2 + Box(Modifier.offset(left, S.pickerGearTop).size(L.gear).focusScale(state.gearFocused, WolfskinTokens.Focus.scaleCard) + .focusRing(state.gearFocused, L.gear / 2, WolfskinTokens.Focus.ringPickerRing) + .clickable { state.gearFocused = true; state.key(ShellKey.Ok) }, contentAlignment = Alignment.Center) { + Glyph("gear", if (state.gearFocused) C.fg else C.dim.copy(alpha = C.dim.alpha * S.gearOpacity), Modifier.size(L.buttonIcon)) + if (state.gearFocused) { + Box(Modifier.align(Alignment.TopCenter).offset(y = S.pickerGearCaptionTop).requiredWidth(L.pickerAvatar), contentAlignment = Alignment.TopCenter) { + Text("Settings", color = C.muted, style = WolfskinTokens.Type.caption, maxLines = 1, softWrap = false) + } + } + } +} + +@Composable +private fun RecoveryPicker(state: ShellState) { + val loading = state.peopleStatus == PeopleStatus.Loading + val titleTop = if (loading) S.pickerLoadingTitleTop else S.pickerErrorTitleTop + Box(Modifier.fillMaxWidth().offset(y = titleTop), contentAlignment = Alignment.TopCenter) { + Text("Who’s watching?", color = C.fg, style = WolfskinTokens.Type.screenTitle) + } + if (loading) { + Row(Modifier.fillMaxWidth().offset(y = S.pickerLoadingPeopleTop), horizontalArrangement = Arrangement.spacedBy(L.pickerGap, Alignment.CenterHorizontally)) { + repeat(state.people.size) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Box(Modifier.size(L.pickerAvatar).background(C.surface2, CircleShape)) + Spacer(Modifier.height(S.pickerNameGap)) + Box(Modifier.size(S.pickerSkeletonNameWidth, S.pickerSkeletonNameHeight).background(C.surface, RoundedCornerShape(S.skeletonRadius))) + } + } + } + } else { + Column(Modifier.offset((S.stageWidth - S.pickerErrorPanelWidth) / 2, S.pickerErrorPanelTop) + .size(S.pickerErrorPanelWidth, S.pickerErrorPanelHeight).background(C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.panel)) + .padding(horizontal = S.panelPaddingX, vertical = S.panelPaddingY), verticalArrangement = Arrangement.spacedBy(S.panelGap)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(S.panelHeadingGap)) { + Glyph("alert", C.warn, Modifier.size(S.panelIcon)) + Text("Can’t read who’s here", color = C.warn, style = WolfskinTokens.Type.stateTitle.copy(fontWeight = androidx.compose.ui.text.font.FontWeight.Bold)) + } + Text("Conductarr didn’t answer. You can continue as the last person on\nthis TV.", color = C.muted, style = WolfskinTokens.Type.body) + } + } + Row(Modifier.fillMaxWidth().offset(y = if (loading) S.pickerLoadingButtonTop else S.pickerErrorButtonTop), horizontalArrangement = Arrangement.spacedBy(L.buttonGap, Alignment.CenterHorizontally)) { + if (!loading) PickerButton("Try again", state.recoveryIndex == 0, true) { state.recoveryIndex = 0; state.key(ShellKey.Ok) } + PickerButton("Continue as ${state.person.name}", loading || state.recoveryIndex == 1, false) { state.recoveryIndex = 1; state.key(ShellKey.Ok) } + } +} + +/** Recovery actions remain focusable while profiles cannot be read. */ +@Composable +fun PickerButton(label: String, focused: Boolean, primary: Boolean, icon: String? = null, onFocus: (() -> Unit)? = null, + textStyle: androidx.compose.ui.text.TextStyle = WolfskinTokens.Type.button, onClick: () -> Unit) { + val background = if (primary) C.fg else if (focused) C.buttonSecondaryFocused else C.buttonSecondary + Box(Modifier.height(L.buttonHeight).focusScale(focused, WolfskinTokens.Focus.scaleControl) + .focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringControlRing, FocusLift.Button) + .then(if (onFocus != null) Modifier.focusOnPointerEnter(onFocus) else Modifier) + .background(background, RoundedCornerShape(WolfskinTokens.Radius.button)).clickable(onClick = onClick) + .padding(horizontal = L.buttonPaddingX), contentAlignment = Alignment.Center) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Home.plateTop)) { + if (icon != null) Glyph(icon, if (primary) C.onFocus else C.fg, Modifier.size(L.buttonIcon)) + Text(label, modifier = Modifier.exactLineBox(textStyle), color = if (primary) C.onFocus else C.fg, style = textStyle) + } + } +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinImages.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinImages.kt new file mode 100644 index 00000000..349fd2d2 --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinImages.kt @@ -0,0 +1,109 @@ +package org.justinvisser.wolfskin.design + +import android.content.Context +import android.net.Uri +import coil3.ComponentRegistry +import coil3.ImageLoader +import coil3.disk.DiskCache +import coil3.intercept.Interceptor +import coil3.disk.directory +import coil3.request.crossfade +import coil3.memory.MemoryCache +import coil3.request.ImageRequest +import coil3.request.allowRgb565 +import coil3.size.Scale +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit + +/** Wolfskin screens share one bounded loader; hidden-switch screens retain the upstream loader. */ +object WolfskinImages { + private var application: Context? = null + private var loader: ImageLoader? = null + private var networkConfiguration: (ComponentRegistry.Builder.() -> Unit)? = null + private const val MEBIBYTE = 1024L * 1024L + private const val IMAGE_READS = 4 + private val readPermits = Semaphore(IMAGE_READS) + + /** The app supplies authenticated networking without exposing its server/session to design code. */ + @Synchronized + fun configureNetwork(context: Context, configure: ComponentRegistry.Builder.() -> Unit) { + val owner = context.applicationContext + if (application === owner && networkConfiguration === configure) return + loader?.shutdown() + loader = null + application = owner + networkConfiguration = configure + } + + @Synchronized + fun loader(context: Context): ImageLoader { + val owner = context.applicationContext + if (application !== owner) { + loader?.shutdown() + loader = null + application = owner + networkConfiguration = null + } + if (loader == null) { + val reads = Dispatchers.IO.limitedParallelism(IMAGE_READS) + // Parent components may attach a Jellyfin token to every HTTP host. + val builder = ImageLoader.Builder(owner) + loader = builder + .memoryCache { MemoryCache.Builder().maxSizeBytes((WolfskinTokens.Image.memoryCacheMB * MEBIBYTE).toLong()).build() } + .diskCache { DiskCache.Builder().directory(owner.cacheDir.resolve("wolfskin-images")).maxSizeBytes((WolfskinTokens.Image.diskCacheMB * MEBIBYTE).toLong()).build() } + .components { + // Coil replaces this registry; install networking and bounds together. + networkConfiguration?.invoke(this) + add(Interceptor { chain -> readPermits.withPermit { chain.proceed() } }) + } + .fetcherCoroutineContext(reads).decoderCoroutineContext(reads).crossfade(false).build() + } + return loader!! + } + + fun request(context: Context, source: Any, width: Int, height: Int, poster: Boolean): ImageRequest = + ImageRequest.Builder(context).data(sourceForSize(source, width, height)).size(width, height).scale(Scale.FIT).allowRgb565(poster).build() + + fun prefetch(context: Context, row: HomeRow) { + val count = when (row.cardKind) { HomeCardKind.Poster -> 7; HomeCardKind.Tile -> 5; HomeCardKind.Wide -> 4 } + val width = (row.cardWidth.value * 2).toInt() + val height = (row.cardHeight.value * 2).toInt() + row.cards.take(count).forEach { title -> + val poster = row.cardKind == HomeCardKind.Poster + val source = if (poster) title.poster else title.backdrop ?: title.poster + if (source != null) loader(context).enqueue(request(context, source, width, height, poster)) + } + } + + private val jellyfinImagePath = Regex("/Items/[^/]+/Images/[^/]+(?:/\\d+)?", RegexOption.IGNORE_CASE) + private val tmdbSizePath = Regex("/t/p/(?:w\\d+|original)/") + private val dimensionParameters = setOf("width", "height", "maxwidth", "maxheight", "fillwidth", "fillheight") + + private fun sourceForSize(source: Any, width: Int, height: Int): Any { + if (source !is String) return source + val uri = Uri.parse(source) + if (uri.scheme != "https" && uri.scheme != "http") return source + if (uri.host == "image.tmdb.org") { + val size = if (height > width) when { + width <= 500 -> "w500" + width <= 780 -> "w780" + else -> "original" + } else when { + width <= 780 -> "w780" + width <= 1280 -> "w1280" + else -> "original" + } + return uri.buildUpon().path(uri.path?.replace(tmdbSizePath, "/t/p/$size/")).build().toString() + } + if (!jellyfinImagePath.matches(uri.path.orEmpty())) return source + val builder = uri.buildUpon().clearQuery() + for (name in uri.queryParameterNames) { + if (name.lowercase() !in dimensionParameters) { + uri.getQueryParameters(name).forEach { value -> builder.appendQueryParameter(name, value) } + } + } + return builder.appendQueryParameter("fillWidth", width.toString()) + .appendQueryParameter("fillHeight", height.toString()).build().toString() + } +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTheme.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTheme.kt new file mode 100644 index 00000000..d3e155a8 --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTheme.kt @@ -0,0 +1,41 @@ +package org.justinvisser.wolfskin.design + +import androidx.compose.runtime.Composable +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.darkColorScheme + +/** Both Compose Material families use the same opaque TV palette for hand-offs. */ +object WolfskinColors { + private val c = WolfskinTokens.Color + val darkScheme = darkColorScheme( + primary = c.accent, onPrimary = c.onFocus, + primaryContainer = c.accentSoft, onPrimaryContainer = c.accentText, + secondary = c.accent, onSecondary = c.onFocus, + secondaryContainer = c.accentSoft, onSecondaryContainer = c.accentText, + tertiary = c.accent, onTertiary = c.onFocus, + tertiaryContainer = c.accentSoft, onTertiaryContainer = c.accentText, + background = c.bg, onBackground = c.fg, + surface = c.bg, onSurface = c.fg, + surfaceVariant = c.surface2, onSurfaceVariant = c.fg, + border = c.accent, + ) + val darkSchemeMaterial = androidx.compose.material3.darkColorScheme( + primary = c.accent, onPrimary = c.onFocus, + primaryContainer = c.accentSoft, onPrimaryContainer = c.accentText, + secondary = c.accent, onSecondary = c.onFocus, + secondaryContainer = c.accentSoft, onSecondaryContainer = c.accentText, + tertiary = c.accent, onTertiary = c.onFocus, + tertiaryContainer = c.accentSoft, onTertiaryContainer = c.accentText, + background = c.bg, onBackground = c.fg, + surface = c.bg, onSurface = c.fg, + surfaceVariant = c.surface2, onSurfaceVariant = c.fg, + ) +} + +/** Upstream content inherits tokens without a fork-specific theme enum. */ +@Composable +fun WolfskinTheme(content: @Composable () -> Unit) { + androidx.compose.material3.MaterialTheme(colorScheme = WolfskinColors.darkSchemeMaterial) { + MaterialTheme(colorScheme = WolfskinColors.darkScheme, content = content) + } +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt new file mode 100644 index 00000000..fa730cd2 --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt @@ -0,0 +1,679 @@ +// Generated by generate_tokens.py; edit the JSON sources instead. +package org.justinvisser.wolfskin.design + +import androidx.compose.ui.graphics.Color as ComposeColor +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.em +import androidx.compose.ui.unit.sp + +/** The locked TV design, converted at the source density. */ +object WolfskinTokens { + object Color { + val bg = ComposeColor(0xFF07070A) + val surface = ComposeColor(0xFF13131A) + val surface2 = ComposeColor(0xFF1B1B25) + val surface3 = ComposeColor(0xFF252532) + val fg = ComposeColor(0xFFF4F4F6) + val muted = ComposeColor(0xBDF4F4F6) + val dim = ComposeColor(0x8FF4F4F6) + val border = ComposeColor(0x1AFFFFFF) + val accent = ComposeColor(0xFFCC9FFF) + val accentSoft = ComposeColor(0xFF2C1A3F) + val accentText = ComposeColor(0xFFE4CBFF) + val ok = ComposeColor(0xFF89DA9B) + val warn = ComposeColor(0xFFF4C26A) + val fail = ComposeColor(0xFFF97770) + val onFocus = ComposeColor(0xFF0B0B10) + val buttonSecondary = ComposeColor(0x24FFFFFF) + val buttonSecondaryFocused = ComposeColor(0x42FFFFFF) + val titleButtonSecondary = ComposeColor(0xC72C2C38) + val titleButtonSecondaryFocused = ComposeColor(0xE6464654) + val chipRest = ComposeColor(0x1AFFFFFF) + val navBlobRest = ComposeColor(0x1AFFFFFF) + val listRowRest = ComposeColor(0x0DFFFFFF) + val sheetScrim = ComposeColor(0x9E000000) + val badgeRest = ComposeColor(0xBF07070A) + val badgeWatched = ComposeColor(0xD9F4F4F6) + val progressTrack = ComposeColor(0x38FFFFFF) + val railEmpty = ComposeColor(0x38F4F4F6) + } + object PersonColor { + val justin = ComposeColor(0xFF62C4FF) + val mindel = ComposeColor(0xFFFF9D72) + val shared = ComposeColor(0xFF9BE38F) + } + object Gradient { + const val heroScrim = "linear-gradient(90deg,rgb(7 7 10 / .92) 0%,rgb(7 7 10 / .55) 42%,rgb(7 7 10 / 0) 70%),linear-gradient(0deg,var(--bg) 0%,rgb(7 7 10 / .6) 26%,rgb(7 7 10 / 0) 48%)" + const val detailScrim = "linear-gradient(90deg,rgb(7 7 10 / .94) 0%,rgb(7 7 10 / .6) 46%,rgb(7 7 10 / .05) 75%),linear-gradient(0deg,var(--bg) 0%,rgb(7 7 10 / .35) 34%,rgb(7 7 10 / 0) 55%)" + const val navBand = "linear-gradient(180deg,rgb(7 7 10 / .72),rgb(7 7 10 / 0))" + const val navBandSolid = "linear-gradient(180deg,var(--bg) 0%,var(--bg) 84%,rgb(7 7 10 / 0) 100%)" + const val ratingPlate = "linear-gradient(180deg,rgb(7 7 10 / .9) 0%,rgb(7 7 10 / .72) 52%,rgb(7 7 10 / 0) 100%)" + const val tileOverlay = "linear-gradient(0deg,rgb(7 7 10 / .92),rgb(7 7 10 / 0))" + const val fallbackArt = "linear-gradient(160deg,hsl(var(--h,260) 22% 22%),hsl(var(--h,260) 26% 10%))" + const val entryTile = "linear-gradient(150deg,hsl(var(--h) 30% 26%),hsl(var(--h) 30% 12%))" + const val initialsCircle = "linear-gradient(160deg,hsl(var(--h) 24% 30%),hsl(var(--h) 26% 14%))" + const val heroAmbientFilter = "blur(60px) saturate(1.2) brightness(.55)" + const val cardAmbientFilter = "blur(26px) brightness(.5)" + } + object Type { + val displayDetail = TextStyle(fontSize = 44.0.sp, fontWeight = FontWeight(800), lineHeight = 44.88.sp, letterSpacing = -0.02.em) + val displayHero = TextStyle(fontSize = 42.0.sp, fontWeight = FontWeight(800), lineHeight = 42.84.sp, letterSpacing = -0.02.em) + val screenTitle = TextStyle(fontSize = 32.0.sp, fontWeight = FontWeight(800), lineHeight = 41.6.sp, letterSpacing = -0.02.em) + val sectionTitle = TextStyle(fontSize = 28.0.sp, fontWeight = FontWeight(800), lineHeight = 36.4.sp, letterSpacing = -0.02.em) + val sheetTitle = TextStyle(fontSize = 22.0.sp, fontWeight = FontWeight(800), lineHeight = 28.6.sp, letterSpacing = -0.01.em) + val wordmark = TextStyle(fontSize = 18.0.sp, fontWeight = FontWeight(800), lineHeight = 23.400000000000002.sp, letterSpacing = -0.02.em) + val stateTitle = TextStyle(fontSize = 18.0.sp, fontWeight = FontWeight(400), lineHeight = 23.400000000000002.sp, letterSpacing = 0.0.em) + val rowHeading = TextStyle(fontSize = 17.0.sp, fontWeight = FontWeight.Bold, lineHeight = 22.1.sp, letterSpacing = -0.01.em) + val pickerName = TextStyle(fontSize = 17.0.sp, fontWeight = FontWeight.Bold, lineHeight = 22.1.sp, letterSpacing = 0.0.em) + val nav = TextStyle(fontSize = 15.0.sp, fontWeight = FontWeight.Bold, lineHeight = 19.5.sp, letterSpacing = 0.0.em) + val listTitle = TextStyle(fontSize = 15.0.sp, fontWeight = FontWeight.Bold, lineHeight = 19.5.sp, letterSpacing = 0.0.em) + val body = TextStyle(fontSize = 14.0.sp, fontWeight = FontWeight(400), lineHeight = 18.2.sp, letterSpacing = 0.0.em) + val button = TextStyle(fontSize = 14.0.sp, fontWeight = FontWeight.Bold, lineHeight = 18.2.sp, letterSpacing = 0.0.em) + val label = TextStyle(fontSize = 13.0.sp, fontWeight = FontWeight.Bold, lineHeight = 16.900000000000002.sp, letterSpacing = 0.0.em) + val caption = TextStyle(fontSize = 12.0.sp, fontWeight = FontWeight(400), lineHeight = 15.600000000000001.sp, letterSpacing = 0.0.em) + val ratingPlate = TextStyle(fontSize = 14.0.sp, fontWeight = FontWeight.Bold, lineHeight = 16.8.sp, letterSpacing = 0.0.em) + val avatarLetterPicker = TextStyle(fontSize = 50.0.sp, fontWeight = FontWeight(800)) + val avatarLetterNav = TextStyle(fontSize = 15.0.sp, fontWeight = FontWeight(800)) + val avatarLetterSheetRow = TextStyle(fontSize = 14.0.sp, fontWeight = FontWeight(800)) + val avatarLetterShelfHead = TextStyle(fontSize = 12.0.sp, fontWeight = FontWeight(800)) + val avatarLetterChip = TextStyle(fontSize = 12.0.sp, fontWeight = FontWeight(800)) + val avatarLetterLibHeader = TextStyle(fontSize = 13.0.sp, fontWeight = FontWeight(800)) + val avatarLetterSectionHeader = TextStyle(fontSize = 14.0.sp, fontWeight = FontWeight(800)) + const val stageLineHeight = 1.3f + } + object Radius { + val card = 8.0.dp + val button = 19.0.dp + val chip = 17.0.dp + val navItem = 16.0.dp + val listRow = 12.0.dp + val panel = 11.0.dp + val menu = 13.0.dp + val heroSheet = 9.0.dp + val cardSheet = 5.0.dp + val thumb = 6.0.dp + val pill = 11.0.dp + val progress = 2.0.dp + } + object Layout { + val safeSide = 48.0.dp + val contentWidth = 864.0.dp + val safeTopPicker = 27.0.dp + val navBand = 75.0.dp + val navPaddingTop = 22.0.dp + val navItemHeight = 32.0.dp + val navItemPaddingX = 13.0.dp + val navItemGap = 6.0.dp + val wordmarkGap = 20.0.dp + val navUnderline = 2.0.dp + val navBlobAvatarPad = 4.0.dp + val navSolidAfter = 20.0.dp + val pinnedLine = 84.0.dp + val windowAhead = 350.0.dp + val rowHeadingBand = 42.0.dp + val rowGapAfter = 34.0.dp + val gridRowGap = 22.0.dp + val row_hero = 410.0.dp + val row_phero = 370.0.dp + val row_section = 75.0.dp + val row_chips = 52.0.dp + val row_note = 42.0.dp + val row_state = 150.0.dp + val row_lhead = 118.0.dp + val row_dhead = 450.0.dp + val row_phead = 235.0.dp + val row_gh = 42.0.dp + val row_endcta = 100.0.dp + val row_srow = 62.0.dp + val heroCopyLeft = 48.0.dp + val heroCopyBottom = 55.0.dp + val heroCopyWidth = 500.0.dp + val heroOverviewMax = 480.0.dp + val detailCopyTop = 100.0.dp + val detailCopyWidth = 530.0.dp + val heroSheetWidth = 210.0.dp + val heroSheetHeight = 315.0.dp + val buttonHeight = 38.0.dp + val buttonPaddingX = 19.0.dp + val buttonGap = 10.0.dp + val buttonIcon = 15.0.dp + val iconButton = 38.0.dp + val iconCaptionLine = 19.0.dp + val sheetWidth = 400.0.dp + val sheetRow = 50.0.dp + val settingsRowWidth = 650.0.dp + val settingsRowHeight = 52.0.dp + val stateWidth = 550.0.dp + val captionOffset = 19.0.dp + val badge = 24.0.dp + val badgeInset = 7.0.dp + val progressBar = 4.0.dp + val pickerAvatar = 110.0.dp + val pickerGap = 45.0.dp + val pickerStackGap = 35.0.dp + val gear = 28.0.dp + val navAvatar = 32.0.dp + val castCircle = 100.0.dp + val personPortrait = 120.0.dp + val thumbWidth = 48.0.dp + val thumbHeight = 72.0.dp + val row_chipsGrid = 68.0.dp + } + object Card { + val posterW = 128.0.dp + val posterH = 192.0.dp + val posterGap = 20.0.dp + val posterPitch = 148.0.dp + val posterCaptionBand = 48.0.dp + const val posterSlots = 6 + val tileW = 200.0.dp + val tileH = 112.5.dp + val tileGap = 20.0.dp + val tilePitch = 220.0.dp + val tileCaptionBand = 0.0.dp + const val tileSlots = 4 + val wideW = 272.0.dp + val wideH = 153.0.dp + val wideGap = 20.0.dp + val widePitch = 292.0.dp + val wideCaptionBand = 0.0.dp + const val wideSlots = 3 + val entryW = 128.0.dp + val entryH = 72.0.dp + val entryGap = 20.0.dp + val entryPitch = 148.0.dp + val entryCaptionBand = 0.0.dp + const val entrySlots = 6 + val personW = 100.0.dp + val personH = 155.0.dp + val personGap = 22.0.dp + val personPitch = 122.0.dp + val personCaptionBand = 0.0.dp + const val personSlots = 7 + } + object Focus { + const val scaleCard = 1.08f + const val scaleControl = 1.05f + const val scaleRow = 1.02f + val ringCardGap = 0.0.dp + val ringCardRing = 2.0.dp + val ringControlGap = 0.0.dp + val ringControlRing = 2.0.dp + val ringAvatarGap = 0.0.dp + val ringAvatarRing = 2.0.dp + val ringPickerGap = 0.0.dp + val ringPickerRing = 2.0.dp + const val fillInverse = "background color.fg, text color.onFocus" + } + object Motion { + const val fast = 140 + const val base = 160 + const val scroll = 180 + const val navBlobDampingRatio = 1.0f + const val navBlobStiffness = 650.0f + const val ratingDwell = 350 + const val toast = 3200 + const val reducedMotion = "all transitions instant" + } + object Image { + const val posterRequestWidth = 256 + const val posterRequestHeight = 384 + const val tileRequestWidth = 416 + const val tileRequestHeight = 234 + const val wideRequestWidth = 560 + const val wideRequestHeight = 315 + const val heroRequestWidth = 1920 + const val heroRequestHeight = 1080 + const val heroPosterRequestWidth = 420 + const val heroPosterRequestHeight = 630 + const val episodeRequestWidth = 400 + const val episodeRequestHeight = 280 + const val castRequestWidth = 200 + const val castRequestHeight = 200 + const val memoryCacheMB = 64.0f + const val diskCacheMB = 128.0f + const val concurrentReadsCatalogue = 2 + const val concurrentReadsImage = 4 + } + object Behaviour { + const val rowLimit = 20.0f + const val letterRailMin = 200.0f + const val gridColumns = 6.0f + const val libraryChunk = 200.0f + const val laneLoadRows = 2.0f + const val nextPageCards = 10.0f + const val heroSlides = 5.0f + const val cacheRatingsHours = 6.0f + const val laneCacheMinutes = 30.0f + const val libraryRefreshMinutes = 10.0f + const val requestsPollSeconds = 30.0f + const val comingInVisibleRows = 5.0f + } + object Requests { + val sheetPaddingY = 40.0.dp + val sheetPaddingX = 32.0.dp + val sheetGap = 9.0.dp + val sheetTitleLineHeight = 28.0.dp + val sheetTitleBaseline = 22.0.dp + val titleLineHeight = 19.5.dp + val titleBaseline = 15.5.dp + val factsLineHeight = 15.6.dp + val factsBaseline = 12.0.dp + val stageLineHeight = 19.8.dp + val stageLabelLineHeight = 16.9.dp + val stageBaseline = 12.0.dp + val emptyMargin = 10.0.dp + val rowPaddingY = 7.0.dp + val rowPaddingX = 9.0.dp + val rowGap = 12.0.dp + val listGap = 3.0.dp + val stageMargin = 2.0.dp + val stageGlyph = 14.0.dp + val stageGap = 5.0.dp + val readinessGapX = 11.0.dp + val readinessGapY = 5.0.dp + val readinessMargin = 4.0.dp + val readinessGlyph = 13.0.dp + val chevron = 15.0.dp + const val chevronAlpha = 0.6f + val receiptPaddingY = 14.0.dp + val receiptPaddingX = 15.0.dp + val receiptGap = 5.0.dp + val receiptGlyph = 18.0.dp + val buttonMargin = 6.0.dp + val skeletonTitleWidth = 180.0.dp + val skeletonTitleHeight = 17.0.dp + val skeletonSubtitleWidth = 120.0.dp + val skeletonSubtitleHeight = 13.0.dp + val skeletonGap = 6.0.dp + val ratingsGap = 10.0.dp + val ratingsEntryGap = 4.0.dp + val shadowBlur = 40.0.dp + val shadowOffset = -15.0.dp + val shadowColor = ComposeColor(0x80000000) + const val ratingsWordmarkRatio = 1.8f + } + object Shell { + const val navLabelFade = 200 + val zero = 0.0.dp + val stageWidth = 960.0.dp + val stageHeight = 540.0.dp + val wordmarkWidth = 84.5.dp + val wordmarkTop = 36.8.dp + val navHomeWidth = 74.5.dp + val navDiscoverWidth = 99.0.dp + val navMoviesWidth = 85.5.dp + val navShowsWidth = 80.5.dp + val navAnimeWidth = 79.0.dp + val navItemTop = 32.5.dp + val pickerTitleTop = 136.15.dp + val pickerPeopleTop = 212.75.dp + val pickerNameGap = 13.0.dp + val pickerGearTop = 375.85.dp + val pickerGearCaptionTop = 36.0.dp + val pickerLetter = 50.0.dp + val pickerSkeletonNameWidth = 70.0.dp + val pickerSkeletonNameHeight = 20.0.dp + val pickerLoadingTitleTop = 123.7.dp + val pickerLoadingPeopleTop = 200.3.dp + val pickerLoadingButtonTop = 378.3.dp + val pickerErrorTitleTop = 148.8.dp + val pickerErrorPanelTop = 225.4.dp + val pickerErrorPanelWidth = 490.0.dp + val pickerErrorPanelHeight = 92.8.dp + val pickerErrorButtonTop = 353.2.dp + val settingsHeaderTop = 75.0.dp + val settingsHeaderAvatar = 26.0.dp + val settingsHeaderGap = 12.0.dp + val settingsRowTop = 5.0.dp + val settingsRowPadding = 15.0.dp + val settingsRowGap = 12.0.dp + val settingsIcon = 18.0.dp + val settingsPillHeight = 20.0.dp + val settingsPillStroke = 1.0.dp + const val settingsPillDashRatio = 3.0f + const val settingsPillDashGapRatio = 2.0f + val settingsPillBorder = ComposeColor(0x38FFFFFF) + val settingsPillFocusedBorder = ComposeColor(0x660B0B10) + val settingsPillPadding = 8.0.dp + val settingsPillGap = 6.0.dp + val settingsChevron = 15.0.dp + val settingsEndPadding = 310.0.dp + val panelPaddingX = 15.0.dp + val panelPaddingY = 14.0.dp + val panelGap = 5.0.dp + val panelHeadingGap = 7.0.dp + val panelIcon = 18.0.dp + val skeletonRadius = 5.0.dp + const val gearOpacity = 0.7f + const val navBandOpacity = 0.72f + const val navSolidStop = 0.84f + } + object Library { + val chipHeight = 34.0.dp + val chipPadding = 14.0.dp + val chipGap = 8.0.dp + val chipTop = 8.0.dp + val chipIcon = 13.0.dp + val captionWidth = 320.0.dp + const val captionSeparatorOpacity = 0.4f + val captionFactsGap = 9.0.dp + val badgeIcon = 14.0.dp + val plateTop = 7.0.dp + val plateSide = 8.0.dp + val plateBottom = 23.0.dp + val plateInlineDescent = 3.3.dp + val plateFloor = TextStyle(fontSize = 12.0.sp, fontWeight = FontWeight.Bold, lineHeight = 15.600000000000001.sp, letterSpacing = 0.0.em) + const val plateStep = 0.25f + const val plateGapEm = 0.64f + const val markGapEm = 0.29f + const val wordmarkRatio = 1.8f + const val plateOpacity = 0.9f + const val plateMidOpacity = 0.72f + const val plateMidStop = 0.52f + val railWidth = 26.0.dp + val railLetterHeight = 15.0.dp + val railFocusHeight = 22.0.dp + val railLetter = TextStyle(fontSize = 12.0.sp, fontWeight = FontWeight.Bold, lineHeight = 15.600000000000001.sp, letterSpacing = 0.0.em) + val railFocusedLetter = TextStyle(fontSize = 14.0.sp, fontWeight = FontWeight.Bold, lineHeight = 18.2.sp, letterSpacing = 0.0.em) + val panelPaddingX = 20.0.dp + val panelPaddingY = 17.0.dp + val panelTextGap = 12.0.dp + val panelTitleGap = 4.0.dp + val panelIconGap = 7.0.dp + val panelTop = 10.0.dp + val borderWidth = 1.0.dp + val chipContentGap = 6.0.dp + const val chipLabelOpacity = 0.7f + val tailIcon = 22.0.dp + val tailPadding = 10.0.dp + val tailGap = 5.0.dp + val badgeRing = 1.5.dp + val seeAllGap = 4.0.dp + val tailDescription = TextStyle(fontSize = 12.0.sp, fontWeight = FontWeight(500), lineHeight = 15.600000000000001.sp, letterSpacing = 0.0.em) + val headerSubtitle = TextStyle(fontSize = 15.0.sp, fontWeight = FontWeight(400), lineHeight = 19.5.sp, letterSpacing = 0.0.em) + const val imageReads = 4.0f + const val unknownCriticOpacity = 0.55f + val fallbackPadding = 11.0.dp + val fallbackIcon = 22.0.dp + const val fallbackHueMultiplier = 31.0f + const val fallbackSaturationTop = 0.22f + const val fallbackLightnessTop = 0.22f + const val fallbackSaturationBottom = 0.26f + const val fallbackLightnessBottom = 0.1f + const val fallbackIconOpacity = 0.45f + } + object Lift { + val cardBlur = 25.0.dp + val cardY = 9.0.dp + const val cardOpacity = 0.65f + val buttonBlur = 20.0.dp + val buttonY = 6.0.dp + const val buttonOpacity = 0.5f + val heroBlur = 40.0.dp + val heroY = 15.0.dp + const val heroOpacity = 0.6f + val sheetBlur = 40.0.dp + val sheetX = -15.0.dp + const val sheetOpacity = 0.5f + } + object Title { + val episodeCardHeight = 140.0.dp + val seriesHeaderGap = 20.0.dp + val lineGap = 11.0.dp + val lineLift = -2.0.dp + val overviewGap = 17.0.dp + val factsGap = 11.0.dp + val factsBottom = 7.0.dp + val factsHeight = 19.0.dp + val titleBottom = 8.0.dp + val iconGap = 7.0.dp + val iconSize = 16.0.dp + val captionTop = 8.0.dp + val progressWidth = 160.0.dp + val progressBottom = 15.0.dp + val progressGap = 9.0.dp + val progressLift = -7.0.dp + val castHeight = 231.0.dp + val castNameTop = 118.0.dp + val castNameSide = 6.0.dp + val personTop = 85.0.dp + val personGap = 24.0.dp + val personNameBottom = 5.0.dp + val personNoteBottom = 3.0.dp + val captionWidth = 320.0.dp + val sheetPaddingTop = 40.0.dp + val sheetPaddingSide = 32.0.dp + val sheetGap = 9.0.dp + val sheetSubLift = -4.0.dp + val sheetSubBottom = 7.0.dp + val sheetLabelTop = 4.0.dp + val sheetButtonTop = 6.0.dp + val sheetRowSide = 13.0.dp + val versionRowVertical = 10.0.dp + val trackHeight = 42.0.dp + val trackGap = 6.0.dp + val radioSize = 20.0.dp + val radioDot = 8.0.dp + val radioBorder = 1.5.dp + val infoGap = 7.0.dp + val infoLabelWidth = 100.0.dp + val infoRowGap = 3.0.dp + val infoColumnGap = 10.0.dp + val infoKey = TextStyle(fontSize = 12.0.sp, fontWeight = FontWeight.Bold, lineHeight = 15.600000000000001.sp, letterSpacing = 0.0.em) + val gridHeight = 262.0.dp + val minimumEndPadding = 40.0.dp + val ratingGap = 10.0.dp + val ratingMarkGap = 4.0.dp + val initialSize = TextStyle(fontSize = 32.0.sp, fontWeight = FontWeight(800), lineHeight = 41.6.sp, letterSpacing = 0.0.em) + val personInitialSize = TextStyle(fontSize = 48.0.sp, fontWeight = FontWeight(800), lineHeight = 62.400000000000006.sp, letterSpacing = 0.0.em) + const val fallbackHue = 260.0f + val skeletonKickerWidth = 210.0.dp + val skeletonKickerHeight = 17.0.dp + val skeletonTitleGap = 13.0.dp + val skeletonFactsGap = 12.0.dp + val skeletonOverviewGap = 15.0.dp + val skeletonTitleWidth = 380.0.dp + val skeletonTitleHeight = 46.0.dp + val skeletonFactsWidth = 280.0.dp + val skeletonOverviewWidth = 490.0.dp + val skeletonOverviewHeight = 55.0.dp + val skeletonPlayWidth = 110.0.dp + val dLine = TextStyle(fontSize = 13.0.sp, fontWeight = FontWeight(400), lineHeight = 16.900000000000002.sp, letterSpacing = 0.0.em) + val iconCaption = TextStyle(fontSize = 13.0.sp, fontWeight = FontWeight(400), lineHeight = 16.900000000000002.sp, letterSpacing = 0.0.em) + val lineIcon = 14.0.dp + val chipHeight = 34.0.dp + val chipPadding = 14.0.dp + val chipGap = 8.0.dp + val chipTop = 8.0.dp + val chipBorder = 1.0.dp + val heroSheetRight = 90.0.dp + val heroSheetTop = 75.0.dp + val gridHeadingTop = 12.0.dp + val castName = TextStyle(fontSize = 13.0.sp, fontWeight = FontWeight.Bold, lineHeight = 16.900000000000002.sp, letterSpacing = 0.0.em) + val receiptSide = 15.0.dp + val receiptTop = 14.0.dp + val receiptGap = 5.0.dp + val receiptIcon = 18.0.dp + val receiptTitle = TextStyle(fontSize = 18.0.sp, fontWeight = FontWeight.Bold, lineHeight = 23.400000000000002.sp, letterSpacing = 0.0.em) + val toastMaxWidth = 380.0.dp + val toastSide = 14.0.dp + val toastTop = 11.0.dp + const val heroAmbientBackground = "radial-gradient(120% 90% at 70% 20%,var(--surface3),var(--bg))" + const val dimScrollPx = 1400.0f + const val dimMaximum = 0.7f + val errorTop = 85.0.dp + val errorSide = 20.0.dp + val errorVertical = 17.0.dp + val errorHeadingBottom = 4.0.dp + val errorBodyBottom = 12.0.dp + val errorTitle = TextStyle(fontSize = 18.0.sp, fontWeight = FontWeight.Bold, lineHeight = 23.400000000000002.sp, letterSpacing = 0.0.em) + val fallbackGlyph = 22.0.dp + val fallbackInset = 11.0.dp + val tileSheetInset = 9.0.dp + val tileSheetLeft = 11.0.dp + val tileSheetRadius = 5.0.dp + val tileAmbientBlur = 13.0.dp + val tileAmbientInset = 15.0.dp + val tileShadowRadius = 15.0.dp + val tileShadowY = 5.0.dp + const val tileShadowOpacity = 0.5f + const val tileAmbientBrightness = 0.5f + const val cardFallbackGradient = "linear-gradient(160deg,hsl(var(--h) 22% 22%),hsl(var(--h) 26% 10%))" + const val personFallbackGradient = "linear-gradient(160deg,hsl(var(--h) 24% 30%),hsl(var(--h) 26% 14%))" + val initialsColor = ComposeColor(0xD1FFFFFF) + val fallbackGlyphColor = ComposeColor(0x73FFFFFF) + val fallbackTitle = TextStyle(fontSize = 14.0.sp, fontWeight = FontWeight.Bold, lineHeight = 16.099999999999998.sp, letterSpacing = 0.0.em) + val tileOverlayTop = 27.0.dp + val tileOverlaySide = 11.0.dp + val tileOverlayBottom = 9.0.dp + const val cardOverlayOpacity = 0.92f + val cardProgressInset = 9.0.dp + val tileProgressBottom = 5.0.dp + val ambientInset = 40.0.dp + const val ambientScale = 1.1f + val posterShadowRadius = 40.0.dp + val posterShadowY = 15.0.dp + const val posterShadowOpacity = 0.6f + val sheetShadowRadius = 40.0.dp + val sheetShadowX = -15.0.dp + const val sheetShadowOpacity = 0.5f + } + object SavedIndicator { + val height = 26.0.dp + val side = 10.0.dp + val gap = 5.0.dp + val icon = 14.0.dp + val border = 1.0.dp + const val borderOpacity = 0.5f + } + object Home { + val kickerGap = 6.0.dp + val kickerBottom = 5.0.dp + val titleBottom = 8.0.dp + val factsHeight = 19.0.dp + val factsBottom = 7.0.dp + val factsGap = 11.0.dp + val overviewBottom = 15.0.dp + val cardPadding = 11.0.dp + val cardOverlayTop = 27.0.dp + val cardOverlayBottom = 9.0.dp + val sectionTop = 20.0.dp + val sectionAvatar = 28.0.dp + val sectionGap = 11.0.dp + val stateTextBottom = 12.0.dp + val stateTop = 10.0.dp + val statePaddingX = 20.0.dp + val statePaddingY = 17.0.dp + val stateGap = 4.0.dp + val stateIcon = 18.0.dp + val captionWidth = 320.0.dp + val plateTop = 7.0.dp + val plateSide = 8.0.dp + val plateFade = 23.0.dp + val ratingGap = 10.0.dp + val ratingMarkGap = 4.0.dp + val heroSkeletonWidth = 320.0.dp + val heroSkeletonHeight = 42.0.dp + val heroSkeletonFactsWidth = 230.0.dp + val heroSkeletonFactsHeight = 17.0.dp + val heroSkeletonButtonWidth = 130.0.dp + val heroSkeletonGap = 11.0.dp + val heroSkeletonButtonGap = 20.0.dp + val artGlyph = 22.0.dp + val cardSheetLeft = 11.0.dp + val cardSheetTop = 9.0.dp + val cardSheetInset = 18.0.dp + val heroSheetRight = 90.0.dp + val heroSheetTop = 75.0.dp + val cardBlur = 13.0.dp + val heroBlur = 30.0.dp + val progressInset = 9.0.dp + val progressBottom = 5.0.dp + val endTop = 20.0.dp + val frameBottom = 40.0.dp + val toastPadding = 14.0.dp + val toastTop = 75.0.dp + val toastWidth = 380.0.dp + val toastPaddingY = 11.0.dp + val toastGap = 7.0.dp + val toastShadowBlur = 30.0.dp + val toastShadowY = 10.0.dp + const val toastShadowOpacity = 0.5f + val cardWashInset = 15.0.dp + val heroWashInset = 40.0.dp + val noteTop = 7.0.dp + val noteGap = 8.0.dp + val hairline = 1.0.dp + val savedHeight = 26.0.dp + val savedPadding = 10.0.dp + val savedGap = 5.0.dp + val savedIcon = 14.0.dp + val savedRadius = 14.0.dp + val posterProgressBottom = 6.0.dp + } + object Discover { + val shelfHeadingBand = 52.0.dp + val sheetPaddingX = 32.0.dp + val sheetPaddingY = 40.0.dp + val sheetGap = 9.0.dp + val sheetSubGap = 7.0.dp + val sheetRowPadding = 13.0.dp + val sheetAvatar = 30.0.dp + val sheetRadio = 20.0.dp + val sheetRadioDot = 8.0.dp + val sheetPersonGap = 12.0.dp + val sheetButtonTop = 6.0.dp + val chipTop = 8.0.dp + val chipHeight = 34.0.dp + val chipPadding = 14.0.dp + val chipGap = 8.0.dp + val heroTitleGap = 8.0.dp + val heroKickerGap = 5.0.dp + val heroMetaGap = 7.0.dp + val heroOverviewGap = 15.0.dp + val heroFactsGap = 11.0.dp + val heroFactsHeight = 19.0.dp + val heroSheetRight = 90.0.dp + val heroSheetTop = 70.0.dp + val heroSheetWidth = 180.0.dp + val heroSheetHeight = 270.0.dp + val dotSize = 7.0.dp + val dotActive = 22.0.dp + val dotGap = 7.0.dp + val dotMargin = 9.0.dp + val dotChevron = 20.0.dp + val stateTop = 10.0.dp + val statePaddingX = 20.0.dp + val statePaddingY = 17.0.dp + val stateTextGap = 12.0.dp + val noteTop = 7.0.dp + val headerTop = 75.0.dp + val headerGap = 12.0.dp + val entryPaddingX = 11.0.dp + val entryPaddingY = 10.0.dp + val entryText = TextStyle(fontSize = 15.0.sp, fontWeight = FontWeight(800), lineHeight = 19.5.sp, letterSpacing = -0.01.em) + val sheetRadioRing = 1.5.dp + val skeletonNameWidth = 130.0.dp + val skeletonNameHeight = 17.0.dp + val sheetSubTop = -4.0.dp + val sheetRatingTop = -5.0.dp + val sheetRatingBottom = 3.0.dp + val sheetLabelTop = 4.0.dp + val noteIcon = 15.0.dp + val noteGap = 8.0.dp + val frameBottomMin = 40.0.dp + val anchorFitTop = 350.0.dp + val anchorBottomClearance = 20.0.dp + const val reasonOpacity = 0.8f + val stageGap = 5.0.dp + const val entryBorderOpacity = 0.08f + val ambientInset = 40.0.dp + val ambientBlur = 30.0.dp + const val ambientScale = 1.1f + const val ambientSaturation = 1.2f + const val ambientBrightness = 0.55f + } +} diff --git a/wolfskin-design/src/main/res/drawable-nodpi/coming_in_rating_fresh.png b/wolfskin-design/src/main/res/drawable-nodpi/coming_in_rating_fresh.png new file mode 100644 index 00000000..ef0a98a6 Binary files /dev/null and b/wolfskin-design/src/main/res/drawable-nodpi/coming_in_rating_fresh.png differ diff --git a/wolfskin-design/src/main/res/drawable-nodpi/coming_in_rating_imdb.png b/wolfskin-design/src/main/res/drawable-nodpi/coming_in_rating_imdb.png new file mode 100644 index 00000000..bb7becf5 Binary files /dev/null and b/wolfskin-design/src/main/res/drawable-nodpi/coming_in_rating_imdb.png differ diff --git a/wolfskin-design/src/main/res/drawable-nodpi/coming_in_rating_rotten.png b/wolfskin-design/src/main/res/drawable-nodpi/coming_in_rating_rotten.png new file mode 100644 index 00000000..678af063 Binary files /dev/null and b/wolfskin-design/src/main/res/drawable-nodpi/coming_in_rating_rotten.png differ diff --git a/wolfskin-design/src/main/res/drawable-nodpi/coming_in_rating_spilled.png b/wolfskin-design/src/main/res/drawable-nodpi/coming_in_rating_spilled.png new file mode 100644 index 00000000..988d6554 Binary files /dev/null and b/wolfskin-design/src/main/res/drawable-nodpi/coming_in_rating_spilled.png differ diff --git a/wolfskin-design/src/main/res/drawable-nodpi/coming_in_rating_tmdb.png b/wolfskin-design/src/main/res/drawable-nodpi/coming_in_rating_tmdb.png new file mode 100644 index 00000000..48a14150 Binary files /dev/null and b/wolfskin-design/src/main/res/drawable-nodpi/coming_in_rating_tmdb.png differ diff --git a/wolfskin-design/src/main/res/drawable-nodpi/coming_in_rating_upright.png b/wolfskin-design/src/main/res/drawable-nodpi/coming_in_rating_upright.png new file mode 100644 index 00000000..2a2bfa89 Binary files /dev/null and b/wolfskin-design/src/main/res/drawable-nodpi/coming_in_rating_upright.png differ diff --git a/wolfskin-design/src/main/res/drawable/ic_rating_imdb.xml b/wolfskin-design/src/main/res/drawable/ic_rating_imdb.xml new file mode 100644 index 00000000..6725d42f --- /dev/null +++ b/wolfskin-design/src/main/res/drawable/ic_rating_imdb.xml @@ -0,0 +1,22 @@ + + + + + + + diff --git a/wolfskin-design/src/main/res/drawable/ic_rating_tmdb.xml b/wolfskin-design/src/main/res/drawable/ic_rating_tmdb.xml new file mode 100644 index 00000000..31c0be7d --- /dev/null +++ b/wolfskin-design/src/main/res/drawable/ic_rating_tmdb.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + diff --git a/wolfskin-design/src/main/res/drawable/ic_rotten_tomatoes_fresh.xml b/wolfskin-design/src/main/res/drawable/ic_rotten_tomatoes_fresh.xml new file mode 100644 index 00000000..5dbfb65e --- /dev/null +++ b/wolfskin-design/src/main/res/drawable/ic_rotten_tomatoes_fresh.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/wolfskin-design/src/main/res/drawable/ic_rotten_tomatoes_rotten.xml b/wolfskin-design/src/main/res/drawable/ic_rotten_tomatoes_rotten.xml new file mode 100644 index 00000000..1e3dc568 --- /dev/null +++ b/wolfskin-design/src/main/res/drawable/ic_rotten_tomatoes_rotten.xml @@ -0,0 +1,9 @@ + + + diff --git a/wolfskin-design/src/main/res/drawable/ic_rt_spilled.xml b/wolfskin-design/src/main/res/drawable/ic_rt_spilled.xml new file mode 100644 index 00000000..fea81f97 --- /dev/null +++ b/wolfskin-design/src/main/res/drawable/ic_rt_spilled.xml @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/wolfskin-design/src/main/res/drawable/ic_rt_upright.xml b/wolfskin-design/src/main/res/drawable/ic_rt_upright.xml new file mode 100644 index 00000000..e7166b39 --- /dev/null +++ b/wolfskin-design/src/main/res/drawable/ic_rt_upright.xml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + diff --git a/wolfskin-design/src/main/res/drawable/title_eyeoff.xml b/wolfskin-design/src/main/res/drawable/title_eyeoff.xml new file mode 100644 index 00000000..2e70993e --- /dev/null +++ b/wolfskin-design/src/main/res/drawable/title_eyeoff.xml @@ -0,0 +1,3 @@ + + + diff --git a/wolfskin-design/src/main/res/drawable/title_more.xml b/wolfskin-design/src/main/res/drawable/title_more.xml new file mode 100644 index 00000000..5a0c05ff --- /dev/null +++ b/wolfskin-design/src/main/res/drawable/title_more.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/wolfskin-design/src/main/res/drawable/title_restart.xml b/wolfskin-design/src/main/res/drawable/title_restart.xml new file mode 100644 index 00000000..56b12c0e --- /dev/null +++ b/wolfskin-design/src/main/res/drawable/title_restart.xml @@ -0,0 +1,4 @@ + + + + diff --git a/wolfskin-design/src/main/res/drawable/title_tracks.xml b/wolfskin-design/src/main/res/drawable/title_tracks.xml new file mode 100644 index 00000000..2bd8d4ea --- /dev/null +++ b/wolfskin-design/src/main/res/drawable/title_tracks.xml @@ -0,0 +1,4 @@ + + + + diff --git a/wolfskin-design/src/main/res/drawable/title_trailer.xml b/wolfskin-design/src/main/res/drawable/title_trailer.xml new file mode 100644 index 00000000..b81fba2c --- /dev/null +++ b/wolfskin-design/src/main/res/drawable/title_trailer.xml @@ -0,0 +1,4 @@ + + + + diff --git a/wolfskin-design/src/main/res/drawable/title_version.xml b/wolfskin-design/src/main/res/drawable/title_version.xml new file mode 100644 index 00000000..2d6a9570 --- /dev/null +++ b/wolfskin-design/src/main/res/drawable/title_version.xml @@ -0,0 +1,4 @@ + + + + diff --git a/wolfskin-design/src/main/res/drawable/wolfskin_alert.xml b/wolfskin-design/src/main/res/drawable/wolfskin_alert.xml new file mode 100644 index 00000000..ff7d9894 --- /dev/null +++ b/wolfskin-design/src/main/res/drawable/wolfskin_alert.xml @@ -0,0 +1,4 @@ + + + + diff --git a/wolfskin-design/src/main/res/drawable/wolfskin_check.xml b/wolfskin-design/src/main/res/drawable/wolfskin_check.xml new file mode 100644 index 00000000..948e1e20 --- /dev/null +++ b/wolfskin-design/src/main/res/drawable/wolfskin_check.xml @@ -0,0 +1,4 @@ + + + + diff --git a/wolfskin-design/src/main/res/drawable/wolfskin_clock.xml b/wolfskin-design/src/main/res/drawable/wolfskin_clock.xml new file mode 100644 index 00000000..be066ab9 --- /dev/null +++ b/wolfskin-design/src/main/res/drawable/wolfskin_clock.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/wolfskin-design/src/main/res/drawable/wolfskin_down.xml b/wolfskin-design/src/main/res/drawable/wolfskin_down.xml new file mode 100644 index 00000000..e55cd1cb --- /dev/null +++ b/wolfskin-design/src/main/res/drawable/wolfskin_down.xml @@ -0,0 +1,4 @@ + + + + diff --git a/wolfskin-design/src/main/res/drawable/wolfskin_download.xml b/wolfskin-design/src/main/res/drawable/wolfskin_download.xml new file mode 100644 index 00000000..4df16c9e --- /dev/null +++ b/wolfskin-design/src/main/res/drawable/wolfskin_download.xml @@ -0,0 +1,3 @@ + + + diff --git a/wolfskin-design/src/main/res/drawable/wolfskin_eye.xml b/wolfskin-design/src/main/res/drawable/wolfskin_eye.xml new file mode 100644 index 00000000..221d9b5e --- /dev/null +++ b/wolfskin-design/src/main/res/drawable/wolfskin_eye.xml @@ -0,0 +1 @@ + diff --git a/wolfskin-design/src/main/res/drawable/wolfskin_film.xml b/wolfskin-design/src/main/res/drawable/wolfskin_film.xml new file mode 100644 index 00000000..45648655 --- /dev/null +++ b/wolfskin-design/src/main/res/drawable/wolfskin_film.xml @@ -0,0 +1,4 @@ + + + + diff --git a/wolfskin-design/src/main/res/drawable/wolfskin_gear.xml b/wolfskin-design/src/main/res/drawable/wolfskin_gear.xml new file mode 100644 index 00000000..b823a403 --- /dev/null +++ b/wolfskin-design/src/main/res/drawable/wolfskin_gear.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/wolfskin-design/src/main/res/drawable/wolfskin_grid.xml b/wolfskin-design/src/main/res/drawable/wolfskin_grid.xml new file mode 100644 index 00000000..e19e2857 --- /dev/null +++ b/wolfskin-design/src/main/res/drawable/wolfskin_grid.xml @@ -0,0 +1,3 @@ + + + diff --git a/wolfskin-design/src/main/res/drawable/wolfskin_hide.xml b/wolfskin-design/src/main/res/drawable/wolfskin_hide.xml new file mode 100644 index 00000000..fe20e18d --- /dev/null +++ b/wolfskin-design/src/main/res/drawable/wolfskin_hide.xml @@ -0,0 +1,4 @@ + + + + diff --git a/wolfskin-design/src/main/res/drawable/wolfskin_info.xml b/wolfskin-design/src/main/res/drawable/wolfskin_info.xml new file mode 100644 index 00000000..7bcf471d --- /dev/null +++ b/wolfskin-design/src/main/res/drawable/wolfskin_info.xml @@ -0,0 +1,4 @@ + + + + diff --git a/wolfskin-design/src/main/res/drawable/wolfskin_left.xml b/wolfskin-design/src/main/res/drawable/wolfskin_left.xml new file mode 100644 index 00000000..62060544 --- /dev/null +++ b/wolfskin-design/src/main/res/drawable/wolfskin_left.xml @@ -0,0 +1,4 @@ + + + + diff --git a/wolfskin-design/src/main/res/drawable/wolfskin_play.xml b/wolfskin-design/src/main/res/drawable/wolfskin_play.xml new file mode 100644 index 00000000..96af58e1 --- /dev/null +++ b/wolfskin-design/src/main/res/drawable/wolfskin_play.xml @@ -0,0 +1,3 @@ + + + diff --git a/wolfskin-design/src/main/res/drawable/wolfskin_plus.xml b/wolfskin-design/src/main/res/drawable/wolfskin_plus.xml new file mode 100644 index 00000000..dc780c85 --- /dev/null +++ b/wolfskin-design/src/main/res/drawable/wolfskin_plus.xml @@ -0,0 +1,4 @@ + + + + diff --git a/wolfskin-design/src/main/res/drawable/wolfskin_right.xml b/wolfskin-design/src/main/res/drawable/wolfskin_right.xml new file mode 100644 index 00000000..7a1835b2 --- /dev/null +++ b/wolfskin-design/src/main/res/drawable/wolfskin_right.xml @@ -0,0 +1,3 @@ + + + diff --git a/wolfskin-design/src/main/res/drawable/wolfskin_sort.xml b/wolfskin-design/src/main/res/drawable/wolfskin_sort.xml new file mode 100644 index 00000000..fd08bb9f --- /dev/null +++ b/wolfskin-design/src/main/res/drawable/wolfskin_sort.xml @@ -0,0 +1,3 @@ + + + diff --git a/wolfskin-design/src/main/res/drawable/wolfskin_sparkle.xml b/wolfskin-design/src/main/res/drawable/wolfskin_sparkle.xml new file mode 100644 index 00000000..aa2679ce --- /dev/null +++ b/wolfskin-design/src/main/res/drawable/wolfskin_sparkle.xml @@ -0,0 +1,3 @@ + + + diff --git a/wolfskin-design/src/main/res/drawable/wolfskin_tray.xml b/wolfskin-design/src/main/res/drawable/wolfskin_tray.xml new file mode 100644 index 00000000..b902f2fd --- /dev/null +++ b/wolfskin-design/src/main/res/drawable/wolfskin_tray.xml @@ -0,0 +1,4 @@ + + + + diff --git a/wolfskin-design/src/main/res/drawable/wolfskin_tv.xml b/wolfskin-design/src/main/res/drawable/wolfskin_tv.xml new file mode 100644 index 00000000..52f1c851 --- /dev/null +++ b/wolfskin-design/src/main/res/drawable/wolfskin_tv.xml @@ -0,0 +1,4 @@ + + + + diff --git a/wolfskin-design/src/main/res/drawable/wolfskin_undo.xml b/wolfskin-design/src/main/res/drawable/wolfskin_undo.xml new file mode 100644 index 00000000..a600fb81 --- /dev/null +++ b/wolfskin-design/src/main/res/drawable/wolfskin_undo.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/wolfskin-design/src/main/res/drawable/wolfskin_users.xml b/wolfskin-design/src/main/res/drawable/wolfskin_users.xml new file mode 100644 index 00000000..adbd06d3 --- /dev/null +++ b/wolfskin-design/src/main/res/drawable/wolfskin_users.xml @@ -0,0 +1,4 @@ + + + + diff --git a/wolfskin-design/src/main/res/drawable/wolfskin_wifi.xml b/wolfskin-design/src/main/res/drawable/wolfskin_wifi.xml new file mode 100644 index 00000000..f43ded04 --- /dev/null +++ b/wolfskin-design/src/main/res/drawable/wolfskin_wifi.xml @@ -0,0 +1,4 @@ + + + + diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ComingInRatingsGateTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ComingInRatingsGateTest.kt new file mode 100644 index 00000000..c79d10db --- /dev/null +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ComingInRatingsGateTest.kt @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.design + +import java.util.Locale +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.* +import org.junit.Test + +/** The fake coroutine clock proves the production dwell/cache decision without a Compose scheduler. */ +@OptIn(ExperimentalCoroutinesApi::class) +class ComingInRatingsGateTest { + private val first = ComingInRequest("first", "movie", 1, "First", "justin", "Justin", "justin", "Justin", ComingInStage.Waiting, + ratings = listOf(ComingInRating("imdb", 6.0))) + private val second = first.copy(id = "second", tmdbId = 2, title = "Second", ratings = listOf(ComingInRating("tmdb", 7.2))) + + @Test fun uncachedDwellCancelsOnLeaveAndReentryStartsAFreshRead() = runTest { + val state = ComingInState() + state.update(ComingInSnapshot(listOf(first, second), ComingInStatus.Ready, signedIn = true)) + val abandoned = launch { state.awaitFocusedRatings() } + runCurrent() + advanceTimeBy(100) + assertFalse(state.ratingsVisible) + abandoned.cancel() + state.focus(second.id) + val next = launch { state.awaitFocusedRatings() } + runCurrent() + advanceTimeBy(349) + runCurrent() + assertFalse(state.ratingsVisible) + advanceTimeBy(1) + runCurrent() + assertTrue(state.ratingsVisible) + next.join() + state.focus(first.id) + assertFalse(state.ratingsVisible) + launch { state.awaitFocusedRatings() } + runCurrent() + advanceTimeBy(350) + runCurrent() + assertTrue("Returning to the abandoned uncached title must complete a new dwell", state.ratingsVisible) + state.focus(second.id) + assertTrue("A previously read title must be visible on the focus transition itself", state.ratingsVisible) + } + + @Test fun signOutClearsCachedVisibilityAndSignInUsesFreshRatingAndLocale() = runTest { + val state = ComingInState() + val snapshot = ComingInSnapshot(listOf(first), ComingInStatus.Ready, signedIn = true) + state.update(snapshot) + launch { state.awaitFocusedRatings() } + runCurrent(); advanceTimeBy(350); runCurrent() + assertTrue(state.ratingsVisible) + state.update(snapshot.copy(signedIn = false)) + assertFalse(state.ratingsVisible) + val refreshed = first.copy(ratings = listOf(ComingInRating("imdb", 7.6))) + state.update(snapshot.copy(items = listOf(refreshed))) + assertFalse("Sign-out invalidates the previous session's rating gate", state.ratingsVisible) + launch { state.awaitFocusedRatings() } + runCurrent(); advanceTimeBy(350); runCurrent() + assertTrue(state.ratingsVisible) + val rating = state.focused!!.ratings.single() + assertEquals("7,6", formatComingInRating(rating, Locale.forLanguageTag("nl-NL"))) + assertEquals("7.6", formatComingInRating(rating, Locale.ENGLISH)) + assertEquals("87%", formatComingInRating(ComingInRating("fresh", 87.9, true), Locale.forLanguageTag("nl-NL"))) + } + + @Test fun savedOfflineRowsOnlyShowPreviouslyReadRatingsAcrossHouseholdRequests() = runTest { + val state = ComingInState() + val sharedCopy = first.copy(id = "other-request", requesterKey = "shared", requesterName = "Shared") + val online = ComingInSnapshot(listOf(first, second, sharedCopy), ComingInStatus.Ready, signedIn = true) + state.update(online) + launch { state.awaitFocusedRatings() } + runCurrent(); advanceTimeBy(350); runCurrent() + state.update(online.copy(status = ComingInStatus.Unavailable)) + state.focus(second.id) + state.awaitFocusedRatings() + assertFalse("An unread title cannot acquire ratings while offline", state.ratingsVisible) + state.focus(sharedCopy.id) + assertTrue("The title cache is shared across request rows for the same media type and TMDB id", state.ratingsVisible) + } +} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverCaptureTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverCaptureTest.kt new file mode 100644 index 00000000..68ee58f5 --- /dev/null +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverCaptureTest.kt @@ -0,0 +1,185 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.design + +import android.app.Application +import android.graphics.BitmapFactory +import android.view.View +import android.view.WindowManager +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.text.TextLayoutResult +import org.json.JSONArray +import org.json.JSONObject +import com.github.takahirom.roborazzi.captureRoboImage +import java.io.File +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.Assert.assertTrue +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** Captures execute the shipped Compose surface with offline fixture artwork and actual shell focus. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class DiscoverCaptureTest { + @get:Rule val rule = createAndroidComposeRule() + private val fixture = discoverTestFixture() + + @Before fun fullscreen() { + rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN) + @Suppress("DEPRECATION") + rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY + } + + private fun capture(name: String, person: String = "justin", snapshotTransform: (DiscoverSnapshot) -> DiscoverSnapshot = { it }, configure: (DiscoverState) -> Unit = {}) { + val titleState = name.substringAfter("st-").toInt() in setOf(58, 59, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 87, 110) + val shell = ShellState(fixturePeople(), person).apply { pickerVisible = false; enterDestination(if (titleState) "home" else "discover") } + val snapshot = snapshotTransform(fixture.snapshot(person)) + val state = DiscoverState(shell, snapshot, RecordingDiscoverOperations(snapshot, person), CoroutineScope(Dispatchers.Main.immediate), + onPush = shell::enterDestination, onPop = { shell.enterDestination("discover") }, serviceLanes = fixture::service) + configure(state) + val painters = mutableMapOf() + rule.setContent { + WolfskinTheme { + CompositionLocalProvider(LocalDiscoverArt provides { path -> painters.getOrPut(path) { BitmapFactory.decodeFile(path).asImageBitmap() } }) { + ShellSurface(shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight), overlay = { DiscoverOverlay(state) }) { DiscoverScreen(state, it) } + } + } + } + rule.mainClock.advanceTimeBy(1000) + rule.waitForIdle() + val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "discover").apply { mkdirs() } + rule.onRoot().captureRoboImage(File(directory, "$name.png").absolutePath) + recordTypography(directory, name) + } + + private fun recordTypography(directory: File, name: String) { + val rows = JSONArray() + rule.onAllNodes(SemanticsMatcher.keyIsDefined(SemanticsProperties.Text), useUnmergedTree = true).fetchSemanticsNodes().forEach { node -> + val layouts = mutableListOf() + if (SemanticsActions.GetTextLayoutResult in node.config) node.config[SemanticsActions.GetTextLayoutResult].action?.invoke(layouts) + layouts.forEach { layout -> + val text = layout.layoutInput.text.text + val lines = JSONArray() + repeat(layout.lineCount) { index -> lines.put(text.substring(layout.getLineStart(index), layout.getLineEnd(index, visibleEnd = true))) } + rows.put(JSONObject().put("text", text).put("lines", layout.lineCount).put("lineTexts", lines) + .put("fontSizeSp", layout.layoutInput.style.fontSize.value).put("fontWeight", layout.layoutInput.style.fontWeight?.weight) + .put("lineHeightSp", layout.layoutInput.style.lineHeight.value.takeIf { it.isFinite() } ?: JSONObject.NULL).put("trim", layout.layoutInput.style.lineHeightStyle?.trim.toString()) + .put("density", layout.layoutInput.density.density).put("fontScale", layout.layoutInput.density.fontScale) + .put("left", node.boundsInRoot.left).put("top", node.boundsInRoot.top).put("width", node.boundsInRoot.width).put("height", node.boundsInRoot.height)) + } + } + File(directory, "$name-type-metrics.json").writeText(rows.toString(2)) + } + + @Test fun st029_discover() = capture("st-029") + @Test fun focusFilterChip() = capture("st-120") { it.focus(1, 0) } + @Test fun st030_picksCollection() = capture("st-030") { state -> state.openCollection(state.lanes.first { it.id == "pk-movie" }) } + @Test fun st031_hidden() = capture("st-031") { hidden(it) } + @Test fun st032_restore() { + capture("st-032") { state -> hidden(state); state.overlay = DiscoverOverlayState.Restore(state.snapshot.picks[7]) } + val title = rule.onAllNodesWithText(fixture.snapshot("justin").picks[7].title, useUnmergedTree = true) + .fetchSemanticsNodes().maxBy { it.boundsInRoot.left }.boundsInRoot + val sub = rule.onNodeWithText("Hidden from Justin’s picks.", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot + val gap = with(rule.density) { (WolfskinTokens.Discover.sheetGap + WolfskinTokens.Discover.sheetSubTop).toPx() } + assertTrue("Restore subtitle must retain the negative top margin", kotlin.math.abs(sub.top - title.bottom - gap) <= 1f) + } + @Test fun st033_failedBadge() = capture("st-033") { state -> + val lane = state.lanes.first { it.id == "pk-movie" } + state.requests[lane.titles[1].key] = listOf(DiscoverRequestResult(RequestPhase.Receipt, "Justin", "Failed", requester = "justin")) + state.focus(state.lanes.indexOf(lane) + 2, 1) + } + @Test fun st034_retryTail() = capture("st-034", snapshotTransform = { snapshot -> + snapshot.copy(lanes = snapshot.lanes.map { if (it.id == "trending") it.copy(tail = DiscoverTail.Retry) else it }) + }) { state -> val lane = state.lanes.first { it.id == "trending" }; state.focus(state.lanes.indexOf(lane) + 2, lane.titles.size) } + @Test fun st035_duplicateTail() = capture("st-035") { state -> + val titles = state.snapshot.lanes.first { it.id == "trending" }.fullTitles.take(36) + state.openCollection(DiscoverLane("trending", "Trending", titles, tail = DiscoverTail.ShowMore)) + state.collection!!.index = state.collection!!.tailIndex + } + @Test fun st036_needsSignIn() { + capture("st-036", snapshotTransform = { DiscoverSnapshot(status = DiscoverStatus.NeedsSignIn) }) + val button = rule.onNodeWithText("Try again").fetchSemanticsNode().boundsInRoot + val minimum = with(rule.density) { WolfskinTokens.Layout.buttonHeight.toPx() } + assertTrue("The sign-in button must not shrink to fit the scroll row", button.height >= minimum) + } + @Test fun st037_series() { + capture("st-037", person = "mindel") { it.filter = "tv" } + val title = rule.onNodeWithText("56 Days", useUnmergedTree = true).fetchSemanticsNode() + val layouts = mutableListOf() + title.config[SemanticsActions.GetTextLayoutResult].action?.invoke(layouts) + val layout = layouts.single() + val cssHeight = with(rule.density) { DiscoverType.hero.lineHeight.toPx() } * layout.lineCount + assertTrue("Natural font height must not inflate the hero CSS line box", kotlin.math.abs(title.boundsInRoot.height - cssHeight) <= 1f) + } + @Test fun st038_noSeeds() = capture("st-038", snapshotTransform = { it.copy(status = DiscoverStatus.NoSeeds) }) + @Test fun st039_trendingCollection() = capture("st-039") { state -> state.openCollection(state.lanes.first { it.id == "trending" }) } + @Test fun st040_service() = capture("st-040") { state -> state.openService(state.snapshot.services.first()) } + @Test fun st041_unreachable() = capture("st-041", snapshotTransform = { DiscoverSnapshot(status = DiscoverStatus.Unavailable) }) + @Test fun st058_hiddenReceipt() = capture("st-058") { state -> state.openTitle(state.snapshot.picks.first()); state.titleButton = 2; state.hide(state.title!!) } + @Test fun st059_hiddenTitle() = capture("st-059") { state -> state.hide(state.snapshot.picks.first()); state.overlay = null; state.openTitle(state.snapshot.picks.first()); state.titleButton = 2 } + @Test fun st071_requestSelf() = capture("st-071") { request(it) } + @Test fun st072_requestOther() = capture("st-072") { request(it, other = true) } + @Test fun st073_addHeld() = capture("st-073") { state -> request(state, fixture.find("The Girl Who Leapt Through Time"), other = true) } + @Test fun st074_receipt() = capture("st-074") { request(it, other = true, phase = RequestPhase.Receipt) } + @Test fun st075_notConfirmed() = capture("st-075") { request(it, phase = RequestPhase.NotConfirmed) } + @Test fun st076_pending() = capture("st-076") { request(it, phase = RequestPhase.Pending) } + @Test fun st077_refused() = capture("st-077", person = "mindel") { request(it, other = true, phase = RequestPhase.Refused) } + @Test fun st078_recipientsLoading() = capture("st-078") { request(it, other = true, phase = RequestPhase.LoadingRecipients) } + @Test fun st079_recipientsUnavailable() = capture("st-079") { request(it, other = true, phase = RequestPhase.RecipientsUnavailable) } + @Test fun st080_seasonsUnknown() = capture("st-080") { state -> request(state, state.snapshot.picks.first { it.type == "tv" }) } + @Test fun st087_sharedReceipt() = capture("st-087", person = "shared") { request(it, phase = RequestPhase.Receipt) } + @Test fun st105_pickPlate() = capture("st-105") { state -> val lane = state.lanes.first { it.id == "pk-movie" }; state.focus(state.lanes.indexOf(lane) + 2, 1) } + @Test fun st110_heldRatings() = capture("st-110") { state -> request(state, fixture.find("The Girl Who Leapt Through Time"), other = true) } + + private fun hidden(state: DiscoverState) { + state.hide(state.snapshot.picks[7]); state.hide(state.snapshot.picks[20]); state.overlay = null + val lane = state.lanes.first { it.hidden } + state.focus(state.lanes.indexOf(lane) + 2, lane.titles.indexOfFirst { it.key == state.snapshot.picks[7].key }) + } + + private fun request(state: DiscoverState, title: DiscoverTitle = state.snapshot.picks.first(), other: Boolean = false, phase: RequestPhase = RequestPhase.Choose) { + state.openTitle(title); state.openRequest(other, phase = phase) + if (phase == RequestPhase.Receipt) { + val sheet = (state.overlay as DiscoverOverlayState.Request).state + val receipt = DiscoverRequestResult(phase, state.shell.person.name, requester = sheet.selectedPerson.key) + sheet.result = receipt; state.requests[title.key] = listOf(receipt) + } + } +} + +internal fun discoverTestFixture(): DiscoverFixture { + val json = DiscoverCaptureTest::class.java.getResourceAsStream("/wolfskin-discover.json")!!.bufferedReader().use { it.readText() } + val assets = File("../app/src/debug/assets/wolfskin-discover").canonicalFile + return DiscoverFixture(json) { File(assets, it).absolutePath } +} + +/** In-process effects are inspectable without exposing any live client or endpoint. */ +internal class RecordingDiscoverOperations(private val snapshot: DiscoverSnapshot, private val actor: String) : DiscoverOperations { + val sent = mutableListOf() + var checked = 0 + var next = RequestPhase.Receipt + var mutationSucceeds = true + override suspend fun request(intent: DiscoverRequest): DiscoverRequestResult { sent += intent; return DiscoverRequestResult(next, actor.replaceFirstChar { it.uppercase() }) } + override suspend fun check(intent: DiscoverRequest): DiscoverRequestResult { checked++; return DiscoverRequestResult(next, actor.replaceFirstChar { it.uppercase() }) } + override suspend fun hide(profile: String, title: DiscoverTitle) = mutationSucceeds + override suspend fun restore(profile: String, title: DiscoverTitle) = mutationSucceeds + override suspend fun refresh() = snapshot +} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverKeysTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverKeysTest.kt new file mode 100644 index 00000000..e5bd3c3a --- /dev/null +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverKeysTest.kt @@ -0,0 +1,274 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.design + +import android.app.Application +import android.view.KeyEvent as AndroidKeyEvent +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEvent +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.pressKey +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import org.json.JSONObject +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** Acceptance data drives real Android events through the shell's production focus adapter. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +class DiscoverKeysTest { + @get:Rule val rule = createComposeRule() + private val fixture = discoverTestFixture() + private val shell = ShellState(fixturePeople(), "justin") + private val snapshot = fixture.snapshot("justin") + private val operations = RecordingDiscoverOperations(snapshot, "justin") + private val state = DiscoverState(shell, snapshot, operations, CoroutineScope(Dispatchers.Main.immediate), shell::enterDestination, + { shell.enterDestination("discover") }, fixture::service) + + private fun launch() { + rule.setContent { + WolfskinTheme { + ShellSurface(shell, overlay = { DiscoverOverlay(state) }) { modifier -> + if (shell.route == "discover" || shell.route.startsWith("discover-") || shell.route.startsWith("title/")) DiscoverScreen(state, modifier) else ShellPlaceholder(shell.route, modifier) + } + } + } + } + + private fun press(first: Key, second: Key? = null) = listOfNotNull(first, second).forEach { key -> rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(key) } } + + private fun replay(goal: String) { + val paths = JSONObject(javaClass.getResourceAsStream("/discover-keys.json")!!.bufferedReader().use { it.readText() }).getJSONArray("pressPaths") + val path = (0 until paths.length()).map { paths.getJSONObject(it) }.single { it.getString("goal") == goal } + val keys = path.getJSONArray("keys") + assertEquals(path.getInt("presses"), keys.length()) + launch() + repeat(keys.length()) { index -> press(when (keys.getString(index)) { + "OK" -> Key.DirectionCenter; "UP" -> Key.DirectionUp; "DOWN" -> Key.DirectionDown; "LEFT" -> Key.DirectionLeft; "RIGHT" -> Key.DirectionRight; else -> Key.Back + }) } + rule.waitForIdle() + } + + @Test fun openDiscoverInFourPresses() { + replay("Open Discover") + assertEquals("discover", shell.route) + assertFalse(shell.navFocused) + assertEquals("The Amazing Spider-Man", state.hero?.title) + } + + @Test fun requestForYourselfInSevenPresses() { + replay("Request a pick for yourself") + val sheet = (state.overlay as DiscoverOverlayState.Request).state + assertEquals(RequestPhase.Receipt, sheet.phase) + assertEquals("justin", operations.sent.single().requester) + assertEquals("Justin", sheet.result?.actor) + assertEquals("Waiting", state.requests.getValue(requireNotNull(state.title).key).single().stage) + } + + @Test fun requestForMindelInNinePresses() { + replay("Request a pick for Mindel") + val sheet = (state.overlay as DiscoverOverlayState.Request).state + assertEquals(RequestPhase.Receipt, sheet.phase) + assertEquals("mindel", operations.sent.single().requester) + assertEquals("Justin", sheet.result?.actor) + } + + @Test fun notInterestedInEightPressesAndUndoRestoresEveryIndex() { + val original = state.lanes.map { lane -> lane.id to lane.titles.map { it.key } } + replay("Round 3 · Not interested on a pick (Discover hero)") + val hidden = state.overlay as DiscoverOverlayState.Hidden + assertTrue(state.isHidden(hidden.title)) + assertTrue(state.heroes.none { it.key == hidden.title.key }) + assertTrue(state.lanes.filterNot { it.hidden }.none { lane -> lane.titles.any { it.key == hidden.title.key } }) + press(Key.DirectionCenter) + assertNull(state.overlay) + assertEquals("The Amazing Spider-Man", state.hero?.title) + assertEquals(original, state.lanes.map { lane -> lane.id to lane.titles.map { it.key } }) + assertNull("Undo restores silently; only Show again announces the return", state.toast) + } + + @Test fun showAgainRestoresTheHiddenPickAndAnnouncesItsReturn() { + replay("Round 3 · Not interested on a pick (Discover hero)") + val title = (state.overlay as DiscoverOverlayState.Hidden).title + press(Key.DirectionRight, Key.DirectionCenter) + rule.runOnIdle { + val lane = state.lanes.first { it.hidden } + state.focus(state.lanes.indexOf(lane) + 2, lane.titles.indexOfFirst { it.key == title.key }) + } + press(Key.DirectionCenter) + assertTrue(state.overlay is DiscoverOverlayState.Restore) + press(Key.DirectionCenter) + assertFalse(state.isHidden(title)) + assertEquals("Back in your picks.", state.toast) + } + + @Test fun heldOkOpensSheetButNeverSubmits() { + replay("Open Discover") + press(Key.DirectionCenter) + rule.runOnIdle { + shell.handleKey(KeyEvent(AndroidKeyEvent(0, 0, AndroidKeyEvent.ACTION_DOWN, AndroidKeyEvent.KEYCODE_DPAD_CENTER, 0))) + repeat(8) { index -> shell.handleKey(KeyEvent(AndroidKeyEvent(0, 0, AndroidKeyEvent.ACTION_DOWN, AndroidKeyEvent.KEYCODE_DPAD_CENTER, index + 1))) } + shell.handleKey(KeyEvent(AndroidKeyEvent(0, 0, AndroidKeyEvent.ACTION_UP, AndroidKeyEvent.KEYCODE_DPAD_CENTER, 0))) + } + assertEquals(RequestPhase.Choose, (state.overlay as DiscoverOverlayState.Request).state.phase) + assertTrue(operations.sent.isEmpty()) + } + + @Test fun uncertainOutcomeChecksWithoutResending() { + operations.next = RequestPhase.NotConfirmed + replay("Request a pick for yourself") + assertEquals(1, operations.sent.size) + assertEquals(RequestPhase.NotConfirmed, (state.overlay as DiscoverOverlayState.Request).state.phase) + operations.next = RequestPhase.Receipt + press(Key.DirectionCenter) + assertEquals(1, operations.sent.size) + assertEquals(1, operations.checked) + assertEquals(RequestPhase.Receipt, (state.overlay as DiscoverOverlayState.Request).state.phase) + } + + @Test fun closingAnUncertainReceiptNeverTurnsReopeningIntoAnotherPost() { + operations.next = RequestPhase.NotConfirmed + replay("Request a pick for yourself") + press(Key.Back, Key.DirectionCenter) + assertEquals(RequestPhase.NotConfirmed, (state.overlay as DiscoverOverlayState.Request).state.phase) + operations.next = RequestPhase.Receipt + press(Key.DirectionCenter) + assertEquals(1, operations.sent.size) + assertEquals(1, operations.checked) + assertEquals(RequestPhase.Receipt, (state.overlay as DiscoverOverlayState.Request).state.phase) + } + + @Test fun requestingForAnotherPersonKeepsYourOwnRequestedAction() { + replay("Request a pick for yourself") + press(Key.DirectionCenter) + press(Key.DirectionRight, Key.DirectionCenter) + press(Key.DirectionCenter, Key.DirectionCenter) + assertEquals(setOf("justin", "mindel"), state.requests.getValue(state.title!!.key).map { it.requester }.toSet()) + press(Key.DirectionCenter) + press(Key.DirectionLeft, Key.DirectionCenter) + assertTrue(shell.requestsVisible) + assertEquals(2, operations.sent.size) + } + + @Test fun choosingAnUncertainRecipientAgainNeverResubmits() { + operations.next = RequestPhase.NotConfirmed + replay("Open Discover") + press(Key.DirectionCenter) + press(Key.DirectionRight, Key.DirectionCenter) + press(Key.DirectionDown, Key.DirectionCenter) + press(Key.DirectionCenter) + assertEquals("shared", operations.sent.single().requester) + press(Key.Back, Key.DirectionCenter) + press(Key.DirectionDown, Key.DirectionCenter) + press(Key.DirectionCenter) + assertEquals(1, operations.sent.size) + assertEquals(RequestPhase.NotConfirmed, (state.overlay as DiscoverOverlayState.Request).state.phase) + operations.next = RequestPhase.Receipt + press(Key.DirectionCenter) + assertEquals(1, operations.sent.size) + assertEquals(1, operations.checked) + assertEquals(RequestPhase.Receipt, (state.overlay as DiscoverOverlayState.Request).state.phase) + } + + @Test fun unknownSeriesSeasonsNeverSubmitAndBackRestoresOpener() { + replay("Open Discover") + rule.runOnIdle { state.openTitle(snapshot.picks.first { it.type == "tv" }); state.openRequest(false) } + press(Key.DirectionCenter) + assertTrue(operations.sent.isEmpty()) + assertNull(state.overlay) + assertNotNull(state.title) + assertFalse(shell.navFocused) + } + + @Test fun cardOkWaitsForReleaseAndFocusMoveCancelsActivation() { + replay("Open Discover") + press(Key.DirectionDown, Key.DirectionDown) + rule.runOnIdle { shell.handleKey(KeyEvent(AndroidKeyEvent(0, 0, AndroidKeyEvent.ACTION_DOWN, AndroidKeyEvent.KEYCODE_DPAD_CENTER, 0))) } + assertNull(state.title) + press(Key.DirectionRight) + rule.runOnIdle { shell.handleKey(KeyEvent(AndroidKeyEvent(0, 0, AndroidKeyEvent.ACTION_UP, AndroidKeyEvent.KEYCODE_DPAD_CENTER, 0))) } + assertNull(state.title) + press(Key.DirectionCenter) + assertNotNull(state.title) + } + @Test fun downClampsToOccupiedCardsAndThenTheTail() { + shell.pickerVisible = false + val items = snapshot.picks.take(8) + state.openCollection(DiscoverLane("short", "Picks", items, tail = DiscoverTail.ShowMore)) + launch() + repeat(5) { press(Key.DirectionRight) } + assertEquals(5, state.collection!!.index % state.collection!!.columns) + press(Key.DirectionDown) + assertEquals(1, state.collection!!.index / state.collection!!.columns) + assertEquals(1, state.collection!!.index % state.collection!!.columns) + assertEquals(items.last().key, state.collection!!.items[state.collection!!.index].id) + press(Key.DirectionDown, Key.DirectionRight) + assertEquals(2, state.collection!!.index / state.collection!!.columns) + assertEquals(0, state.collection!!.index % state.collection!!.columns) + assertEquals(GridTail.ShowMore, state.collection!!.tail) + } + + @Test fun collectionRetryReadsNewTitlesAndOpensTheFirstAppendedTitle() { + shell.pickerVisible = false + val lane = state.lanes.first { it.id == "trending" } + state.openCollection(lane.copy(titles = lane.fullTitles.take(6), fullTitles = lane.fullTitles.take(6), tail = DiscoverTail.Retry)) + val grid = state.collection!! + grid.index = grid.tailIndex + launch() + press(Key.DirectionCenter) + assertEquals(GridTail.None, grid.tail) + assertEquals(lane.fullTitles.map { it.key }, grid.items.map { it.id }) + assertEquals(lane.fullTitles[6].key, grid.focusedTitle?.id) + press(Key.DirectionCenter) + assertEquals(lane.fullTitles[6], state.title) + } + + @Test fun collectionDismissalAndUndoKeepTheOriginalOrderAndFocus() { + shell.pickerVisible = false + state.openCollection(state.lanes.first { it.id == "pk-movie" }) + val grid = state.collection!! + val original = grid.items.map { it.id } + launch() + press(Key.DirectionCenter) + val opener = state.title!! + press(Key.DirectionRight, Key.DirectionRight) + press(Key.DirectionCenter) + assertFalse(grid.items.any { it.id == opener.key }) + press(Key.DirectionCenter) + assertEquals(original, grid.items.map { it.id }) + assertEquals(opener.key, grid.focusedTitle?.id) + } + + @Test fun collectionBackRestoresTheExactOrigin() { + shell.pickerVisible = false; shell.enterDestination("discover") + state.focus(3, 2) + val origin = shell.view + state.openCollection(state.lanes.first { it.id == "pk-movie" }) + launch() + press(Key.DirectionDown, Key.Back) + assertNull(state.collection) + assertSame(origin, shell.view) + assertEquals(3, shell.view.row) + assertEquals(2, shell.view.cards[state.rowKey]) + } + + @Test + @Config(qualifiers = "nl-rNL-w960dp-h540dp-xhdpi") + fun dutchPlateUsesLocaleAndStaysOnOneLine() { + shell.pickerVisible = false + val title = snapshot.picks.first().copy(ratings = listOf(DiscoverRating("tmdb", 7.8))) + state.openCollection(DiscoverLane("locale", "Picks", listOf(title))) + launch() + rule.mainClock.advanceTimeBy(1000) + rule.onNodeWithText("7,8").assertExists() + assertEquals("7.8", formatDiscoverRating(title.ratings.single(), java.util.Locale.ENGLISH)) + assertEquals("91%", formatDiscoverRating(DiscoverRating("rtCritics", 91.7), java.util.Locale.forLanguageTag("nl-NL"))) + } +} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverLaneProjectionTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverLaneProjectionTest.kt new file mode 100644 index 00000000..1eb30c02 --- /dev/null +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverLaneProjectionTest.kt @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.design + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** Feed projection must not lose titles when a contribution row is too short to display. */ +class DiscoverLaneProjectionTest { + @Test fun discardedSeedGroupRemainsAvailableInCatalogue() { + val titles = (1L..6L).map { DiscoverTitle(it, "movie", "Film $it") } + val snapshot = DiscoverSnapshot(picks = titles, seeds = listOf(DiscoverSeed("movie:10", "Seed", titles.subList(3, 5), watched = true, added = false)), + lanes = listOf(DiscoverLane("trending", "Trending", titles)), status = DiscoverStatus.Ready) + val rows = projectDiscoverLanes(snapshot, emptyList(), "all", emptyMap()) + assertEquals(listOf("pk-movie", "trending"), rows.map { it.id }) + assertEquals(titles.take(3), rows.first().titles) + assertEquals(titles.drop(3), rows.last().titles) + } + + @Test fun catalogueDeduplicationKeepsMediaTypesDistinctAndHiddenPicksLast() { + val movie = DiscoverTitle(1, "movie", "Film") + val series = DiscoverTitle(1, "tv", "Series") + val hidden = DiscoverTitle(2, "movie", "Hidden") + val later = DiscoverTitle(3, "movie", "Later") + val snapshot = DiscoverSnapshot(hidden = listOf(hidden), status = DiscoverStatus.Ready, lanes = listOf( + DiscoverLane("first", "First", listOf(movie, series, hidden)), + DiscoverLane("second", "Second", listOf(movie, series, later)))) + val rows = projectDiscoverLanes(snapshot, emptyList(), "all", mapOf(hidden.key to hidden)) + assertEquals(listOf(listOf(movie, series), listOf(later), listOf(hidden)), rows.map { it.titles }) + assertEquals("hidden", rows.last().id) + val filtered = projectDiscoverLanes(snapshot, emptyList(), "tv", mapOf(hidden.key to hidden)) + assertEquals(listOf(listOf(series)), filtered.map { it.titles }) + } + + @Test fun addedOnlySeedsKeepTheirPicksWithoutClaimingTheyWereWatched() { + val titles = (1L..9L).map { DiscoverTitle(it, "movie", "Film $it") } + val snapshot = DiscoverSnapshot(picks = titles, seeds = listOf( + DiscoverSeed("movie:10", "Added seed", titles, watched = false, added = true)), status = DiscoverStatus.Ready) + val lanes = projectDiscoverLanes(snapshot, emptyList(), "all", emptyMap()) + assertEquals(listOf("Movies for you"), lanes.map { it.title }) + assertEquals(titles, lanes.single().fullTitles) + } + + @Test fun watchedLanesKeepWireOrderAndStableIdentityWhenTitlesCollide() { + val titles = (1L..24L).map { DiscoverTitle(it, "movie", "Film $it") } + val snapshot = DiscoverSnapshot(picks = titles, seeds = listOf( + DiscoverSeed("movie:50", "Added seed", titles.take(8), watched = false, added = true), + DiscoverSeed("tv:70", "Same title", titles.subList(8, 16), watched = true, added = true), + DiscoverSeed("movie:70", "Same title", titles.subList(16, 24), watched = true, added = false)), + status = DiscoverStatus.Ready) + val lanes = projectDiscoverLanes(snapshot, emptyList(), "all", emptyMap()) + assertEquals(listOf("pk-movie", "pk-w-tv:70", "pk-w-movie:70"), lanes.map { it.id }) + assertEquals(listOf(titles.subList(8, 16), titles.subList(16, 24)), lanes.drop(1).map { it.titles }) + } +} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/FocusRingTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/FocusRingTest.kt new file mode 100644 index 00000000..9a2b6ae5 --- /dev/null +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/FocusRingTest.kt @@ -0,0 +1,84 @@ +package org.justinvisser.wolfskin.design + +import android.app.Application +import android.graphics.BitmapFactory +import android.view.View +import android.view.WindowManager +import androidx.activity.ComponentActivity +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.unit.dp +import com.github.takahirom.roborazzi.captureRoboImage +import java.io.File +import org.junit.Assert.assertTrue +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** Opaque content must not cover either half of the shared accent focus stroke. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class FocusRingTest { + @get:Rule val rule = createAndroidComposeRule() + + @Test fun ringStraddlesOpaqueSurfaceAndDisappearsWithoutFocus() { + rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN) + @Suppress("DEPRECATION") + rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY + val focused = mutableStateOf(true) + val radius = mutableStateOf(WolfskinTokens.Radius.button) + rule.setContent { + Box(Modifier.requiredSize(120.dp).background(WolfskinTokens.Color.bg)) { + Box(Modifier.offset(40.dp, 40.dp).size(60.dp) + .focusRing(focused.value, radius.value, WolfskinTokens.Focus.ringControlRing) + .background(WolfskinTokens.Color.surface, RoundedCornerShape(radius.value))) + } + } + val file = File.createTempFile("focus-ring-", ".png") + try { + for (corner in listOf(WolfskinTokens.Radius.button, 30.dp, WolfskinTokens.Radius.card)) { + rule.runOnIdle { radius.value = corner } + rule.mainClock.advanceTimeBy(1000) + rule.waitForIdle() + rule.onRoot().captureRoboImage(file.absolutePath) + val bitmap = BitmapFactory.decodeFile(file.absolutePath) + for ((x, expected) in listOf(77 to WolfskinTokens.Color.bg, 78 to WolfskinTokens.Color.accent, + 79 to WolfskinTokens.Color.accent, 80 to WolfskinTokens.Color.accent, + 81 to WolfskinTokens.Color.accent, 82 to WolfskinTokens.Color.surface)) { + assertPixel("Left edge x=$x, radius=$corner", expected.toArgb(), bitmap.getPixel(x, 140)) + assertPixel("Top edge y=$x, radius=$corner", expected.toArgb(), bitmap.getPixel(140, x)) + } + bitmap.recycle() + } + rule.runOnIdle { focused.value = false } + rule.mainClock.advanceTimeBy(1000) + rule.waitForIdle() + rule.onRoot().captureRoboImage(file.absolutePath) + val bitmap = BitmapFactory.decodeFile(file.absolutePath) + assertEquals(WolfskinTokens.Color.bg.toArgb(), bitmap.getPixel(79, 140)) + assertEquals(WolfskinTokens.Color.surface.toArgb(), bitmap.getPixel(80, 140)) + bitmap.recycle() + } finally { file.delete() } + } + private fun assertPixel(label: String, expected: Int, actual: Int) { + // Circular edge coverage can differ by a few channel values at pixel centres. + for (shift in listOf(0, 8, 16, 24)) { + val difference = kotlin.math.abs((expected ushr shift and 255) - (actual ushr shift and 255)) + assertTrue("$label: channel $shift differs by $difference", difference <= 8) + } + } +} \ No newline at end of file diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeArtTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeArtTest.kt new file mode 100644 index 00000000..970ea46e --- /dev/null +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeArtTest.kt @@ -0,0 +1,26 @@ +package org.justinvisser.wolfskin.design + +import android.app.Application +import androidx.compose.foundation.layout.size +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createComposeRule +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** An unreadable local image exercises Coil's real error path without a network provider. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +class HomeArtTest { + @get:Rule val rule = createComposeRule() + + @Test fun imageFailureShowsTheTitleFallbackInsteadOfAnEmptyCard() { + val title = HomeTitle("missing-image", "A missing film", "movie", poster = "file:///android_asset/wolfskin-home/not-present.jpg") + rule.setContent { WolfskinTheme { TitleArtwork(title.title, title.type, title.poster, WolfskinTokens.Card.posterW, WolfskinTokens.Card.posterH, Modifier.size(WolfskinTokens.Card.posterW, WolfskinTokens.Card.posterH)) } } + rule.waitUntil(5000) { rule.onAllNodesWithText(title.title).fetchSemanticsNodes().isNotEmpty() } + rule.onNodeWithText(title.title).assertIsDisplayed() + } +} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeCaptureTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeCaptureTest.kt new file mode 100644 index 00000000..323b52ff --- /dev/null +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeCaptureTest.kt @@ -0,0 +1,121 @@ +package org.justinvisser.wolfskin.design + +import android.app.Application +import android.view.View +import android.view.WindowManager +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.text.TextLayoutResult +import org.json.JSONArray +import org.json.JSONObject +import com.github.takahirom.roborazzi.captureRoboImage +import java.io.File +import java.util.Locale +import kotlinx.coroutines.runBlocking +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** Every frozen Home acceptance state renders the actual native Compose surface at density 2. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class HomeCaptureTest { + @get:Rule val rule = createAndroidComposeRule() + + @Before fun fullscreen() { + rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN) + @Suppress("DEPRECATION") + rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY + } + + private fun capture(name: String, person: String = "justin", mode: String = "ready", row: String = "hero", column: Int = 0, nav: Int? = null) { + val previous = Locale.getDefault() + Locale.setDefault(Locale.US) + try { + val snapshot = HomeFixture(rule.activity).snapshot(person, mode) + val shell = ShellState(fixturePeople(), person).apply { + pickerVisible = false + view.row = snapshot.rows.indexOfFirst { it.id == row }.coerceAtLeast(0) + view.cards[row] = column + if (nav != null) { navFocused = true; navIndex = nav } + } + preload(snapshot, shell.view.row) + rule.setContent { WolfskinTheme { + ShellSurface(shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) { modifier -> HomeScreen(shell, snapshot, modifier) {} } + } } + rule.mainClock.advanceTimeBy(1200) + rule.waitForIdle() + val directory = File(System.getProperty("wolfskin.captureDirectory"), "home").apply { mkdirs() } + rule.onRoot().captureRoboImage(File(directory, "$name.png").absolutePath) + recordTextLayouts(File(directory, "$name-layout.json")) + } finally { Locale.setDefault(previous) } + } + + private fun recordTextLayouts(output: File) { + val nodes = JSONArray() + rule.onAllNodes(SemanticsMatcher.keyIsDefined(SemanticsActions.GetTextLayoutResult), useUnmergedTree = true).fetchSemanticsNodes().forEach { node -> + val results = mutableListOf() + node.config[SemanticsActions.GetTextLayoutResult].action?.invoke(results) + results.forEach { layout -> + val lines = JSONArray() + repeat(layout.lineCount) { line -> + lines.put(JSONObject().put("top", layout.getLineTop(line)).put("bottom", layout.getLineBottom(line)).put("baseline", layout.getLineBaseline(line))) + } + nodes.put(JSONObject().put("text", layout.layoutInput.text.text) + .put("left", node.boundsInRoot.left).put("top", node.boundsInRoot.top) + .put("width", layout.size.width).put("height", layout.size.height) + .put("lineHeight", layout.layoutInput.style.lineHeight.toString()) + .put("lineHeightStyle", layout.layoutInput.style.lineHeightStyle.toString()).put("lines", lines)) + } + } + output.writeText(nodes.toString(2)) + } + + private fun preload(snapshot: HomeSnapshot, focused: Int) = runBlocking { + val rows = if (focused == 0) snapshot.rows.take(2) else snapshot.rows.drop(focused).take(3) + for (row in rows) for (title in row.cards.take(8)) { + val hero = row.kind == HomeRowKind.Hero + val landscape = hero || row.cardKind != HomeCardKind.Poster + val width = if (hero) 1280 else (row.cardWidth.value * 2).toInt() + val height = if (hero) 720 else (row.cardHeight.value * 2).toInt() + val source = if (landscape) title.backdrop ?: title.poster else title.poster + if (source != null) WolfskinImages.loader(rule.activity).execute(WolfskinImages.request(rule.activity, source, width, height, !landscape)) + } + } + + @Test fun st005_loading() = capture("st-005", mode = "loading", nav = 0) + @Test fun st006_justinNext() = capture("st-006") + @Test fun st007_mindelResume() = capture("st-007", person = "mindel") + @Test fun st008_sharedResume() = capture("st-008", person = "shared") + @Test fun st009_deepTrending() = capture("st-009", row = "trending", column = 2) + @Test fun st010_heroNav() = capture("st-010", nav = 0) + @Test fun st011_deepBackNav() = capture("st-011", row = "lib-shows", column = 2, nav = 0) + @Test fun st012_explore() = capture("st-012", row = "explore") + @Test fun st013_unsigned() = capture("st-013", mode = "unsigned", row = "signin") + @Test fun st014_discoverNav() = capture("st-014", nav = 1) + @Test fun st015_offlineSaved() = capture("st-015", mode = "offline") + @Test fun st016_conductarrDown() = capture("st-016", mode = "cond-down", row = "cond-down") + @Test fun st017_conductarrSaved() = capture("st-017", mode = "cond-cached") + @Test fun st018_jellyfinDown() = capture("st-018", mode = "jf-down") + @Test fun st019_picksUnavailable() = capture("st-019", mode = "picks-unavailable", row = "trending") + @Test fun st020_missingArt() = capture("st-020", person = "shared", mode = "missing", row = "lib-movies") + @Test fun st096_widestTilePlate() = capture("st-096", person = "mindel", row = "new", column = 4) + @Test fun st100_episodePlate() = capture("st-100", person = "mindel", row = "nu") + @Test fun st099_posterPlate() = capture("st-099", row = "lib-movies", column = 1) + @Test fun st101_unratedEpisode() = capture("st-101", person = "mindel", row = "nu", column = 2) + @Test fun st102_filmTile() = capture("st-102", person = "mindel", row = "new", column = 4) + @Test fun st103_heroRatings() = capture("st-103") + @Test fun spacingNextUpFirstFocused() = capture("spacing-next-up", row = "nu") + @Test fun focusHeroDetails() = capture("focus-hero-details", column = 1) + @Test fun xHomeDiscoverStart() = capture("x-home-discover-start", row = "pk-m") +} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeKeysTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeKeysTest.kt new file mode 100644 index 00000000..a23faa73 --- /dev/null +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeKeysTest.kt @@ -0,0 +1,223 @@ +package org.justinvisser.wolfskin.design + +import android.app.Application +import androidx.activity.ComponentActivity +import androidx.compose.runtime.* +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import org.json.JSONArray +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** Approved replay data drives the same Android event handling used by the Home surface. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +class HomeKeysTest { + @get:Rule val rule = createAndroidComposeRule() + + @Test fun approvedHomePathsMatchFocusAndCounts() { + val fixture = HomeFixture(rule.activity) + var shell by mutableStateOf(ShellState(fixturePeople(), "justin")) + var action: HomeAction? = null + rule.setContent { WolfskinTheme { + val snapshot = remember(shell, shell.person) { fixture.snapshot(shell.person.key) } + ShellSurface(shell) { modifier -> HomeScreen(shell, snapshot, modifier) { action = it } } + } } + val paths = javaClass.getResourceAsStream("/wolfskin-home-presses.json")!!.bufferedReader().use { JSONArray(it.readText()) } + for (index in 0 until paths.length()) { + val path = paths.getJSONObject(index) + val keys = path.getJSONArray("keys") + rule.runOnIdle { shell = ShellState(fixturePeople(), "justin"); action = null } + for (key in 0 until keys.length()) press(keys.getString(key)) + assertEquals(path.getString("goal"), path.getInt("presses"), keys.length()) + when (index) { + 0 -> { assertEquals("mindel", shell.person.key); assertTrue(action is HomeAction.Play) } + 1 -> { assertTrue(action is HomeAction.Play); assertEquals("Yu Yu Hakusho", (action as HomeAction.Play).title.seriesTitle) } + 2 -> assertEquals("trending", fixture.snapshot("justin").rows[shell.view.row].id) + 3 -> { val row = fixture.snapshot("justin").rows[shell.view.row]; assertEquals("trending", row.id); assertEquals(row.cards.size, shell.view.cards[row.id]); assertTrue(row.hasSeeAll) } + 4 -> { val row = fixture.snapshot("justin").rows[shell.view.row]; assertEquals("lib-shows", row.id); assertEquals(2, shell.view.cards[row.id]); assertFalse(shell.navFocused) } + 5 -> assertEquals("discover", shell.route) + } + } + } + + @Test fun cardsActivateOnlyOnReleaseAndMovementCancelsArming() { + val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false } + val snapshot = HomeFixture(rule.activity).snapshot("justin") + var action: HomeAction? = null + rule.setContent { WolfskinTheme { ShellSurface(shell) { modifier -> HomeScreen(shell, snapshot, modifier) { action = it } } } } + press("DOWN") + rule.onNodeWithTag("wolfskin-shell").performKeyInput { keyDown(Key.DirectionCenter) } + assertNull(action) + rule.onNodeWithTag("wolfskin-shell").performKeyInput { keyUp(Key.DirectionCenter) } + assertTrue(action is HomeAction.Details) + rule.runOnIdle { action = null } + rule.onNodeWithTag("wolfskin-shell").performKeyInput { keyDown(Key.DirectionCenter); pressKey(Key.DirectionRight); keyUp(Key.DirectionCenter) } + assertNull(action) + } + + @Test fun loadingHasNavFocus() { + val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false } + val snapshot = HomeFixture(rule.activity).snapshot("justin", "loading") + rule.setContent { WolfskinTheme { ShellSurface(shell) { modifier -> HomeScreen(shell, snapshot, modifier) {} } } } + rule.waitForIdle() + assertTrue(shell.navFocused) + assertEquals(0, shell.navIndex) + } + + @Test fun returningHomeRestoresTheExactViewportAndArrowReentersVisibleContent() { + val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false } + val snapshot = HomeFixture(rule.activity).snapshot("justin") + rule.setContent { WolfskinTheme { ShellSurface(shell) { modifier -> + if (shell.route == "home") HomeScreen(shell, snapshot, modifier) {} else ShellPlaceholder(shell.route, modifier) + } } } + rule.onNodeWithTag("wolfskin-home").performScrollToIndex(3) + val view = shell.view + val savedIndex = view.listState.firstVisibleItemIndex + val savedOffset = view.listState.firstVisibleItemScrollOffset + assertEquals(3, savedIndex) + rule.runOnIdle { shell.enterDestination("movies") } + rule.runOnIdle { shell.enterDestination("home") } + rule.waitForIdle() + assertSame(view, shell.view) + assertEquals(savedIndex, view.listState.firstVisibleItemIndex) + assertEquals(savedOffset, view.listState.firstVisibleItemScrollOffset) + press("RIGHT") + assertEquals("lib-movies", snapshot.rows[view.row].id) + assertEquals(0, view.cards["lib-movies"]) + } + + @Test fun pointerHoverMovesFocusWithoutActivatingTheCard() { + val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false } + val snapshot = HomeFixture(rule.activity).snapshot("justin") + var action: HomeAction? = null + rule.setContent { WolfskinTheme { ShellSurface(shell) { modifier -> HomeScreen(shell, snapshot, modifier) { action = it } } } } + press("DOWN") + rule.onNodeWithTag("home-card:nu:2").performMouseInput { enter(center) } + rule.waitForIdle() + assertEquals("nu", snapshot.rows[shell.view.row].id) + assertEquals(2, shell.view.cards["nu"]) + assertNull(action) + } + + @Test fun downClampsToShortRowsAndNeverMovesPastTheFinalSeeAll() { + val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false; navFocused = false } + val cards = HomeFixture(rule.activity).snapshot("justin").rows.first { it.id == "lib-movies" }.cards + val long = HomeRow(id = "long", kind = HomeRowKind.Shelf, cards = cards) + val short = long.copy(id = "short", cards = cards.take(2), total = 2) + val tail = short.copy(id = "tail", total = 20) + shell.view.cards[long.id] = cards.lastIndex + var action: HomeAction? = null + rule.setContent { WolfskinTheme { + ShellSurface(shell) { modifier -> HomeScreen(shell, HomeSnapshot(listOf(long, short, tail)), modifier) { action = it } } + } } + press("DOWN") + assertEquals(short.id, listOf(long, short, tail)[shell.view.row].id) + assertEquals(1, shell.view.cards[short.id]) + rule.onNodeWithTag("home-card:short:1").assertIsDisplayed() + press("RIGHT") + assertEquals(1, shell.view.cards[short.id]) + press("DOWN") + press("RIGHT") + press("DOWN") + assertEquals(2, shell.view.row) + assertEquals(2, shell.view.cards[tail.id]) + rule.onNodeWithTag("home-card:tail:2").assertIsDisplayed() + press("OK") + assertEquals(tail.id, (action as HomeAction.SeeAll).row.id) + } + + @Test fun mixedWidthRowsUseNearestCenterAndKeepTheTrackWhenMovingBackInsideItsMiddle() { + val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false; navFocused = false } + val cards = HomeFixture(rule.activity).snapshot("justin").rows.first { it.id == "lib-movies" }.cards + val posters = HomeRow(id = "posters", kind = HomeRowKind.Shelf, cards = cards) + val tiles = posters.copy(id = "tiles", cardKind = HomeCardKind.Tile) + shell.view.cards[posters.id] = 3 + rule.setContent { WolfskinTheme { + ShellSurface(shell) { modifier -> HomeScreen(shell, HomeSnapshot(listOf(posters, tiles)), modifier) {} } + } } + press("DOWN") + assertEquals("The third tile is nearest the fourth poster's horizontal center", 2, shell.view.cards[tiles.id]) + repeat(4) { press("RIGHT") } + val left = rule.onNodeWithTag("home-card:tiles:4").fetchSemanticsNode().boundsInRoot.left + press("LEFT") + assertEquals("Moving back within the visible middle must not shift the track", left, + rule.onNodeWithTag("home-card:tiles:4").fetchSemanticsNode().boundsInRoot.left, .1f) + press("UP") + assertEquals("A visited row retains its column", 3, shell.view.cards[posters.id]) + } + + @Test fun asynchronousFreshHomePlaysNextInTwoPresses() { + val fixture = HomeFixture(rule.activity) + val shell = ShellState(fixturePeople(), "justin") + val delivery = kotlinx.coroutines.CompletableDeferred() + var action: HomeAction? = null + rule.setContent { + val snapshot by produceState(fixture.snapshot("justin", "loading")) { value = delivery.await() } + WolfskinTheme { ShellSurface(shell) { modifier -> HomeScreen(shell, snapshot, modifier) { action = it } } } + } + press("OK") + rule.waitForIdle() + assertTrue(shell.navFocused) + rule.runOnIdle { delivery.complete(fixture.snapshot("justin")) } + rule.waitForIdle() + assertFalse(shell.navFocused) + assertEquals(0, shell.view.cards["hero"]) + press("OK") + val play = action as HomeAction.Play + assertEquals("Yu Yu Hakusho", play.title.seriesTitle) + } + + @Test fun asynchronousHomeDoesNotStealFocusAfterNavInput() { + val fixture = HomeFixture(rule.activity) + val shell = ShellState(fixturePeople(), "justin") + val delivery = kotlinx.coroutines.CompletableDeferred() + rule.setContent { + val snapshot by produceState(fixture.snapshot("justin", "loading")) { value = delivery.await() } + WolfskinTheme { ShellSurface(shell) { modifier -> HomeScreen(shell, snapshot, modifier) {} } } + } + press("OK") + press("RIGHT") + rule.runOnIdle { delivery.complete(fixture.snapshot("justin")) } + rule.waitForIdle() + assertTrue(shell.navFocused) + assertEquals(1, shell.navIndex) + } + + @Test fun heldOkRepeatEventsDoNotReplayButtonsOrActivateCardsBeforeRelease() { + val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false } + val snapshot = HomeFixture(rule.activity).snapshot("justin") + val actions = mutableListOf() + rule.setContent { WolfskinTheme { ShellSurface(shell) { modifier -> HomeScreen(shell, snapshot, modifier) { actions += it } } } } + val input = rule.onNodeWithTag("wolfskin-shell") + fun repeatOk() = rule.runOnIdle { + rule.activity.dispatchKeyEvent(android.view.KeyEvent(0L, 0L, android.view.KeyEvent.ACTION_DOWN, android.view.KeyEvent.KEYCODE_DPAD_CENTER, 3)) + } + input.performKeyInput { keyDown(Key.DirectionCenter) } + repeatOk() + assertEquals(1, actions.size) + assertTrue(actions.single() is HomeAction.Play) + input.performKeyInput { keyUp(Key.DirectionCenter) } + press("DOWN") + input.performKeyInput { keyDown(Key.DirectionCenter) } + repeatOk() + assertEquals(1, actions.size) + input.performKeyInput { keyUp(Key.DirectionCenter) } + assertEquals(2, actions.size) + assertTrue(actions.last() is HomeAction.Details) + } + + private fun press(name: String) { + val key = when (name) { + "UP" -> Key.DirectionUp; "DOWN" -> Key.DirectionDown + "LEFT" -> Key.DirectionLeft; "RIGHT" -> Key.DirectionRight + "BACK" -> Key.Back; else -> Key.DirectionCenter + } + rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(key) } + } +} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeRatingCacheTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeRatingCacheTest.kt new file mode 100644 index 00000000..fdab628e --- /dev/null +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeRatingCacheTest.kt @@ -0,0 +1,66 @@ +package org.justinvisser.wolfskin.design + +import java.util.Locale +import org.junit.Assert.* +import org.junit.Test + +/** Cache boundaries protect visible ratings during offline browsing and fast repeated focus. */ +class HomeRatingCacheTest { + private val title = HomeTitle("jellyfin-1", "A film", "movie", tmdbId = 42) + private val ratings = listOf(HomeRating("tmdb", 7.8)) + + @Test fun cacheIsSharedByCatalogueIdentityAndExpiresAtSixHours() { + var now = 0L + val cache = HomeRatingCache { now } + cache.save(title, ratings) + assertEquals(ratings, cache.read(title.copy(id = "another-server-copy"))) + now = 6L * 60 * 60 * 1000 - 1 + assertEquals(ratings, cache.read(title)) + now++ + assertNull(cache.read(title)) + } + + @Test fun cacheKeepsObservedAbsenceAndEvictsOldestBeyondCapacity() { + var now = 0L + val cache = HomeRatingCache { now++ } + cache.save(title, emptyList()) + assertEquals(emptyList(), cache.read(title)) + repeat(300) { index -> cache.save(title.copy(id = "other-$index", tmdbId = index + 100), ratings) } + assertNull(cache.read(title)) + assertEquals(ratings, cache.read(title.copy(id = "last", tmdbId = 399))) + } + + @Test fun episodeScoresShareSeriesSeasonIdentityWithoutCrossingEpisodesOrSeasons() { + var now = 0L + val cache = HomeRatingCache { now } + val episode = HomeTitle("episode-a", "Pilot", "episode", tmdbId = 42, season = 1, episode = 1) + val pilot = listOf(HomeRating("tmdb", 8.1, episode = true)) + val second = listOf(HomeRating("tmdb", 7.2, episode = true)) + cache.save(episode, pilot) + cache.save(episode.copy(id = "episode-b", episode = 2), second) + assertEquals(pilot, cache.read(episode.copy(id = "another-jellyfin-copy"))) + assertEquals(second, cache.read(episode.copy(id = "another-second-copy", episode = 2))) + assertNull(cache.read(episode.copy(season = 2))) + assertNull(cache.read(episode.copy(tmdbId = 43))) + now = 6L * 60 * 60 * 1000 + assertNull(cache.read(episode)) + } + + @Test fun seasonCapacityCountsSeasonsNotEpisodesAndIsIndependentOfTitleCapacity() { + val cache = HomeRatingCache { 0L } + val episode = HomeTitle("episode-a", "Pilot", "episode", tmdbId = 42, season = 1, episode = 1) + repeat(301) { index -> cache.save(episode.copy(id = "episode-$index", episode = index + 1), ratings) } + repeat(300) { index -> cache.save(title.copy(tmdbId = index + 100), ratings) } + assertEquals(ratings, cache.read(episode)) + repeat(300) { index -> cache.save(episode.copy(season = index + 2), ratings) } + assertNull(cache.read(episode)) + assertEquals(ratings, cache.read(episode.copy(season = 301))) + assertEquals(ratings, cache.read(title.copy(tmdbId = 100))) + } + + @Test fun decimalScoresFollowLocaleButPercentagesDoNot() { + assertEquals("7,8", ratings.single().format(Locale.forLanguageTag("nl-NL"))) + assertEquals("7.8", ratings.single().format(Locale.US)) + assertEquals("87%", HomeRating("fresh", 87.9, percentage = true).format(Locale.forLanguageTag("nl-NL"))) + } +} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeRatingsUiTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeRatingsUiTest.kt new file mode 100644 index 00000000..664ecba6 --- /dev/null +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeRatingsUiTest.kt @@ -0,0 +1,67 @@ +package org.justinvisser.wolfskin.design + +import android.app.Application +import androidx.compose.foundation.layout.width +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.text.TextLayoutResult +import java.util.Locale +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Assert.assertFalse +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** Real text layout proves locale formatting and the focus dwell instead of testing copied strings. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +class HomeRatingsUiTest { + @get:Rule val rule = createComposeRule() + + @Test fun dutchDecimalPlateStaysOnOneLine() { + val previous = Locale.getDefault() + Locale.setDefault(Locale.forLanguageTag("nl-NL")) + try { + rule.setContent { WolfskinTheme { HomeRatingPlate(listOf(HomeRating("tmdb", 7.8)), true, Modifier.width(WolfskinTokens.Card.posterW)) } } + val layouts = mutableListOf() + rule.onNodeWithText("7,8").performSemanticsAction(SemanticsActions.GetTextLayoutResult) { it(layouts) } + assertEquals(1, layouts.single().lineCount) + rule.onNodeWithText("7.8").assertDoesNotExist() + } finally { Locale.setDefault(previous) } + } + + @Test fun focusDwellDefersUncachedPlateButCachedFocusReturnsImmediately() { + val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false } + val title = HomeTitle("dwell", "A film", "movie", ratings = listOf(HomeRating("tmdb", 7.8))) + val snapshot = HomeSnapshot(listOf(HomeRow("shelf", HomeRowKind.Shelf, "Shelf", listOf(title))), HomeAvailability.Ready) + rule.mainClock.autoAdvance = false + rule.setContent { WolfskinTheme { ShellSurface(shell) { modifier -> HomeScreen(shell, snapshot, modifier) {} } } } + rule.mainClock.advanceTimeBy(100) + rule.onNodeWithText("7.8").assertDoesNotExist() + rule.mainClock.advanceTimeBy(450) + rule.onNodeWithText("7.8").assertExists() + rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.Back) } + rule.runOnIdle { assertTrue(shell.navFocused) } + rule.mainClock.advanceTimeBy(32) + rule.onNodeWithText("7.8").assertDoesNotExist() + rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionDown) } + rule.runOnIdle { assertFalse(shell.navFocused) } + rule.mainClock.advanceTimeBy(32) + rule.onNodeWithText("7.8").assertExists() + } + + @Test fun signedOutHomeNeverDrawsHeroOrPlateScores() { + val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false } + val title = HomeTitle("signed-out", "A film", "movie", ratings = listOf(HomeRating("tmdb", 7.8))) + val rows = buildHomeRows(HomeLibrary(nextUp = listOf(title)), listOf(homeDiscoverState(HomeAvailability.SignedOut))) + rule.setContent { WolfskinTheme { ShellSurface(shell) { modifier -> HomeScreen(shell, HomeSnapshot(rows), modifier) {} } } } + rule.mainClock.advanceTimeBy(1000) + rule.onNodeWithText("7.8").assertDoesNotExist() + } +} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeRowsTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeRowsTest.kt new file mode 100644 index 00000000..9fa7e9a9 --- /dev/null +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeRowsTest.kt @@ -0,0 +1,50 @@ +package org.justinvisser.wolfskin.design + +import org.junit.Assert.* +import org.junit.Test + +/** Hero precedence and empty/error transitions preserve useful, focusable Home content. */ +class HomeRowsTest { + private val resume = HomeTitle("resume", "Resume", "movie") + private val next = HomeTitle("next", "Next", "episode") + private val newest = HomeTitle("new", "New", "movie") + private val pick = HomeTitle("pick", "Pick", "movie") + private val continuation = listOf(HomeRow("pk-m", HomeRowKind.Shelf, cards = listOf(pick))) + + @Test fun heroChainUsesResumeThenNextThenNewThenPickAndNeverRepeatsHeroOnShelves() { + val cases = listOf( + HomeLibrary(listOf(resume), listOf(next), listOf(newest)) to resume, + HomeLibrary(nextUp = listOf(next), newest = listOf(newest)) to next, + HomeLibrary(newest = listOf(newest)) to newest, + HomeLibrary() to pick, + ) + for ((library, expected) in cases) { + val rows = buildHomeRows(library, continuation) + assertEquals(expected.id, rows.first().cards.single().id) + assertFalse(rows.filter { it.id in setOf("cw", "nu", "new") }.flatMap { it.cards }.any { it.id == expected.id }) + } + } + + @Test fun heldShelvesAreUnwatchedFirstAndSeeAllUsesFullDestinationCount() { + val watched = newest.copy(id = "watched", played = true, addedAt = "2026-11-05") + val older = newest.copy(id = "older", addedAt = "2026-01-01") + val recent = newest.copy(id = "recent", addedAt = "2026-10-01") + val rows = buildHomeRows(HomeLibrary(destinations = mapOf("movies" to listOf(watched, older, recent)), destinationTotals = mapOf("movies" to 84)), continuation) + val shelf = rows.single { it.id == "lib-movies" } + assertEquals(listOf("recent", "older", "watched"), shelf.cards.map { it.id }) + assertEquals(84, shelf.total) + assertTrue(shelf.hasSeeAll) + } + + @Test fun unavailableLibraryKeepsDiscoverAndFocusableRecoveryInsteadOfHeldShelves() { + val rows = buildHomeRows(HomeLibrary(listOf(resume), listOf(next), listOf(newest), mapOf("movies" to listOf(newest))), continuation, libraryAvailable = false) + assertEquals(listOf("hero", "jf-down", "disc-h", "pk-m"), rows.map { it.id }) + assertTrue(rows.single { it.id == "jf-down" }.focusable) + } + + @Test fun firstReadSkeletonHasNoFocusableContent() { + val rows = buildHomeRows(HomeLibrary(), continuation, loading = true) + assertEquals(listOf(HomeRowKind.Hero, HomeRowKind.Shelf, HomeRowKind.Shelf, HomeRowKind.Shelf, HomeRowKind.Shelf), rows.map { it.kind }) + assertTrue(rows.all { !it.focusable }) + } +} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeViewportTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeViewportTest.kt new file mode 100644 index 00000000..fe86448f --- /dev/null +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeViewportTest.kt @@ -0,0 +1,95 @@ +package org.justinvisser.wolfskin.design + +import android.app.Application +import androidx.activity.ComponentActivity +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.pressKey +import com.github.takahirom.roborazzi.captureRoboImage +import java.io.File +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 org.robolectric.annotation.GraphicsMode + +/** Row ends, mixed card widths and real title Back navigation keep the selected art inside the view. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class HomeViewportTest { + @get:Rule val rule = createAndroidComposeRule() + + @Test fun rowEndsRowChangesAndTitleBackPreserveTheFocusedCardMargin() { + val cards = HomeFixture(rule.activity).snapshot("justin").rows.flatMap { it.cards }.distinctBy { it.id }.take(12) + val rows = HomeCardKind.entries.flatMap { kind -> + val slots = when (kind) { HomeCardKind.Poster -> 6; HomeCardKind.Tile -> 4; HomeCardKind.Wide -> 3 } + listOf(slots, cards.size).map { count -> HomeRow("${kind.name}-$count", HomeRowKind.Shelf, + "${kind.name} shelf", cards.take(count), kind) } + } + val snapshot = HomeSnapshot(rows) + val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false } + var title by mutableStateOf(null) + rule.setContent { WolfskinTheme { + val page = title + if (page != null) TitleSurface(page, shell) + else ShellSurface(shell) { modifier -> HomeScreen(shell, snapshot, modifier) { action -> + if (action is HomeAction.Details) { + shell.enterDestination("title/viewport") + title = TitleState(TitlePageData(TitleItem(action.title.id, action.title.title, "movie", held = true)), shell.person) { effect -> + if (effect == TitleAction.Pop) { shell.enterDestination("home"); title = null } + } + } + } } + } } + for ((index, row) in rows.withIndex()) { + if (index > 0) press(Key.DirectionDown) + assertVisible(shell, rows) + repeat(row.count) { press(Key.DirectionLeft) } + assertVisible(shell, rows) + repeat(row.count - 1) { press(Key.DirectionRight) } + assertVisible(shell, rows) + capture("viewport-${row.id}") + press(Key.DirectionCenter) + rule.onNodeWithTag("title-page").assertIsDisplayed() + press(Key.Back) + assertVisible(shell, rows) + } + repeat(rows.lastIndex) { press(Key.DirectionUp); assertVisible(shell, rows) } + } + + private fun press(key: Key) { + rule.onRoot().performKeyInput { pressKey(key) } + rule.mainClock.advanceTimeBy(1000) + rule.waitForIdle() + } + + private fun assertVisible(shell: ShellState, rows: List) { + rule.mainClock.advanceTimeBy(1000) + rule.waitForIdle() + val row = rows[shell.view.row] + val column = shell.view.cards[row.id] ?: 0 + val bounds = rule.onNodeWithTag("home-card:${row.id}:$column").fetchSemanticsNode().boundsInRoot + val density = rule.density.density + val margin = WolfskinTokens.Card.posterGap.value * density + val stage = WolfskinTokens.Shell.stageWidth.value * density + assertTrue("Focused card is clipped: ${row.id}:$column $bounds", bounds.width >= row.cardWidth.value * density) + assertTrue("Focused card needs side margins: ${row.id}:$column $bounds", bounds.left >= margin && bounds.right <= stage - margin) + assertTrue("Focused row must clear the nav: ${row.id}:$column $bounds", bounds.top >= WolfskinTokens.Layout.navBand.value * density) + assertTrue("Focused card must clear the bottom: ${row.id}:$column $bounds", bounds.bottom <= WolfskinTokens.Shell.stageHeight.value * density - margin) + } + + private fun capture(name: String) { + val directory = File(System.getProperty("wolfskin.captureDirectory"), "home").apply { mkdirs() } + rule.onRoot().captureRoboImage(File(directory, "$name.png").absolutePath) + } +} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/LibraryCaptureTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/LibraryCaptureTest.kt new file mode 100644 index 00000000..8dd2e7f4 --- /dev/null +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/LibraryCaptureTest.kt @@ -0,0 +1,98 @@ +package org.justinvisser.wolfskin.design + +import android.app.Application +import android.graphics.BitmapFactory +import android.view.View +import android.view.WindowManager +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onRoot +import com.github.takahirom.roborazzi.captureRoboImage +import java.io.File +import java.util.Locale +import org.junit.Before +import org.junit.After +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** Each native screen state uses offline prototype data and real shell focus drawing. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class LibraryCaptureTest { + @get:Rule val rule = createAndroidComposeRule() + private val previousLocale = Locale.getDefault() + private val fixture by lazy { libraryFixture() } + + @Before fun fullscreen() { + Locale.setDefault(Locale.US) + rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN) + @Suppress("DEPRECATION") + rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY + } + @After fun restoreLocale() { Locale.setDefault(previousLocale) } + + private fun capture(name: String, person: String = "shared", destination: String = "movies", catalogue: String? = null, ratings: Boolean = true, reachable: Boolean = true, cached: Boolean = false, configure: (LibraryGridState) -> Unit = {}) { + val shell = ShellState(fixturePeople(), person).apply { pickerVisible = false; enterDestination(destination) } + val source = when (catalogue) { + "picks" -> fixture.picks(person, "movie") + "trending" -> fixture.trending() + "duplicate" -> fixture.duplicatePage() + else -> fixture.library(person, destination) + } + val grid = LibraryGridState(if (catalogue == "picks") "Movies for you" else if (catalogue != null) "Trending" else destination.replaceFirstChar { it.uppercase() }, source, catalogue != null) + configure(grid) + shell.view.library = grid + rule.setContent { + WolfskinTheme { + ShellSurface(shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) { modifier -> + LibraryScreen(shell, grid, modifier, ratingsSignedIn = ratings, ratingsReachable = reachable, + cachedRatings = if (cached) setOf(grid.items[grid.index].id) else emptySet(), offlineArt = ::fixtureArt) + } + } + } + rule.mainClock.advanceTimeBy(1000) + rule.waitForIdle() + val directory = File(System.getProperty("wolfskin.captureDirectory"), "library").apply { mkdirs() } + rule.onRoot().captureRoboImage(File(directory, "$name.png").absolutePath) + } + + @Test fun st021_sharedMovies() = capture("st-021") + @Test fun st022_ratingSort() = capture("st-022") { it.sort = 2 } + @Test fun st023_rowEndClamps() = capture("st-023") { it.index = 5; it.key(ShellKey.Right) } + @Test fun st024_anime() = capture("st-024", "justin", "anime") + @Test fun st025_unwatched() = capture("st-025") { it.unwatched = true } + @Test fun st026_empty() = capture("st-026", "mindel") { it.source = emptyList() } + @Test fun st027_unavailable() = capture("st-027", "mindel", "shows") { it.status = LibraryStatus.Unavailable } + @Test fun st028_loading() = capture("st-028") { it.status = LibraryStatus.Loading } + @Test fun st030_fullPicks() = capture("st-030", "justin", "discover", "picks") + @Test fun st035_duplicateOnlyTail() = capture("st-035", "justin", "discover", "duplicate") { it.tail = GridTail.ShowMore; it.index = it.tailIndex } + @Test fun st039_trending() = capture("st-039", "justin", "discover", "trending") + @Test fun st095_widestPosterPlate() = capture("st-095") { grid -> grid.index = grid.items.indexOfFirst { it.title == "Grave of the Fireflies" } } + @Test fun st097_criticsPlate() = capture("st-097") + @Test fun st098_tmdbPlate() = capture("st-098", "justin", "anime") { grid -> grid.index = grid.items.indexOfFirst { it.title == "Chainsaw Man" } } + @Test fun st112_cachedWhileDown() = capture("st-112", reachable = false, cached = true) + @Test fun st113_uncachedWhileDown() = capture("st-113", reachable = false) + @Test fun unsignedRatingsHidden() = capture("library-unsigned", ratings = false) + @Test fun spacingShowsFirstFocused() = capture("spacing-shows", "mindel", "shows") + @Test fun focusToolbarChip() = capture("focus-toolbar-chip") { it.toolbar = true; it.tool = 0 } + @Test fun spacingLetterRailClearsFocusedFifthColumn() = capture("spacing-letter-rail") { grid -> + val original = grid.source + grid.source = List(200) { index -> + original[index % original.size].copy(id = "rail-$index", title = "${('A'.code + index / 10).toChar()} Title $index") + } + grid.index = 4 + } +} + +fun libraryFixture(): LibraryFixture = LibraryFixture(LibraryCaptureTest::class.java.getResourceAsStream("/wolfskin-library.json")!!.bufferedReader().use { it.readText() }, "/library/") + +fun fixtureArt(path: String): ImageBitmap? = LibraryCaptureTest::class.java.getResourceAsStream(path)?.use { BitmapFactory.decodeStream(it)?.asImageBitmap() } diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/LibraryKeysTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/LibraryKeysTest.kt new file mode 100644 index 00000000..5eae18b5 --- /dev/null +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/LibraryKeysTest.kt @@ -0,0 +1,211 @@ +package org.justinvisser.wolfskin.design + +import android.app.Application +import android.view.KeyEvent as AndroidKeyEvent +import androidx.compose.runtime.remember +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEvent +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.pressKey +import org.json.JSONObject +import org.junit.Assert.* +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.Locale + +/** Acceptance data drives Android keys through the real shell and grid controller. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +class LibraryKeysTest { + @get:Rule val rule = createComposeRule() + private val shell = ShellState(fixturePeople(), "justin") + private val fixture by lazy { libraryFixture() } + private var opened: LibraryTitle? = null + + private fun render() { + rule.setContent { + WolfskinTheme { + ShellSurface(shell) { modifier -> + if (shell.route in listOf("movies", "shows", "anime")) { + val view = shell.view + val grid = remember(shell.person.key, shell.route, view) { + view.library ?: LibraryGridState(shell.route, fixture.library(shell.person.key, shell.route), onTitle = { opened = it }).also { view.library = it } + } + LibraryScreen(shell, grid, modifier, offlineArt = ::fixtureArt) + } else ShellPlaceholder(shell.route, modifier) + } + } + } + } + private fun press(key: Key) { rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(key) }; rule.waitForIdle() } + private fun press(keys: List) { keys.forEach(::press) } + + @Test fun spiritedAwayInApprovedTwentyPresses() { + val document = LibraryKeysTest::class.java.getResourceAsStream("/library-screen.json")!!.bufferedReader().use { JSONObject(it.readText()) } + val paths = document.getJSONArray("pressPaths") + render() + for (index in 0 until paths.length()) { + val path = paths.getJSONObject(index) + val keys = path.getJSONArray("keys") + assertEquals(path.getInt("presses"), keys.length()) + for (keyIndex in 0 until keys.length()) press(when (keys.getString(keyIndex)) { + "UP" -> Key.DirectionUp + "DOWN" -> Key.DirectionDown + "LEFT" -> Key.DirectionLeft + "RIGHT" -> Key.DirectionRight + "OK" -> Key.DirectionCenter + else -> error("Unknown acceptance key") + }) + assertEquals("shared", shell.person.key) + assertEquals("movies", shell.route) + assertFalse(shell.navFocused) + assertEquals("Spirited Away", shell.view.library!!.focusedTitle!!.title) + } + } + + @Test fun rowEdgesToolbarSortAndBackKeepExactCard() { + render() + press(listOf(Key.DirectionRight, Key.DirectionRight, Key.DirectionCenter, Key.DirectionUp, Key.DirectionRight, Key.DirectionRight, Key.DirectionCenter)) + val grid = shell.view.library!! + press(Key.DirectionLeft) + assertEquals(0, grid.index) + repeat(6) { press(Key.DirectionRight) } + assertEquals(5, grid.index) + assertFalse(grid.inRail) + press(listOf(Key.DirectionDown, Key.Back)) + assertTrue(shell.navFocused) + assertEquals(11, grid.index) + press(Key.DirectionDown) + assertEquals(11, grid.index) + press(listOf(Key.DirectionUp, Key.DirectionUp)) + assertTrue(grid.toolbar) + press(Key.DirectionCenter) + assertEquals(1, grid.sort) + assertTrue(grid.toolbar) + press(Key.DirectionCenter) + assertEquals(2, grid.sort) + assertEquals("Grave of the Fireflies", grid.items.first().title) + } + + @Test fun cardActsOnReleaseAndHeldRepeatCannotOpenTwice() { + render() + press(listOf(Key.DirectionCenter, Key.DirectionUp, Key.DirectionRight, Key.DirectionRight, Key.DirectionCenter)) + rule.runOnIdle { + shell.handleKey(KeyEvent(AndroidKeyEvent(0, 0, AndroidKeyEvent.ACTION_DOWN, AndroidKeyEvent.KEYCODE_DPAD_CENTER, 0))) + assertNull(opened) + shell.handleKey(KeyEvent(AndroidKeyEvent(0, 0, AndroidKeyEvent.ACTION_DOWN, AndroidKeyEvent.KEYCODE_DPAD_CENTER, 1))) + assertNull(opened) + shell.handleKey(KeyEvent(AndroidKeyEvent(0, 0, AndroidKeyEvent.ACTION_UP, AndroidKeyEvent.KEYCODE_DPAD_CENTER, 0))) + assertEquals(shell.view.library!!.focusedTitle, opened) + opened = null + shell.handleKey(KeyEvent(AndroidKeyEvent(0, 0, AndroidKeyEvent.ACTION_UP, AndroidKeyEvent.KEYCODE_DPAD_CENTER, 0))) + assertNull(opened) + } + } + + @Test fun railGateJumpAndBackRespectOriginalCard() { + val titles = (0 until 200).map { index -> LibraryTitle("$index", "${('A'.code + index / 10).toChar()}Title $index", "movie") } + val grid = LibraryGridState("Movies", titles) + assertTrue(grid.railVisible) + grid.index = 4 + grid.key(ShellKey.Right) + assertTrue(grid.inRail) + grid.key(ShellKey.Down) + assertEquals('B', grid.railLetter) + grid.key(ShellKey.Back) + assertEquals(4, grid.index) + assertFalse(grid.inRail) + grid.key(ShellKey.Down) + assertEquals(9, grid.index) + grid.key(ShellKey.Up) + assertEquals(4, grid.index) + grid.key(ShellKey.Right); grid.key(ShellKey.Down); grid.key(ShellKey.Ok) + assertEquals('B', grid.focusedTitle!!.letter) + grid.source = titles.take(199) + assertFalse(grid.railVisible) + grid.index = 4 + grid.key(ShellKey.Right) + assertEquals(5, grid.index) + grid.source = titles; grid.sort = 1 + assertFalse(grid.railVisible) + } + + @Test fun mixedSeeAllPreservesFullListAndClampsShortRow() { + val grid = LibraryGridState("Full list", (0 until 8).map { LibraryTitle("$it", "Title $it", if (it % 2 == 0) "movie" else "tv") }, catalogue = true) + grid.index = 5 + grid.key(ShellKey.Down) + assertEquals(7, grid.index) + grid.key(ShellKey.Up); grid.key(ShellKey.Up) + assertTrue(grid.toolbar) + grid.key(ShellKey.Right); grid.key(ShellKey.Ok) + assertEquals(listOf("0", "2", "4", "6"), grid.items.map { it.id }) + grid.key(ShellKey.Left); grid.key(ShellKey.Ok) + assertEquals(8, grid.items.size) + } + + @Test fun downClampsShortLastRowBeforeEnteringRecoveryTail() { + var recoveries = 0 + val grid = LibraryGridState("Trending", + (0 until 20).map { LibraryTitle("$it", "Title $it", "movie") }, + catalogue = true, onRecover = { recoveries++ }, + ).apply { tail = GridTail.Retry; index = 14 } + shell.pickerVisible = false + shell.enterDestination("discover") + rule.setContent { + WolfskinTheme { + ShellSurface(shell) { modifier -> + LibraryScreen(shell, grid, modifier) + } + } + } + press(Key.DirectionDown) + assertEquals(19, grid.index) + assertEquals("Title 19", grid.focusedTitle!!.title) + assertEquals(0, recoveries) + press(Key.DirectionDown) + assertEquals(24, grid.index) + assertEquals("grid-tail", grid.cardKey) + press(Key.DirectionDown) + assertEquals(24, grid.index) + press(Key.DirectionCenter) + assertEquals(1, recoveries) + press(Key.DirectionUp) + assertEquals(18, grid.index) + assertEquals("Title 18", grid.focusedTitle!!.title) + } + + @Test fun mixedCatalogueRowsKeepMovieAndSeriesWithTheSameNumericIdentityDistinct() { + val titles = List(12) { index -> + val type = if (index < 6) "movie" else "tv" + LibraryTitle((index % 6).toString(), "$type title ${index % 6}", type, tmdbId = (index % 6).toString()) + } + val grid = LibraryGridState("Trending", titles, catalogue = true, onTitle = { opened = it }) + shell.pickerVisible = false + shell.enterDestination("discover") + rule.setContent { WolfskinTheme { ShellSurface(shell) { LibraryScreen(shell, grid, it) } } } + press(Key.DirectionDown) + assertEquals(6, grid.index) + press(Key.DirectionCenter) + assertEquals("tv", opened!!.type) + assertEquals("0", opened!!.tmdbId) + assertEquals("tv title 0", opened!!.title) + } + + @Test fun scoresFollowLocaleAndUnratedSortsLast() { + assertEquals("7,8", LibraryRating("tmdb", 7.8).formatted(Locale.forLanguageTag("nl-NL"))) + assertEquals("7.8", LibraryRating("tmdb", 7.8).formatted(Locale.US)) + assertEquals("87%", LibraryRating("rtCritics", 87.9).formatted(Locale.forLanguageTag("nl-NL"))) + val grid = LibraryGridState("Movies", listOf( + LibraryTitle("none", "The Apple", "movie"), + LibraryTitle("imdb", "A Bee", "movie", ratings = listOf(LibraryRating("imdb", 8.0))), + LibraryTitle("rt", "An Ant", "movie", ratings = listOf(LibraryRating("rtCritics", 80.0))), + )) + grid.sort = 2 + assertEquals(listOf("rt", "imdb", "none"), grid.items.map { it.id }) + } +} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/LibraryPaginationTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/LibraryPaginationTest.kt new file mode 100644 index 00000000..6b0e41ba --- /dev/null +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/LibraryPaginationTest.kt @@ -0,0 +1,55 @@ +package org.justinvisser.wolfskin.design + +import kotlinx.coroutines.runBlocking +import org.junit.Assert.* +import org.junit.Test + +/** Paging never loops through repeated pages or loses the readable list after a failure. */ +class LibraryPaginationTest { + private fun title(id: Int) = LibraryTitle("$id", "Title $id", "movie") + + @Test fun duplicateOnlyReadWaitsForExplicitRetryThenAppendsUniqueTitles() = runBlocking { + val grid = LibraryGridState("Trending", (0 until 20).map(::title), catalogue = true) + var calls = 0 + val pager = LibraryPagination(grid) { page -> + calls++ + when (page) { + 2 -> LibraryPage((0 until 20).map(::title), true) + 3 -> LibraryPage((18 until 25).map(::title), false) + else -> error("Unexpected extra read") + } + } + grid.index = 9 + pager.readAhead() + assertEquals(0, calls) + grid.index = 10 + pager.readAhead() + assertEquals(GridTail.ShowMore, grid.tail) + pager.readAhead() + assertEquals(1, calls) + pager.readAhead(force = true) + assertEquals((0 until 25).map { "$it" }, grid.source.map { it.id }) + assertEquals(GridTail.None, grid.tail) + pager.readAhead(force = true) + assertEquals(2, calls) + } + + @Test fun failedReadRetainsTitlesAndRetriesSamePage() = runBlocking { + val grid = LibraryGridState("Trending", (0 until 12).map(::title), catalogue = true) + var reachable = false + val pages = mutableListOf() + val pager = LibraryPagination(grid) { page -> + pages += page + if (!reachable) throw java.io.IOException("offline") + LibraryPage(listOf(title(12)), false) + } + grid.index = 11 + pager.readAhead() + assertEquals(GridTail.Retry, grid.tail) + assertEquals((0 until 12).map { "$it" }, grid.source.map { it.id }) + reachable = true + pager.readAhead(force = true) + assertEquals(listOf(2, 2), pages) + assertEquals("12", grid.source.last().id) + } +} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/LibrarySpacingRegressionTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/LibrarySpacingRegressionTest.kt new file mode 100644 index 00000000..79e89277 --- /dev/null +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/LibrarySpacingRegressionTest.kt @@ -0,0 +1,53 @@ +package org.justinvisser.wolfskin.design + +import android.app.Application +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** Caption spacing and live rail changes must preserve poster geometry and retained focus. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +class LibrarySpacingRegressionTest { + @get:Rule val rule = createComposeRule() + + @Test fun partiallyWatchedPosterKeepsItsOriginalProgressInsets() { + val title = LibraryTitle("partial", "Partially watched", "movie", progress = .5f) + rule.setContent { WolfskinTheme { LibraryPoster(title, false, false, false) } } + val poster = rule.onNodeWithTag("poster-partial").fetchSemanticsNode().boundsInRoot + val track = rule.onNodeWithTag("poster-progress-partial").fetchSemanticsNode().boundsInRoot + assertEquals(256f, poster.width, .1f) + assertEquals(220f, track.width, .1f) + assertEquals(18f, track.left - poster.left, .1f) + assertEquals(18f, poster.right - track.right, .1f) + } + + @Test fun crossingRailThresholdKeepsTheSameDeepFocusedPosterVisible() { + val titles = List(200) { LibraryTitle("$it", "Title ${it.toString().padStart(3, '0')}", "tv") } + val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false; enterDestination("shows") } + val grid = LibraryGridState("Shows", titles.take(199)).apply { index = 198 } + rule.setContent { WolfskinTheme { ShellSurface(shell) { LibraryScreen(shell, grid, it) } } } + rule.mainClock.advanceTimeBy(1000) + rule.waitForIdle() + val poster = rule.onNodeWithTag("poster-198") + poster.assertIsDisplayed() + val original = poster.fetchSemanticsNode().boundsInRoot + for (count in listOf(200, 199)) { + rule.runOnIdle { grid.source = titles.take(count) } + rule.mainClock.advanceTimeBy(1000) + rule.waitForIdle() + poster.assertIsDisplayed() + val current = poster.fetchSemanticsNode().boundsInRoot + assertEquals(198, grid.index) + assertEquals("198", grid.focusedTitle!!.id) + assertEquals(original.top, current.top, 1f) + assertEquals(original.height, current.height, 1f) + } + } +} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/NavSpringTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/NavSpringTest.kt new file mode 100644 index 00000000..07dec9a8 --- /dev/null +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/NavSpringTest.kt @@ -0,0 +1,38 @@ +package org.justinvisser.wolfskin.design + +import androidx.compose.animation.core.TargetBasedAnimation +import androidx.compose.animation.core.VectorConverter +import androidx.compose.animation.core.spring +import java.io.File +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Sample the shipped Compose spring, not a separately approximated motion function. */ +class NavSpringTest { + @Test + fun criticallyDampedBlobMatchesLockedCurveWithoutOvershoot() { + val animation = TargetBasedAnimation( + animationSpec = spring(WolfskinTokens.Motion.navBlobDampingRatio, WolfskinTokens.Motion.navBlobStiffness), + typeConverter = Float.VectorConverter, + initialValue = 478f, + targetValue = 688f, + ) + val times = listOf(50, 99, 150, 200) + val expected = listOf(554.5f, 628.7f, 665.9f, 680.2f) + val values = times.map { animation.getValueFromNanos(it * 1_000_000L) } + values.zip(expected).forEach { (actual, target) -> assertEquals(target, actual, .2f) } + assertTrue((values[1] - 478) / 210 in .55f.. .8f) + assertEquals(688f, animation.getValueFromNanos(300_000_000), 1f) + var previous = 478f + for (millisecond in 0..1000) { + val value = animation.getValueFromNanos(millisecond * 1_000_000L) + assertTrue("Spring reversed at $millisecond ms", value >= previous) + assertTrue("Spring overshot at $millisecond ms", value <= 688f) + previous = value + } + val directory = File(System.getProperty("wolfskin.captureDirectory")) + directory.mkdirs() + File(directory, "nav-spring.json").writeText(times.zip(values).joinToString(prefix = "[", postfix = "]") { (time, value) -> "{\"ms\":$time,\"x\":$value}" }) + } +} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/OwnedSourceAuditTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/OwnedSourceAuditTest.kt new file mode 100644 index 00000000..ec217257 --- /dev/null +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/OwnedSourceAuditTest.kt @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.design + +import java.io.File +import org.junit.Assert.* +import org.junit.Test + +/** The normal JVM gate rejects untokened styling, undersized typography and internal screen wording. */ +class OwnedSourceAuditTest { + @Test fun ownedScreensUseTokensReadableTextAndViewerFacingNames() { + val roots = listOf(File("src/main/java"), File("../app/src/wolfskin")) + roots.forEach { assertTrue("Missing owned source root: $it", it.isDirectory) } + val violations = roots.flatMap { root -> + root.walkTopDown().filter { it.isFile && it.extension == "kt" }.flatMap { source -> + audit(source.name, source.readText()).map { "$source:$it" } + }.toList() + } + assertTrue(violations.joinToString("\n"), violations.isEmpty()) + } + + @Test fun plantedViolationsFailEachDoneBarRule() { + val cases = listOf( + Triple("Screen.kt", "Box(Modifier.width(12.dp))", "unit"), + Triple("Screen.kt", "Box(Modifier.width(12f.dp))", "unit"), + Triple("Screen.kt", "Text(fontSize = 14.sp)", "unit"), + Triple("Screen.kt", "Box(Modifier.width((width + gap).dp))", "unit"), + Triple("Screen.kt", "Text(color = Color(0xFF123456))", "color"), + Triple("Screen.kt", "Text(color = ComposeColor(red = 1f, green = 0f, blue = 0f))", "color"), + Triple("WolfskinTokens.kt", "val tiny = TextStyle(fontSize = 11.5.sp)", "font-floor"), + Triple("Screen.kt", "Text(\"Recs Movies\")", "wording"), + Triple("Screen.kt", "Text(\"DGiC\")", "wording"), + Triple("Screen.kt", "Text(\"Justin Shows\")", "wording"), + Triple("Screen.kt", "Text(\"Next page\")", "wording"), + Triple("Screen.kt", "Text(\"Previous page\")", "wording"), + Triple("Screen.kt", "Text(\"Page 2\")", "wording"), + ) + cases.forEach { (name, source, rule) -> + assertTrue("$rule accepted $source", audit(name, source).any { it.endsWith(":$rule") }) + } + assertTrue(audit("WolfskinTokens.kt", "val body = TextStyle(fontSize = 12.sp); val fill = Color(0xFF123456)").isEmpty()) + assertTrue(audit("Screen.kt", "import androidx.compose.ui.unit.dp\nText(\"This title can’t be read\", style = WolfskinTokens.Type.body)").isEmpty()) + } + + private fun audit(name: String, source: String): List { + val generated = name == "WolfskinTokens.kt" + return source.lineSequence().mapIndexedNotNull { index, line -> + val code = line.trimStart() + if (code.startsWith("import ") || code.startsWith("//") || code.startsWith("*") || code.startsWith("/*")) return@mapIndexedNotNull null + val rules = buildList { + if (!generated && units.containsMatchIn(line)) add("unit") + if (!generated && colors.containsMatchIn(line)) add("color") + if (fonts.findAll(line).any { it.groupValues[1].toDouble() < 12 }) add("font-floor") + if (strings.findAll(line).any { forbidden.containsMatchIn(it.value) }) add("wording") + } + rules.map { "${index + 1}:$it" } + }.flatten().toList() + } + + companion object { + private val units = Regex("\\.(?:dp|sp)\\b") + private val colors = Regex("\\b(?:Compose)?Color\\s*\\(\\s*(?:0[xX]|[0-9.]|(?:red|green|blue|alpha|color)\\s*=)") + private val fonts = Regex("\\bfontSize\\s*=\\s*([0-9]+(?:\\.[0-9]+)?)[fF]?\\.sp\\b") + private val strings = Regex("\"(?:\\\\.|[^\"\\\\])*\"") + private val forbidden = Regex("Recs (?:Movies|Shows)|DGiC|(?:Justin|Mindel|Shared) (?:Movies|Shows)|(?:Next|Previous) page|Page (?:[0-9]|\\$)|\\bpagination\\b", RegexOption.IGNORE_CASE) + } +} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/RecoveryCaptureTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/RecoveryCaptureTest.kt new file mode 100644 index 00000000..f61d97e1 --- /dev/null +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/RecoveryCaptureTest.kt @@ -0,0 +1,197 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.design + +import android.app.Application +import android.view.View +import android.view.WindowManager +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.text.TextLayoutResult +import com.github.takahirom.roborazzi.captureRoboImage +import java.io.File +import java.util.Locale +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Assert.* +import org.junit.Before +import org.junit.After +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** Native state proof uses only packaged fixtures and records the actual text/font/layout surface. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class RecoveryCaptureTest { + @get:Rule val rule = createAndroidComposeRule() + private val previousLocale = Locale.getDefault() + + @Before fun fullscreen() { + Locale.setDefault(Locale.US) + rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN) + @Suppress("DEPRECATION") + rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY + } + @After fun restoreLocale() { Locale.setDefault(previousLocale) } + + private fun shell(route: String = "home") = ShellState(fixturePeople(), "mindel").apply { pickerVisible = false; enterDestination(route) } + + private fun mount(shell: ShellState, overlay: @Composable () -> Unit = {}, content: @Composable (Modifier) -> Unit) { + rule.setContent { WolfskinTheme { + ShellSurface(shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight), overlay = overlay, content = content) + } } + rule.mainClock.advanceTimeBy(1000) + rule.waitForIdle() + } + + private fun capture(name: String) { + val directory = File(System.getProperty("wolfskin.captureDirectory"), "recovery").apply { mkdirs() } + rule.onRoot().captureRoboImage(File(directory, "$name.png").absolutePath) + val text = JSONArray() + val forbidden = Regex("Recs Movies|Recs Shows|DGiC|(?:Justin|Mindel|Shared) (?:Movies|Shows)|(?:Next|Previous) page|Page [0-9]") + rule.onAllNodes(SemanticsMatcher.keyIsDefined(SemanticsActions.GetTextLayoutResult), useUnmergedTree = true).fetchSemanticsNodes().forEach { node -> + val layouts = mutableListOf() + node.config[SemanticsActions.GetTextLayoutResult].action?.invoke(layouts) + layouts.forEach { layout -> + val words = layout.layoutInput.text.text + val style = layout.layoutInput.style + assertTrue("Text smaller than 12sp: $words", style.fontSize.value >= 12f) + assertFalse("Internal wording: $words", forbidden.containsMatchIn(words)) + text.put(JSONObject().put("text", words).put("fontSizeSp", style.fontSize.value).put("weight", style.fontWeight?.weight) + .put("lineCount", layout.lineCount).put("lineHeightSp", style.lineHeight.value.takeIf { it.isFinite() } ?: JSONObject.NULL) + .put("left", node.boundsInRoot.left).put("top", node.boundsInRoot.top).put("width", node.boundsInRoot.width).put("height", node.boundsInRoot.height)) + } + } + File(directory, "$name-layout.json").writeText(text.toString(2)) + } + + @Test fun st090_offlinePlayToastRetainsHomeAndFocusedResume() { + val shell = shell() + val snapshot = HomeFixture(rule.activity).snapshot("mindel", "offline") + shell.toast = RecoveryMessage.PlaybackUnavailable + mount(shell) { HomeScreen(shell, snapshot, it) {} } + rule.onNodeWithText(RecoveryMessage.PlaybackUnavailable).assertIsDisplayed() + rule.onNodeWithText("Resume S4 E9").assertIsDisplayed() + assertFalse(shell.navFocused) + val toast = rule.onNodeWithTag("recovery-toast").fetchSemanticsNode().boundsInRoot + val message = rule.onNodeWithText(RecoveryMessage.PlaybackUnavailable).fetchSemanticsNode().boundsInRoot + assertEquals(1064f, toast.left, 1f) + assertEquals(150f, toast.top, 1f) + assertEquals(760f, toast.width, 1f) + assertEquals(1136f, message.left, 1f) + capture("st-090") + } + + @Test fun savedLibraryHasNavAndHeaderChipAndAReachableRetryTail() { + var retried = false + val shell = shell("movies") + val grid = LibraryGridState("Movies", listOf(LibraryTitle("saved", "A saved film", "movie")), onRecover = { retried = true }).apply { + savedAt = "20:42"; tail = GridTail.Retry + } + mount(shell) { LibraryScreen(shell, grid, it) } + assertEquals(2, rule.onAllNodesWithText("Saved 20:42").fetchSemanticsNodes().size) + rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionDown); pressKey(Key.DirectionCenter) } + assertTrue(retried) + capture("library-saved") + } + + @Test fun libraryWithoutSavedDataOffersRecovery() { + var retried = false + val shell = shell("shows") + val grid = LibraryGridState("Shows", emptyList(), onRecover = { retried = true }).apply { status = LibraryStatus.Unavailable } + mount(shell) { LibraryScreen(shell, grid, it) } + rule.onNodeWithText("Try again").assertIsDisplayed() + rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionCenter) } + assertTrue(retried) + capture("library-unavailable") + } + + @Test fun savedDiscoverKeepsHeroAndRecoveryWithoutAllowingRequests() { + val shell = shell("discover") + val snapshot = discoverTestFixture().snapshot("mindel").copy(savedAt = "20:42") + val state = DiscoverState(shell, snapshot, RecordingDiscoverOperations(DiscoverSnapshot(status = DiscoverStatus.Unavailable), "Mindel"), CoroutineScope(Dispatchers.Main.immediate)) + mount(shell, overlay = { DiscoverOverlay(state) }) { DiscoverScreen(state, it) } + rule.onNodeWithText("Saved 20:42").assertIsDisplayed() + assertTrue(state.hasHero) + assertFalse(state.canRequest) + rule.runOnIdle { state.focus(state.rowCount - 1) } + rule.mainClock.advanceTimeBy(1000) + rule.onNodeWithText("More titles couldn’t load").assertIsDisplayed() + capture("discover-saved") + } + + @Test fun unavailableDiscoverRetryShowsExactFailureToast() { + val shell = shell("discover") + val snapshot = DiscoverSnapshot(status = DiscoverStatus.Unavailable) + val state = DiscoverState(shell, snapshot, RecordingDiscoverOperations(snapshot, "Mindel"), CoroutineScope(Dispatchers.Main.immediate)) + mount(shell, overlay = { DiscoverOverlay(state) }) { DiscoverScreen(state, it) } + rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionCenter) } + rule.onNodeWithText(RecoveryMessage.StillUnreachable).assertIsDisplayed() + capture("discover-unavailable-retry") + } + + @Test fun savedRequestsKeepTheirRowsAndRecoveryAfterFailure() { + val shell = shell("settings") + val item = ComingInRequest("saved", "movie", 1, "A saved request", "mindel", "Mindel", "shared", "Shared", ComingInStage.Waiting) + shell.requests.update(ComingInSnapshot(listOf(item), ComingInStatus.Ready, signedIn = true, savedAt = "20:42")) + shell.requests.update(ComingInSnapshot(status = ComingInStatus.Unavailable, signedIn = true)) + shell.openRequests() + mount(shell) { SettingsList(shell, it) } + rule.onNodeWithText("A saved request").assertIsDisplayed() + rule.onNodeWithText("Try again").assertIsDisplayed() + rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionDown) } + assertTrue(shell.requests.recoveryFocused) + capture("requests-saved") + } + + @Test fun failedPosterReadUsesTheSameMissingArtFallbackAsHome() { + val title = LibraryTitle("missing", "A missing film", "movie", poster = "file:///missing-offline-poster.jpg") + mount(shell("movies")) { LibraryScreen(shell("movies"), LibraryGridState("Movies", listOf(title)), it) } + rule.waitUntil(5000) { rule.onAllNodesWithText(title.title).fetchSemanticsNodes().size >= 2 } + capture("library-missing-art") + } + + @Test fun dutchLibraryScoreRendersCommaWithoutBreakingThePlate() { + Locale.setDefault(Locale.forLanguageTag("nl-NL")) + val title = LibraryTitle("dutch", "A film", "movie", ratings = listOf(LibraryRating("tmdb", 7.8))) + val shell = shell("movies") + mount(shell) { LibraryScreen(shell, LibraryGridState("Movies", listOf(title)), it, ratingsSignedIn = true) } + rule.onNodeWithText("7,8").assertIsDisplayed() + capture("library-dutch-score") + } + + @Config(qualifiers = "nl-rNL-w960dp-h540dp-xhdpi") + @Test fun dutchDiscoverHeroScoreRendersComma() { + Locale.setDefault(Locale.forLanguageTag("nl-NL")) + val title = DiscoverTitle(1, "movie", "A film", ratings = listOf(DiscoverRating("tmdb", 7.8))) + val snapshot = DiscoverSnapshot(picks = listOf(title), status = DiscoverStatus.Ready) + val shell = shell("discover") + val state = DiscoverState(shell, snapshot, RecordingDiscoverOperations(snapshot, "Mindel"), CoroutineScope(Dispatchers.Main.immediate)) + mount(shell) { DiscoverScreen(state, it) } + rule.onNodeWithText("7,8").assertIsDisplayed() + capture("discover-dutch-score") + } + + @Test fun failedDiscoverBackdropUsesTheSharedAmbientFallback() { + val shell = shell("discover") + val title = DiscoverTitle(1, "movie", "A film", backdrop = "file:///missing-offline-backdrop.jpg") + val snapshot = DiscoverSnapshot(picks = listOf(title), status = DiscoverStatus.Ready) + val state = DiscoverState(shell, snapshot, RecordingDiscoverOperations(snapshot, "Mindel"), CoroutineScope(Dispatchers.Main.immediate)) + mount(shell) { DiscoverScreen(state, it) } + rule.waitUntil(5000) { rule.onAllNodesWithTag("discover-missing-art").fetchSemanticsNodes().isNotEmpty() } + rule.onNodeWithTag("discover-missing-art").assertIsDisplayed() + capture("discover-missing-art") + } +} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/RecoveryStateTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/RecoveryStateTest.kt new file mode 100644 index 00000000..d0a98595 --- /dev/null +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/RecoveryStateTest.kt @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.design + +import android.app.Application +import kotlinx.coroutines.test.runTest +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** Failed refresh, loading focus and identity changes must never discard or leak a saved view. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class) +class RecoveryStateTest { + @Test fun failedDiscoverRefreshKeepsTitlesAndFocusButDisablesWritesUntilRecovery() = runTest { + val title = DiscoverTitle(1, "movie", "Saved film") + val initial = DiscoverSnapshot(picks = listOf(title), status = DiscoverStatus.Ready) + var response = DiscoverSnapshot(status = DiscoverStatus.Unavailable) + var writes = 0 + val operations = object : DiscoverOperations { + override suspend fun refresh() = response + override suspend fun request(intent: DiscoverRequest): DiscoverRequestResult { writes++; return DiscoverRequestResult(RequestPhase.Receipt) } + override suspend fun check(intent: DiscoverRequest) = DiscoverRequestResult(RequestPhase.NotConfirmed) + override suspend fun hide(profile: String, title: DiscoverTitle): Boolean { writes++; return true } + override suspend fun restore(profile: String, title: DiscoverTitle): Boolean { writes++; return true } + } + val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false; enterDestination("discover") } + val state = DiscoverState(shell, initial, operations, backgroundScope, nowMillis = { 1000L }) + state.focus(0) + state.refresh() + testScheduler.runCurrent() + assertEquals(listOf(title), state.heroes) + assertEquals(0, state.focusedRow) + assertNotNull(state.snapshot.savedAt) + assertFalse(state.canRequest) + assertEquals(RecoveryMessage.StillUnreachable, state.toast) + state.hide(title) + testScheduler.runCurrent() + assertEquals(0, writes) + val recovery = state.lanes.last() + state.focus(state.lanes.indexOf(recovery) + state.firstShelf) + assertEquals(DiscoverTail.Retry, state.focusedLane?.tail) + response = initial + state.key(ShellKey.Ok) + testScheduler.runCurrent() + assertTrue(state.canRequest) + assertNull(state.snapshot.savedAt) + assertNull(state.toast) + } + + @Test fun revokedDiscoverIdentityDropsSavedTitlesInsteadOfPaintingThemWhileSignedOut() = runTest { + val initial = discoverTestFixture().snapshot("justin") + val operations = RecordingDiscoverOperations(DiscoverSnapshot(status = DiscoverStatus.NeedsSignIn), "Justin") + val state = DiscoverState(ShellState(fixturePeople(), "justin"), initial, operations, backgroundScope) + state.refresh() + testScheduler.runCurrent() + assertFalse(state.isSignedIn) + assertTrue(state.heroes.isEmpty()) + assertTrue(state.lanes.isEmpty()) + } + + @Test fun DownCannotLeaveNavForSkeletonOnlyHomeLibraryOrDiscover() = runTest { + val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false } + val loading = LibraryGridState("Movies", emptyList()).apply { status = LibraryStatus.Loading } + val home = HomeFocus(shell, buildHomeRows(HomeLibrary(), emptyList(), loading = true)) {} + val discover = DiscoverState(shell, DiscoverSnapshot(status = DiscoverStatus.Loading), RecordingDiscoverOperations(DiscoverSnapshot(), "Justin"), backgroundScope) + for (content in listOf(loading, home, discover)) { + shell.contentFocus = content + shell.focusNav(shell.currentTab) + shell.key(ShellKey.Down) + assertTrue("Skeleton-only content must retain nav focus", shell.navFocused) + } + loading.status = LibraryStatus.Unavailable + shell.contentFocus = loading + shell.key(ShellKey.Down) + assertFalse("A recovery button is reachable once loading fails", shell.navFocused) + } + + @Test fun requestsKeepSavedRowsOnFailureAndRecoveryNeverOpensTheLastTitle() { + var retried = false + var opened = false + val shell = ShellState(fixturePeople(), "justin") { + if (it == ShellAction.RetryRequests) retried = true + if (it is ShellAction.RequestTitle) opened = true + }.apply { pickerVisible = false; enterDestination("settings") } + val request = ComingInRequest("a", "movie", 1, "Saved film", "justin", "Justin", "justin", "Justin", ComingInStage.Waiting) + shell.requests.update(ComingInSnapshot(listOf(request), ComingInStatus.Ready, signedIn = true, savedAt = "20:42")) + shell.requests.update(ComingInSnapshot(status = ComingInStatus.Unavailable, signedIn = true)) + shell.openRequests() + assertEquals(listOf(request), shell.requests.snapshot.items) + assertEquals("20:42", shell.navSavedAt) + shell.key(ShellKey.Down) + assertTrue(shell.requests.recoveryFocused) + shell.key(ShellKey.Ok) + assertTrue(retried) + assertFalse(opened) + shell.key(ShellKey.Up) + assertFalse(shell.requests.recoveryFocused) + shell.key(ShellKey.Back) + assertFalse(shell.requestsVisible) + } + + @Test fun switchingPeopleNeverShowsAnotherPersonsCachedRequestsOrSavedChip() { + val shell = ShellState(fixturePeople(), "justin") + val item = ComingInRequest("private", "movie", 1, "Person A film", "justin", "Justin", "justin", "Justin", ComingInStage.Waiting) + shell.requests.update(ComingInSnapshot(listOf(item), ComingInStatus.Ready, signedIn = true, savedAt = "20:42")) + val original = shell.requests + shell.pickerIndex = 1 + shell.key(ShellKey.Ok) + shell.openRequests() + assertEquals("mindel", shell.person.key) + assertTrue(shell.requests.snapshot.items.isEmpty()) + assertNull(shell.navSavedAt) + assertNotSame(original, shell.requests) + } +} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/RequestsCaptureTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/RequestsCaptureTest.kt new file mode 100644 index 00000000..7f114a64 --- /dev/null +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/RequestsCaptureTest.kt @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.design + +import android.app.Application +import android.view.View +import android.view.WindowManager +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onRoot +import com.github.takahirom.roborazzi.captureRoboImage +import java.io.File +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** Every approved Requests state uses the actual shell overlay at native television density. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class RequestsCaptureTest { + @get:Rule val rule = createAndroidComposeRule() + + @Before fun fullscreen() { + rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN) + @Suppress("DEPRECATION") + rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY + } + + private fun capture(name: String, snapshot: ComingInSnapshot, person: String = "justin", expanded: Boolean = false) { + val state = ShellState(fixturePeople(), person) + state.pickerVisible = false + state.enterDestination("settings") + state.requests.update(snapshot) + state.openRequests() + if (expanded) { state.requests.key(ShellKey.Down); state.requests.key(ShellKey.Right) } + rule.setContent { + WolfskinTheme { ShellSurface(state, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) } + } + rule.mainClock.advanceTimeBy(1000) + rule.waitForIdle() + val directory = File(System.getProperty("wolfskin.captureDirectory")) + directory.mkdirs() + rule.onRoot().captureRoboImage(File(directory, "$name.png").absolutePath) + } + + @Test fun st081_empty() = capture("st-081", ComingInSnapshot(status = ComingInStatus.Ready, signedIn = true)) + @Test fun st082_stages() = capture("st-082", requestsFixture()) + @Test fun st083_unavailable() = capture("st-083", ComingInSnapshot()) + @Test fun st084_loading() = capture("st-084", ComingInSnapshot(status = ComingInStatus.Loading)) + @Test fun st085_partial() = capture("st-085", requestsFixture().copy(items = requestsFixture().items.take(3), partial = true)) + @Test fun st086_readiness() = capture("st-086", requestsFixture(), expanded = true) + @Test fun st088_sharedActor() = capture("st-088", requestsFixture(shared = true), person = "shared") + @Test fun st111_focusedRatings() = capture("st-111", requestsFixture(ratings = true)) +} + +/** Shared actor and stage fixtures are projections, never network calls or synthetic UI redraws. */ +fun requestsFixture(shared: Boolean = false, ratings: Boolean = false): ComingInSnapshot { + val source = RequestsCaptureTest::class.java.getResourceAsStream("/wolfskin-requests.json")!!.bufferedReader().use { it.readText() } + val items = readComingInFixture(source) { name -> + RequestsCaptureTest::class.java.getResourceAsStream("/wolfskin-requests/$name")!!.use { it.readBytes() } + } + val selected = when { shared -> items.filter { it.id == "x7" }; ratings -> items.filter { it.id == "x8" }; else -> items.take(6) } + return ComingInSnapshot(selected, ComingInStatus.Ready, signedIn = true) +} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/RequestsKeysTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/RequestsKeysTest.kt new file mode 100644 index 00000000..f678f68e --- /dev/null +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/RequestsKeysTest.kt @@ -0,0 +1,147 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.design + +import android.app.Application +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createComposeRule +import java.util.Locale +import org.json.JSONArray +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** Key replays exercise real shell handling, including trapped overlays and title-return memory. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +class RequestsKeysTest { + @get:Rule val rule = createComposeRule() + + private fun mount(state: ShellState) { + rule.setContent { WolfskinTheme { ShellSurface(state) } } + rule.mainClock.advanceTimeByFrame() + } + + private fun send(first: Key, second: Key? = null, third: Key? = null, fourth: Key? = null) { + listOfNotNull(first, second, third, fourth).forEach { key -> + rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(key) } + rule.mainClock.advanceTimeByFrame() + } + } + + private fun sheet(snapshot: ComingInSnapshot = requestsFixture(), actions: (ShellAction) -> Unit = {}): ShellState = + ShellState(fixturePeople(), "justin", actions).apply { + pickerVisible = false + enterDestination("settings") + requests.update(snapshot) + openRequests() + } + + @Test fun approvedNinePressPathUsesRealShellFocus() { + val paths = RequestsKeysTest::class.java.getResourceAsStream("/requests-press-paths.json")!!.bufferedReader().use { JSONArray(it.readText()) } + val keys = mapOf("OK" to Key.DirectionCenter, "UP" to Key.DirectionUp, "LEFT" to Key.DirectionLeft, + "RIGHT" to Key.DirectionRight, "DOWN" to Key.DirectionDown) + val state = ShellState(fixturePeople(), "justin") + state.requests.update(requestsFixture()) + mount(state) + val path = paths.getJSONObject(0) + val presses = path.getJSONArray("keys") + assertEquals(9, path.getInt("presses")) + assertEquals(path.getInt("presses"), presses.length()) + for (index in 0 until presses.length()) send(keys.getValue(presses.getString(index))) + rule.onNodeWithTag("coming-in").assertExists() + assertEquals("settings", state.route) + assertEquals("x5", state.requests.focusedId) + assertFalse(state.navFocused) + } + + @Test fun readinessWindowAndTitleRoundTripPreserveRequest() { + lateinit var state: ShellState + state = sheet { action -> if (action is ShellAction.RequestTitle) state.enterDestination("title/${action.request.mediaType}:${action.request.tmdbId}") } + mount(state) + send(Key.DirectionDown, Key.DirectionRight) + rule.onNodeWithText("Download · Downloading").assertExists() + assertEquals(4, state.requests.windowSize) + assertEquals("x1", state.requests.focusedId) + send(Key.DirectionLeft) + rule.onNodeWithText("Download · Downloading").assertDoesNotExist() + send(Key.DirectionDown, Key.DirectionDown, Key.DirectionDown, Key.DirectionDown) + assertEquals("x6", state.requests.focusedId) + assertEquals(1, state.requests.windowStart) + send(Key.DirectionDown) + assertEquals("x6", state.requests.focusedId) + send(Key.DirectionCenter) + assertFalse(state.requestsVisible) + assertTrue(state.route.startsWith("title/")) + send(Key.Back) + rule.onNodeWithTag("coming-row-x6").assertExists() + assertEquals("x6", state.requests.focusedId) + assertTrue(state.requestsVisible) + send(Key.Back) + assertFalse(state.requestsVisible) + assertEquals("settings", state.route) + assertEquals(0, state.settingsRow) + } + + @Test fun emptyAndLoadingTrapDirectionsWhileUnavailableRetries() { + var retries = 0 + val state = sheet(ComingInSnapshot(status = ComingInStatus.Loading)) { if (it == ShellAction.RetryRequests) retries++ } + mount(state) + send(Key.DirectionUp, Key.DirectionLeft) + assertFalse(state.navFocused) + assertTrue(state.requestsVisible) + send(Key.DirectionCenter) + assertFalse(state.requestsVisible) + rule.runOnIdle { state.requests.update(ComingInSnapshot()); state.openRequests() } + send(Key.DirectionCenter) + assertEquals(1, retries) + assertTrue(state.requestsVisible) + rule.runOnIdle { state.requests.update(ComingInSnapshot(status = ComingInStatus.Ready)) } + send(Key.DirectionDown, Key.DirectionCenter) + rule.onNodeWithText("Nothing coming in.").assertExists() + assertTrue(state.requestsVisible) + } + + @Test fun cachedRatingsRenderInTheTelevisionLocale() { + val original = Locale.getDefault() + try { + Locale.setDefault(Locale.forLanguageTag("nl-NL")) + val state = sheet(requestsFixture(ratings = true)) + kotlinx.coroutines.test.runTest { state.requests.awaitFocusedRatings() } + mount(state) + rule.onNodeWithText("7,6").assertExists() + rule.onNodeWithText("87%").assertExists() + rule.runOnIdle { state.requests.update(state.requests.snapshot.copy(signedIn = false)) } + rule.onNodeWithText("7,6").assertDoesNotExist() + rule.onNodeWithText("87%").assertDoesNotExist() + } finally { Locale.setDefault(original) } + } + + @Test fun selectingARootAfterARequestTitleDiscardsTheSheetReturnLink() { + lateinit var state: ShellState + state = sheet { action -> if (action is ShellAction.RequestTitle) state.enterDestination("title/${action.request.mediaType}:${action.request.tmdbId}") } + mount(state) + send(Key.DirectionCenter, Key.DirectionUp, Key.DirectionCenter) + assertEquals("home", state.route) + send(Key.Back) + assertFalse("Root Back must focus navigation, not reopen the abandoned sheet", state.requestsVisible) + assertTrue(state.navFocused) + } + + @Test fun refreshKeepsStableFocusAndSharedActorNeverBecomesYou() { + val snapshot = requestsFixture() + val state = sheet(snapshot) + mount(state) + send(Key.DirectionDown) + rule.runOnIdle { state.requests.update(snapshot.copy(items = snapshot.items.reversed(), partial = true)) } + assertEquals("x1", state.requests.focusedId) + rule.onNodeWithText("Some requests may be missing: Conductarr answered only in part.").assertExists() + val shared = requestsFixture(shared = true).items.single() + val viewer = fixturePeople().single { it.key == "shared" } + assertEquals("Shared", shared.actor(viewer)) + assertEquals("Justin", shared.recipient(viewer)) + } +} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ShellCaptureTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ShellCaptureTest.kt new file mode 100644 index 00000000..6b508fd3 --- /dev/null +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ShellCaptureTest.kt @@ -0,0 +1,82 @@ +package org.justinvisser.wolfskin.design + +import android.app.Application +import android.view.View +import android.view.WindowManager +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performSemanticsAction +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.text.TextLayoutResult +import com.github.takahirom.roborazzi.captureRoboImage +import java.io.File +import org.json.JSONObject +import org.junit.Before +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** Native 2.0-density captures are compared against the frozen browser states outside the build tree. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class ShellCaptureTest { + @get:Rule val rule = createAndroidComposeRule() + + @Before + fun fullscreen() { + rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN) + @Suppress("DEPRECATION") + rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY + } + + private fun capture(name: String, requestCount: Int? = null, configure: (ShellState) -> Unit = {}) { + val state = ShellState(fixturePeople(), "justin") + configure(state) + rule.setContent { + WolfskinTheme { + ShellSurface(state, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight), settingsRequestCount = requestCount) + } + } + rule.mainClock.advanceTimeBy(1000) + rule.waitForIdle() + val directory = File(System.getProperty("wolfskin.captureDirectory")) + directory.mkdirs() + rule.onRoot().captureRoboImage(File(directory, "$name.png").absolutePath) + } + + @Test fun st001_launch() = capture("st-001") + @Test fun focusNavAvatar() = capture("focus-nav-avatar") { it.pickerVisible = false; it.focusNav(it.tabs.size) } + @Test fun st002_gear() { + capture("st-002") { it.pickerIndex = 2; it.gearFocused = true } + val layouts = mutableListOf() + rule.onNodeWithText("Settings").performSemanticsAction(SemanticsActions.GetTextLayoutResult) { get -> get(layouts) } + assertEquals("The caption must not wrap under the small gear", 1, layouts.single().lineCount) + } + @Test fun st003_loading() = capture("st-003") { it.peopleStatus = PeopleStatus.Loading } + @Test fun st004_unavailable() = capture("st-004") { it.peopleStatus = PeopleStatus.Unavailable } + @Test fun st010_homeNav() = capture("st-010") { it.pickerVisible = false; it.navFocused = true } + @Test fun st011_rememberedNav() = capture("st-011") { it.pickerVisible = false; it.navFocused = true; it.view.row = 4; it.view.cards["row-4"] = 2 } + @Test fun st014_discoverNav() = capture("st-014") { it.pickerVisible = false; it.navFocused = true; it.navIndex = 1 } + @Test fun st091_settings() = capture("st-091") { it.pickerVisible = false; it.enterDestination("settings") } + @Test fun st092_requestsCountFixture() = capture("st-092", requestCount = 5) { it.pickerVisible = false; it.enterDestination("settings") } + @Test fun st093_profileFocus() = capture("st-093") { it.pickerVisible = false; it.enterDestination("settings"); it.settingsRow = 4 } +} + +/** Tests and debug builds share the same shell-only projection of the locked fixture. */ +fun fixturePeople(): List { + val json = ShellCaptureTest::class.java.getResourceAsStream("/wolfskin-shell.json")!!.bufferedReader().use { JSONObject(it.readText()) } + val people = json.getJSONArray("profiles") + return List(people.length()) { index -> + val person = people.getJSONObject(index) + ShellPerson(person.getString("key"), person.getString("name"), person.getString("initial"), person.getJSONObject("destinations").has("Anime")) + } +} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ShellKeysTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ShellKeysTest.kt new file mode 100644 index 00000000..77052d6f --- /dev/null +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ShellKeysTest.kt @@ -0,0 +1,114 @@ +package org.justinvisser.wolfskin.design + +import android.app.Application +import android.view.KeyEvent as AndroidKeyEvent +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEvent +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.pressKey +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** Press counts exercise the actual Android-to-shell event path, not a second focus implementation. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +class ShellKeysTest { + @get:Rule val rule = createComposeRule() + private val state = ShellState(fixturePeople(), "justin") + private val settings = listOf(Key.DirectionCenter, Key.DirectionUp, Key.DirectionLeft, Key.DirectionCenter, Key.DirectionRight, Key.DirectionRight, Key.DirectionDown, Key.DirectionCenter) + + private fun replay(keys: List, presses: Int) { + assertEquals("Acceptance press count", presses, keys.size) + rule.setContent { WolfskinTheme { ShellSurface(state) } } + keys.forEach { key -> rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(key) } } + } + + @Test fun switchPersonInSixPresses() { + replay(listOf(Key.DirectionCenter, Key.DirectionUp, Key.DirectionLeft, Key.DirectionCenter, Key.DirectionRight, Key.DirectionCenter), 6) + assertEquals("mindel", state.person.key) + assertEquals("home", state.route) + assertFalse(state.pickerVisible) + assertFalse(state.navFocused) + assertFalse(state.tabs.contains("anime")) + } + + @Test fun openSettingsInEightPresses() { + replay(settings, 8) + assertEquals("settings", state.route) + assertEquals(0, state.settingsRow) + assertFalse(state.pickerVisible) + assertFalse("Requests content, not the nav, must own focus", state.navFocused) + } + + @Test fun settingsBackBackInTenPresses() { + replay(settings + listOf(Key.Back, Key.Back), 10) + assertEquals("home", state.route) + assertFalse(state.pickerVisible) + assertTrue(state.navFocused) + assertEquals(state.tabs.size, state.navIndex) + } + + @Test fun playbackHandOffInElevenPresses() { + replay(settings + listOf(Key.DirectionDown, Key.DirectionDown, Key.DirectionCenter), 11) + assertEquals("playback", state.handOff) + assertEquals(2, state.settingsRow) + } + + @Test fun onlyLastPersonReachesGearAndUpReturnsThere() { + replay(listOf(Key.DirectionDown, Key.DirectionRight, Key.DirectionDown), 3) + assertFalse(state.gearFocused) + rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionRight); pressKey(Key.DirectionDown) } + assertTrue(state.gearFocused) + rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionUp) } + assertFalse(state.gearFocused) + assertEquals(2, state.pickerIndex) + } + + @Test fun rootBackAndNavDownPreserveViewAndWrap() { + replay(listOf(Key.DirectionCenter), 1) + val view = state.view + rule.runOnIdle { view.row = 4; view.cards["row-4"] = 2 } + rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.Back); pressKey(Key.DirectionDown) } + assertSame(view, state.view) + assertEquals(4, state.view.row) + assertEquals(2, state.view.cards["row-4"]) + rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionUp); pressKey(Key.DirectionLeft) } + assertEquals(state.tabs.size, state.navIndex) + rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionRight) } + assertEquals(0, state.navIndex) + } + + @Test fun destinationViewsAreIsolatedAndReselectResets() { + replay(listOf(Key.DirectionCenter), 1) + rule.runOnIdle { + state.enterDestination("movies") + val movies = state.view + movies.row = 3; movies.cards["movies"] = 2 + state.enterDestination("shows") + state.view.row = 1 + state.enterDestination("movies") + assertSame(movies, state.view) + state.focusNav(2); state.key(ShellKey.Ok) + assertNotSame(movies, state.view) + assertEquals(0, state.view.row) + assertTrue(state.view.cards.isEmpty()) + } + } + + @Test fun heldOkCannotActivateNewOverlay() { + replay(listOf(Key.DirectionCenter, Key.DirectionUp, Key.DirectionLeft), 3) + rule.runOnIdle { + state.handleKey(KeyEvent(AndroidKeyEvent(0, 0, AndroidKeyEvent.ACTION_DOWN, AndroidKeyEvent.KEYCODE_DPAD_CENTER, 0))) + repeat(5) { repeat -> state.handleKey(KeyEvent(AndroidKeyEvent(0, 0, AndroidKeyEvent.ACTION_DOWN, AndroidKeyEvent.KEYCODE_DPAD_CENTER, repeat + 1))) } + state.handleKey(KeyEvent(AndroidKeyEvent(0, 0, AndroidKeyEvent.ACTION_UP, AndroidKeyEvent.KEYCODE_DPAD_CENTER, 0))) + } + assertTrue(state.pickerVisible) + assertEquals("justin", state.person.key) + } +} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ShellSnapshotReadTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ShellSnapshotReadTest.kt new file mode 100644 index 00000000..a1762334 --- /dev/null +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ShellSnapshotReadTest.kt @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.design + +import android.app.Application +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.snapshots.Snapshot +import androidx.compose.ui.test.junit4.createComposeRule +import kotlinx.coroutines.flow.first +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** A first read must work without composition prewarming the person's route memory. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class) +class ShellSnapshotReadTest { + @get:Rule val rule = createComposeRule() + + @Test fun unseenViewCanBeReadInAReadOnlySnapshot() { + val shell = ShellState(fixturePeople(), "justin") + val snapshot = Snapshot.takeSnapshot() + try { + snapshot.enter { + assertEquals(0, shell.view.row) + assertTrue(shell.view.cards.isEmpty()) + assertNull(shell.view.savedAt) + assertEquals(0, shell.rememberedView("never-seen").row) + assertNotSame(shell.rememberedView("never-seen"), shell.rememberedView("never-seen")) + } + } finally { snapshot.dispose() } + } + + @Test fun launchedSnapshotFlowReadsDefaultsAndThenRetainsExplicitNavigationMemory() { + val shell = ShellState(fixturePeople(), "justin") + var observed: RememberedView? = null + rule.setContent { + LaunchedEffect(shell) { observed = snapshotFlow { shell.view }.first() } + } + rule.runOnIdle { + val home = requireNotNull(observed) + assertEquals(0, home.row) + assertTrue(home.cards.isEmpty()) + home.row = 3 + home.cards["shelf"] = 2 + shell.enterDestination("movies") + assertEquals(0, shell.view.row) + shell.view.row = 5 + shell.enterDestination("home") + assertSame(home, shell.view) + assertEquals(3, shell.view.row) + assertEquals(2, shell.view.cards["shelf"]) + shell.enterDestination("movies") + assertEquals(5, shell.view.row) + shell.focusPerson(1) + shell.key(ShellKey.Ok) + shell.enterDestination("movies") + assertEquals(0, shell.view.row) + shell.view.row = 7 + shell.pickerVisible = true + shell.focusPerson(0) + shell.key(ShellKey.Ok) + shell.enterDestination("movies") + assertEquals(5, shell.view.row) + } + } +} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/SnapshotReadAuditTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/SnapshotReadAuditTest.kt new file mode 100644 index 00000000..ab5fdd4d --- /dev/null +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/SnapshotReadAuditTest.kt @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.design + +import java.io.File +import org.junit.Assert.* +import org.junit.Test + +/** Guard known state writers; runtime tests cover transitive property reads. */ +class SnapshotReadAuditTest { + @Test fun snapshotObserversDoNotCallKnownStateWriters() { + val roots = listOf(File("src/main/java"), File("../app/src/wolfskin")) + val violations = roots.flatMap { root -> + assertTrue("Missing owned source root: $root", root.isDirectory) + root.walkTopDown().filter { it.isFile && it.extension == "kt" }.flatMap { file -> + observerBodies(file.readText()).filter { writingCalls.containsMatchIn(it) }.map { "$file: $it" } + }.toList() + } + assertTrue(violations.joinToString("\n"), violations.isEmpty()) + } + + @Test fun shellReadAccessorDoesNotInsertMemory() { + val source = File("src/main/java/org/justinvisser/wolfskin/design/ShellState.kt").readText() + val accessor = source.substringAfter("fun rememberedView(").substringBefore("/**") + assertFalse("rememberedView must not insert into snapshot state", Regex("getOrPut|\\.put\\s*\\(|\\]\\s*=").containsMatchIn(accessor)) + } + + @Test fun knownWriterIsRejectedWithoutRejectingItsCollector() { + val unsafe = observerBodies("snapshotFlow { state.focus(2) }").single() + assertTrue(writingCalls.containsMatchIn(unsafe)) + val safe = observerBodies("snapshotFlow { state.focusedLane?.id to state.column }.collect { state.focus(2) }").single() + assertFalse(writingCalls.containsMatchIn(safe)) + } + + private fun observerBodies(source: String): List = Regex("snapshotFlow\\s*\\{").findAll(source).map { match -> + val start = match.range.last + 1 + var depth = 1 + var end = start + while (end < source.length && depth > 0) { + when (source[end]) { '{' -> depth++; '}' -> depth-- } + end++ + } + source.substring(start, end - 1) + }.toList() + + private val writingCalls = Regex("\\b(?:retainView|preparePersonMemory|enterDestination|focus|updateSnapshot|updateLanes|invalidateLanes|updateRatings|recordCenter|getOrPut|put|putAll|clear|remove)\\s*\\(") +} diff --git a/wolfskin-design/src/test/resources/discover-keys.json b/wolfskin-design/src/test/resources/discover-keys.json new file mode 100644 index 00000000..d1328e10 --- /dev/null +++ b/wolfskin-design/src/test/resources/discover-keys.json @@ -0,0 +1,92 @@ +{ + "pressPaths": [ + { + "goal": "Open Discover", + "keys": [ + "OK", + "UP", + "RIGHT", + "OK" + ], + "presses": 4, + "note": "", + "start": "app launch: Who's watching with Justin focused (index.html:357)", + "replayed": "PASS", + "source": "index.html:2152-2174 PRESSES, replayed 2026-11-05" + }, + { + "goal": "Request a pick for yourself", + "keys": [ + "OK", + "UP", + "RIGHT", + "OK", + "OK", + "OK", + "OK" + ], + "presses": 7, + "note": "", + "start": "app launch: Who's watching with Justin focused (index.html:357)", + "replayed": "PASS", + "source": "index.html:2152-2174 PRESSES, replayed 2026-11-05" + }, + { + "goal": "Request a pick for Mindel", + "keys": [ + "OK", + "UP", + "RIGHT", + "OK", + "OK", + "RIGHT", + "OK", + "OK", + "OK" + ], + "presses": 9, + "note": "", + "start": "app launch: Who's watching with Justin focused (index.html:357)", + "replayed": "PASS", + "source": "index.html:2152-2174 PRESSES, replayed 2026-11-05" + }, + { + "goal": "Round 3 · Not interested on a pick (Discover hero)", + "keys": [ + "OK", + "UP", + "RIGHT", + "OK", + "OK", + "RIGHT", + "RIGHT", + "OK" + ], + "presses": 8, + "note": "", + "start": "app launch: Who's watching with Justin focused (index.html:357)", + "replayed": "PASS", + "source": "index.html:2152-2174 PRESSES, replayed 2026-11-05" + }, + { + "goal": "Round 4 · Discover pick nobody holds → its Trailer (sample hand-off)", + "keys": [ + "OK", + "UP", + "RIGHT", + "OK", + "OK", + "RIGHT", + "RIGHT", + "RIGHT", + "OK" + ], + "presses": 9, + "note": "4 on the title page (3 Rights to Trailer, OK); The Amazing Spider-Man", + "start": "app launch: Who's watching with Justin focused (index.html:357)", + "replayed": "PASS", + "source": "index.html:2152-2174 PRESSES, replayed 2026-11-05", + "requires": "Conductarr trailer field (spec 3.2, fifth addition); the press path runs once it lands" + } + ] +} diff --git a/wolfskin-design/src/test/resources/library-screen.json b/wolfskin-design/src/test/resources/library-screen.json new file mode 100644 index 00000000..07f33c34 --- /dev/null +++ b/wolfskin-design/src/test/resources/library-screen.json @@ -0,0 +1,494 @@ +{ + "id": "library", + "title": "Libraries: Movies, Shows, Anime (continuous grid; five columns with letter rail)", + "specSection": "6.4", + "source": "index.html:1403-1426, 1563-1611", + "layout": [ + { + "id": "lhead", + "type": "lhead", + "token": null, + "title": "Movies", + "yPx": 0, + "heightPx": 236, + "cards": null, + "focusable": false, + "endsInSeeAll": false, + "sampleFill": false + }, + { + "id": "ltools", + "type": "chips", + "token": null, + "title": "", + "yPx": 236, + "heightPx": 136, + "cards": null, + "focusable": true, + "endsInSeeAll": false, + "sampleFill": false + }, + { + "id": "g0", + "type": "grid", + "token": "poster", + "title": "", + "yPx": 372, + "heightPx": 524, + "cards": 6, + "focusable": true, + "endsInSeeAll": false, + "sampleFill": false + }, + { + "id": "g1", + "type": "grid", + "token": "poster", + "title": "", + "yPx": 896, + "heightPx": 524, + "cards": 6, + "focusable": true, + "endsInSeeAll": false, + "sampleFill": false + }, + { + "id": "g2", + "type": "grid", + "token": "poster", + "title": "", + "yPx": 1420, + "heightPx": 524, + "cards": 6, + "focusable": true, + "endsInSeeAll": false, + "sampleFill": false + }, + { + "id": "g3", + "type": "grid", + "token": "poster", + "title": "", + "yPx": 1944, + "heightPx": 524, + "cards": 6, + "focusable": true, + "endsInSeeAll": false, + "sampleFill": false + }, + { + "id": "g4", + "type": "grid", + "token": "poster", + "title": "", + "yPx": 2468, + "heightPx": 524, + "cards": 6, + "focusable": true, + "endsInSeeAll": false, + "sampleFill": false + }, + { + "id": "g5", + "type": "grid", + "token": "poster", + "title": "", + "yPx": 2992, + "heightPx": 524, + "cards": 6, + "focusable": true, + "endsInSeeAll": false, + "sampleFill": false + }, + { + "id": "g6", + "type": "grid", + "token": "poster", + "title": "", + "yPx": 3516, + "heightPx": 524, + "cards": 6, + "focusable": true, + "endsInSeeAll": false, + "sampleFill": false + }, + { + "id": "g7", + "type": "grid", + "token": "poster", + "title": "", + "yPx": 4040, + "heightPx": 524, + "cards": 6, + "focusable": true, + "endsInSeeAll": false, + "sampleFill": false + }, + { + "id": "g8", + "type": "grid", + "token": "poster", + "title": "", + "yPx": 4564, + "heightPx": 524, + "cards": 6, + "focusable": true, + "endsInSeeAll": false, + "sampleFill": false + }, + { + "id": "g9", + "type": "grid", + "token": "poster", + "title": "", + "yPx": 5088, + "heightPx": 524, + "cards": 6, + "focusable": true, + "endsInSeeAll": false, + "sampleFill": false + }, + { + "id": "g10", + "type": "grid", + "token": "poster", + "title": "", + "yPx": 5612, + "heightPx": 524, + "cards": 6, + "focusable": true, + "endsInSeeAll": false, + "sampleFill": false + }, + { + "id": "g11", + "type": "grid", + "token": "poster", + "title": "", + "yPx": 6136, + "heightPx": 524, + "cards": 6, + "focusable": true, + "endsInSeeAll": false, + "sampleFill": false + }, + { + "id": "g12", + "type": "grid", + "token": "poster", + "title": "", + "yPx": 6660, + "heightPx": 524, + "cards": 6, + "focusable": true, + "endsInSeeAll": false, + "sampleFill": false + }, + { + "id": "g13", + "type": "grid", + "token": "poster", + "title": "", + "yPx": 7184, + "heightPx": 524, + "cards": 6, + "focusable": true, + "endsInSeeAll": false, + "sampleFill": false + } + ], + "states": [ + { + "id": "st-021", + "name": "Shared Movies: 84, continuous grid", + "statesGroup": "Libraries", + "capture": "st-021-shared-movies-84-continuous-grid.jpg", + "captureUrl": "https://pages.justinvisser.org/wolfskin/tv-spec/img/st-021-shared-movies-84-continuous-grid.jpg", + "screenKind": "library", + "overlay": null, + "focusZone": "content", + "person": "shared", + "focusReadout": "State: Shared Movies: 84, continuous grid · Person: Shared · Screen: Movies · Focus: g0 › American Gangster" + }, + { + "id": "st-022", + "name": "Shared Movies sorted by Rating (G10)", + "statesGroup": "Libraries", + "capture": "st-022-shared-movies-sorted-by-rating-g10.jpg", + "captureUrl": "https://pages.justinvisser.org/wolfskin/tv-spec/img/st-022-shared-movies-sorted-by-rating-g10.jpg", + "screenKind": "library", + "overlay": null, + "focusZone": "content", + "person": "shared", + "focusReadout": "State: Shared Movies sorted by Rating (G10) · Person: Shared · Screen: Movies · Focus: g0 › Grave of the Fireflies" + }, + { + "id": "st-023", + "name": "Right at a row end clamps (no letter rail below 200)", + "statesGroup": "Libraries", + "capture": "st-023-right-at-a-row-end-clamps-no-letter-rail-below-200.jpg", + "captureUrl": "https://pages.justinvisser.org/wolfskin/tv-spec/img/st-023-right-at-a-row-end-clamps-no-letter-rail-below-200.jpg", + "screenKind": "library", + "overlay": null, + "focusZone": "content", + "person": "shared", + "focusReadout": "State: Right at a row end clamps (no letter rail below 200) · Person: Shared · Screen: Movies · Focus: g0 › Bohemian Rhapsody" + }, + { + "id": "st-024", + "name": "Justin Anime: 18 series", + "statesGroup": "Libraries", + "capture": "st-024-justin-anime-18-series.jpg", + "captureUrl": "https://pages.justinvisser.org/wolfskin/tv-spec/img/st-024-justin-anime-18-series.jpg", + "screenKind": "library", + "overlay": null, + "focusZone": "content", + "person": "justin", + "focusReadout": "State: Justin Anime: 18 series · Person: Justin · Screen: Anime · Focus: g0 › Bleach" + }, + { + "id": "st-025", + "name": "Unwatched filter", + "statesGroup": "Libraries", + "capture": "st-025-unwatched-filter.jpg", + "captureUrl": "https://pages.justinvisser.org/wolfskin/tv-spec/img/st-025-unwatched-filter.jpg", + "screenKind": "library", + "overlay": null, + "focusZone": "content", + "person": "shared", + "focusReadout": "State: Unwatched filter · Person: Shared · Screen: Movies · Focus: g0 › Annie Hall" + }, + { + "id": "st-026", + "name": "Empty destination", + "statesGroup": "Libraries", + "capture": "st-026-empty-destination.jpg", + "captureUrl": "https://pages.justinvisser.org/wolfskin/tv-spec/img/st-026-empty-destination.jpg", + "screenKind": "library", + "overlay": null, + "focusZone": "content", + "person": "mindel", + "focusReadout": "State: Empty destination · Person: Mindel · Screen: Movies · Focus: Nothing in Movies yet › Open Discover" + }, + { + "id": "st-027", + "name": "Jellyfin unreachable (error)", + "statesGroup": "Libraries", + "capture": "st-027-jellyfin-unreachable-error.jpg", + "captureUrl": "https://pages.justinvisser.org/wolfskin/tv-spec/img/st-027-jellyfin-unreachable-error.jpg", + "screenKind": "library", + "overlay": null, + "focusZone": "content", + "person": "mindel", + "focusReadout": "State: Jellyfin unreachable (error) · Person: Mindel · Screen: Shows · Focus: Couldn’t reach your library › Try again" + }, + { + "id": "st-028", + "name": "Loading (skeleton, no saved copy)", + "statesGroup": "Libraries", + "capture": "st-028-loading-skeleton-no-saved-copy.jpg", + "captureUrl": "https://pages.justinvisser.org/wolfskin/tv-spec/img/st-028-loading-skeleton-no-saved-copy.jpg", + "screenKind": "library", + "overlay": null, + "focusZone": "nav", + "person": "shared", + "focusReadout": "State: Loading (skeleton, no saved copy) · Person: Shared · Screen: Movies · Focus: nav › Movies" + }, + { + "id": "st-095", + "name": "Plate: widest real set on a 256 px poster", + "statesGroup": "Ratings: one-line plate (round 3)", + "capture": "st-095-plate-widest-real-set-on-a-256-px-poster.jpg", + "captureUrl": "https://pages.justinvisser.org/wolfskin/tv-spec/img/st-095-plate-widest-real-set-on-a-256-px-poster.jpg", + "screenKind": "library", + "overlay": null, + "focusZone": "content", + "person": "shared", + "focusReadout": "State: Plate: widest real set on a 256 px poster · Person: Shared · Screen: Movies · Focus: g4 › Grave of the Fireflies" + }, + { + "id": "st-097", + "name": "Poster: RT critics on a library grid (Shared Movies)", + "statesGroup": "Ratings (round 2)", + "capture": "st-097-poster-rt-critics-on-a-library-grid-shared-movies.jpg", + "captureUrl": "https://pages.justinvisser.org/wolfskin/tv-spec/img/st-097-poster-rt-critics-on-a-library-grid-shared-movies.jpg", + "screenKind": "library", + "overlay": null, + "focusZone": "content", + "person": "shared", + "focusReadout": "State: Poster: RT critics on a library grid (Shared Movies) · Person: Shared · Screen: Movies · Focus: g0 › American Gangster" + }, + { + "id": "st-098", + "name": "Poster: TMDB only (Justin Anime)", + "statesGroup": "Ratings (round 2)", + "capture": "st-098-poster-tmdb-only-justin-anime.jpg", + "captureUrl": "https://pages.justinvisser.org/wolfskin/tv-spec/img/st-098-poster-tmdb-only-justin-anime.jpg", + "screenKind": "library", + "overlay": null, + "focusZone": "content", + "person": "justin", + "focusReadout": "State: Poster: TMDB only (Justin Anime) · Person: Justin · Screen: Anime · Focus: g0 › Chainsaw Man" + }, + { + "id": "st-112", + "name": "Conductarr down: a cached rating still shows", + "statesGroup": "Ratings (round 2)", + "capture": "st-112-conductarr-down-a-cached-rating-still-shows.jpg", + "captureUrl": "https://pages.justinvisser.org/wolfskin/tv-spec/img/st-112-conductarr-down-a-cached-rating-still-shows.jpg", + "screenKind": "library", + "overlay": null, + "focusZone": "content", + "person": "shared", + "focusReadout": "State: Conductarr down: a cached rating still shows · Person: Shared · Screen: Movies · Focus: g0 › American Gangster" + }, + { + "id": "st-113", + "name": "Conductarr down: an uncached title shows nothing", + "statesGroup": "Ratings (round 2)", + "capture": "st-113-conductarr-down-an-uncached-title-shows-nothing.jpg", + "captureUrl": "https://pages.justinvisser.org/wolfskin/tv-spec/img/st-113-conductarr-down-an-uncached-title-shows-nothing.jpg", + "screenKind": "library", + "overlay": null, + "focusZone": "content", + "person": "shared", + "focusReadout": "State: Conductarr down: an uncached title shows nothing · Person: Shared · Screen: Movies · Focus: g0 › American Gangster" + } + ], + "focusMap": { + "entry": "first grid card (not the toolbar); returning: remembered card per person", + "zones": { + "toolbar": { + "up": "nav", + "down": "grid", + "left": "previous chip", + "right": "next chip", + "ok": "Sort cycles A–Z -> Recently added -> Rating; Filter toggles All / Unwatched; focus returns to the toolbar", + "back": "nav", + "source": "index.html:445" + }, + "grid": { + "up": "row above, same column (top row -> toolbar)", + "down": "row below, same column (shorter last row: its last card)", + "left": "previous column (column 0: stays)", + "right": "next column; at the row end: stays (no rail below 200 titles)", + "ok": "title page (key-up)", + "back": "nav, place kept", + "ratings": "one-line plate after 350 ms", + "source": "index.html:446" + }, + "letterRail": "only at >= 200 titles and A–Z sort: Right at a row end enters; Up/Down move letters and scroll live; OK or Left drops focus on that letter's first title; Back returns to where you were (index.html:1586-1605)" + }, + "exit": [ + "Up from toolbar -> nav", + "Back -> nav" + ] + }, + "pressPaths": [ + { + "goal": "Reach “Spirited Away” (68th of 84) in Shared Movies by scrolling", + "keys": [ + "RIGHT", + "RIGHT", + "OK", + "UP", + "RIGHT", + "RIGHT", + "OK", + "DOWN", + "DOWN", + "DOWN", + "DOWN", + "DOWN", + "DOWN", + "DOWN", + "DOWN", + "DOWN", + "DOWN", + "DOWN", + "RIGHT", + "RIGHT" + ], + "presses": 20, + "note": "", + "start": "app launch: Who's watching with Justin focused (index.html:357)", + "replayed": "PASS", + "source": "index.html:2152-2174 PRESSES, replayed 2026-11-05" + } + ], + "data": [ + { + "component": "Library grid", + "source": "Jellyfin /Users/{id}/Items?ParentId=…&SortBy=SortName over the destination's libraries, read in full in 200-item chunks ahead of focus", + "fields": [ + "SortName", + "ProductionYear", + "ImageTags.Primary", + "UserData.Played", + "UserData.PlaybackPositionTicks", + "ProviderIds.Tmdb" + ], + "cache": "LKG snapshot per destination; refresh on open if older than 10 min", + "states": [ + "empty", + "Jellyfin unreachable (LKG + Saved chip, or state row)", + "missing art" + ], + "pageRow": "index.html:469" + }, + { + "component": "Destinations", + "source": "Conductarr GET /curation/library-audiences (owner, media_type, is_anime, libraries[], users[]); until sign-in, the name rule (parked board mapping-source = name-rule)", + "pageRow": "index.html:468" + } + ], + "strings": { + "count": "{n} films | {n} series", + "empty": [ + "Nothing in {Destination} yet", + "Titles you or others request for you land here.", + "Open Discover" + ], + "emptyUnwatched": [ + "Nothing unwatched in {Destination}", + "Everything here has been watched.", + "Show all" + ], + "down": [ + "Couldn’t reach your library", + "Jellyfin isn’t answering. Nothing here is lost; try again in a moment.", + "Try again" + ], + "sort": [ + "A–Z", + "Recently added", + "Rating" + ], + "filter": [ + "All", + "Unwatched" + ] + }, + "ratingScoreFormat": { + "rule": "TMDB, IMDb and \"Ep\" scores (0-10) are formatted with the TV locale and exactly one decimal, e.g. String.format(Locale.getDefault(), \"%.1f\", x): 7,8 on a Dutch-locale box, 7.8 on an English one. RT percentages unchanged (87%). The page prints 7.8 always: a stand-in for the English locale, not authoritative.", + "acceptance": "on a Dutch-locale emulator a TMDB 7.8 renders 7,8 and the one-line plate rule (spec 4.8) still holds because the width is unchanged [INFERENCE: comma and point advance widths not measured]; on an English-locale emulator it renders 7.8 as in the captures", + "source": "Justin 2026-11-05, corr=c248698d8546e1ab; spec 4.7 rule 5, 10.1 check 8" + }, + "$schema": "wolfskin-tv-spec/screen@1", + "tokens": "data/tv-spec/tokens.json", + "captures": [ + "st-021-shared-movies-84-continuous-grid.jpg", + "st-022-shared-movies-sorted-by-rating-g10.jpg", + "st-023-right-at-a-row-end-clamps-no-letter-rail-below-200.jpg", + "st-024-justin-anime-18-series.jpg", + "st-025-unwatched-filter.jpg", + "st-026-empty-destination.jpg", + "st-027-jellyfin-unreachable-error.jpg", + "st-028-loading-skeleton-no-saved-copy.jpg", + "st-095-plate-widest-real-set-on-a-256-px-poster.jpg", + "st-097-poster-rt-critics-on-a-library-grid-shared-movies.jpg", + "st-098-poster-tmdb-only-justin-anime.jpg", + "st-112-conductarr-down-a-cached-rating-still-shows.jpg", + "st-113-conductarr-down-an-uncached-title-shows-nothing.jpg" + ] +} \ No newline at end of file diff --git a/wolfskin-design/src/test/resources/library/assets/movie-101-dalmatians-ii-patch-s-london-adventure-2003-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-101-dalmatians-ii-patch-s-london-adventure-2003-poster.jpg new file mode 100644 index 00000000..b9b72894 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-101-dalmatians-ii-patch-s-london-adventure-2003-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-12-years-a-slave-2013-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-12-years-a-slave-2013-poster.jpg new file mode 100644 index 00000000..ff1f55e6 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-12-years-a-slave-2013-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-a-bronx-tale-1993-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-a-bronx-tale-1993-poster.jpg new file mode 100644 index 00000000..f7444271 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-a-bronx-tale-1993-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-a-silent-voice-the-movie-2016-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-a-silent-voice-the-movie-2016-poster.jpg new file mode 100644 index 00000000..580b22f4 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-a-silent-voice-the-movie-2016-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-absolution-2024-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-absolution-2024-poster.jpg new file mode 100644 index 00000000..3ce49112 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-absolution-2024-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-across-the-hall-2009-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-across-the-hall-2009-poster.jpg new file mode 100644 index 00000000..52362b4e Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-across-the-hall-2009-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-addicted-2014-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-addicted-2014-poster.jpg new file mode 100644 index 00000000..e9a03c14 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-addicted-2014-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-adulterers-2016-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-adulterers-2016-poster.jpg new file mode 100644 index 00000000..8b65745b Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-adulterers-2016-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-all-the-money-in-the-world-2017-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-all-the-money-in-the-world-2017-poster.jpg new file mode 100644 index 00000000..8beb2f7f Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-all-the-money-in-the-world-2017-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-alone-with-her-2006-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-alone-with-her-2006-poster.jpg new file mode 100644 index 00000000..7ce6fa96 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-alone-with-her-2006-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-american-gangster-2007-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-american-gangster-2007-poster.jpg new file mode 100644 index 00000000..0f41d299 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-american-gangster-2007-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-annie-hall-1977-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-annie-hall-1977-poster.jpg new file mode 100644 index 00000000..9023a691 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-annie-hall-1977-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-ant-man-and-the-wasp-quantumania-2023-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-ant-man-and-the-wasp-quantumania-2023-poster.jpg new file mode 100644 index 00000000..030addf0 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-ant-man-and-the-wasp-quantumania-2023-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-apocalypse-now-1979-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-apocalypse-now-1979-poster.jpg new file mode 100644 index 00000000..09cd6aa8 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-apocalypse-now-1979-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-appleseed-alpha-2014-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-appleseed-alpha-2014-poster.jpg new file mode 100644 index 00000000..62bd0afa Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-appleseed-alpha-2014-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-aquaman-and-the-lost-kingdom-2023-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-aquaman-and-the-lost-kingdom-2023-poster.jpg new file mode 100644 index 00000000..4550bdaa Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-aquaman-and-the-lost-kingdom-2023-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-arctic-dogs-2019-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-arctic-dogs-2019-poster.jpg new file mode 100644 index 00000000..07552443 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-arctic-dogs-2019-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-arrival-2016-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-arrival-2016-poster.jpg new file mode 100644 index 00000000..fe224a15 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-arrival-2016-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-avatar-aang-the-last-airbender-2026-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-avatar-aang-the-last-airbender-2026-poster.jpg new file mode 100644 index 00000000..f57f1052 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-avatar-aang-the-last-airbender-2026-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-avengers-age-of-ultron-2015-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-avengers-age-of-ultron-2015-poster.jpg new file mode 100644 index 00000000..300840ff Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-avengers-age-of-ultron-2015-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-avengers-confidential-black-widow-punisher-2014-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-avengers-confidential-black-widow-punisher-2014-poster.jpg new file mode 100644 index 00000000..b096fff2 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-avengers-confidential-black-widow-punisher-2014-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-avengers-infinity-war-2018-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-avengers-infinity-war-2018-poster.jpg new file mode 100644 index 00000000..57df9298 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-avengers-infinity-war-2018-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-awake-2007-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-awake-2007-poster.jpg new file mode 100644 index 00000000..32595897 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-awake-2007-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-batman-ninja-2018-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-batman-ninja-2018-poster.jpg new file mode 100644 index 00000000..7f59127b Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-batman-ninja-2018-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-batman-the-dark-knight-returns-part-1-2012-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-batman-the-dark-knight-returns-part-1-2012-poster.jpg new file mode 100644 index 00000000..ce5623fe Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-batman-the-dark-knight-returns-part-1-2012-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-batman-v-superman-dawn-of-justice-2016-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-batman-v-superman-dawn-of-justice-2016-poster.jpg new file mode 100644 index 00000000..f8115170 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-batman-v-superman-dawn-of-justice-2016-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-black-adam-2022-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-black-adam-2022-poster.jpg new file mode 100644 index 00000000..dde008b5 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-black-adam-2022-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-black-bear-2020-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-black-bear-2020-poster.jpg new file mode 100644 index 00000000..61c8860f Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-black-bear-2020-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-black-panther-2018-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-black-panther-2018-poster.jpg new file mode 100644 index 00000000..6fdaa3d2 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-black-panther-2018-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-black-panther-wakanda-forever-2022-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-black-panther-wakanda-forever-2022-poster.jpg new file mode 100644 index 00000000..d0219a75 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-black-panther-wakanda-forever-2022-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-blame-2017-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-blame-2017-poster.jpg new file mode 100644 index 00000000..8d50255f Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-blame-2017-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-bleach-the-movie-fade-to-black-2008-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-bleach-the-movie-fade-to-black-2008-poster.jpg new file mode 100644 index 00000000..d3619fbd Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-bleach-the-movie-fade-to-black-2008-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-bohemian-rhapsody-2018-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-bohemian-rhapsody-2018-poster.jpg new file mode 100644 index 00000000..20d2f9ee Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-bohemian-rhapsody-2018-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-braveheart-1995-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-braveheart-1995-poster.jpg new file mode 100644 index 00000000..32fd8d4d Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-braveheart-1995-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-butterfly-on-a-wheel-2007-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-butterfly-on-a-wheel-2007-poster.jpg new file mode 100644 index 00000000..b54cc5c7 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-butterfly-on-a-wheel-2007-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-by-any-means-2026-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-by-any-means-2026-poster.jpg new file mode 100644 index 00000000..92fd601f Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-by-any-means-2026-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-captain-america-civil-war-2016-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-captain-america-civil-war-2016-poster.jpg new file mode 100644 index 00000000..426142ef Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-captain-america-civil-war-2016-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-captain-america-the-first-avenger-2011-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-captain-america-the-first-avenger-2011-poster.jpg new file mode 100644 index 00000000..bba96574 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-captain-america-the-first-avenger-2011-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-captain-america-the-winter-soldier-2014-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-captain-america-the-winter-soldier-2014-poster.jpg new file mode 100644 index 00000000..c2dc5612 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-captain-america-the-winter-soldier-2014-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-careful-what-you-wish-for-2015-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-careful-what-you-wish-for-2015-poster.jpg new file mode 100644 index 00000000..b58ce600 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-careful-what-you-wish-for-2015-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-castle-in-the-sky-1986-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-castle-in-the-sky-1986-poster.jpg new file mode 100644 index 00000000..a6a99fee Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-castle-in-the-sky-1986-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-chicago-2002-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-chicago-2002-poster.jpg new file mode 100644 index 00000000..1def9414 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-chicago-2002-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-citizen-kane-1941-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-citizen-kane-1941-poster.jpg new file mode 100644 index 00000000..f26414a7 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-citizen-kane-1941-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-coherence-2014-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-coherence-2014-poster.jpg new file mode 100644 index 00000000..f3601b9b Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-coherence-2014-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-conclave-2024-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-conclave-2024-poster.jpg new file mode 100644 index 00000000..c6aecefa Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-conclave-2024-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-crime-of-passion-1956-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-crime-of-passion-1956-poster.jpg new file mode 100644 index 00000000..4acfa871 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-crime-of-passion-1956-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-deep-water-2022-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-deep-water-2022-poster.jpg new file mode 100644 index 00000000..018d4f4b Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-deep-water-2022-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-derailed-2005-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-derailed-2005-poster.jpg new file mode 100644 index 00000000..d93f3124 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-derailed-2005-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-die-my-love-2025-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-die-my-love-2025-poster.jpg new file mode 100644 index 00000000..f6eca818 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-die-my-love-2025-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-dream-house-2011-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-dream-house-2011-poster.jpg new file mode 100644 index 00000000..d3444637 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-dream-house-2011-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-dream-lover-1994-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-dream-lover-1994-poster.jpg new file mode 100644 index 00000000..3b563cf9 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-dream-lover-1994-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-drop-2025-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-drop-2025-poster.jpg new file mode 100644 index 00000000..4cf7d66b Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-drop-2025-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-eastern-promises-2007-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-eastern-promises-2007-poster.jpg new file mode 100644 index 00000000..01a26f3b Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-eastern-promises-2007-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-easy-a-2010-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-easy-a-2010-poster.jpg new file mode 100644 index 00000000..45e13a32 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-easy-a-2010-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-el-camino-a-breaking-bad-movie-2019-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-el-camino-a-breaking-bad-movie-2019-poster.jpg new file mode 100644 index 00000000..7a62df45 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-el-camino-a-breaking-bad-movie-2019-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-emancipation-2022-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-emancipation-2022-poster.jpg new file mode 100644 index 00000000..c1bee971 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-emancipation-2022-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-faces-1968-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-faces-1968-poster.jpg new file mode 100644 index 00000000..43f996bd Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-faces-1968-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-fatale-2020-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-fatale-2020-poster.jpg new file mode 100644 index 00000000..dda9d622 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-fatale-2020-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-fighting-with-my-family-2019-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-fighting-with-my-family-2019-poster.jpg new file mode 100644 index 00000000..169c466d Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-fighting-with-my-family-2019-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-force-of-nature-the-dry-2-2024-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-force-of-nature-the-dry-2-2024-poster.jpg new file mode 100644 index 00000000..93ba0d71 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-force-of-nature-the-dry-2-2024-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-free-state-of-jones-2016-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-free-state-of-jones-2016-poster.jpg new file mode 100644 index 00000000..e69af7c0 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-free-state-of-jones-2016-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-from-up-on-poppy-hill-2011-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-from-up-on-poppy-hill-2011-poster.jpg new file mode 100644 index 00000000..264076e2 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-from-up-on-poppy-hill-2011-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-ghost-in-the-shell-1995-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-ghost-in-the-shell-1995-poster.jpg new file mode 100644 index 00000000..58d221ba Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-ghost-in-the-shell-1995-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-ghost-in-the-shell-2-innocence-2004-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-ghost-in-the-shell-2-innocence-2004-poster.jpg new file mode 100644 index 00000000..ae262147 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-ghost-in-the-shell-2-innocence-2004-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-gosford-park-2001-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-gosford-park-2001-poster.jpg new file mode 100644 index 00000000..6a6aa651 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-gosford-park-2001-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-grave-of-the-fireflies-1988-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-grave-of-the-fireflies-1988-poster.jpg new file mode 100644 index 00000000..1b71996a Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-grave-of-the-fireflies-1988-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-howl-s-moving-castle-2004-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-howl-s-moving-castle-2004-poster.jpg new file mode 100644 index 00000000..042a6c80 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-howl-s-moving-castle-2004-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-in-the-heights-2021-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-in-the-heights-2021-poster.jpg new file mode 100644 index 00000000..e05bfdb0 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-in-the-heights-2021-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-jennifer-s-body-2009-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-jennifer-s-body-2009-poster.jpg new file mode 100644 index 00000000..41cc1eb5 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-jennifer-s-body-2009-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-kiki-s-delivery-service-1989-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-kiki-s-delivery-service-1989-poster.jpg new file mode 100644 index 00000000..80a59b1e Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-kiki-s-delivery-service-1989-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-lemony-snicket-s-a-series-of-unfortunate-events-2004-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-lemony-snicket-s-a-series-of-unfortunate-events-2004-poster.jpg new file mode 100644 index 00000000..4c78fee9 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-lemony-snicket-s-a-series-of-unfortunate-events-2004-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-locke-2014-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-locke-2014-poster.jpg new file mode 100644 index 00000000..94ebe97d Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-locke-2014-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-look-who-s-back-2015-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-look-who-s-back-2015-poster.jpg new file mode 100644 index 00000000..423043cf Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-look-who-s-back-2015-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-memories-1995-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-memories-1995-poster.jpg new file mode 100644 index 00000000..f3725b42 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-memories-1995-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-mercy-2026-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-mercy-2026-poster.jpg new file mode 100644 index 00000000..cfc0d812 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-mercy-2026-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-night-is-short-walk-on-girl-2017-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-night-is-short-walk-on-girl-2017-poster.jpg new file mode 100644 index 00000000..c6fa6a26 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-night-is-short-walk-on-girl-2017-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-normal-2026-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-normal-2026-poster.jpg new file mode 100644 index 00000000..f4c89db7 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-normal-2026-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-outlaw-king-2018-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-outlaw-king-2018-poster.jpg new file mode 100644 index 00000000..d6760468 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-outlaw-king-2018-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-panic-room-2002-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-panic-room-2002-poster.jpg new file mode 100644 index 00000000..0b2a0d30 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-panic-room-2002-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-peaky-blinders-the-immortal-man-2026-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-peaky-blinders-the-immortal-man-2026-poster.jpg new file mode 100644 index 00000000..a4814472 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-peaky-blinders-the-immortal-man-2026-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-ponyo-2008-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-ponyo-2008-poster.jpg new file mode 100644 index 00000000..f548e831 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-ponyo-2008-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-porco-rosso-1992-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-porco-rosso-1992-poster.jpg new file mode 100644 index 00000000..4a6a2bd1 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-porco-rosso-1992-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-princess-mononoke-1997-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-princess-mononoke-1997-poster.jpg new file mode 100644 index 00000000..21dd9359 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-princess-mononoke-1997-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-prisoners-2013-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-prisoners-2013-poster.jpg new file mode 100644 index 00000000..411b149b Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-prisoners-2013-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-racing-stripes-2005-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-racing-stripes-2005-poster.jpg new file mode 100644 index 00000000..cc2ef2ce Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-racing-stripes-2005-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-reincarnated-2012-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-reincarnated-2012-poster.jpg new file mode 100644 index 00000000..e9ab449c Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-reincarnated-2012-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-requiem-for-a-dream-2000-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-requiem-for-a-dream-2000-poster.jpg new file mode 100644 index 00000000..6f89abc3 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-requiem-for-a-dream-2000-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-rogue-one-a-star-wars-story-2016-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-rogue-one-a-star-wars-story-2016-poster.jpg new file mode 100644 index 00000000..c79e70a1 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-rogue-one-a-star-wars-story-2016-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-secondhand-lions-2003-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-secondhand-lions-2003-poster.jpg new file mode 100644 index 00000000..33408fa0 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-secondhand-lions-2003-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-self-reliance-2024-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-self-reliance-2024-poster.jpg new file mode 100644 index 00000000..0f6017ed Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-self-reliance-2024-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-serpico-1973-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-serpico-1973-poster.jpg new file mode 100644 index 00000000..8ddd6e52 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-serpico-1973-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-shaft-2000-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-shaft-2000-poster.jpg new file mode 100644 index 00000000..6b2b79ce Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-shaft-2000-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-shipwrecked-nightmare-at-sea-2026-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-shipwrecked-nightmare-at-sea-2026-poster.jpg new file mode 100644 index 00000000..f200a28d Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-shipwrecked-nightmare-at-sea-2026-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-sinbad-legend-of-the-seven-seas-2003-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-sinbad-legend-of-the-seven-seas-2003-poster.jpg new file mode 100644 index 00000000..428bb38b Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-sinbad-legend-of-the-seven-seas-2003-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-sleeping-with-other-people-2015-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-sleeping-with-other-people-2015-poster.jpg new file mode 100644 index 00000000..a5ffff1f Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-sleeping-with-other-people-2015-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-snowpiercer-2013-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-snowpiercer-2013-poster.jpg new file mode 100644 index 00000000..be1873fd Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-snowpiercer-2013-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-spider-man-brand-new-day-2026-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-spider-man-brand-new-day-2026-poster.jpg new file mode 100644 index 00000000..51fa155f Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-spider-man-brand-new-day-2026-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-spider-man-far-from-home-2019-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-spider-man-far-from-home-2019-poster.jpg new file mode 100644 index 00000000..28521997 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-spider-man-far-from-home-2019-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-spider-man-homecoming-2017-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-spider-man-homecoming-2017-poster.jpg new file mode 100644 index 00000000..97fc9c05 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-spider-man-homecoming-2017-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-spider-man-no-way-home-2021-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-spider-man-no-way-home-2021-poster.jpg new file mode 100644 index 00000000..bc92a4cd Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-spider-man-no-way-home-2021-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-spirited-away-2001-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-spirited-away-2001-poster.jpg new file mode 100644 index 00000000..5a79da62 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-spirited-away-2001-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-steins-gate-the-movie-load-region-of-deja-vu-2013-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-steins-gate-the-movie-load-region-of-deja-vu-2013-poster.jpg new file mode 100644 index 00000000..e85ddb90 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-steins-gate-the-movie-load-region-of-deja-vu-2013-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-taxi-driver-1976-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-taxi-driver-1976-poster.jpg new file mode 100644 index 00000000..c410b981 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-taxi-driver-1976-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-the-alto-knights-2025-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-the-alto-knights-2025-poster.jpg new file mode 100644 index 00000000..bb009cbf Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-the-alto-knights-2025-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-the-amazing-spider-man-2-2014-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-the-amazing-spider-man-2-2014-poster.jpg new file mode 100644 index 00000000..4de2711f Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-the-amazing-spider-man-2-2014-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-the-amazing-spider-man-2012-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-the-amazing-spider-man-2012-poster.jpg new file mode 100644 index 00000000..c1aec876 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-the-amazing-spider-man-2012-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-the-animatrix-2003-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-the-animatrix-2003-poster.jpg new file mode 100644 index 00000000..65553d47 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-the-animatrix-2003-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-the-avengers-2012-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-the-avengers-2012-poster.jpg new file mode 100644 index 00000000..be87067c Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-the-avengers-2012-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-the-boy-and-the-heron-2023-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-the-boy-and-the-heron-2023-poster.jpg new file mode 100644 index 00000000..094df64e Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-the-boy-and-the-heron-2023-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-the-breakfast-club-1985-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-the-breakfast-club-1985-poster.jpg new file mode 100644 index 00000000..d1823120 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-the-breakfast-club-1985-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-the-dry-2021-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-the-dry-2021-poster.jpg new file mode 100644 index 00000000..a0379f2f Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-the-dry-2021-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-the-girl-who-leapt-through-time-2006-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-the-girl-who-leapt-through-time-2006-poster.jpg new file mode 100644 index 00000000..ded6cd2a Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-the-girl-who-leapt-through-time-2006-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-the-godfather-1972-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-the-godfather-1972-poster.jpg new file mode 100644 index 00000000..94111d9b Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-the-godfather-1972-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-the-godfather-part-ii-1974-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-the-godfather-part-ii-1974-poster.jpg new file mode 100644 index 00000000..1328c99e Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-the-godfather-part-ii-1974-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-the-godfather-part-iii-1990-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-the-godfather-part-iii-1990-poster.jpg new file mode 100644 index 00000000..abbe5e99 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-the-godfather-part-iii-1990-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-the-great-gatsby-2013-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-the-great-gatsby-2013-poster.jpg new file mode 100644 index 00000000..e949fb3e Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-the-great-gatsby-2013-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-the-hours-2002-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-the-hours-2002-poster.jpg new file mode 100644 index 00000000..f87e919f Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-the-hours-2002-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-the-killing-of-a-sacred-deer-2017-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-the-killing-of-a-sacred-deer-2017-poster.jpg new file mode 100644 index 00000000..83464d4c Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-the-killing-of-a-sacred-deer-2017-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-the-last-of-the-mohicans-1992-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-the-last-of-the-mohicans-1992-poster.jpg new file mode 100644 index 00000000..950e9ba1 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-the-last-of-the-mohicans-1992-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-the-last-samurai-2003-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-the-last-samurai-2003-poster.jpg new file mode 100644 index 00000000..0d58440c Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-the-last-samurai-2003-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-the-lighthouse-2019-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-the-lighthouse-2019-poster.jpg new file mode 100644 index 00000000..15f26533 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-the-lighthouse-2019-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-the-matrix-1999-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-the-matrix-1999-poster.jpg new file mode 100644 index 00000000..c292dce5 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-the-matrix-1999-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-the-matrix-reloaded-2003-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-the-matrix-reloaded-2003-poster.jpg new file mode 100644 index 00000000..ab11f50f Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-the-matrix-reloaded-2003-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-the-matrix-resurrections-2021-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-the-matrix-resurrections-2021-poster.jpg new file mode 100644 index 00000000..4477ef1e Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-the-matrix-resurrections-2021-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-the-matrix-revolutions-2003-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-the-matrix-revolutions-2003-poster.jpg new file mode 100644 index 00000000..7236cc4e Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-the-matrix-revolutions-2003-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-the-patriot-2000-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-the-patriot-2000-poster.jpg new file mode 100644 index 00000000..c8582133 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-the-patriot-2000-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-the-secret-life-of-pets-2-2019-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-the-secret-life-of-pets-2-2019-poster.jpg new file mode 100644 index 00000000..fe50c2f8 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-the-secret-life-of-pets-2-2019-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-the-substance-2024-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-the-substance-2024-poster.jpg new file mode 100644 index 00000000..72accfed Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-the-substance-2024-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-the-tale-of-the-princess-kaguya-2013-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-the-tale-of-the-princess-kaguya-2013-poster.jpg new file mode 100644 index 00000000..882621cb Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-the-tale-of-the-princess-kaguya-2013-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-the-uprising-2026-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-the-uprising-2026-poster.jpg new file mode 100644 index 00000000..21976395 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-the-uprising-2026-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-the-wild-robot-2024-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-the-wild-robot-2024-poster.jpg new file mode 100644 index 00000000..0894754d Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-the-wild-robot-2024-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-the-wind-rises-2013-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-the-wind-rises-2013-poster.jpg new file mode 100644 index 00000000..b7688c77 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-the-wind-rises-2013-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-the-witcher-nightmare-of-the-wolf-2021-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-the-witcher-nightmare-of-the-wolf-2021-poster.jpg new file mode 100644 index 00000000..6db33db1 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-the-witcher-nightmare-of-the-wolf-2021-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-the-witcher-sirens-of-the-deep-2025-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-the-witcher-sirens-of-the-deep-2025-poster.jpg new file mode 100644 index 00000000..8a54bc7f Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-the-witcher-sirens-of-the-deep-2025-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-thelma-louise-1991-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-thelma-louise-1991-poster.jpg new file mode 100644 index 00000000..5a4ff0ab Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-thelma-louise-1991-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-triangle-of-sadness-2022-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-triangle-of-sadness-2022-poster.jpg new file mode 100644 index 00000000..aee092c6 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-triangle-of-sadness-2022-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-tsotsi-2005-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-tsotsi-2005-poster.jpg new file mode 100644 index 00000000..362ddd82 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-tsotsi-2005-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-unknown-number-the-high-school-catfish-2025-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-unknown-number-the-high-school-catfish-2025-poster.jpg new file mode 100644 index 00000000..e32685f3 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-unknown-number-the-high-school-catfish-2025-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-vice-2018-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-vice-2018-poster.jpg new file mode 100644 index 00000000..76855126 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-vice-2018-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-vicky-cristina-barcelona-2008-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-vicky-cristina-barcelona-2008-poster.jpg new file mode 100644 index 00000000..1351cad1 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-vicky-cristina-barcelona-2008-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-warriors-of-the-wind-1984-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-warriors-of-the-wind-1984-poster.jpg new file mode 100644 index 00000000..918e424f Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-warriors-of-the-wind-1984-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-we-bury-the-dead-2026-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-we-bury-the-dead-2026-poster.jpg new file mode 100644 index 00000000..a0d65904 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-we-bury-the-dead-2026-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-white-hot-the-rise-fall-of-abercrombie-fitch-2022-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-white-hot-the-rise-fall-of-abercrombie-fitch-2022-poster.jpg new file mode 100644 index 00000000..3a123e80 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/movie-white-hot-the-rise-fall-of-abercrombie-fitch-2022-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-12-monkeys-2015-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-12-monkeys-2015-poster.jpg new file mode 100644 index 00000000..96e3596e Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-12-monkeys-2015-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-56-days-2026-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-56-days-2026-poster.jpg new file mode 100644 index 00000000..64ca587b Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-56-days-2026-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-90-day-fiance-before-the-90-days-2017-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-90-day-fiance-before-the-90-days-2017-poster.jpg new file mode 100644 index 00000000..3362da59 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-90-day-fiance-before-the-90-days-2017-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-90-day-fiance-the-other-way-2019-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-90-day-fiance-the-other-way-2019-poster.jpg new file mode 100644 index 00000000..b4d256eb Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-90-day-fiance-the-other-way-2019-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-a-knight-of-the-seven-kingdoms-2026-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-a-knight-of-the-seven-kingdoms-2026-poster.jpg new file mode 100644 index 00000000..79f1351c Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-a-knight-of-the-seven-kingdoms-2026-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-absolutely-fabulous-1992-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-absolutely-fabulous-1992-poster.jpg new file mode 100644 index 00000000..afd371a9 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-absolutely-fabulous-1992-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-accel-world-2012-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-accel-world-2012-poster.jpg new file mode 100644 index 00000000..207b1dd1 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-accel-world-2012-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-accused-2023-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-accused-2023-poster.jpg new file mode 100644 index 00000000..74af010a Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-accused-2023-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-adults-2025-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-adults-2025-poster.jpg new file mode 100644 index 00000000..53a7ba33 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-adults-2025-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-after-life-2019-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-after-life-2019-poster.jpg new file mode 100644 index 00000000..4fae4e33 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-after-life-2019-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-agatha-christie-s-marple-2004-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-agatha-christie-s-marple-2004-poster.jpg new file mode 100644 index 00000000..ea7dda99 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-agatha-christie-s-marple-2004-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-ahsoka-2023-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-ahsoka-2023-poster.jpg new file mode 100644 index 00000000..a29d6cb4 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-ahsoka-2023-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-all-american-homecoming-2022-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-all-american-homecoming-2022-poster.jpg new file mode 100644 index 00000000..09ccfc33 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-all-american-homecoming-2022-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-all-creatures-great-and-small-1978-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-all-creatures-great-and-small-1978-poster.jpg new file mode 100644 index 00000000..5b1666e6 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-all-creatures-great-and-small-1978-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-all-creatures-great-small-2020-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-all-creatures-great-small-2020-poster.jpg new file mode 100644 index 00000000..b506f990 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-all-creatures-great-small-2020-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-american-crime-story-2016-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-american-crime-story-2016-poster.jpg new file mode 100644 index 00000000..4086bf17 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-american-crime-story-2016-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-american-dad-2005-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-american-dad-2005-poster.jpg new file mode 100644 index 00000000..1f9e239a Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-american-dad-2005-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-american-gods-2017-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-american-gods-2017-poster.jpg new file mode 100644 index 00000000..c02c46da Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-american-gods-2017-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-american-housewife-2016-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-american-housewife-2016-poster.jpg new file mode 100644 index 00000000..5f92bf05 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-american-housewife-2016-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-american-primeval-2025-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-american-primeval-2025-poster.jpg new file mode 100644 index 00000000..7f10e6aa Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-american-primeval-2025-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-and-just-like-that-2021-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-and-just-like-that-2021-poster.jpg new file mode 100644 index 00000000..9904c74b Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-and-just-like-that-2021-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-andor-2022-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-andor-2022-poster.jpg new file mode 100644 index 00000000..df5dc498 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-andor-2022-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-andromeda-2000-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-andromeda-2000-poster.jpg new file mode 100644 index 00000000..67b766b0 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-andromeda-2000-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-anne-with-an-e-2017-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-anne-with-an-e-2017-poster.jpg new file mode 100644 index 00000000..55d443b4 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-anne-with-an-e-2017-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-aqua-teen-hunger-force-2000-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-aqua-teen-hunger-force-2000-poster.jpg new file mode 100644 index 00000000..e0c97321 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-aqua-teen-hunger-force-2000-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-arifureta-from-commonplace-to-world-s-strongest-2019-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-arifureta-from-commonplace-to-world-s-strongest-2019-poster.jpg new file mode 100644 index 00000000..6a8f9a24 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-arifureta-from-commonplace-to-world-s-strongest-2019-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-arrested-development-2003-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-arrested-development-2003-poster.jpg new file mode 100644 index 00000000..02b0dd43 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-arrested-development-2003-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-arrow-2012-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-arrow-2012-poster.jpg new file mode 100644 index 00000000..adee3bb7 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-arrow-2012-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-atlantis-2013-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-atlantis-2013-poster.jpg new file mode 100644 index 00000000..5cd5f098 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-atlantis-2013-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-avatar-seven-havens-2026-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-avatar-seven-havens-2026-poster.jpg new file mode 100644 index 00000000..61f459c6 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-avatar-seven-havens-2026-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-avatar-the-last-airbender-2005-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-avatar-the-last-airbender-2005-poster.jpg new file mode 100644 index 00000000..6f99263a Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-avatar-the-last-airbender-2005-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-avatar-the-last-airbender-2024-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-avatar-the-last-airbender-2024-poster.jpg new file mode 100644 index 00000000..9d46c2ff Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-avatar-the-last-airbender-2024-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-awkward-2011-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-awkward-2011-poster.jpg new file mode 100644 index 00000000..8da6e8e2 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-awkward-2011-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-b-b-vol-liefde-2021-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-b-b-vol-liefde-2021-poster.jpg new file mode 100644 index 00000000..a12ac33e Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-b-b-vol-liefde-2021-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-back-to-life-2019-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-back-to-life-2019-poster.jpg new file mode 100644 index 00000000..b00730a8 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-back-to-life-2019-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-ballers-2015-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-ballers-2015-poster.jpg new file mode 100644 index 00000000..f1c18f69 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-ballers-2015-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-baretta-1975-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-baretta-1975-poster.jpg new file mode 100644 index 00000000..ceef0c9d Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-baretta-1975-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-based-on-a-true-story-2023-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-based-on-a-true-story-2023-poster.jpg new file mode 100644 index 00000000..386d3d90 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-based-on-a-true-story-2023-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-batman-the-brave-and-the-bold-2008-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-batman-the-brave-and-the-bold-2008-poster.jpg new file mode 100644 index 00000000..a864f50c Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-batman-the-brave-and-the-bold-2008-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-batwoman-2019-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-batwoman-2019-poster.jpg new file mode 100644 index 00000000..7ca86592 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-batwoman-2019-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-beat-bobby-flay-2013-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-beat-bobby-flay-2013-poster.jpg new file mode 100644 index 00000000..fe11122d Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-beat-bobby-flay-2013-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-benson-1979-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-benson-1979-poster.jpg new file mode 100644 index 00000000..fe264c76 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-benson-1979-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-big-boys-2022-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-big-boys-2022-poster.jpg new file mode 100644 index 00000000..93e1e86a Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-big-boys-2022-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-big-city-greens-2018-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-big-city-greens-2018-poster.jpg new file mode 100644 index 00000000..ef024946 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-big-city-greens-2018-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-big-mouth-2017-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-big-mouth-2017-poster.jpg new file mode 100644 index 00000000..4f535fec Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-big-mouth-2017-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-black-ish-2014-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-black-ish-2014-poster.jpg new file mode 100644 index 00000000..97c885ad Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-black-ish-2014-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-black-lightning-2018-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-black-lightning-2018-poster.jpg new file mode 100644 index 00000000..733f5cbc Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-black-lightning-2018-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-black-rabbit-2025-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-black-rabbit-2025-poster.jpg new file mode 100644 index 00000000..3ff9aeed Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-black-rabbit-2025-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-bleach-2004-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-bleach-2004-poster.jpg new file mode 100644 index 00000000..b233e033 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-bleach-2004-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-blue-planet-ii-2017-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-blue-planet-ii-2017-poster.jpg new file mode 100644 index 00000000..5e193ab2 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-blue-planet-ii-2017-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-brassic-2019-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-brassic-2019-poster.jpg new file mode 100644 index 00000000..73b77df2 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-brassic-2019-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-breaking-bad-2008-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-breaking-bad-2008-poster.jpg new file mode 100644 index 00000000..c4f7217c Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-breaking-bad-2008-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-breeders-2020-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-breeders-2020-poster.jpg new file mode 100644 index 00000000..49c61208 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-breeders-2020-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-californication-2007-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-californication-2007-poster.jpg new file mode 100644 index 00000000..38428e76 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-californication-2007-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-call-the-midwife-2012-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-call-the-midwife-2012-poster.jpg new file mode 100644 index 00000000..466a1a7b Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-call-the-midwife-2012-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-chainsaw-man-2022-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-chainsaw-man-2022-poster.jpg new file mode 100644 index 00000000..a3779772 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-chainsaw-man-2022-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-chernobyl-2019-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-chernobyl-2019-poster.jpg new file mode 100644 index 00000000..7664f4b0 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-chernobyl-2019-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-community-2009-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-community-2009-poster.jpg new file mode 100644 index 00000000..e75072ae Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-community-2009-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-cyberpunk-edgerunners-2022-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-cyberpunk-edgerunners-2022-poster.jpg new file mode 100644 index 00000000..8d338532 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-cyberpunk-edgerunners-2022-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-dan-da-dan-2024-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-dan-da-dan-2024-poster.jpg new file mode 100644 index 00000000..f42cec16 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-dan-da-dan-2024-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-dead-end-job-2026-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-dead-end-job-2026-poster.jpg new file mode 100644 index 00000000..f5546d88 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-dead-end-job-2026-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-derry-girls-2018-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-derry-girls-2018-poster.jpg new file mode 100644 index 00000000..56a8cd79 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-derry-girls-2018-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-desperate-housewives-2004-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-desperate-housewives-2004-poster.jpg new file mode 100644 index 00000000..0c4c38bc Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-desperate-housewives-2004-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-dopesick-2021-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-dopesick-2021-poster.jpg new file mode 100644 index 00000000..ac0b5900 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-dopesick-2021-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-dr-stone-2019-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-dr-stone-2019-poster.jpg new file mode 100644 index 00000000..0783ed8c Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-dr-stone-2019-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-dune-prophecy-2024-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-dune-prophecy-2024-poster.jpg new file mode 100644 index 00000000..eef2a4f3 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-dune-prophecy-2024-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-east-of-eden-2026-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-east-of-eden-2026-poster.jpg new file mode 100644 index 00000000..6f06f2c9 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-east-of-eden-2026-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-etoile-2025-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-etoile-2025-poster.jpg new file mode 100644 index 00000000..f078bc89 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-etoile-2025-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-fallout-2024-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-fallout-2024-poster.jpg new file mode 100644 index 00000000..5e87a07d Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-fallout-2024-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-fawlty-towers-1975-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-fawlty-towers-1975-poster.jpg new file mode 100644 index 00000000..53b406da Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-fawlty-towers-1975-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-firefly-2002-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-firefly-2002-poster.jpg new file mode 100644 index 00000000..9ad599b3 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-firefly-2002-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-fleabag-2016-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-fleabag-2016-poster.jpg new file mode 100644 index 00000000..bd3ccea5 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-fleabag-2016-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-frieren-beyond-journey-s-end-2023-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-frieren-beyond-journey-s-end-2023-poster.jpg new file mode 100644 index 00000000..cb94104e Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-frieren-beyond-journey-s-end-2023-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-fullmetal-alchemist-brotherhood-2009-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-fullmetal-alchemist-brotherhood-2009-poster.jpg new file mode 100644 index 00000000..935e4944 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-fullmetal-alchemist-brotherhood-2009-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-gilmore-girls-2000-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-gilmore-girls-2000-poster.jpg new file mode 100644 index 00000000..23b668cb Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-gilmore-girls-2000-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-ginny-georgia-2021-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-ginny-georgia-2021-poster.jpg new file mode 100644 index 00000000..1fca7bf6 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-ginny-georgia-2021-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-gravity-falls-2012-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-gravity-falls-2012-poster.jpg new file mode 100644 index 00000000..41c051e0 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-gravity-falls-2012-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-hellcats-2010-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-hellcats-2010-poster.jpg new file mode 100644 index 00000000..2427108b Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-hellcats-2010-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-high-potential-2024-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-high-potential-2024-poster.jpg new file mode 100644 index 00000000..00a07ca7 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-high-potential-2024-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-how-to-change-your-mind-2022-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-how-to-change-your-mind-2022-poster.jpg new file mode 100644 index 00000000..e80c7999 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-how-to-change-your-mind-2022-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-how-to-get-to-heaven-from-belfast-2026-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-how-to-get-to-heaven-from-belfast-2026-poster.jpg new file mode 100644 index 00000000..eb5fc487 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-how-to-get-to-heaven-from-belfast-2026-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-interrogation-2020-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-interrogation-2020-poster.jpg new file mode 100644 index 00000000..f3a21b66 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-interrogation-2020-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-invincible-2021-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-invincible-2021-poster.jpg new file mode 100644 index 00000000..c278e37e Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-invincible-2021-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-it-s-always-sunny-in-philadelphia-2005-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-it-s-always-sunny-in-philadelphia-2005-poster.jpg new file mode 100644 index 00000000..652923ee Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-it-s-always-sunny-in-philadelphia-2005-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-jujutsu-kaisen-2020-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-jujutsu-kaisen-2020-poster.jpg new file mode 100644 index 00000000..45f5ff22 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-jujutsu-kaisen-2020-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-kaiju-no-8-2024-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-kaiju-no-8-2024-poster.jpg new file mode 100644 index 00000000..062496fa Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-kaiju-no-8-2024-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-landman-2024-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-landman-2024-poster.jpg new file mode 100644 index 00000000..3d348798 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-landman-2024-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-last-samurai-standing-2025-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-last-samurai-standing-2025-poster.jpg new file mode 100644 index 00000000..a9789909 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-last-samurai-standing-2025-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-lord-of-the-flies-2026-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-lord-of-the-flies-2026-poster.jpg new file mode 100644 index 00000000..b1229ed8 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-lord-of-the-flies-2026-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-love-death-robots-2019-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-love-death-robots-2019-poster.jpg new file mode 100644 index 00000000..08449aa9 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-love-death-robots-2019-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-love-is-blind-2020-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-love-is-blind-2020-poster.jpg new file mode 100644 index 00000000..62ccf357 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-love-is-blind-2020-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-maniac-2018-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-maniac-2018-poster.jpg new file mode 100644 index 00000000..a1985217 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-maniac-2018-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-maximum-pleasure-guaranteed-2026-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-maximum-pleasure-guaranteed-2026-poster.jpg new file mode 100644 index 00000000..abe1b2e2 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-maximum-pleasure-guaranteed-2026-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-minx-2022-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-minx-2022-poster.jpg new file mode 100644 index 00000000..a8797fe5 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-minx-2022-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-mussolini-son-of-the-century-2024-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-mussolini-son-of-the-century-2024-poster.jpg new file mode 100644 index 00000000..925d5c49 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-mussolini-son-of-the-century-2024-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-my-hero-academia-2016-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-my-hero-academia-2016-poster.jpg new file mode 100644 index 00000000..cf571b93 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-my-hero-academia-2016-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-neon-genesis-evangelion-1995-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-neon-genesis-evangelion-1995-poster.jpg new file mode 100644 index 00000000..f86cd698 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-neon-genesis-evangelion-1995-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-norsemen-2016-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-norsemen-2016-poster.jpg new file mode 100644 index 00000000..e7206b88 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-norsemen-2016-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-offspring-2010-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-offspring-2010-poster.jpg new file mode 100644 index 00000000..ae305043 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-offspring-2010-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-one-piece-2023-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-one-piece-2023-poster.jpg new file mode 100644 index 00000000..286e6260 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-one-piece-2023-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-only-murders-in-the-building-2021-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-only-murders-in-the-building-2021-poster.jpg new file mode 100644 index 00000000..a6b147a4 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-only-murders-in-the-building-2021-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-outlander-blood-of-my-blood-2025-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-outlander-blood-of-my-blood-2025-poster.jpg new file mode 100644 index 00000000..930943b0 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-outlander-blood-of-my-blood-2025-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-over-the-garden-wall-2014-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-over-the-garden-wall-2014-poster.jpg new file mode 100644 index 00000000..34b1ed84 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-over-the-garden-wall-2014-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-peaky-blinders-2013-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-peaky-blinders-2013-poster.jpg new file mode 100644 index 00000000..25ceea88 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-peaky-blinders-2013-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-pluribus-2025-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-pluribus-2025-poster.jpg new file mode 100644 index 00000000..1ea4711b Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-pluribus-2025-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-record-of-ragnarok-2021-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-record-of-ragnarok-2021-poster.jpg new file mode 100644 index 00000000..97d481d6 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-record-of-ragnarok-2021-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-rick-and-morty-2013-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-rick-and-morty-2013-poster.jpg new file mode 100644 index 00000000..b95c4890 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-rick-and-morty-2013-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-ripley-2024-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-ripley-2024-poster.jpg new file mode 100644 index 00000000..de0b32dd Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-ripley-2024-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-rivals-2024-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-rivals-2024-poster.jpg new file mode 100644 index 00000000..296ac67a Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-rivals-2024-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-rooster-2026-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-rooster-2026-poster.jpg new file mode 100644 index 00000000..b698f8a6 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-rooster-2026-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-sakamoto-days-2025-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-sakamoto-days-2025-poster.jpg new file mode 100644 index 00000000..ed9358b3 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-sakamoto-days-2025-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-say-nothing-2024-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-say-nothing-2024-poster.jpg new file mode 100644 index 00000000..cf50fc09 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-say-nothing-2024-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-secret-level-2024-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-secret-level-2024-poster.jpg new file mode 100644 index 00000000..1b6e67f6 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-secret-level-2024-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-sentenced-to-be-a-hero-2025-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-sentenced-to-be-a-hero-2025-poster.jpg new file mode 100644 index 00000000..2fb1c815 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-sentenced-to-be-a-hero-2025-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-severance-2022-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-severance-2022-poster.jpg new file mode 100644 index 00000000..5a29d6f9 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-severance-2022-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-sex-and-the-city-1998-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-sex-and-the-city-1998-poster.jpg new file mode 100644 index 00000000..e546cc6d Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-sex-and-the-city-1998-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-silo-2023-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-silo-2023-poster.jpg new file mode 100644 index 00000000..b493bc7b Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-silo-2023-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-six-feet-under-2001-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-six-feet-under-2001-poster.jpg new file mode 100644 index 00000000..559d6bd1 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-six-feet-under-2001-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-skymed-2022-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-skymed-2022-poster.jpg new file mode 100644 index 00000000..2540dad7 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-skymed-2022-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-so-you-think-you-can-dance-2008-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-so-you-think-you-can-dance-2008-poster.jpg new file mode 100644 index 00000000..a2e91496 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-so-you-think-you-can-dance-2008-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-south-park-1997-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-south-park-1997-poster.jpg new file mode 100644 index 00000000..d8568d54 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-south-park-1997-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-spy-x-family-2022-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-spy-x-family-2022-poster.jpg new file mode 100644 index 00000000..fc326930 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-spy-x-family-2022-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-the-100-2014-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-the-100-2014-poster.jpg new file mode 100644 index 00000000..4d254cb0 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-the-100-2014-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-the-abbott-and-costello-show-1952-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-the-abbott-and-costello-show-1952-poster.jpg new file mode 100644 index 00000000..7d6768ea Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-the-abbott-and-costello-show-1952-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-the-afterparty-2022-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-the-afterparty-2022-poster.jpg new file mode 100644 index 00000000..9beacde2 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-the-afterparty-2022-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-the-baby-sitters-club-2020-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-the-baby-sitters-club-2020-poster.jpg new file mode 100644 index 00000000..7e4e7be2 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-the-baby-sitters-club-2020-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-the-bear-2022-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-the-bear-2022-poster.jpg new file mode 100644 index 00000000..bbf0ab28 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-the-bear-2022-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-the-beast-in-me-2025-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-the-beast-in-me-2025-poster.jpg new file mode 100644 index 00000000..c78c5cf0 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-the-beast-in-me-2025-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-the-bold-type-2017-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-the-bold-type-2017-poster.jpg new file mode 100644 index 00000000..f663eb8b Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-the-bold-type-2017-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-the-boys-2019-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-the-boys-2019-poster.jpg new file mode 100644 index 00000000..f67ff16f Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-the-boys-2019-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-the-capture-2019-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-the-capture-2019-poster.jpg new file mode 100644 index 00000000..a4039011 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-the-capture-2019-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-the-diplomat-2023-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-the-diplomat-2023-poster.jpg new file mode 100644 index 00000000..2eb48b62 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-the-diplomat-2023-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-the-durrells-2016-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-the-durrells-2016-poster.jpg new file mode 100644 index 00000000..d29448bb Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-the-durrells-2016-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-the-final-problem-2026-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-the-final-problem-2026-poster.jpg new file mode 100644 index 00000000..f0332826 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-the-final-problem-2026-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-the-four-seasons-2025-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-the-four-seasons-2025-poster.jpg new file mode 100644 index 00000000..fd252088 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-the-four-seasons-2025-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-the-get-down-2016-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-the-get-down-2016-poster.jpg new file mode 100644 index 00000000..7bb38abd Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-the-get-down-2016-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-the-hunting-wives-2025-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-the-hunting-wives-2025-poster.jpg new file mode 100644 index 00000000..034273eb Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-the-hunting-wives-2025-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-the-last-of-us-2023-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-the-last-of-us-2023-poster.jpg new file mode 100644 index 00000000..7da462da Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-the-last-of-us-2023-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-the-legend-of-korra-2012-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-the-legend-of-korra-2012-poster.jpg new file mode 100644 index 00000000..a7086d5e Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-the-legend-of-korra-2012-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-the-lord-of-the-rings-the-rings-of-power-2022-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-the-lord-of-the-rings-the-rings-of-power-2022-poster.jpg new file mode 100644 index 00000000..dc387fcd Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-the-lord-of-the-rings-the-rings-of-power-2022-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-the-lost-flowers-of-alice-hart-2023-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-the-lost-flowers-of-alice-hart-2023-poster.jpg new file mode 100644 index 00000000..71407f36 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-the-lost-flowers-of-alice-hart-2023-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-the-night-manager-2016-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-the-night-manager-2016-poster.jpg new file mode 100644 index 00000000..7ab2dbf5 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-the-night-manager-2016-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-the-pitt-2025-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-the-pitt-2025-poster.jpg new file mode 100644 index 00000000..ad1ec8ff Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-the-pitt-2025-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-the-tatami-galaxy-2010-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-the-tatami-galaxy-2010-poster.jpg new file mode 100644 index 00000000..5324dc75 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-the-tatami-galaxy-2010-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-the-walk-in-2022-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-the-walk-in-2022-poster.jpg new file mode 100644 index 00000000..715d0c80 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-the-walk-in-2022-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-the-white-lotus-2021-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-the-white-lotus-2021-poster.jpg new file mode 100644 index 00000000..2f23d1fa Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-the-white-lotus-2021-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-the-witcher-2019-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-the-witcher-2019-poster.jpg new file mode 100644 index 00000000..f5eb8f59 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-the-witcher-2019-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-undercover-2019-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-undercover-2019-poster.jpg new file mode 100644 index 00000000..8611eab4 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-undercover-2019-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-upload-2020-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-upload-2020-poster.jpg new file mode 100644 index 00000000..547d3b7a Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-upload-2020-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-vinland-saga-2019-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-vinland-saga-2019-poster.jpg new file mode 100644 index 00000000..7d0ba17a Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-vinland-saga-2019-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-widow-s-bay-2026-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-widow-s-bay-2026-poster.jpg new file mode 100644 index 00000000..8c02a0d7 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-widow-s-bay-2026-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-yellowjackets-2021-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-yellowjackets-2021-poster.jpg new file mode 100644 index 00000000..c7e16a57 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-yellowjackets-2021-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-yu-yu-hakusho-1992-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-yu-yu-hakusho-1992-poster.jpg new file mode 100644 index 00000000..f5843766 Binary files /dev/null and b/wolfskin-design/src/test/resources/library/assets/tv-yu-yu-hakusho-1992-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/requests-press-paths.json b/wolfskin-design/src/test/resources/requests-press-paths.json new file mode 100644 index 00000000..5d76c663 --- /dev/null +++ b/wolfskin-design/src/test/resources/requests-press-paths.json @@ -0,0 +1,21 @@ +[ + { + "goal": "Round 4 \u00b7 Home hero \u2192 Requests (Coming in) in Settings", + "keys": [ + "OK", + "UP", + "LEFT", + "OK", + "RIGHT", + "RIGHT", + "DOWN", + "OK", + "OK" + ], + "presses": 9, + "note": "8 from the hero", + "start": "app launch: Who's watching with Justin focused (index.html:357)", + "replayed": "PASS", + "source": "index.html:2152-2174 PRESSES, replayed 2026-11-05" + } +] diff --git a/wolfskin-design/src/test/resources/wolfskin-discover.json b/wolfskin-design/src/test/resources/wolfskin-discover.json new file mode 100644 index 00000000..450f0ece --- /dev/null +++ b/wolfskin-design/src/test/resources/wolfskin-discover.json @@ -0,0 +1 @@ +{"schemaVersion": 1, "generatedAt": "2026-11-04T20:42:12.030108+00:00", "profiles": [{"key": "justin", "name": "Justin", "initial": "J", "color": "#62c4ff", "destinations": {"Home": {"kind": "home"}, "Movies": {"kind": "library", "collection": "movies"}, "Shows": {"kind": "library", "collection": "shows"}, "Anime": {"kind": "library", "collection": "anime"}, "Discover": {"kind": "discover", "collection": "discover"}}, "movies": [{"id": "movie-the-girl-who-leapt-through-time-2006", "title": "The Girl Who Leapt Through Time", "year": 2006, "type": "movie", "runtimeSeconds": 5886, "seasonCount": null, "communityRating": 7.772, "criticRating": 87, "genres": ["Fantasy", "Animation", "Drama"], "overview": "When 17-year-old Makoto Konno gains the ability to 'leap' backwards through time, she immediately sets about improving her grades and preventing personal mishaps. However, she soon realises that changing the past\u2026", "poster": "assets/movie-the-girl-who-leapt-through-time-2006-poster.jpg", "backdrop": "assets/movie-the-girl-who-leapt-through-time-2006-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T17:39:11.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 14069, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 87, "rtCriticsLabel": "Fresh", "rtAudience": 90, "rtAudienceLabel": "Upright", "imdb": 7.6, "imdbVotes": 76917, "tmdb": 7.775, "tmdbVotes": 2345}}, {"id": "movie-locke-2014", "title": "Locke", "year": 2014, "type": "movie", "runtimeSeconds": 5090, "seasonCount": null, "communityRating": 6.891, "criticRating": 91, "genres": ["Drama", "Thriller"], "overview": "Ivan Locke has worked hard to craft a good life for himself. Tonight, that life will collapse around him. On the eve of the biggest challenge of his career, Ivan receives a phone call that sets in motion a series of\u2026", "poster": "assets/movie-locke-2014-poster.jpg", "backdrop": "assets/movie-locke-2014-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T17:38:48.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 210479, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 90, "rtCriticsLabel": "Certified Fresh", "rtAudience": 72, "rtAudienceLabel": "Upright", "imdb": 7.1, "imdbVotes": 171265, "tmdb": 6.889, "tmdbVotes": 3088}}, {"id": "movie-look-who-s-back-2015", "title": "Look Who's Back", "year": 2015, "type": "movie", "runtimeSeconds": 6936, "seasonCount": null, "communityRating": 6.793, "criticRating": null, "genres": ["Comedy"], "overview": "When Adolf Hitler reawakens at the site of his former bunker in present-day Berlin, he is mistaken for a comedian and quickly becomes a media phenomenon.", "poster": "assets/movie-look-who-s-back-2015-poster.jpg", "backdrop": "assets/movie-look-who-s-back-2015-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:27:48.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 318917, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": 81, "rtAudienceLabel": "Upright", "imdb": 7, "imdbVotes": 54672, "tmdb": 6.796, "tmdbVotes": 1990}}, {"id": "movie-memories-1995", "title": "Memories", "year": 1995, "type": "movie", "runtimeSeconds": 6805, "seasonCount": null, "communityRating": 7.465, "criticRating": null, "genres": ["Fantasy", "Animation", "Science Fiction"], "overview": "Three back-to-back anime films by three different directors make up this sci-fi trilogy three years in the making.", "poster": "assets/movie-memories-1995-poster.jpg", "backdrop": "assets/movie-memories-1995-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-30T21:36:03.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 42994, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": 82, "rtAudienceLabel": "Upright", "imdb": 7.5, "imdbVotes": 26638, "tmdb": 7.459, "tmdbVotes": 622}}, {"id": "movie-reincarnated-2012", "title": "Reincarnated", "year": 2012, "type": "movie", "runtimeSeconds": 5759, "seasonCount": null, "communityRating": 6.145, "criticRating": 28, "genres": ["Music", "Documentary"], "overview": "Evolution as an artist is often times what separates legends from the more mundane. After being heavily influenced by his experience in Jamaica \u2013 and his subsequent name change from Snoop Dogg to Snoop Lion \u2013 the LBC\u2026", "poster": "assets/movie-reincarnated-2012-poster.jpg", "backdrop": "assets/movie-reincarnated-2012-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:27:26.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 123431, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 28, "rtCriticsLabel": "Rotten", "rtAudience": 41, "rtAudienceLabel": "Spilled", "imdb": 6.2, "imdbVotes": 2563, "tmdb": 6.1, "tmdbVotes": 69}}, {"id": "movie-shipwrecked-nightmare-at-sea-2026", "title": "Shipwrecked: Nightmare at Sea", "year": 2026, "type": "movie", "runtimeSeconds": 5385, "seasonCount": null, "communityRating": 7.181, "criticRating": null, "genres": ["Documentary"], "overview": "Never-before-seen footage and survivor accounts trace the 2012 shipwreck of a luxury cruise and the disaster that ensued, in this immersive documentary.", "poster": "assets/movie-shipwrecked-nightmare-at-sea-2026-poster.jpg", "backdrop": "assets/movie-shipwrecked-nightmare-at-sea-2026-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-20T20:11:43.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 1715492, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": 6.5, "imdbVotes": 5080, "tmdb": 7.172, "tmdbVotes": 87}}, {"id": "movie-steins-gate-the-movie-load-region-of-deja-vu-2013", "title": "Steins;Gate: The Movie - Load Region of D\u00e9j\u00e0 Vu", "year": 2013, "type": "movie", "runtimeSeconds": 5403, "seasonCount": null, "communityRating": 7.8, "criticRating": null, "genres": ["Animation", "Mystery", "Sci-Fi"], "overview": "After the events of the anime, Rintarou begins to feel the repercussions of extensive time travel, and eventually completely fades from reality. Kurisu, being the only companion to remember him, now must find a way\u2026", "poster": "assets/movie-steins-gate-the-movie-load-region-of-deja-vu-2013-poster.jpg", "backdrop": "assets/movie-steins-gate-the-movie-load-region-of-deja-vu-2013-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-22T14:55:54.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}], "shows": [{"id": "tv-andor-2022", "title": "Andor", "year": 2022, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 8.293, "criticRating": null, "genres": ["Sci-Fi & Fantasy", "Action & Adventure", "Drama"], "overview": "In an era filled with danger, deception and intrigue, Cassian Andor will discover the difference he can make in the struggle against the tyrannical Galactic Empire. He embarks on a path that is destined to turn him\u2026", "poster": "assets/tv-andor-2022-poster.jpg", "backdrop": "assets/tv-andor-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0432996Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 83867, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 96, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.321, "tmdbVotes": 2312}}, {"id": "tv-the-boys-2019", "title": "The Boys", "year": 2019, "type": "tv", "runtimeSeconds": null, "seasonCount": 5, "communityRating": 8.443, "criticRating": null, "genres": ["Sci-Fi & Fantasy", "Action & Adventure"], "overview": "A group of vigilantes known informally as \u201cThe Boys\u201d set out to take down corrupt superheroes with no more than blue-collar grit and a willingness to fight dirty.", "poster": "assets/tv-the-boys-2019-poster.jpg", "backdrop": "assets/tv-the-boys-2019-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0410131Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 76479, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 92, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.4, "tmdbVotes": 13628}}, {"id": "tv-californication-2007", "title": "Californication", "year": 2007, "type": "tv", "runtimeSeconds": 1680, "seasonCount": 7, "communityRating": 7.632, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "A self-loathing, alcoholic writer attempts to repair his damaged relationships with his daughter and her mother while combating sex addiction, a budding drug problem, and the seeming inability to avoid making bad\u2026", "poster": "assets/tv-californication-2007-poster.jpg", "backdrop": "assets/tv-californication-2007-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0428037Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 1215, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 57, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.62, "tmdbVotes": 1406}}, {"id": "tv-firefly-2002", "title": "Firefly", "year": 2002, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 8.347, "criticRating": null, "genres": ["Drama", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "In the year 2517, the universe is a vast frontier where lawlessness reigns and adventure awaits! Join Captain Malcolm Reynolds and his ragtag crew aboard the spaceship Serenity as they navigate the fringes of\u2026", "poster": "assets/tv-firefly-2002-poster.jpg", "backdrop": "assets/tv-firefly-2002-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-20T11:40:08.4202369Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 1437, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 77, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.351, "tmdbVotes": 2550}}, {"id": "tv-fleabag-2016", "title": "Fleabag", "year": 2016, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 8.278, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "A portrait into the mind of a dry-witted, sexual, angry, porn-watching, grief-riddled woman, trying to make sense of the world. As she hurls herself headlong at modern living, Fleabag is thrown roughly up against the\u2026", "poster": "assets/tv-fleabag-2016-poster.jpg", "backdrop": "assets/tv-fleabag-2016-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T17:42:25.4848214Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 67070, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.3, "tmdbVotes": 1993}}, {"id": "tv-gravity-falls-2012", "title": "Gravity Falls", "year": 2012, "type": "tv", "runtimeSeconds": 1320, "seasonCount": 2, "communityRating": 8.619, "criticRating": null, "genres": ["Action & Adventure", "Animation", "Comedy"], "overview": "Twin brother and sister Dipper and Mabel Pines are in for an unexpected adventure when they spend the summer helping their great uncle Stan run a tourist trap in the mysterious town of Gravity Falls, Oregon.", "poster": "assets/tv-gravity-falls-2012-poster.jpg", "backdrop": "assets/tv-gravity-falls-2012-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0435827Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 40075, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.624, "tmdbVotes": 3647}}, {"id": "tv-invincible-2021", "title": "INVINCIBLE", "year": 2021, "type": "tv", "runtimeSeconds": null, "seasonCount": 4, "communityRating": 8.638, "criticRating": null, "genres": ["Animation", "Drama", "Sci-Fi & Fantasy"], "overview": "Mark Grayson is a normal teenager except for the fact that his father is the most powerful superhero on the planet. Shortly after his seventeenth birthday, Mark begins to develop powers of his own and enters into his\u2026", "poster": "assets/tv-invincible-2021-poster.jpg", "backdrop": "assets/tv-invincible-2021-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.040159Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 95557, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 99, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.6, "tmdbVotes": 6155}}, {"id": "tv-the-legend-of-korra-2012", "title": "The Legend of Korra", "year": 2012, "type": "tv", "runtimeSeconds": 1380, "seasonCount": 4, "communityRating": 8.2, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "Avatar Korra, a headstrong, rebellious, feisty young woman who continually challenges and breaks with tradition, is on her quest to become a fully realized Avatar. In this story, the Avatar struggles to find balance\u2026", "poster": "assets/tv-the-legend-of-korra-2012-poster.jpg", "backdrop": "assets/tv-the-legend-of-korra-2012-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-20T00:19:23.2494945Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 33880, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 88, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.221, "tmdbVotes": 2482}}, {"id": "tv-love-death-robots-2019", "title": "Love, Death & Robots", "year": 2019, "type": "tv", "runtimeSeconds": null, "seasonCount": 4, "communityRating": 8.205, "criticRating": null, "genres": ["Animation", "Sci-Fi & Fantasy"], "overview": "Terrifying creatures, wicked surprises and dark comedy converge in this NSFW anthology of animated stories presented by Tim Miller and David Fincher.", "poster": "assets/tv-love-death-robots-2019-poster.jpg", "backdrop": "assets/tv-love-death-robots-2019-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-20T11:31:20.8818757Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 86831, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 89, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.203, "tmdbVotes": 4238}}, {"id": "tv-norsemen-2016", "title": "Norsemen", "year": 2016, "type": "tv", "runtimeSeconds": 1800, "seasonCount": 3, "communityRating": 7.177, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "Norsemen is an epic and humorous drama series set in the Viking Age. The residents of an 8th-century Viking village experience political rivalry, social change and innovations that upend their culture and way of life.", "poster": "assets/tv-norsemen-2016-poster.jpg", "backdrop": "assets/tv-norsemen-2016-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.034182Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 68126, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.2, "tmdbVotes": 233}}, {"id": "tv-rick-and-morty-2013", "title": "Rick and Morty", "year": 2013, "type": "tv", "runtimeSeconds": null, "seasonCount": 9, "communityRating": 8.679, "criticRating": null, "genres": ["Animation", "Comedy", "Sci-Fi & Fantasy"], "overview": "Follows a sociopathic genius scientist who drags his inherently timid grandson on adventures across the universe.", "poster": "assets/tv-rick-and-morty-2013-poster.jpg", "backdrop": "assets/tv-rick-and-morty-2013-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0419581Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 60625, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 91, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.7, "tmdbVotes": 11548}}, {"id": "tv-secret-level-2024", "title": "Secret Level", "year": 2024, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.411, "criticRating": null, "genres": ["Animation", "Sci-Fi & Fantasy", "Action & Adventure"], "overview": "Adult animated series of original short stories which are set within the worlds of beloved video games. Each episode serves as a gateway to a new adventure, unlocking exciting worlds from beloved gaming classics and\u2026", "poster": "assets/tv-secret-level-2024-poster.jpg", "backdrop": "assets/tv-secret-level-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0336417Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 261579, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 65, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.392, "tmdbVotes": 612}}, {"id": "tv-silo-2023", "title": "Silo", "year": 2023, "type": "tv", "runtimeSeconds": null, "seasonCount": 3, "communityRating": 8.178, "criticRating": null, "genres": ["Sci-Fi & Fantasy", "Drama"], "overview": "In a ruined and toxic future, thousands live in a giant silo deep underground. After its sheriff breaks a cardinal rule and residents die mysteriously, engineer Juliette starts to uncover shocking secrets and the\u2026", "poster": "assets/tv-silo-2023-poster.jpg", "backdrop": "assets/tv-silo-2023-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0346526Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 125988, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 92, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.232, "tmdbVotes": 2881}}, {"id": "tv-south-park-1997", "title": "South Park", "year": 1997, "type": "tv", "runtimeSeconds": null, "seasonCount": 29, "communityRating": 8.336, "criticRating": null, "genres": ["Animation", "Comedy"], "overview": "Follow the misadventures of four irreverent grade-schoolers in the quiet, dysfunctional town of South Park, Colorado.", "poster": "assets/tv-south-park-1997-poster.jpg", "backdrop": "assets/tv-south-park-1997-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0456124Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 2190, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 80, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.332, "tmdbVotes": 5250}}], "anime": [{"id": "tv-bleach-2004", "title": "Bleach", "year": 2004, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 17, "communityRating": 8.363, "criticRating": null, "genres": ["Action & Adventure", "Animation", "Sci-Fi & Fantasy"], "overview": "For as long as he can remember, Ichigo Kurosaki has been able to see ghosts. But when he meets Rukia, a Soul Reaper who battles evil spirits known as Hollows, he finds his life is changed forever. Now, with a\u2026", "poster": "assets/tv-bleach-2004-poster.jpg", "backdrop": "assets/tv-bleach-2004-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0359827Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 30984, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.4, "tmdbVotes": 2331}}, {"id": "tv-chainsaw-man-2022", "title": "Chainsaw Man", "year": 2022, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 1, "communityRating": 8.515, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "Denji has a simple dream\u2014to live a happy and peaceful life, spending time with a girl he likes. This is a far cry from reality, however, as Denji is forced by the yakuza into killing devils in order to pay off his\u2026", "poster": "assets/tv-chainsaw-man-2022-poster.jpg", "backdrop": "assets/tv-chainsaw-man-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-20T22:28:13.2095501Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 114410, "artwork": {"posterKind": "center_crop", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 97, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.508, "tmdbVotes": 2364}}, {"id": "tv-cyberpunk-edgerunners-2022", "title": "Cyberpunk: Edgerunners", "year": 2022, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 8.466, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "In a dystopia riddled with corruption and cybernetic implants, a talented but reckless street kid strives to become a mercenary outlaw \u2014 an edgerunner.", "poster": "assets/tv-cyberpunk-edgerunners-2022-poster.jpg", "backdrop": "assets/tv-cyberpunk-edgerunners-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-20T22:28:13.2093107Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 105248, "artwork": {"posterKind": "center_crop", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 89, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.507, "tmdbVotes": 2132}}, {"id": "tv-dan-da-dan-2024", "title": "Dan Da Dan", "year": 2024, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 8.538, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Comedy"], "overview": "In a bet to prove whether ghosts or aliens exist, two high schoolers face terrifying paranormal threats, gain superpowers and maybe even fall in love?!", "poster": "assets/tv-dan-da-dan-2024-poster.jpg", "backdrop": "assets/tv-dan-da-dan-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-22T10:31:16.2652252Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 240411, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.517, "tmdbVotes": 986}}, {"id": "tv-dr-stone-2019", "title": "Dr. STONE", "year": 2019, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 4, "communityRating": 8.541, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Comedy"], "overview": "One fateful day, all of humanity was petrified by a blinding flash of light. After several millennia, high schooler Taiju awakens and finds himself lost in a world of statues. However, he\u2019s not alone! His\u2026", "poster": "assets/tv-dr-stone-2019-poster.jpg", "backdrop": "assets/tv-dr-stone-2019-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-20T22:28:13.2091579Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 86031, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.53, "tmdbVotes": 1814}}, {"id": "tv-frieren-beyond-journey-s-end-2023", "title": "Frieren: Beyond Journey's End", "year": 2023, "type": "tv", "runtimeSeconds": 1500, "seasonCount": 2, "communityRating": 8.803, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Drama"], "overview": "After the party of heroes defeated the Demon King, they restored peace to the land and returned to lives of solitude. Generations pass, and the elven mage Frieren comes face to face with humanity\u2019s mortality. She\u2026", "poster": "assets/tv-frieren-beyond-journey-s-end-2023-poster.jpg", "backdrop": "assets/tv-frieren-beyond-journey-s-end-2023-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-20T22:28:13.2094435Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 209867, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.826, "tmdbVotes": 1016}}, {"id": "tv-fullmetal-alchemist-brotherhood-2009", "title": "Fullmetal Alchemist: Brotherhood", "year": 2009, "type": "tv", "runtimeSeconds": 1500, "seasonCount": 1, "communityRating": 8.7, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "Disregard for alchemy\u2019s laws ripped half of Edward Elric\u2019s limbs from his body and left his brother Alphonse\u2019s soul clinging to a suit of armor. To restore what was lost, the brothers seek the Philosopher\u2019s Stone.\u2026", "poster": "assets/tv-fullmetal-alchemist-brotherhood-2009-poster.jpg", "backdrop": "assets/tv-fullmetal-alchemist-brotherhood-2009-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-20T10:27:12.3573939Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 31911, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.7, "tmdbVotes": 2633}}, {"id": "tv-jujutsu-kaisen-2020", "title": "JUJUTSU KAISEN", "year": 2020, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 3, "communityRating": 8.583, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "Yuji Itadori is a boy with tremendous physical strength, though he lives a completely ordinary high school life. One day, to save a classmate who has been attacked by curses, he eats the finger of Ryomen Sukuna,\u2026", "poster": "assets/tv-jujutsu-kaisen-2020-poster.jpg", "backdrop": "assets/tv-jujutsu-kaisen-2020-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-20T22:28:13.207573Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 95479, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.574, "tmdbVotes": 4735}}, {"id": "tv-kaiju-no-8-2024", "title": "Kaiju No. 8", "year": 2024, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 2, "communityRating": 8.436, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "In a world plagued by creatures known as Kaiju, Kafka Hibino aspired to enlist in The Defense Force. He makes a promise to enlist with his childhood friend, Mina Ashiro. Soon, life takes them in separate ways. While\u2026", "poster": "assets/tv-kaiju-no-8-2024-poster.jpg", "backdrop": "assets/tv-kaiju-no-8-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-22T10:31:16.2646566Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 207468, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.4, "tmdbVotes": 836}}, {"id": "tv-my-hero-academia-2016", "title": "My Hero Academia", "year": 2016, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 8, "communityRating": 8.607, "criticRating": null, "genres": ["Action & Adventure", "Animation", "Sci-Fi & Fantasy"], "overview": "After he saves a bully from a Villain, a normal student is granted a superpower that allows him to attend a high school training academy for Heroes.", "poster": "assets/tv-my-hero-academia-2016-poster.jpg", "backdrop": "assets/tv-my-hero-academia-2016-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-20T22:28:13.2089315Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 65930, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.6, "tmdbVotes": 5426}}, {"id": "tv-neon-genesis-evangelion-1995", "title": "Neon Genesis Evangelion", "year": 1995, "type": "tv", "runtimeSeconds": 1500, "seasonCount": 1, "communityRating": 8.568, "criticRating": null, "genres": ["Sci-Fi & Fantasy", "Animation", "Drama"], "overview": "At the turn of the century, the Angels returned to Earth, seeking to wipe out humanity in an apocalyptic fury. Devastated, mankind's last remnants moved underground to wait for the day when the Angels would come back\u2026", "poster": "assets/tv-neon-genesis-evangelion-1995-poster.jpg", "backdrop": "assets/tv-neon-genesis-evangelion-1995-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0340629Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 890, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.56, "tmdbVotes": 2205}}, {"id": "tv-record-of-ragnarok-2021", "title": "Record of Ragnarok", "year": 2021, "type": "tv", "runtimeSeconds": null, "seasonCount": 3, "communityRating": 8.402, "criticRating": null, "genres": ["Action & Adventure", "Animation", "Sci-Fi & Fantasy"], "overview": "Before eradicating humankind from the world, the gods give them one last chance to prove themselves worthy of survival. Let the Ragnarok battles begin.", "poster": "assets/tv-record-of-ragnarok-2021-poster.jpg", "backdrop": "assets/tv-record-of-ragnarok-2021-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-20T22:28:13.2085766Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 114868, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.403, "tmdbVotes": 1929}}, {"id": "tv-sakamoto-days-2025", "title": "SAKAMOTO DAYS", "year": 2025, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 8.071, "criticRating": null, "genres": ["Animation", "Comedy", "Action & Adventure"], "overview": "Once the greatest hitman of all, Taro Sakamoto retired in the name of love. But when his past catches up, he must fight to protect his beloved family.", "poster": "assets/tv-sakamoto-days-2025-poster.jpg", "backdrop": "assets/tv-sakamoto-days-2025-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-22T10:31:16.266066Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 207332, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.057, "tmdbVotes": 375}}, {"id": "tv-sentenced-to-be-a-hero-2025", "title": "Sentenced to Be a Hero", "year": 2025, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 1, "communityRating": 8.338, "criticRating": null, "genres": ["Animation", "Sci-Fi & Fantasy", "Action & Adventure"], "overview": "In a world where heroism is a punishment, Xylo Forbartz, a condemned goddess killer, battles endless hordes of monstrous abominations as part of Penal Hero Unit 9004. Death is no escape, only a cycle of resurrection\u2026", "poster": "assets/tv-sentenced-to-be-a-hero-2025-poster.jpg", "backdrop": "assets/tv-sentenced-to-be-a-hero-2025-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-22T10:31:16.2666791Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 249907, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.244, "tmdbVotes": 129}}, {"id": "tv-spy-x-family-2022", "title": "SPY x FAMILY", "year": 2022, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 3, "communityRating": 8.506, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Comedy"], "overview": "A spy, an assassin and a telepath come together to pose as a family, each for their own reasons, while hiding their true identities from each other.", "poster": "assets/tv-spy-x-family-2022-poster.jpg", "backdrop": "assets/tv-spy-x-family-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-22T10:31:16.2670364Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 120089, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.5, "tmdbVotes": 2382}}, {"id": "tv-the-tatami-galaxy-2010", "title": "The Tatami Galaxy", "year": 2010, "type": "tv", "runtimeSeconds": 1500, "seasonCount": 2, "communityRating": 8.271, "criticRating": null, "genres": ["Animation", "Comedy", "Drama"], "overview": "When a college dropout stops for a late night bite at a mysterious ramen stand, he crosses paths with a self-proclaimed deity of matrimony. This bizarre meeting sends the young man hurtling through a horrifying\u2026", "poster": "assets/tv-the-tatami-galaxy-2010-poster.jpg", "backdrop": "assets/tv-the-tatami-galaxy-2010-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-18T05:52:51.0023256Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 36243, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.3, "tmdbVotes": 152}}, {"id": "tv-vinland-saga-2019", "title": "Vinland Saga", "year": 2019, "type": "tv", "runtimeSeconds": 1500, "seasonCount": 2, "communityRating": 8.527, "criticRating": null, "genres": ["Animation", "War & Politics", "Action & Adventure"], "overview": "For a thousand years, the Vikings have made quite a name and reputation for themselves as the strongest families with a thirst for violence. Thorfinn, the son of one of the Vikings' greatest warriors, spends his\u2026", "poster": "assets/tv-vinland-saga-2019-poster.jpg", "backdrop": "assets/tv-vinland-saga-2019-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-20T22:28:13.207389Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 88803, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.509, "tmdbVotes": 1064}}, {"id": "tv-yu-yu-hakusho-1992", "title": "Yu Yu Hakusho", "year": 1992, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 4, "communityRating": 8.5, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "After dying to save a boy, delinquent tough guy Yusuke Urameshi is granted another chance at life by redeeming himself as a \"Spirit Detective.\"", "poster": "assets/tv-yu-yu-hakusho-1992-poster.jpg", "backdrop": "assets/tv-yu-yu-hakusho-1992-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-19T21:46:01.5733049Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 30669, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.475, "tmdbVotes": 519}}], "resume": [], "nextUp": [{"id": "episode-three-monsters-1992-s1e6", "title": "Three Monsters", "year": 1992, "type": "episode", "runtimeSeconds": 1421, "seasonCount": null, "communityRating": 7.4, "criticRating": null, "genres": ["Animation", "Action", "Adventure"], "overview": "Yusuke receives his first real case as Spirit Detective. Three demon thieves have muscled their way into King Yama's sacred vault, stealing the three Artifacts of Darkness. Yusuke must recover the artifacts and bring\u2026", "poster": "assets/episode-three-monsters-1992-s1e6-poster.jpg", "backdrop": "assets/episode-three-monsters-1992-s1e6-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-19T21:59:21.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "Yu Yu Hakusho", "seasonNumber": 1, "episodeNumber": 6, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}, {"id": "episode-all-hands-on-deck-class-1-a-2021-s5e1", "title": "All Hands on Deck! Class 1-A", "year": 2021, "type": "episode", "runtimeSeconds": 1433, "seasonCount": null, "communityRating": 6.938, "criticRating": null, "genres": ["Animation", "Action", "Adventure"], "overview": "There's an emergency drill at U.A. High School, and Class 1-A is requested to report for duty!", "poster": "assets/episode-all-hands-on-deck-class-1-a-2021-s5e1-poster.jpg", "backdrop": "assets/episode-all-hands-on-deck-class-1-a-2021-s5e1-backdrop.jpg", "played": false, "playCount": 2, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-22T04:04:50.0000000Z", "lastPlayedAt": "2026-10-19T17:47:09.3028025Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "My Hero Academia", "seasonNumber": 5, "episodeNumber": 1, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}, {"id": "episode-erickerhead-2026-s9e6", "title": "Erickerhead", "year": 2026, "type": "episode", "runtimeSeconds": 1357, "seasonCount": null, "communityRating": 8.196, "criticRating": null, "genres": [], "overview": "Rick's stomach is mad, broh. But it's all-you-can-eat-and-drink here, broh!", "poster": "assets/episode-erickerhead-2026-s9e6-poster.jpg", "backdrop": "assets/episode-erickerhead-2026-s9e6-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-28T10:23:23.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "Rick and Morty", "seasonNumber": 9, "episodeNumber": 6, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}, {"id": "episode-girls-interrupted-2007-s1e7", "title": "Girls, Interrupted", "year": 2007, "type": "episode", "runtimeSeconds": 1642, "seasonCount": null, "communityRating": 7.4, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "Hank is disturbed by Becca's negative reaction towards the concept of happy endings, even more so when he finds out that it is partially his fault.\nMarcy comes up with the idea of a threesome with Charlie and Dani,\u2026", "poster": "assets/episode-girls-interrupted-2007-s1e7-poster.jpg", "backdrop": "assets/episode-girls-interrupted-2007-s1e7-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-05T02:46:18.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "Californication", "seasonNumber": 1, "episodeNumber": 7, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}, {"id": "episode-announcement-2022-s1e7", "title": "Announcement", "year": 2022, "type": "episode", "runtimeSeconds": 3012, "seasonCount": null, "communityRating": 7.8, "criticRating": null, "genres": ["Action", "Adventure", "Drama"], "overview": "The repercussions of the Aldhani heist reach all the way to Coruscant, while Cassian returns home.", "poster": "assets/episode-announcement-2022-s1e7-poster.jpg", "backdrop": "assets/episode-announcement-2022-s1e7-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T12:56:24.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "Andor", "seasonNumber": 1, "episodeNumber": 7, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}, {"id": "episode-south-american-biker-gangs-2026-s29e1", "title": "South American Biker Gangs", "year": 2026, "type": "episode", "runtimeSeconds": 1335, "seasonCount": null, "communityRating": 6.4, "criticRating": null, "genres": ["Animation", "Comedy"], "overview": "When e-bikes arrive in South America, the boys take over the streets and the parents of South America can hardly recognize South America.", "poster": "assets/episode-south-american-biker-gangs-2026-s29e1-poster.jpg", "backdrop": "assets/episode-south-american-biker-gangs-2026-s29e1-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-01T06:40:46.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "South Park", "seasonNumber": 29, "episodeNumber": 1, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}], "recentlyAdded": [{"id": "movie-steins-gate-the-movie-load-region-of-deja-vu-2013", "title": "Steins;Gate: The Movie - Load Region of D\u00e9j\u00e0 Vu", "year": 2013, "type": "movie", "runtimeSeconds": 5403, "seasonCount": null, "communityRating": 7.8, "criticRating": null, "genres": ["Animation", "Mystery", "Sci-Fi"], "overview": "After the events of the anime, Rintarou begins to feel the repercussions of extensive time travel, and eventually completely fades from reality. Kurisu, being the only companion to remember him, now must find a way\u2026", "poster": "assets/movie-steins-gate-the-movie-load-region-of-deja-vu-2013-poster.jpg", "backdrop": "assets/movie-steins-gate-the-movie-load-region-of-deja-vu-2013-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-22T14:55:54.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}, {"id": "tv-yu-yu-hakusho-1992", "title": "Yu Yu Hakusho", "year": 1992, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 4, "communityRating": 8.5, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "After dying to save a boy, delinquent tough guy Yusuke Urameshi is granted another chance at life by redeeming himself as a \"Spirit Detective.\"", "poster": "assets/tv-yu-yu-hakusho-1992-poster.jpg", "backdrop": "assets/tv-yu-yu-hakusho-1992-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-19T21:46:01.5733049Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 30669, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.475, "tmdbVotes": 519}}, {"id": "movie-memories-1995", "title": "Memories", "year": 1995, "type": "movie", "runtimeSeconds": 6805, "seasonCount": null, "communityRating": 7.465, "criticRating": null, "genres": ["Fantasy", "Animation", "Science Fiction"], "overview": "Three back-to-back anime films by three different directors make up this sci-fi trilogy three years in the making.", "poster": "assets/movie-memories-1995-poster.jpg", "backdrop": "assets/movie-memories-1995-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-30T21:36:03.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 42994, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": 82, "rtAudienceLabel": "Upright", "imdb": 7.5, "imdbVotes": 26638, "tmdb": 7.459, "tmdbVotes": 622}}, {"id": "tv-firefly-2002", "title": "Firefly", "year": 2002, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 8.347, "criticRating": null, "genres": ["Drama", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "In the year 2517, the universe is a vast frontier where lawlessness reigns and adventure awaits! Join Captain Malcolm Reynolds and his ragtag crew aboard the spaceship Serenity as they navigate the fringes of\u2026", "poster": "assets/tv-firefly-2002-poster.jpg", "backdrop": "assets/tv-firefly-2002-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-20T11:40:08.4202369Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 1437, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 77, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.351, "tmdbVotes": 2550}}, {"id": "tv-love-death-robots-2019", "title": "Love, Death & Robots", "year": 2019, "type": "tv", "runtimeSeconds": null, "seasonCount": 4, "communityRating": 8.205, "criticRating": null, "genres": ["Animation", "Sci-Fi & Fantasy"], "overview": "Terrifying creatures, wicked surprises and dark comedy converge in this NSFW anthology of animated stories presented by Tim Miller and David Fincher.", "poster": "assets/tv-love-death-robots-2019-poster.jpg", "backdrop": "assets/tv-love-death-robots-2019-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-20T11:31:20.8818757Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 86831, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 89, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.203, "tmdbVotes": 4238}}, {"id": "tv-the-legend-of-korra-2012", "title": "The Legend of Korra", "year": 2012, "type": "tv", "runtimeSeconds": 1380, "seasonCount": 4, "communityRating": 8.2, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "Avatar Korra, a headstrong, rebellious, feisty young woman who continually challenges and breaks with tradition, is on her quest to become a fully realized Avatar. In this story, the Avatar struggles to find balance\u2026", "poster": "assets/tv-the-legend-of-korra-2012-poster.jpg", "backdrop": "assets/tv-the-legend-of-korra-2012-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-20T00:19:23.2494945Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 33880, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 88, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.221, "tmdbVotes": 2482}}, {"id": "tv-spy-x-family-2022", "title": "SPY x FAMILY", "year": 2022, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 3, "communityRating": 8.506, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Comedy"], "overview": "A spy, an assassin and a telepath come together to pose as a family, each for their own reasons, while hiding their true identities from each other.", "poster": "assets/tv-spy-x-family-2022-poster.jpg", "backdrop": "assets/tv-spy-x-family-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-22T10:31:16.2670364Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 120089, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.5, "tmdbVotes": 2382}}, {"id": "tv-sentenced-to-be-a-hero-2025", "title": "Sentenced to Be a Hero", "year": 2025, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 1, "communityRating": 8.338, "criticRating": null, "genres": ["Animation", "Sci-Fi & Fantasy", "Action & Adventure"], "overview": "In a world where heroism is a punishment, Xylo Forbartz, a condemned goddess killer, battles endless hordes of monstrous abominations as part of Penal Hero Unit 9004. Death is no escape, only a cycle of resurrection\u2026", "poster": "assets/tv-sentenced-to-be-a-hero-2025-poster.jpg", "backdrop": "assets/tv-sentenced-to-be-a-hero-2025-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-22T10:31:16.2666791Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 249907, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.244, "tmdbVotes": 129}}, {"id": "tv-sakamoto-days-2025", "title": "SAKAMOTO DAYS", "year": 2025, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 8.071, "criticRating": null, "genres": ["Animation", "Comedy", "Action & Adventure"], "overview": "Once the greatest hitman of all, Taro Sakamoto retired in the name of love. But when his past catches up, he must fight to protect his beloved family.", "poster": "assets/tv-sakamoto-days-2025-poster.jpg", "backdrop": "assets/tv-sakamoto-days-2025-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-22T10:31:16.266066Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 207332, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.057, "tmdbVotes": 375}}, {"id": "tv-dan-da-dan-2024", "title": "Dan Da Dan", "year": 2024, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 8.538, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Comedy"], "overview": "In a bet to prove whether ghosts or aliens exist, two high schoolers face terrifying paranormal threats, gain superpowers and maybe even fall in love?!", "poster": "assets/tv-dan-da-dan-2024-poster.jpg", "backdrop": "assets/tv-dan-da-dan-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-22T10:31:16.2652252Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 240411, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.517, "tmdbVotes": 986}}, {"id": "tv-kaiju-no-8-2024", "title": "Kaiju No. 8", "year": 2024, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 2, "communityRating": 8.436, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "In a world plagued by creatures known as Kaiju, Kafka Hibino aspired to enlist in The Defense Force. He makes a promise to enlist with his childhood friend, Mina Ashiro. Soon, life takes them in separate ways. While\u2026", "poster": "assets/tv-kaiju-no-8-2024-poster.jpg", "backdrop": "assets/tv-kaiju-no-8-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-22T10:31:16.2646566Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 207468, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.4, "tmdbVotes": 836}}, {"id": "tv-chainsaw-man-2022", "title": "Chainsaw Man", "year": 2022, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 1, "communityRating": 8.515, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "Denji has a simple dream\u2014to live a happy and peaceful life, spending time with a girl he likes. This is a far cry from reality, however, as Denji is forced by the yakuza into killing devils in order to pay off his\u2026", "poster": "assets/tv-chainsaw-man-2022-poster.jpg", "backdrop": "assets/tv-chainsaw-man-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-20T22:28:13.2095501Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 114410, "artwork": {"posterKind": "center_crop", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 97, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.508, "tmdbVotes": 2364}}], "discover": [{"id": "movie-the-amazing-spider-man-2012", "title": "The Amazing Spider-Man", "year": 2012, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Adventure", "Science Fiction"], "overview": "A teenage Peter Parker grapples with both high school and amazing super-human crises as his alter-ego Spider-Man.", "poster": "assets/movie-the-amazing-spider-man-2012-poster.jpg", "backdrop": "assets/movie-the-amazing-spider-man-2012-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 1930, "ratings": {"rtCritics": 71, "rtCriticsLabel": "Certified Fresh", "rtAudience": 77, "rtAudienceLabel": "Upright", "imdb": 7, "imdbVotes": 791938, "tmdb": 6.768, "tmdbVotes": 19312}}, {"id": "movie-the-amazing-spider-man-2-2014", "title": "The Amazing Spider-Man 2", "year": 2014, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Adventure", "Science Fiction"], "overview": "For Peter Parker, life is busy. Between taking out the bad guys as Spider-Man and spending time with the person he loves, Gwen Stacy, high school graduation cannot come quickly enough. Peter has not forgotten about\u2026", "poster": "assets/movie-the-amazing-spider-man-2-2014-poster.jpg", "backdrop": "assets/movie-the-amazing-spider-man-2-2014-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 102382, "ratings": {"rtCritics": 51, "rtCriticsLabel": "Rotten", "rtAudience": 64, "rtAudienceLabel": "Upright", "imdb": 6.6, "imdbVotes": 611981, "tmdb": 6.567, "tmdbVotes": 14919}}, {"id": "movie-the-animatrix-2003", "title": "The Animatrix", "year": 2003, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Animation", "Science Fiction"], "overview": "Straight from the creators of the groundbreaking Matrix trilogy, this collection of short animated films from the world's leading anime directors fuses computer graphics and Japanese anime to provide the background\u2026", "poster": "assets/movie-the-animatrix-2003-poster.jpg", "backdrop": "assets/movie-the-animatrix-2003-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 55931, "ratings": {"rtCritics": 86, "rtCriticsLabel": "Fresh", "rtAudience": 78, "rtAudienceLabel": "Upright", "imdb": 7.3, "imdbVotes": 91929, "tmdb": 7.221, "tmdbVotes": 1872}}, {"id": "movie-ant-man-and-the-wasp-quantumania-2023", "title": "Ant-Man and the Wasp: Quantumania", "year": 2023, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Adventure", "Science Fiction"], "overview": "Super-Hero partners Scott Lang and Hope van Dyne, along with with Hope's parents Janet van Dyne and Hank Pym, and Scott's daughter Cassie Lang, find themselves exploring the Quantum Realm, interacting with strange\u2026", "poster": "assets/movie-ant-man-and-the-wasp-quantumania-2023-poster.jpg", "backdrop": "assets/movie-ant-man-and-the-wasp-quantumania-2023-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 640146, "ratings": {"rtCritics": 46, "rtCriticsLabel": "Rotten", "rtAudience": 81, "rtAudienceLabel": "Upright", "imdb": 6, "imdbVotes": 281738, "tmdb": 6.197, "tmdbVotes": 6236}}, {"id": "movie-appleseed-alpha-2014", "title": "Appleseed Alpha", "year": 2014, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Animation", "Science Fiction", "Action"], "overview": "The Third World War (2099\u20132126) devastated part of the Earth. Former nations are reforming while new powers emerge, and groups of humans struggle to survive in ruined cities. The story follows Deunan Knute and her\u2026", "poster": "assets/movie-appleseed-alpha-2014-poster.jpg", "backdrop": "assets/movie-appleseed-alpha-2014-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "center_crop", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 269650, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": 59, "rtAudienceLabel": "Spilled", "imdb": 6.5, "imdbVotes": 9689, "tmdb": 6.808, "tmdbVotes": 436}}, {"id": "movie-aquaman-and-the-lost-kingdom-2023", "title": "Aquaman and the Lost Kingdom", "year": 2023, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Adventure", "Fantasy"], "overview": "Black Manta seeks revenge on Aquaman for his father's death. Wielding the Black Trident's power, he becomes a formidable foe. To defend Atlantis, Arthur (Aquaman) forges an alliance with his imprisoned brother. They\u2026", "poster": "assets/movie-aquaman-and-the-lost-kingdom-2023-poster.jpg", "backdrop": "assets/movie-aquaman-and-the-lost-kingdom-2023-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 572802, "ratings": {"rtCritics": 33, "rtCriticsLabel": "Rotten", "rtAudience": 79, "rtAudienceLabel": "Upright", "imdb": 5.6, "imdbVotes": 122422, "tmdb": 6.48, "tmdbVotes": 3748}}, {"id": "movie-the-avengers-2012", "title": "The Avengers", "year": 2012, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Science Fiction", "Action", "Adventure"], "overview": "When an unexpected enemy emerges and threatens global safety and security, Nick Fury, director of the international peacekeeping agency known as S.H.I.E.L.D., finds himself in need of a team to pull the world back\u2026", "poster": "assets/movie-the-avengers-2012-poster.jpg", "backdrop": "assets/movie-the-avengers-2012-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 24428, "ratings": {"rtCritics": 91, "rtCriticsLabel": "Certified Fresh", "rtAudience": 91, "rtAudienceLabel": "Upright", "imdb": 8, "imdbVotes": 1590693, "tmdb": 8.084, "tmdbVotes": 40262}}, {"id": "movie-avengers-confidential-black-widow-punisher-2014", "title": "Avengers Confidential: Black Widow & Punisher", "year": 2014, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Animation", "Science Fiction", "Action"], "overview": "When the Punisher takes out a black-market weapons dealer, he stumbles upon a far-reaching terrorist plot devised by a group known as Leviathan.", "poster": "assets/movie-avengers-confidential-black-widow-punisher-2014-poster.jpg", "backdrop": "assets/movie-avengers-confidential-black-widow-punisher-2014-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 257346, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": 55, "rtAudienceLabel": "Spilled", "imdb": 5.7, "imdbVotes": 5166, "tmdb": 6.368, "tmdbVotes": 303}}, {"id": "movie-avengers-age-of-ultron-2015", "title": "Avengers: Age of Ultron", "year": 2015, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Adventure", "Science Fiction"], "overview": "When Tony Stark tries to jumpstart a dormant peacekeeping program, things go awry and Earth\u2019s Mightiest Heroes are put to the ultimate test as the fate of the planet hangs in the balance. As the villainous Ultron\u2026", "poster": "assets/movie-avengers-age-of-ultron-2015-poster.jpg", "backdrop": "assets/movie-avengers-age-of-ultron-2015-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 99861, "ratings": {"rtCritics": 75, "rtCriticsLabel": "Certified Fresh", "rtAudience": 82, "rtAudienceLabel": "Upright", "imdb": 7.3, "imdbVotes": 1025876, "tmdb": 7.278, "tmdbVotes": 25289}}, {"id": "movie-avengers-infinity-war-2018", "title": "Avengers: Infinity War", "year": 2018, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Adventure", "Action", "Science Fiction"], "overview": "As the Avengers and their allies have continued to protect the world from threats too large for any one hero to handle, a new danger has emerged from the cosmic shadows: Thanos. A despot of intergalactic infamy, his\u2026", "poster": "assets/movie-avengers-infinity-war-2018-poster.jpg", "backdrop": "assets/movie-avengers-infinity-war-2018-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 299536, "ratings": {"rtCritics": 85, "rtCriticsLabel": "Certified Fresh", "rtAudience": 92, "rtAudienceLabel": "Upright", "imdb": 8.4, "imdbVotes": 1421954, "tmdb": 8.246, "tmdbVotes": 33331}}, {"id": "movie-batman-ninja-2018", "title": "Batman Ninja", "year": 2018, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Animation", "Action", "Science Fiction"], "overview": "Batman, along with many of his allies and adversaries, finds himself transported to feudal Japan by Gorilla Grodd's time displacement machine.", "poster": "assets/movie-batman-ninja-2018-poster.jpg", "backdrop": "assets/movie-batman-ninja-2018-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 485942, "ratings": {"rtCritics": 84, "rtCriticsLabel": "Fresh", "rtAudience": 60, "rtAudienceLabel": "Upright", "imdb": 5.6, "imdbVotes": 24579, "tmdb": 5.9, "tmdbVotes": 1106}}, {"id": "movie-batman-v-superman-dawn-of-justice-2016", "title": "Batman v Superman: Dawn of Justice", "year": 2016, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Adventure", "Fantasy"], "overview": "Fearing the actions of a god-like Super Hero left unchecked, Gotham City\u2019s own formidable, forceful vigilante takes on Metropolis\u2019s most revered, modern-day savior, while the world wrestles with what sort of hero it\u2026", "poster": "assets/movie-batman-v-superman-dawn-of-justice-2016-poster.jpg", "backdrop": "assets/movie-batman-v-superman-dawn-of-justice-2016-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 209112, "ratings": {"rtCritics": 28, "rtCriticsLabel": "Rotten", "rtAudience": 63, "rtAudienceLabel": "Upright", "imdb": 6.4, "imdbVotes": 801382, "tmdb": 6.014, "tmdbVotes": 19527}}, {"id": "movie-batman-the-dark-knight-returns-part-1-2012", "title": "Batman: The Dark Knight Returns, Part 1", "year": 2012, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Science Fiction", "Action", "Animation"], "overview": "Batman has not been seen for ten years. A new breed of criminal ravages Gotham City, forcing 55-year-old Bruce Wayne back into the cape and cowl. But, does he still have what it takes to fight crime in a new era?", "poster": "assets/movie-batman-the-dark-knight-returns-part-1-2012-poster.jpg", "backdrop": "assets/movie-batman-the-dark-knight-returns-part-1-2012-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 123025, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": 93, "rtAudienceLabel": "Upright", "imdb": 7.9, "imdbVotes": 68943, "tmdb": 7.742, "tmdbVotes": 1766}}, {"id": "movie-black-adam-2022", "title": "Black Adam", "year": 2022, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Adventure", "Science Fiction"], "overview": "Nearly 5,000 years after he was bestowed with the almighty powers of the Egyptian gods\u2014and imprisoned just as quickly\u2014Black Adam is freed from his earthly tomb, ready to unleash his unique form of justice on the\u2026", "poster": "assets/movie-black-adam-2022-poster.jpg", "backdrop": "assets/movie-black-adam-2022-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 436270, "ratings": {"rtCritics": 39, "rtCriticsLabel": "Rotten", "rtAudience": 87, "rtAudienceLabel": "Upright", "imdb": 6.1, "imdbVotes": 309624, "tmdb": 6.747, "tmdbVotes": 7370}}, {"id": "movie-black-panther-2018", "title": "Black Panther", "year": 2018, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Adventure", "Science Fiction"], "overview": "King T'Challa returns home to the reclusive, technologically advanced African nation of Wakanda to serve as his country's new leader. However, T'Challa soon finds that he is challenged for the throne by factions\u2026", "poster": "assets/movie-black-panther-2018-poster.jpg", "backdrop": "assets/movie-black-panther-2018-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 284054, "ratings": {"rtCritics": 96, "rtCriticsLabel": "Certified Fresh", "rtAudience": null, "rtAudienceLabel": "Spilled", "imdb": 7.3, "imdbVotes": 931120, "tmdb": 7.36, "tmdbVotes": 24190}}, {"id": "movie-black-panther-wakanda-forever-2022", "title": "Black Panther: Wakanda Forever", "year": 2022, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Adventure", "Science Fiction"], "overview": "Queen Ramonda, Shuri, M\u2019Baku, Okoye and the Dora Milaje fight to protect their nation from intervening world powers in the wake of King T\u2019Challa\u2019s death. As the Wakandans strive to embrace their next chapter, the\u2026", "poster": "assets/movie-black-panther-wakanda-forever-2022-poster.jpg", "backdrop": "assets/movie-black-panther-wakanda-forever-2022-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 505642, "ratings": {"rtCritics": 84, "rtCriticsLabel": "Certified Fresh", "rtAudience": 93, "rtAudienceLabel": "Upright", "imdb": 6.6, "imdbVotes": 405088, "tmdb": 6.984, "tmdbVotes": 7920}}, {"id": "movie-blame-2017", "title": "BLAME!", "year": 2017, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Drama", "Science Fiction"], "overview": "In the distant technological future, civilization has reached its ultimate Net-based form. An \"infection\" in the past caused the automated systems to spiral out of order, resulting in a multi-leveled city structure\u2026", "poster": "assets/movie-blame-2017-poster.jpg", "backdrop": "assets/movie-blame-2017-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 409421, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": 61, "rtAudienceLabel": "Upright", "imdb": 6.6, "imdbVotes": 10258, "tmdb": 6.523, "tmdbVotes": 370}}, {"id": "movie-bleach-the-movie-fade-to-black-2008", "title": "Bleach the Movie: Fade to Black", "year": 2008, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Animation", "Fantasy"], "overview": "A strange disturbance in the World of the Living sends Ichigo and Kon to the Soul Society, where the Seireitei has been devastated by an explosion of reishi and Rukia is missing. Stranger still, none of the Soul\u2026", "poster": "assets/movie-bleach-the-movie-fade-to-black-2008-poster.jpg", "backdrop": "assets/movie-bleach-the-movie-fade-to-black-2008-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 17165, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": 67, "rtAudienceLabel": "Upright", "imdb": 7.1, "imdbVotes": 3693, "tmdb": 7.1, "tmdbVotes": 206}}, {"id": "tv-90-day-fiance-before-the-90-days-2017", "title": "90 Day Fianc\u00e9: Before the 90 Days", "year": 2017, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Reality"], "overview": "Follow couples who have an existing relationship online, but haven't met in person. We'll experience their journey as they travel to the other's foreign country for the first time in an attempt to establish an\u2026", "poster": "assets/tv-90-day-fiance-before-the-90-days-2017-poster.jpg", "backdrop": "assets/tv-90-day-fiance-before-the-90-days-2017-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 73319, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.5, "tmdbVotes": 45}}, {"id": "tv-90-day-fiance-the-other-way-2019", "title": "90 Day Fianc\u00e9: The Other Way", "year": 2019, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Reality"], "overview": "Americans move to a foreign country all in the name of love. Will uprooting their lives in the United States pay off, or will they be packing up and moving back to America brokenhearted?", "poster": "assets/tv-90-day-fiance-the-other-way-2019-poster.jpg", "backdrop": "assets/tv-90-day-fiance-the-other-way-2019-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 90046, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.8, "tmdbVotes": 34}}, {"id": "tv-the-100-2014", "title": "The 100", "year": 2014, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Sci-Fi & Fantasy", "Action & Adventure", "Drama"], "overview": "100 years in the future, when the Earth has been abandoned due to radioactivity, the last surviving humans live on an ark orbiting the planet \u2014 but the ark won't last forever. So the repressive regime picks 100\u2026", "poster": "assets/tv-the-100-2014-poster.jpg", "backdrop": "assets/tv-the-100-2014-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 48866, "ratings": {"rtCritics": 93, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.878, "tmdbVotes": 8855}}, {"id": "tv-the-abbott-and-costello-show-1952", "title": "The Abbott and Costello Show", "year": 1952, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Comedy"], "overview": "Bud and Lou are unemployed actors living in Mr. Fields\u2019 boarding house. Lou\u2019s girlfriend Hillary lives across the hall. Many situations arise leading to slapstick and puns.", "poster": "assets/tv-the-abbott-and-costello-show-1952-poster.jpg", "backdrop": "assets/tv-the-abbott-and-costello-show-1952-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 11821, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.2, "tmdbVotes": 22}}, {"id": "tv-accel-world-2012", "title": "Accel World", "year": 2012, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Drama"], "overview": "A short, overweight student uses the Neuro Link, an evolution of the Nerve Gear, to escape himself from the reality. But his skills in the virtual world brings him to the attention of Kuroyukihime, a popular student\u2026", "poster": "assets/tv-accel-world-2012-poster.jpg", "backdrop": "assets/tv-accel-world-2012-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "center_crop", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 43167, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.429, "tmdbVotes": 163}}, {"id": "tv-ahsoka-2023", "title": "Ahsoka", "year": 2023, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Sci-Fi & Fantasy", "Action & Adventure", "Drama"], "overview": "Former Jedi Knight Ahsoka Tano investigates an emerging threat to a vulnerable galaxy.", "poster": "assets/tv-ahsoka-2023-poster.jpg", "backdrop": "assets/tv-ahsoka-2023-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 114461, "ratings": {"rtCritics": 85, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.4, "tmdbVotes": 1351}}, {"id": "tv-american-dad-2005", "title": "American Dad!", "year": 2005, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Animation", "Comedy"], "overview": "The series focuses on an eccentric motley crew that is the Smith family and their three housemates: Father, husband, and breadwinner Stan Smith; his better half housewife, Francine Smith; their college-aged daughter,\u2026", "poster": "assets/tv-american-dad-2005-poster.jpg", "backdrop": "assets/tv-american-dad-2005-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 1433, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.022, "tmdbVotes": 2481}}, {"id": "tv-american-gods-2017", "title": "American Gods", "year": 2017, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Mystery", "Sci-Fi & Fantasy"], "overview": "An ex-con becomes the traveling partner of a conman who turns out to be one of the older gods trying to recruit troops to battle the upstart deities. Based on Neil Gaiman's fantasy novel.", "poster": "assets/tv-american-gods-2017-poster.jpg", "backdrop": "assets/tv-american-gods-2017-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 46639, "ratings": {"rtCritics": 75, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.1, "tmdbVotes": 1852}}, {"id": "tv-andromeda-2000", "title": "Andromeda", "year": 2000, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action & Adventure", "Drama", "Sci-Fi & Fantasy"], "overview": "Captain Dylan Hunt and his crew quest to restore a government that once presided over an extended peace and prosperity.", "poster": "assets/tv-andromeda-2000-poster.jpg", "backdrop": "assets/tv-andromeda-2000-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 2985, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.068, "tmdbVotes": 315}}, {"id": "tv-aqua-teen-hunger-force-2000", "title": "Aqua Teen Hunger Force", "year": 2000, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Animation", "Comedy"], "overview": "The surreal adventures of three anthropomorphic fast food items: Master Shake, Frylock and Meatwad, and their human nextdoor neighbor, Carl Brutananadilewski.", "poster": "assets/tv-aqua-teen-hunger-force-2000-poster.jpg", "backdrop": "assets/tv-aqua-teen-hunger-force-2000-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 251, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.5, "tmdbVotes": 272}}, {"id": "tv-arifureta-from-commonplace-to-world-s-strongest-2019", "title": "Arifureta: From Commonplace to World's Strongest", "year": 2019, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "Hajime Nagumo and his high school class are suddenly summoned to a fantastical land as heroes. But while most of his classmates have powerful stats and abilities, Hajime does not. Underappreciated and unprepared, he\u2026", "poster": "assets/tv-arifureta-from-commonplace-to-world-s-strongest-2019-poster.jpg", "backdrop": "assets/tv-arifureta-from-commonplace-to-world-s-strongest-2019-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 86034, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.382, "tmdbVotes": 662}}, {"id": "tv-arrow-2012", "title": "Arrow", "year": 2012, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Crime", "Drama", "Action & Adventure"], "overview": "Spoiled billionaire playboy Oliver Queen is missing and presumed dead when his yacht is lost at sea. He returns five years later a changed man, determined to clean up the city as a hooded vigilante armed with a bow.", "poster": "assets/tv-arrow-2012-poster.jpg", "backdrop": "assets/tv-arrow-2012-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 1412, "ratings": {"rtCritics": 86, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.834, "tmdbVotes": 6537}}, {"id": "tv-avatar-the-last-airbender-2024", "title": "Avatar: The Last Airbender", "year": 2024, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action & Adventure", "Sci-Fi & Fantasy", "Drama"], "overview": "A young boy known as the Avatar must master the four elemental powers to save a world at war \u2014 and fight a ruthless enemy bent on stopping him.", "poster": "assets/tv-avatar-the-last-airbender-2024-poster.jpg", "backdrop": "assets/tv-avatar-the-last-airbender-2024-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 82452, "ratings": {"rtCritics": 66, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.8, "tmdbVotes": 1406}}, {"id": "tv-avatar-seven-havens-2026", "title": "Avatar: Seven Havens", "year": 2026, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "In a world shattered by a devastating cataclysm, a young Earthbender discovers she's the new Avatar after Korra. But in this dangerous era, that title marks her as humanity's destroyer, not its savior. Hunted by both\u2026", "poster": "assets/tv-avatar-seven-havens-2026-poster.jpg", "backdrop": "assets/tv-avatar-seven-havens-2026-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 284833, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.9, "tmdbVotes": 183}}, {"id": "tv-batman-the-brave-and-the-bold-2008", "title": "Batman: The Brave and the Bold", "year": 2008, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "The Caped Crusader is teamed up with Blue Beetle, Green Arrow, Aquaman and countless others in his quest to uphold justice.", "poster": "assets/tv-batman-the-brave-and-the-bold-2008-poster.jpg", "backdrop": "assets/tv-batman-the-brave-and-the-bold-2008-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 15804, "ratings": {"rtCritics": 96, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.6, "tmdbVotes": 295}}, {"id": "tv-batwoman-2019", "title": "Batwoman", "year": 2019, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Sci-Fi & Fantasy", "Action & Adventure"], "overview": "Kate Kane, armed with a passion for social justice and a flair for speaking her mind, soars onto the streets of Gotham as Batwoman, an out lesbian and highly trained street fighter primed to snuff out the failing\u2026", "poster": "assets/tv-batwoman-2019-poster.jpg", "backdrop": "assets/tv-batwoman-2019-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 89247, "ratings": {"rtCritics": 83, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.8, "tmdbVotes": 1619}}, {"id": "tv-big-mouth-2017", "title": "Big Mouth", "year": 2017, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Comedy", "Animation"], "overview": "Teenage friends find their lives upended by the wonders and horrors of puberty in this edgy comedy from real-life pals Nick Kroll and Andrew Goldberg.", "poster": "assets/tv-big-mouth-2017-poster.jpg", "backdrop": "assets/tv-big-mouth-2017-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 74204, "ratings": {"rtCritics": 95, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.591, "tmdbVotes": 1274}}, {"id": "tv-black-lightning-2018", "title": "Black Lightning", "year": 2018, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action & Adventure", "Sci-Fi & Fantasy", "Drama"], "overview": "Jefferson Pierce is a man wrestling with a secret. As the father of two daughters and principal of a charter high school that also serves as a safe haven for young people in a New Orleans neighborhood overrun by gang\u2026", "poster": "assets/tv-black-lightning-2018-poster.jpg", "backdrop": "assets/tv-black-lightning-2018-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 71663, "ratings": {"rtCritics": 92, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.907, "tmdbVotes": 991}}], "discoverySource": {"state": "materialized_snapshot", "rankKnown": false, "selection": "First 18 alphabetically indexed movie stubs and first 18 series stubs, not ranked feed order", "observedAt": "2026-11-04T20:42:12.030108+00:00"}}, {"key": "mindel", "name": "Mindel", "initial": "M", "color": "#ff9d72", "destinations": {"Home": {"kind": "home"}, "Movies": {"kind": "library", "collection": "movies"}, "Shows": {"kind": "library", "collection": "shows"}, "Discover": {"kind": "discover", "collection": "discover"}}, "movies": [{"id": "movie-chicago-2002", "title": "Chicago", "year": 2002, "type": "movie", "runtimeSeconds": 6808, "seasonCount": null, "communityRating": 7.131, "criticRating": 87, "genres": ["Comedy", "Crime", "Drama"], "overview": "Murderesses Velma Kelly and Roxie Hart find themselves on death row together and fight for the fame that will keep them from the gallows in 1920s Chicago.", "poster": "assets/movie-chicago-2002-poster.jpg", "backdrop": "assets/movie-chicago-2002-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:24:53.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 1574, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 87, "rtCriticsLabel": "Certified Fresh", "rtAudience": 83, "rtAudienceLabel": "Upright", "imdb": 7.2, "imdbVotes": 259272, "tmdb": 7.135, "tmdbVotes": 3025}}, {"id": "movie-coherence-2014", "title": "Coherence", "year": 2014, "type": "movie", "runtimeSeconds": 5286, "seasonCount": null, "communityRating": 7.21, "criticRating": 89, "genres": ["Thriller", "Science Fiction"], "overview": "Four couples gather for dinner the night a mysterious and powerful comet passes overhead.", "poster": "assets/movie-coherence-2014-poster.jpg", "backdrop": "assets/movie-coherence-2014-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2017-08-12T23:49:00.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 220289, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 89, "rtCriticsLabel": "Certified Fresh", "rtAudience": 81, "rtAudienceLabel": "Upright", "imdb": 7.1, "imdbVotes": 180354, "tmdb": 7.208, "tmdbVotes": 3702}}, {"id": "movie-the-killing-of-a-sacred-deer-2017", "title": "The Killing of a Sacred Deer", "year": 2017, "type": "movie", "runtimeSeconds": 7262, "seasonCount": null, "communityRating": 7.003, "criticRating": 79, "genres": ["Drama", "Thriller", "Mystery"], "overview": "Dr. Steven Murphy is a renowned cardiovascular surgeon who presides over a spotless household with his wife and two children. Lurking at the margins of his idyllic suburban existence is Martin, a fatherless teen who\u2026", "poster": "assets/movie-the-killing-of-a-sacred-deer-2017-poster.jpg", "backdrop": "assets/movie-the-killing-of-a-sacred-deer-2017-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-22T21:49:26.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 399057, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 79, "rtCriticsLabel": "Certified Fresh", "rtAudience": 63, "rtAudienceLabel": "Upright", "imdb": 7, "imdbVotes": 219488, "tmdb": 7.001, "tmdbVotes": 4640}}, {"id": "movie-night-is-short-walk-on-girl-2017", "title": "Night Is Short, Walk On Girl", "year": 2017, "type": "movie", "runtimeSeconds": 5548, "seasonCount": null, "communityRating": 7.421, "criticRating": 90, "genres": ["Comedy", "Romance", "Animation"], "overview": "As a group of university students go out for a night on the town, a sophomore known only as \"The Girl with Black Hair\" experiences a series of surreal encounters with the local nightlife \u2013 all the while unaware of\u2026", "poster": "assets/movie-night-is-short-walk-on-girl-2017-poster.jpg", "backdrop": "assets/movie-night-is-short-walk-on-girl-2017-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T17:39:14.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 430214, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 90, "rtCriticsLabel": "Fresh", "rtAudience": 84, "rtAudienceLabel": "Upright", "imdb": 7.5, "imdbVotes": 7640, "tmdb": 7.4, "tmdbVotes": 267}}, {"id": "movie-thelma-louise-1991", "title": "Thelma & Louise", "year": 1991, "type": "movie", "runtimeSeconds": 7786, "seasonCount": null, "communityRating": 7.596, "criticRating": 87, "genres": ["Drama", "Crime", "Adventure"], "overview": "Taking a break from their dreary lives, close friends Thelma and Louise embark on a short weekend trip that ends in unforeseen incriminating circumstances. As fugitives, both women rediscover the strength of their\u2026", "poster": "assets/movie-thelma-louise-1991-poster.jpg", "backdrop": "assets/movie-thelma-louise-1991-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:24:40.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 1541, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 87, "rtCriticsLabel": "Certified Fresh", "rtAudience": 82, "rtAudienceLabel": "Upright", "imdb": 7.6, "imdbVotes": 197947, "tmdb": 7.594, "tmdbVotes": 4063}}, {"id": "movie-vicky-cristina-barcelona-2008", "title": "Vicky Cristina Barcelona", "year": 2008, "type": "movie", "runtimeSeconds": 5804, "seasonCount": null, "communityRating": 6.798, "criticRating": 80, "genres": ["Drama", "Romance"], "overview": "Two girlfriends on a summer holiday in Spain become enamored with the same painter, unaware that his ex-wife, with whom he has a tempestuous relationship, is about to re-enter the picture.", "poster": "assets/movie-vicky-cristina-barcelona-2008-poster.jpg", "backdrop": "assets/movie-vicky-cristina-barcelona-2008-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:25:34.0000000Z", "lastPlayedAt": "2026-08-11T20:12:57.3064089Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 5038, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 80, "rtCriticsLabel": "Certified Fresh", "rtAudience": 74, "rtAudienceLabel": "Upright", "imdb": 7.1, "imdbVotes": 283286, "tmdb": 6.797, "tmdbVotes": 4164}}], "shows": [{"id": "tv-all-creatures-great-small-2020", "title": "All Creatures Great & Small", "year": 2020, "type": "tv", "runtimeSeconds": 3000, "seasonCount": 7, "communityRating": 8.2, "criticRating": null, "genres": ["Drama", "Comedy"], "overview": "The heartwarming and humorous adventures of a young country vet in the Yorkshire Dales in the 1930s. A remake of the 1978 series.", "poster": "assets/tv-all-creatures-great-small-2020-poster.jpg", "backdrop": "assets/tv-all-creatures-great-small-2020-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-11-04T20:13:03.4266156Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 108255, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 99, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.2, "tmdbVotes": 168}}, {"id": "tv-back-to-life-2019", "title": "Back to Life", "year": 2019, "type": "tv", "runtimeSeconds": 1500, "seasonCount": 2, "communityRating": 7.367, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "When Miri Matteson returns home after eighteen years, can she integrate back into her old life? With a terrible event from her past hanging over her, it won\u2019t be easy.", "poster": "assets/tv-back-to-life-2019-poster.jpg", "backdrop": "assets/tv-back-to-life-2019-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0688758Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 88510, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.4, "tmdbVotes": 65}}, {"id": "tv-big-boys-2022", "title": "Big Boys", "year": 2022, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 3, "communityRating": 7.6, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "Two boys from very different ends of the 'spectrum of masculinity' become best friends at Brent University freshers' week; in their first year of uni, they explore, experiment and try to discover themselves, helping\u2026", "poster": "assets/tv-big-boys-2022-poster.jpg", "backdrop": "assets/tv-big-boys-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-11-04T20:25:58.2156837Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 202851, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.6, "tmdbVotes": 49}}, {"id": "tv-call-the-midwife-2012", "title": "Call the Midwife", "year": 2012, "type": "tv", "runtimeSeconds": 3120, "seasonCount": 15, "communityRating": 7.5, "criticRating": null, "genres": ["Drama"], "overview": "Drama following the lives of a group of midwives working in the poverty-stricken East End of London during the 1950s, based on the best-selling memoirs of Jennifer Worth.", "poster": "assets/tv-call-the-midwife-2012-poster.jpg", "backdrop": "assets/tv-call-the-midwife-2012-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0377216Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 39793, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.577, "tmdbVotes": 267}}, {"id": "tv-derry-girls-2018", "title": "Derry Girls", "year": 2018, "type": "tv", "runtimeSeconds": 1380, "seasonCount": 3, "communityRating": 8.144, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "Amidst the political conflict of Northern Ireland in the 1990s, five secondary school students square off with the universal challenges of being a teenager.", "poster": "assets/tv-derry-girls-2018-poster.jpg", "backdrop": "assets/tv-derry-girls-2018-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-02T20:01:55.8955537Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 76148, "artwork": {"posterKind": "center_crop", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 99, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.138, "tmdbVotes": 454}}, {"id": "tv-desperate-housewives-2004", "title": "Desperate Housewives", "year": 2004, "type": "tv", "runtimeSeconds": 2520, "seasonCount": 8, "communityRating": 7.896, "criticRating": null, "genres": ["Mystery", "Drama", "Comedy"], "overview": "Looking down on her friends and family isn't a way of life for Mary Alice Young... it's a way of death. One day, in her perfect house, in the loveliest of suburbs, Mary Alice ended it all. Now she's taking us into\u2026", "poster": "assets/tv-desperate-housewives-2004-poster.jpg", "backdrop": "assets/tv-desperate-housewives-2004-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-29T20:56:40.3505596Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 693, "artwork": {"posterKind": "poster", "backdropKind": "center_crop"}, "ratings": {"rtCritics": 72, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.896, "tmdbVotes": 2171}}, {"id": "tv-the-diplomat-2023", "title": "The Diplomat", "year": 2023, "type": "tv", "runtimeSeconds": null, "seasonCount": 4, "communityRating": 7.569, "criticRating": null, "genres": ["Drama", "War & Politics"], "overview": "Amid an international crisis, a US diplomat contends with her high-profile job as ambassador to the UK and her strained marriage to a political star.", "poster": "assets/tv-the-diplomat-2023-poster.jpg", "backdrop": "assets/tv-the-diplomat-2023-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0640348Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 203857, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 93, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.652, "tmdbVotes": 521}}, {"id": "tv-the-durrells-2016", "title": "The Durrells", "year": 2016, "type": "tv", "runtimeSeconds": 2760, "seasonCount": 4, "communityRating": 7.402, "criticRating": null, "genres": ["Drama", "Comedy", "Family"], "overview": "In 1935, financially strapped widow Louisa Durrell, whose life has fallen apart, decides to move from England, with her four children (three sons, one daughter), to the island of Corfu, Greece. Once there, the family\u2026", "poster": "assets/tv-the-durrells-2016-poster.jpg", "backdrop": "assets/tv-the-durrells-2016-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0619436Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 66134, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 94, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.4, "tmdbVotes": 127}}, {"id": "tv-etoile-2025", "title": "\u00c9toile", "year": 2025, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.197, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "In an ambitious gambit to save their storied institutions, two world-renowned ballet companies in New York City and Paris swap their most talented stars.", "poster": "assets/tv-etoile-2025-poster.jpg", "backdrop": "assets/tv-etoile-2025-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0601383Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 225383, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 86, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.3, "tmdbVotes": 40}}, {"id": "tv-gilmore-girls-2000", "title": "Gilmore Girls", "year": 2000, "type": "tv", "runtimeSeconds": 2640, "seasonCount": 7, "communityRating": 7.893, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "Set in the charming town of Stars Hollow, Connecticut, the series follows the captivating lives of Lorelai and Rory Gilmore, a mother/daughter pair who have a relationship most people only dream of.", "poster": "assets/tv-gilmore-girls-2000-poster.jpg", "backdrop": "assets/tv-gilmore-girls-2000-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0631035Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 4586, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.9, "tmdbVotes": 1339}}, {"id": "tv-ginny-georgia-2021", "title": "Ginny & Georgia", "year": 2021, "type": "tv", "runtimeSeconds": null, "seasonCount": 3, "communityRating": 8.026, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "Free-spirited Georgia and her two kids, Ginny and Austin, move north in search of a fresh start but find that the road to new beginnings can be bumpy.", "poster": "assets/tv-ginny-georgia-2021-poster.jpg", "backdrop": "assets/tv-ginny-georgia-2021-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0694828Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 117581, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 68, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8, "tmdbVotes": 1840}}, {"id": "tv-hellcats-2010", "title": "Hellcats", "year": 2010, "type": "tv", "runtimeSeconds": 2460, "seasonCount": 1, "communityRating": 7.5, "criticRating": null, "genres": ["Drama"], "overview": "Pre-law student Marti Perkins' world is flipped upside down when she loses her scholarship, and realizes the only way she can stay in school is by reigniting her dormant teen gymnastic skills to win a place on Lancer\u2026", "poster": "assets/tv-hellcats-2010-poster.jpg", "backdrop": "assets/tv-hellcats-2010-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-22T20:39:02.7719318Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 32504, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 65, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.5, "tmdbVotes": 116}}, {"id": "tv-high-potential-2024", "title": "High Potential", "year": 2024, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 8.201, "criticRating": null, "genres": ["Drama", "Crime", "Mystery"], "overview": "Morgan is a single mom with an exceptional mind, whose unconventional knack for solving crimes leads to an unusual and unstoppable partnership with a by-the-book seasoned detective.", "poster": "assets/tv-high-potential-2024-poster.jpg", "backdrop": "assets/tv-high-potential-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0686091Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 226637, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 98, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.3, "tmdbVotes": 707}}, {"id": "tv-how-to-get-to-heaven-from-belfast-2026", "title": "How to Get to Heaven from Belfast", "year": 2026, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.027, "criticRating": null, "genres": ["Mystery", "Comedy", "Drama"], "overview": "Three lifelong pals embark on a chaotic quest to solve the mystery of their old friend's suspicious death and keep their own dark secret under wraps.", "poster": "assets/tv-how-to-get-to-heaven-from-belfast-2026-poster.jpg", "backdrop": "assets/tv-how-to-get-to-heaven-from-belfast-2026-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0683192Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 233079, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 95, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.063, "tmdbVotes": 95}}, {"id": "tv-the-hunting-wives-2025", "title": "The Hunting Wives", "year": 2025, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 6.932, "criticRating": null, "genres": ["Drama", "Mystery"], "overview": "Sophie trades New England for East Texas and falls into a wealthy socialite's magnetic orbit \u2014 where a clique of housewives hide deadly secrets.", "poster": "assets/tv-the-hunting-wives-2025-poster.jpg", "backdrop": "assets/tv-the-hunting-wives-2025-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-20T19:42:46.3227892Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 244447, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 81, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.964, "tmdbVotes": 112}}, {"id": "tv-the-lost-flowers-of-alice-hart-2023", "title": "The Lost Flowers of Alice Hart", "year": 2023, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.634, "criticRating": null, "genres": ["Drama", "Mystery"], "overview": "After losing her parents in a mysterious fire, nine-year-old Alice Hart is raised by her grandmother June on a flower farm where she learns there are secrets within secrets. But years on, an unearthed betrayal sees\u2026", "poster": "assets/tv-the-lost-flowers-of-alice-hart-2023-poster.jpg", "backdrop": "assets/tv-the-lost-flowers-of-alice-hart-2023-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0607236Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 125816, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 82, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.64, "tmdbVotes": 161}}, {"id": "tv-love-is-blind-2020", "title": "Love Is Blind", "year": 2020, "type": "tv", "runtimeSeconds": null, "seasonCount": 11, "communityRating": 6.261, "criticRating": null, "genres": ["Reality"], "overview": "Nick and Vanessa Lachey host this social experiment where single men and women look for love and get engaged, all before meeting in person.", "poster": "assets/tv-love-is-blind-2020-poster.jpg", "backdrop": "assets/tv-love-is-blind-2020-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-22T17:11:08.2447226Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 99353, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 66, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.233, "tmdbVotes": 118}}, {"id": "tv-maximum-pleasure-guaranteed-2026", "title": "Maximum Pleasure Guaranteed", "year": 2026, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 6.676, "criticRating": null, "genres": ["Comedy"], "overview": "A newly divorced mom falls down a dangerous rabbit hole of blackmail, murder, and youth soccer.", "poster": "assets/tv-maximum-pleasure-guaranteed-2026-poster.jpg", "backdrop": "assets/tv-maximum-pleasure-guaranteed-2026-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-07T21:04:02.8277793Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 285404, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 93, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.799, "tmdbVotes": 92}}, {"id": "tv-minx-2022", "title": "Minx", "year": 2022, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 6.833, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "An earnest young feminist joins forces with a low-rent publisher to make the world's first erotic magazine for women.", "poster": "assets/tv-minx-2022-poster.jpg", "backdrop": "assets/tv-minx-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0653613Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 118303, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 93, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.9, "tmdbVotes": 123}}, {"id": "tv-offspring-2010", "title": "Offspring", "year": 2010, "type": "tv", "runtimeSeconds": 3600, "seasonCount": 7, "communityRating": 6.9, "criticRating": null, "genres": ["Drama"], "overview": "An exuberant drama set in Melbourne's Fitzroy, centering on Nina Proudman and her struggle to deal with her fabulously messy family, her hunt for a decent love life and her tendency to overthink and fly off into fantasy.", "poster": "assets/tv-offspring-2010-poster.jpg", "backdrop": "assets/tv-offspring-2010-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0664581Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 33155, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.9, "tmdbVotes": 27}}, {"id": "tv-only-murders-in-the-building-2021", "title": "Only Murders in the Building", "year": 2021, "type": "tv", "runtimeSeconds": null, "seasonCount": 5, "communityRating": 8.398, "criticRating": null, "genres": ["Comedy", "Mystery", "Crime"], "overview": "Three strangers share an obsession with true crime and suddenly find themselves wrapped up in one.", "poster": "assets/tv-only-murders-in-the-building-2021-poster.jpg", "backdrop": "assets/tv-only-murders-in-the-building-2021-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-11-04T20:25:58.2163301Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 107113, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 95, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.398, "tmdbVotes": 2333}}, {"id": "tv-outlander-blood-of-my-blood-2025", "title": "Outlander: Blood of My Blood", "year": 2025, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 8.542, "criticRating": null, "genres": ["Drama"], "overview": "From the battlefields of World War I to the rugged Highlands of 18th century Scotland, two fated couples must defy the forces that seek to tear them apart, unfolding in surprising and unforeseen ways in this romantic\u2026", "poster": "assets/tv-outlander-blood-of-my-blood-2025-poster.jpg", "backdrop": "assets/tv-outlander-blood-of-my-blood-2025-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0691373Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 207484, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 94, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.738, "tmdbVotes": 124}}, {"id": "tv-rivals-2024", "title": "Rivals", "year": 2024, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 7.44, "criticRating": null, "genres": ["Drama"], "overview": "It is 1986 and Britain is booming. \"Rivals\" dives headfirst into the cutthroat world of TV where hair-dos are big and ambitions are even bigger. Deals are brokered in boardrooms, as well as bedrooms. Nobody can be\u2026", "poster": "assets/tv-rivals-2024-poster.jpg", "backdrop": "assets/tv-rivals-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0645815Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 208921, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 96, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.4, "tmdbVotes": 93}}, {"id": "tv-rooster-2026", "title": "Rooster", "year": 2026, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.431, "criticRating": null, "genres": ["Comedy"], "overview": "On a college campus, an author navigates a complicated relationship with his daughter.", "poster": "assets/tv-rooster-2026-poster.jpg", "backdrop": "assets/tv-rooster-2026-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.063389Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 254953, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 88, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.423, "tmdbVotes": 144}}, {"id": "tv-sex-and-the-city-1998", "title": "Sex and the City", "year": 1998, "type": "tv", "runtimeSeconds": null, "seasonCount": 6, "communityRating": 7.365, "criticRating": null, "genres": ["Drama", "Comedy"], "overview": "Based on the bestselling book by Candace Bushnell, Sex and the City tells the story of four best friends, all single and in their late thirties, as they pursue their careers and talk about their sex lives, all while\u2026", "poster": "assets/tv-sex-and-the-city-1998-poster.jpg", "backdrop": "assets/tv-sex-and-the-city-1998-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0679324Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 105, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 70, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.351, "tmdbVotes": 2220}}, {"id": "tv-skymed-2022", "title": "SkyMed", "year": 2022, "type": "tv", "runtimeSeconds": 2640, "seasonCount": 4, "communityRating": 6.665, "criticRating": null, "genres": ["Drama"], "overview": "Life, death and drama at 20,000 feet. The series weaves together intense character journeys and high-stakes medical rescues, as we follow the triumphs, heartbreaks and tribulations of budding nurses and pilots flying\u2026", "poster": "assets/tv-skymed-2022-poster.jpg", "backdrop": "assets/tv-skymed-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-22T19:44:29.2431354Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 133908, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.699, "tmdbVotes": 98}}, {"id": "tv-so-you-think-you-can-dance-2008", "title": "So You Think You Can Dance", "year": 2008, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 9.8, "criticRating": null, "genres": ["Family", "Reality"], "overview": "So You Think You Can Dance is a joint Belgian-Dutch dance competition show broadcast on RTL 5 and vtm television stations. The first season was broadcast only in the Netherlands, whereas starting with season 2, the\u2026", "poster": "assets/tv-so-you-think-you-can-dance-2008-poster.jpg", "backdrop": "assets/tv-so-you-think-you-can-dance-2008-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-23T11:11:35.4124418Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 40231, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 9.8, "tmdbVotes": 4}}, {"id": "tv-the-white-lotus-2021", "title": "The White Lotus", "year": 2021, "type": "tv", "runtimeSeconds": null, "seasonCount": 3, "communityRating": 7.597, "criticRating": null, "genres": ["Comedy", "Drama", "Mystery"], "overview": "Follow the exploits of various guests and employees at an exclusive tropical resort over the span of a week as with each passing day, a darker complexity emerges in these picture-perfect travelers, the hotel\u2019s\u2026", "poster": "assets/tv-the-white-lotus-2021-poster.jpg", "backdrop": "assets/tv-the-white-lotus-2021-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-20T02:45:52.8742674Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 111803, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 90, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.599, "tmdbVotes": 1604}}, {"id": "tv-yellowjackets-2021", "title": "Yellowjackets", "year": 2021, "type": "tv", "runtimeSeconds": null, "seasonCount": 3, "communityRating": 7.431, "criticRating": null, "genres": ["Drama", "Mystery"], "overview": "This equal parts survival epic, psychological horror story and coming-of-age drama tells the saga of a team of wildly talented high school girls soccer players who become the (un)lucky survivors of a plane crash deep\u2026", "poster": "assets/tv-yellowjackets-2021-poster.jpg", "backdrop": "assets/tv-yellowjackets-2021-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-11-04T20:31:47.4592756Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 117488, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 92, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.431, "tmdbVotes": 1070}}], "anime": [], "resume": [{"id": "episode-sex-and-the-country-2001-s4e9", "title": "Sex and the Country", "year": 2001, "type": "episode", "runtimeSeconds": 1751, "seasonCount": null, "communityRating": 5.9, "criticRating": null, "genres": ["Comedy", "Drama", "Romance"], "overview": "Carrie is dragged to Aidan's country home in Suffern, New York. Charlotte visits the MacDougal \"compound\" in Connecticut. Miranda yells at Steve when he tells her he has testicular cancer but has no clue about his\u2026", "poster": "assets/episode-sex-and-the-country-2001-s4e9-poster.jpg", "backdrop": "assets/episode-sex-and-the-country-2001-s4e9-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 96.08, "progress": 0.0549, "addedAt": "2026-07-17T10:53:15.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "Sex and the City", "seasonNumber": 4, "episodeNumber": 9, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}], "nextUp": [{"id": "episode-what-more-do-i-need-2008-s5e7", "title": "What More Do I Need?", "year": 2008, "type": "episode", "runtimeSeconds": 2521, "seasonCount": null, "communityRating": 8, "criticRating": null, "genres": ["Comedy", "Drama", "Mystery"], "overview": "Lynette and Tom uncover the unsettling truth about son Porter. Gabrielle suspects Carlos' best client, Virginia, has ulterior motives. Bree's moment of weakness could bring her great embarrassment and shame.\u2026", "poster": "assets/episode-what-more-do-i-need-2008-s5e7-poster.jpg", "backdrop": "assets/episode-what-more-do-i-need-2008-s5e7-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-29T14:37:28.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "Desperate Housewives", "seasonNumber": 5, "episodeNumber": 7, "artwork": {"posterKind": "center_crop", "backdropKind": "backdrop"}}, {"id": "episode-new-day-2021-s1e2", "title": "New Day", "year": 2021, "type": "episode", "runtimeSeconds": 3445, "seasonCount": null, "communityRating": 7.143, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "A cautiously optimistic Belinda walks a fine line with Tanya, while Armond brainstorms ways to deal with Shane. After her husband belittles her work, Rachel approaches Nicole for advice. Paula and Olivia lose track\u2026", "poster": "assets/episode-new-day-2021-s1e2-poster.jpg", "backdrop": "assets/episode-new-day-2021-s1e2-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-19T22:18:57.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "The White Lotus", "seasonNumber": 1, "episodeNumber": 2, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}, {"id": "episode-the-uncomfortability-2010-s1e2", "title": "The Uncomfortability", "year": 2010, "type": "episode", "runtimeSeconds": 2687, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "Nina's insecurities are revealed when a patient poses an awkward request. Meanwhile Geraldine decides to try online dating, and Jimmy is an unexpected saviour to Cherie and Darcy.", "poster": "assets/episode-the-uncomfortability-2010-s1e2-poster.jpg", "backdrop": "assets/episode-the-uncomfortability-2010-s1e2-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T11:47:42.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "Offspring", "seasonNumber": 1, "episodeNumber": 2, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}, {"id": "episode-episode-2-2016-s1e2", "title": "Episode 2", "year": 2016, "type": "episode", "runtimeSeconds": 2766, "seasonCount": null, "communityRating": 8.3, "criticRating": null, "genres": ["Biography", "Comedy", "Drama"], "overview": "Larry falls dangerously ill, and it falls to Theo and his neighbour Sven to put their rudimentary medical skills to the task of saving his life. The family's money worries continue, but Spiros brings them good news\u2026", "poster": "assets/episode-episode-2-2016-s1e2-poster.jpg", "backdrop": "assets/episode-episode-2-2016-s1e2-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-31T07:29:17.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "The Durrells", "seasonNumber": 1, "episodeNumber": 2, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}, {"id": "episode-remembrance-day-2026-s2e1", "title": "Remembrance Day", "year": 2026, "type": "episode", "runtimeSeconds": 3822, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Fantasy", "Romance"], "overview": "The fiery crosses have been lit across the Highlands; lovers must part, as the call to arms brings friends and enemies home and the Jacobite Rebellion begins in earnest.", "poster": "assets/episode-remembrance-day-2026-s2e1-poster.jpg", "backdrop": "assets/episode-remembrance-day-2026-s2e1-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-19T03:50:07.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "Outlander: Blood of My Blood", "seasonNumber": 2, "episodeNumber": 1, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}, {"id": "episode-one-of-us-2024-s1e7", "title": "One of Us", "year": 2024, "type": "episode", "runtimeSeconds": 2555, "seasonCount": null, "communityRating": 7.7, "criticRating": null, "genres": [], "overview": "Morgan agrees to show Ava around the LAPD, but things quickly take a dangerous turn when the precinct is held hostage by the friends of a recently convicted man. Can Morgan and the detectives outsmart their captors\u2026", "poster": "assets/episode-one-of-us-2024-s1e7-poster.jpg", "backdrop": "assets/episode-one-of-us-2024-s1e7-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T13:57:22.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "High Potential", "seasonNumber": 1, "episodeNumber": 7, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}, {"id": "episode-the-breakup-part-2-2001-s1e17", "title": "The Breakup, Part 2", "year": 2001, "type": "episode", "runtimeSeconds": 2669, "seasonCount": null, "communityRating": 7.4, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "After recent events, Rory keeps busy, refusing to give in to self-pity. Meanwhile, Stars Hollow's residents rally around her, much to her dismay.", "poster": "assets/episode-the-breakup-part-2-2001-s1e17-poster.jpg", "backdrop": "assets/episode-the-breakup-part-2-2001-s1e17-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T11:28:05.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "Gilmore Girls", "seasonNumber": 1, "episodeNumber": 17, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}, {"id": "episode-i-heart-ny-2002-s4e18", "title": "I Heart NY", "year": 2002, "type": "episode", "runtimeSeconds": 1905, "seasonCount": null, "communityRating": 6.667, "criticRating": null, "genres": ["Comedy", "Drama", "Romance"], "overview": "Carrie is shocked to find out that Big is moving to Napa, California and plans to give him a New York send-off. Charlotte begins dating another recent divorc\u00e9. Miranda prepares for the new baby as her delivery date\u2026", "poster": "assets/episode-i-heart-ny-2002-s4e18-poster.jpg", "backdrop": "assets/episode-i-heart-ny-2002-s4e18-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T12:35:14.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "Sex and the City", "seasonNumber": 4, "episodeNumber": 18, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}], "recentlyAdded": [{"id": "tv-yellowjackets-2021", "title": "Yellowjackets", "year": 2021, "type": "tv", "runtimeSeconds": null, "seasonCount": 3, "communityRating": 7.431, "criticRating": null, "genres": ["Drama", "Mystery"], "overview": "This equal parts survival epic, psychological horror story and coming-of-age drama tells the saga of a team of wildly talented high school girls soccer players who become the (un)lucky survivors of a plane crash deep\u2026", "poster": "assets/tv-yellowjackets-2021-poster.jpg", "backdrop": "assets/tv-yellowjackets-2021-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-11-04T20:31:47.4592756Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 117488, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 92, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.431, "tmdbVotes": 1070}}, {"id": "tv-only-murders-in-the-building-2021", "title": "Only Murders in the Building", "year": 2021, "type": "tv", "runtimeSeconds": null, "seasonCount": 5, "communityRating": 8.398, "criticRating": null, "genres": ["Comedy", "Mystery", "Crime"], "overview": "Three strangers share an obsession with true crime and suddenly find themselves wrapped up in one.", "poster": "assets/tv-only-murders-in-the-building-2021-poster.jpg", "backdrop": "assets/tv-only-murders-in-the-building-2021-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-11-04T20:25:58.2163301Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 107113, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 95, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.398, "tmdbVotes": 2333}}, {"id": "tv-big-boys-2022", "title": "Big Boys", "year": 2022, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 3, "communityRating": 7.6, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "Two boys from very different ends of the 'spectrum of masculinity' become best friends at Brent University freshers' week; in their first year of uni, they explore, experiment and try to discover themselves, helping\u2026", "poster": "assets/tv-big-boys-2022-poster.jpg", "backdrop": "assets/tv-big-boys-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-11-04T20:25:58.2156837Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 202851, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.6, "tmdbVotes": 49}}, {"id": "tv-all-creatures-great-small-2020", "title": "All Creatures Great & Small", "year": 2020, "type": "tv", "runtimeSeconds": 3000, "seasonCount": 7, "communityRating": 8.2, "criticRating": null, "genres": ["Drama", "Comedy"], "overview": "The heartwarming and humorous adventures of a young country vet in the Yorkshire Dales in the 1930s. A remake of the 1978 series.", "poster": "assets/tv-all-creatures-great-small-2020-poster.jpg", "backdrop": "assets/tv-all-creatures-great-small-2020-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-11-04T20:13:03.4266156Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 108255, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 99, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.2, "tmdbVotes": 168}}, {"id": "movie-the-killing-of-a-sacred-deer-2017", "title": "The Killing of a Sacred Deer", "year": 2017, "type": "movie", "runtimeSeconds": 7262, "seasonCount": null, "communityRating": 7.003, "criticRating": 79, "genres": ["Drama", "Thriller", "Mystery"], "overview": "Dr. Steven Murphy is a renowned cardiovascular surgeon who presides over a spotless household with his wife and two children. Lurking at the margins of his idyllic suburban existence is Martin, a fatherless teen who\u2026", "poster": "assets/movie-the-killing-of-a-sacred-deer-2017-poster.jpg", "backdrop": "assets/movie-the-killing-of-a-sacred-deer-2017-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-22T21:49:26.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 399057, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 79, "rtCriticsLabel": "Certified Fresh", "rtAudience": 63, "rtAudienceLabel": "Upright", "imdb": 7, "imdbVotes": 219488, "tmdb": 7.001, "tmdbVotes": 4640}}, {"id": "tv-derry-girls-2018", "title": "Derry Girls", "year": 2018, "type": "tv", "runtimeSeconds": 1380, "seasonCount": 3, "communityRating": 8.144, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "Amidst the political conflict of Northern Ireland in the 1990s, five secondary school students square off with the universal challenges of being a teenager.", "poster": "assets/tv-derry-girls-2018-poster.jpg", "backdrop": "assets/tv-derry-girls-2018-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-02T20:01:55.8955537Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 76148, "artwork": {"posterKind": "center_crop", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 99, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.138, "tmdbVotes": 454}}, {"id": "tv-so-you-think-you-can-dance-2008", "title": "So You Think You Can Dance", "year": 2008, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 9.8, "criticRating": null, "genres": ["Family", "Reality"], "overview": "So You Think You Can Dance is a joint Belgian-Dutch dance competition show broadcast on RTL 5 and vtm television stations. The first season was broadcast only in the Netherlands, whereas starting with season 2, the\u2026", "poster": "assets/tv-so-you-think-you-can-dance-2008-poster.jpg", "backdrop": "assets/tv-so-you-think-you-can-dance-2008-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-23T11:11:35.4124418Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 40231, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 9.8, "tmdbVotes": 4}}, {"id": "tv-hellcats-2010", "title": "Hellcats", "year": 2010, "type": "tv", "runtimeSeconds": 2460, "seasonCount": 1, "communityRating": 7.5, "criticRating": null, "genres": ["Drama"], "overview": "Pre-law student Marti Perkins' world is flipped upside down when she loses her scholarship, and realizes the only way she can stay in school is by reigniting her dormant teen gymnastic skills to win a place on Lancer\u2026", "poster": "assets/tv-hellcats-2010-poster.jpg", "backdrop": "assets/tv-hellcats-2010-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-22T20:39:02.7719318Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 32504, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 65, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.5, "tmdbVotes": 116}}, {"id": "tv-skymed-2022", "title": "SkyMed", "year": 2022, "type": "tv", "runtimeSeconds": 2640, "seasonCount": 4, "communityRating": 6.665, "criticRating": null, "genres": ["Drama"], "overview": "Life, death and drama at 20,000 feet. The series weaves together intense character journeys and high-stakes medical rescues, as we follow the triumphs, heartbreaks and tribulations of budding nurses and pilots flying\u2026", "poster": "assets/tv-skymed-2022-poster.jpg", "backdrop": "assets/tv-skymed-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-22T19:44:29.2431354Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 133908, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.699, "tmdbVotes": 98}}, {"id": "tv-love-is-blind-2020", "title": "Love Is Blind", "year": 2020, "type": "tv", "runtimeSeconds": null, "seasonCount": 11, "communityRating": 6.261, "criticRating": null, "genres": ["Reality"], "overview": "Nick and Vanessa Lachey host this social experiment where single men and women look for love and get engaged, all before meeting in person.", "poster": "assets/tv-love-is-blind-2020-poster.jpg", "backdrop": "assets/tv-love-is-blind-2020-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-22T17:11:08.2447226Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 99353, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 66, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.233, "tmdbVotes": 118}}, {"id": "tv-the-hunting-wives-2025", "title": "The Hunting Wives", "year": 2025, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 6.932, "criticRating": null, "genres": ["Drama", "Mystery"], "overview": "Sophie trades New England for East Texas and falls into a wealthy socialite's magnetic orbit \u2014 where a clique of housewives hide deadly secrets.", "poster": "assets/tv-the-hunting-wives-2025-poster.jpg", "backdrop": "assets/tv-the-hunting-wives-2025-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-20T19:42:46.3227892Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 244447, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 81, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.964, "tmdbVotes": 112}}, {"id": "tv-the-white-lotus-2021", "title": "The White Lotus", "year": 2021, "type": "tv", "runtimeSeconds": null, "seasonCount": 3, "communityRating": 7.597, "criticRating": null, "genres": ["Comedy", "Drama", "Mystery"], "overview": "Follow the exploits of various guests and employees at an exclusive tropical resort over the span of a week as with each passing day, a darker complexity emerges in these picture-perfect travelers, the hotel\u2019s\u2026", "poster": "assets/tv-the-white-lotus-2021-poster.jpg", "backdrop": "assets/tv-the-white-lotus-2021-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-20T02:45:52.8742674Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 111803, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 90, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.599, "tmdbVotes": 1604}}], "discover": [{"id": "movie-across-the-hall-2009", "title": "Across the Hall", "year": 2009, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Thriller", "Mystery"], "overview": "Convinced that his fiancee is cheating on him, a man follows her to a hotel and calls his best friend to help him avoid a calamity.", "poster": "assets/movie-across-the-hall-2009-poster.jpg", "backdrop": "assets/movie-across-the-hall-2009-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 21469, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": 39, "rtAudienceLabel": "Spilled", "imdb": 5.8, "imdbVotes": 5130, "tmdb": 6, "tmdbVotes": 129}}, {"id": "movie-addicted-2014", "title": "Addicted", "year": 2014, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Thriller"], "overview": "A gallerist risks her family and flourishing career when she enters into an affair with a talented painter and slowly loses control of her life.", "poster": "assets/movie-addicted-2014-poster.jpg", "backdrop": "assets/movie-addicted-2014-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 235271, "ratings": {"rtCritics": 7, "rtCriticsLabel": "Rotten", "rtAudience": 51, "rtAudienceLabel": "Spilled", "imdb": 5, "imdbVotes": 13131, "tmdb": 5.928, "tmdbVotes": 466}}, {"id": "movie-adulterers-2016", "title": "Adulterers", "year": 2016, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Thriller", "Drama"], "overview": "On a steamy New Orleans afternoon, Sam Deuprey decides to surprise his beautiful wife, Ashley, for their anniversary and comes home early. But Ashley is not alone in the bedroom. She and her lover, Damien are about\u2026", "poster": "assets/movie-adulterers-2016-poster.jpg", "backdrop": "assets/movie-adulterers-2016-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 376004, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": 37, "rtAudienceLabel": "Spilled", "imdb": 5.1, "imdbVotes": 2144, "tmdb": 6.2, "tmdbVotes": 116}}, {"id": "movie-alone-with-her-2006", "title": "Alone With Her", "year": 2006, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Crime", "Drama", "Thriller"], "overview": "The harrowing story of a disturbed young man's attempts to win the affections of an unsuspecting young woman. When Doug first sees Amy, he instantly falls for her and begins to watch her every move, going so far as\u2026", "poster": "assets/movie-alone-with-her-2006-poster.jpg", "backdrop": "assets/movie-alone-with-her-2006-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 18206, "ratings": {"rtCritics": 70, "rtCriticsLabel": "Fresh", "rtAudience": 46, "rtAudienceLabel": "Spilled", "imdb": 6, "imdbVotes": 4359, "tmdb": 5.9, "tmdbVotes": 94}}, {"id": "movie-awake-2007", "title": "Awake", "year": 2007, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Thriller", "Crime", "Mystery"], "overview": "While undergoing heart surgery, a man experiences a phenomenon called \u2018anesthetic awareness\u2019, which leaves him awake but paralyzed throughout the operation. As various obstacles present themselves, his wife must make\u2026", "poster": "assets/movie-awake-2007-poster.jpg", "backdrop": "assets/movie-awake-2007-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 13483, "ratings": {"rtCritics": 23, "rtCriticsLabel": "Rotten", "rtAudience": 55, "rtAudienceLabel": "Spilled", "imdb": 6.5, "imdbVotes": 89389, "tmdb": 6.586, "tmdbVotes": 1649}}, {"id": "movie-black-bear-2020", "title": "Black Bear", "year": 2020, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Thriller"], "overview": "At a remote lake house in the Adirondack Mountains, a couple entertains an out-of-town guest looking for inspiration in her filmmaking. The group quickly falls into a calculated game of desire, manipulation, and\u2026", "poster": "assets/movie-black-bear-2020-poster.jpg", "backdrop": "assets/movie-black-bear-2020-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 624788, "ratings": {"rtCritics": 89, "rtCriticsLabel": "Certified Fresh", "rtAudience": 64, "rtAudienceLabel": "Upright", "imdb": 6.5, "imdbVotes": 18408, "tmdb": 6.337, "tmdbVotes": 315}}, {"id": "movie-butterfly-on-a-wheel-2007", "title": "Butterfly on a Wheel", "year": 2007, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Crime", "Drama", "Thriller"], "overview": "A sociopathic kidnapper methodically pushes a desperate pair of parents to their absolute breaking point.", "poster": "assets/movie-butterfly-on-a-wheel-2007-poster.jpg", "backdrop": "assets/movie-butterfly-on-a-wheel-2007-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 13250, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": 61, "rtAudienceLabel": "Upright", "imdb": 6.6, "imdbVotes": 35409, "tmdb": 6.457, "tmdbVotes": 595}}, {"id": "movie-careful-what-you-wish-for-2015", "title": "Careful What You Wish For", "year": 2015, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Thriller"], "overview": "A guy gets more than he bargained for after entering into an affair with the wife of an investment banker. Soon, a suspicious death and substantial life insurance policy embroil him in a scandal.", "poster": "assets/movie-careful-what-you-wish-for-2015-poster.jpg", "backdrop": "assets/movie-careful-what-you-wish-for-2015-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 317744, "ratings": {"rtCritics": 17, "rtCriticsLabel": "Rotten", "rtAudience": 61, "rtAudienceLabel": "Upright", "imdb": 5.6, "imdbVotes": 9452, "tmdb": 6.129, "tmdbVotes": 446}}, {"id": "movie-crime-of-passion-1956", "title": "Crime of Passion", "year": 1956, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Crime", "Thriller"], "overview": "Kathy leaves the newspaper business to marry homicide detective Bill, but is frustrated by his lack of ambition and the banality of life in the suburbs. Her drive to advance Bill's career soon takes her down a\u2026", "poster": "assets/movie-crime-of-passion-1956-poster.jpg", "backdrop": "assets/movie-crime-of-passion-1956-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "center_crop", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 1920, "ratings": {"rtCritics": 80, "rtCriticsLabel": "Fresh", "rtAudience": 34, "rtAudienceLabel": "Spilled", "imdb": 6.4, "imdbVotes": 3750, "tmdb": 6.1, "tmdbVotes": 57}}, {"id": "movie-deep-water-2022", "title": "Deep Water", "year": 2022, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Mystery", "Thriller"], "overview": "A well-to-do husband who allows his wife to have affairs in order to avoid a divorce becomes a prime suspect in the disappearance of her lovers.", "poster": "assets/movie-deep-water-2022-poster.jpg", "backdrop": "assets/movie-deep-water-2022-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 619979, "ratings": {"rtCritics": 35, "rtCriticsLabel": "Rotten", "rtAudience": 25, "rtAudienceLabel": "Spilled", "imdb": 5.5, "imdbVotes": 74098, "tmdb": 5.834, "tmdbVotes": 1755}}, {"id": "movie-derailed-2005", "title": "Derailed", "year": 2005, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Thriller"], "overview": "When two married business executives having an affair are blackmailed by a violent criminal, they are forced to turn the tables on him to save their families.", "poster": "assets/movie-derailed-2005-poster.jpg", "backdrop": "assets/movie-derailed-2005-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 8999, "ratings": {"rtCritics": 20, "rtCriticsLabel": "Rotten", "rtAudience": 58, "rtAudienceLabel": "Spilled", "imdb": 6.6, "imdbVotes": 79563, "tmdb": 6.405, "tmdbVotes": 1193}}, {"id": "movie-die-my-love-2025", "title": "Die My Love", "year": 2025, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama"], "overview": "After inheriting a remote Montana house, Jackson moves there from New York with his partner Grace, and the couple soon welcome a child. As Jackson becomes increasingly absent and rural isolation sets in, Grace\u2026", "poster": "assets/movie-die-my-love-2025-poster.jpg", "backdrop": "assets/movie-die-my-love-2025-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 1033148, "ratings": {"rtCritics": 74, "rtCriticsLabel": "Certified Fresh", "rtAudience": 45, "rtAudienceLabel": "Spilled", "imdb": 6, "imdbVotes": 50664, "tmdb": 6.1, "tmdbVotes": 683}}, {"id": "movie-dream-house-2011", "title": "Dream House", "year": 2011, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Mystery", "Drama", "Thriller"], "overview": "Publisher Will Atenton quits a lucrative job in New York to relocate his wife, Libby, and their daughters to a quaint town in New England. However, as they settle into their home the Atentons discover that a woman\u2026", "poster": "assets/movie-dream-house-2011-poster.jpg", "backdrop": "assets/movie-dream-house-2011-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 69668, "ratings": {"rtCritics": 7, "rtCriticsLabel": "Rotten", "rtAudience": 36, "rtAudienceLabel": "Spilled", "imdb": 6, "imdbVotes": 71386, "tmdb": 6.212, "tmdbVotes": 1480}}, {"id": "movie-dream-lover-1994", "title": "Dream Lover", "year": 1994, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Mystery", "Thriller"], "overview": "Not long after they cross paths at an art gallery, architect Ray Reardon and hypnotically sensual Lena are married with children. But as strange incidents occur, Ray begins to realize he may not really know the woman\u2026", "poster": "assets/movie-dream-lover-1994-poster.jpg", "backdrop": "assets/movie-dream-lover-1994-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 18190, "ratings": {"rtCritics": 57, "rtCriticsLabel": "Rotten", "rtAudience": 38, "rtAudienceLabel": "Spilled", "imdb": 6.2, "imdbVotes": 7865, "tmdb": 6.844, "tmdbVotes": 167}}, {"id": "movie-drop-2025", "title": "Drop", "year": 2025, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Mystery", "Thriller"], "overview": "Violet, a widowed mother on her first date in years, arrives at an upscale restaurant where she is relieved that her date, Henry, is more charming and handsome than she expected. But their chemistry begins to curdle\u2026", "poster": "assets/movie-drop-2025-poster.jpg", "backdrop": "assets/movie-drop-2025-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 1249213, "ratings": {"rtCritics": 83, "rtCriticsLabel": "Certified Fresh", "rtAudience": 79, "rtAudienceLabel": "Upright", "imdb": 6.1, "imdbVotes": 61018, "tmdb": 6.275, "tmdbVotes": 1023}}, {"id": "movie-eastern-promises-2007", "title": "Eastern Promises", "year": 2007, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Thriller", "Crime", "Mystery"], "overview": "A Russian teenager living in London dies during childbirth but leaves clues in her diary that could tie her child to a rape involving a violent Russian mob family.", "poster": "assets/movie-eastern-promises-2007-poster.jpg", "backdrop": "assets/movie-eastern-promises-2007-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 2252, "ratings": {"rtCritics": 89, "rtCriticsLabel": "Certified Fresh", "rtAudience": 83, "rtAudienceLabel": "Upright", "imdb": 7.6, "imdbVotes": 281015, "tmdb": 7.349, "tmdbVotes": 3900}}, {"id": "movie-faces-1968", "title": "Faces", "year": 1968, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama"], "overview": "Middle-aged suburban husband Richard abruptly tells his wife, Maria, that he wants a divorce. As Richard takes up with a younger woman, Maria enjoys a night on the town with her friends and meets a younger man. As\u2026", "poster": "assets/movie-faces-1968-poster.jpg", "backdrop": "assets/movie-faces-1968-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "center_crop", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 753, "ratings": {"rtCritics": 83, "rtCriticsLabel": "Fresh", "rtAudience": 86, "rtAudienceLabel": "Upright", "imdb": 7.4, "imdbVotes": 12950, "tmdb": 7.093, "tmdbVotes": 227}}, {"id": "movie-fatale-2020", "title": "Fatale", "year": 2020, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Thriller"], "overview": "After a wild one-night stand, successful sports agent Derrick Tyler watches his perfect life slowly disappear when he discovers the sexy and mysterious woman he risked everything for is determined police detective\u2026", "poster": "assets/movie-fatale-2020-poster.jpg", "backdrop": "assets/movie-fatale-2020-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 549514, "ratings": {"rtCritics": 46, "rtCriticsLabel": "Rotten", "rtAudience": 87, "rtAudienceLabel": "Upright", "imdb": 5.5, "imdbVotes": 16392, "tmdb": 6, "tmdbVotes": 401}}, {"id": "tv-56-days-2026", "title": "56 Days", "year": 2026, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Crime", "Mystery"], "overview": "When an unidentified body is found in a luxury apartment linked to Oliver Kennedy and his girlfriend Ciara Wyse, Detectives Lee Reardon and Karl Connolly reconstruct the couple's deadly romance across the past 56 days.", "poster": "assets/tv-56-days-2026-poster.jpg", "backdrop": "assets/tv-56-days-2026-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 241372, "ratings": {"rtCritics": 60, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.388, "tmdbVotes": 245}}, {"id": "tv-90-day-fiance-before-the-90-days-2017", "title": "90 Day Fianc\u00e9: Before the 90 Days", "year": 2017, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Reality"], "overview": "Follow couples who have an existing relationship online, but haven't met in person. We'll experience their journey as they travel to the other's foreign country for the first time in an attempt to establish an\u2026", "poster": "assets/tv-90-day-fiance-before-the-90-days-2017-poster.jpg", "backdrop": "assets/tv-90-day-fiance-before-the-90-days-2017-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 73319, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.5, "tmdbVotes": 45}}, {"id": "tv-90-day-fiance-the-other-way-2019", "title": "90 Day Fianc\u00e9: The Other Way", "year": 2019, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Reality"], "overview": "Americans move to a foreign country all in the name of love. Will uprooting their lives in the United States pay off, or will they be packing up and moving back to America brokenhearted?", "poster": "assets/tv-90-day-fiance-the-other-way-2019-poster.jpg", "backdrop": "assets/tv-90-day-fiance-the-other-way-2019-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 90046, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.8, "tmdbVotes": 34}}, {"id": "tv-absolutely-fabulous-1992", "title": "Absolutely Fabulous", "year": 1992, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Comedy"], "overview": "Set in the world of fashion and PR, immature fun-loving mother Edina Monsoon and her best friend Patsy drive Eddie's sensible daughter, Saffron, up the wall with their constant drug abuse and outrageous selfishness.\u2026", "poster": "assets/tv-absolutely-fabulous-1992-poster.jpg", "backdrop": "assets/tv-absolutely-fabulous-1992-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 4493, "ratings": {"rtCritics": 96, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.6, "tmdbVotes": 198}}, {"id": "tv-the-afterparty-2022", "title": "The Afterparty", "year": 2022, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Mystery", "Comedy", "Crime"], "overview": "Nothing is as it seems in this hilarious murder mystery where each suspect's story is told through a different film genre.", "poster": "assets/tv-the-afterparty-2022-poster.jpg", "backdrop": "assets/tv-the-afterparty-2022-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 106454, "ratings": {"rtCritics": 92, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.071, "tmdbVotes": 245}}, {"id": "tv-agatha-christie-s-marple-2004", "title": "Agatha Christie's Marple", "year": 2004, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Crime", "Mystery"], "overview": "The adventures of Miss Jane Marple, an elderly spinster living in the quiet little village of St Mary Mead. During her many visits to friends and relatives in other villages, Miss Marple often stumbles upon\u2026", "poster": "assets/tv-agatha-christie-s-marple-2004-poster.jpg", "backdrop": "assets/tv-agatha-christie-s-marple-2004-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 6127, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.9, "tmdbVotes": 152}}, {"id": "tv-all-american-homecoming-2022", "title": "All American: Homecoming", "year": 2022, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama"], "overview": "Follow the journeys of Simone Hicks, a tennis hopeful from Beverly Hills, and elite baseball player Damon Sims from Chicago, as they navigate life at the prestigious HBCU Bringston University.", "poster": "assets/tv-all-american-homecoming-2022-poster.jpg", "backdrop": "assets/tv-all-american-homecoming-2022-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 153723, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.3, "tmdbVotes": 70}}, {"id": "tv-and-just-like-that-2021", "title": "And Just Like That\u2026", "year": 2021, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "This new chapter of \u201cSex and the City\u201d follows Carrie, Miranda and Charlotte as they navigate the journey from the complicated reality of life and friendship in their 30s to the even more complicated reality of life\u2026", "poster": "assets/tv-and-just-like-that-2021-poster.jpg", "backdrop": "assets/tv-and-just-like-that-2021-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 116450, "ratings": {"rtCritics": 52, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.211, "tmdbVotes": 556}}, {"id": "tv-anne-with-an-e-2017", "title": "Anne with an E", "year": 2017, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Family"], "overview": "A coming-of-age story about an outsider who, against all odds and numerous challenges, fights for love and acceptance and for her place in the world. The series centers on a young orphaned girl in the late 1890s,\u2026", "poster": "assets/tv-anne-with-an-e-2017-poster.jpg", "backdrop": "assets/tv-anne-with-an-e-2017-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 70785, "ratings": {"rtCritics": 63, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.7, "tmdbVotes": 5019}}, {"id": "tv-awkward-2011", "title": "Awkward.", "year": 2011, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "An irreverent look at the conflict, chaos and humor that defines teenage life through the eyes of 15-year-old Jenna Hamilton whose life begins to change when a simple accident becomes an epic misunderstanding and is\u2026", "poster": "assets/tv-awkward-2011-poster.jpg", "backdrop": "assets/tv-awkward-2011-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 39859, "ratings": {"rtCritics": 78, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.4, "tmdbVotes": 427}}, {"id": "tv-the-baby-sitters-club-2020", "title": "The Baby-Sitters Club", "year": 2020, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Kids", "Drama"], "overview": "In this contemporary take on the beloved book series, five best friends launch a baby-sitting business that's big on fun and adventure.", "poster": "assets/tv-the-baby-sitters-club-2020-poster.jpg", "backdrop": "assets/tv-the-baby-sitters-club-2020-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 100412, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.9, "tmdbVotes": 506}}, {"id": "tv-ballers-2015", "title": "Ballers", "year": 2015, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Comedy"], "overview": "Looking at the lives of former and current football players, the show follows former superstar Spencer Strasmore as he gets his life on track in retirement while mentoring other current and former players through the\u2026", "poster": "assets/tv-ballers-2015-poster.jpg", "backdrop": "assets/tv-ballers-2015-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 62704, "ratings": {"rtCritics": 72, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.809, "tmdbVotes": 820}}, {"id": "tv-baretta-1975", "title": "Baretta", "year": 1975, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Crime", "Drama", "Mystery"], "overview": "Baretta is an American detective television series which ran on ABC from 1975 to 1978. The show was a milder version of a successful 1973\u201374 ABC series, Toma, starring Tony Musante as chameleon-like, real-life New\u2026", "poster": "assets/tv-baretta-1975-poster.jpg", "backdrop": "assets/tv-baretta-1975-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 3216, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.5, "tmdbVotes": 43}}, {"id": "tv-based-on-a-true-story-2023", "title": "Based on a True Story", "year": 2023, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "The lives of a realtor, a plumber and a former tennis star unexpectedly collide, exposing America\u2019s obsession with true crime, murder and the slow-close toilet seat.", "poster": "assets/tv-based-on-a-true-story-2023-poster.jpg", "backdrop": "assets/tv-based-on-a-true-story-2023-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 197373, "ratings": {"rtCritics": 79, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7, "tmdbVotes": 160}}, {"id": "tv-the-beast-in-me-2025", "title": "The Beast in Me", "year": 2025, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Mystery"], "overview": "A famous author is pulled into a twisted mind game with her rich, powerful new neighbor \u2014 who might be a murderer.", "poster": "assets/tv-the-beast-in-me-2025-poster.jpg", "backdrop": "assets/tv-the-beast-in-me-2025-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 250504, "ratings": {"rtCritics": 85, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.072, "tmdbVotes": 334}}, {"id": "tv-big-city-greens-2018", "title": "Big City Greens", "year": 2018, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Family", "Animation", "Comedy"], "overview": "The offbeat adventures of 10-year-old Cricket Green, a mischievous and optimistic country boy who moves to the big city with his wildly out of place family \u2013 older sister Tilly, father Bill and Gramma Alice.", "poster": "assets/tv-big-city-greens-2018-poster.jpg", "backdrop": "assets/tv-big-city-greens-2018-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 80587, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.555, "tmdbVotes": 82}}, {"id": "tv-the-bold-type-2017", "title": "The Bold Type", "year": 2017, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Comedy"], "overview": "A glimpse into the outrageous lives of Jane, Kat and Sutton, who are working at the nation's top women's magazine, Scarlet, while navigating their careers, identities and individual voices.", "poster": "assets/tv-the-bold-type-2017-poster.jpg", "backdrop": "assets/tv-the-bold-type-2017-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 69630, "ratings": {"rtCritics": 89, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.7, "tmdbVotes": 204}}, {"id": "tv-brassic-2019", "title": "Brassic", "year": 2019, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Comedy", "Drama", "Crime"], "overview": "A group of working-class friends finding unconventional ways to win at life in suburban northern England. These lads have dealt, scammed, bribed and conned their way through adolescence, but now, their dealing and\u2026", "poster": "assets/tv-brassic-2019-poster.jpg", "backdrop": "assets/tv-brassic-2019-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 92621, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.336, "tmdbVotes": 263}}], "discoverySource": {"state": "materialized_snapshot", "rankKnown": false, "selection": "First 18 alphabetically indexed movie stubs and first 18 series stubs, not ranked feed order", "observedAt": "2026-11-04T20:42:12.030108+00:00"}}, {"key": "shared", "name": "Shared", "initial": "S", "color": "#9be38f", "destinations": {"Home": {"kind": "home"}, "Movies": {"kind": "library", "collection": "movies"}, "Shows": {"kind": "library", "collection": "shows"}, "Discover": {"kind": "discover", "collection": "discover"}}, "movies": [{"id": "movie-american-gangster-2007", "title": "American Gangster", "year": 2007, "type": "movie", "runtimeSeconds": 10546, "seasonCount": null, "communityRating": 7.595, "criticRating": 81, "genres": ["Drama", "Crime"], "overview": "Loosely based on the criminal career of Frank Lucas, a gangster from La Grange, North Carolina, who smuggled heroin into the United States on American service planes returning from the Vietnam War, before being\u2026", "poster": "assets/movie-american-gangster-2007-poster.jpg", "backdrop": "assets/movie-american-gangster-2007-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-08-23T10:22:36.0000000Z", "lastPlayedAt": "2026-08-30T21:05:39.7952451Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 4982, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 81, "rtCriticsLabel": "Certified Fresh", "rtAudience": 90, "rtAudienceLabel": "Upright", "imdb": 7.8, "imdbVotes": 495656, "tmdb": 7.599, "tmdbVotes": 6350}}, {"id": "movie-annie-hall-1977", "title": "Annie Hall", "year": 1977, "type": "movie", "runtimeSeconds": 5604, "seasonCount": null, "communityRating": 7.712, "criticRating": 97, "genres": ["Comedy", "Drama", "Romance"], "overview": "New York comedian Alvy Singer falls in love with the ditsy Annie Hall.", "poster": "assets/movie-annie-hall-1977-poster.jpg", "backdrop": "assets/movie-annie-hall-1977-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:24:23.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 703, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 97, "rtCriticsLabel": "Certified Fresh", "rtAudience": 92, "rtAudienceLabel": "Upright", "imdb": 7.9, "imdbVotes": 293349, "tmdb": 7.71, "tmdbVotes": 4391}}, {"id": "movie-apocalypse-now-1979", "title": "Apocalypse Now", "year": 1979, "type": "movie", "runtimeSeconds": 8835, "seasonCount": null, "communityRating": 8.261, "criticRating": 91, "genres": ["Drama", "War"], "overview": "At the height of the Vietnam war, Captain Benjamin Willard is sent on a dangerous mission that, officially, \"does not exist, nor will it ever exist.\" His goal is to locate - and eliminate - a mysterious Green Beret\u2026", "poster": "assets/movie-apocalypse-now-1979-poster.jpg", "backdrop": "assets/movie-apocalypse-now-1979-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:22:25.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 28, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 91, "rtCriticsLabel": "Certified Fresh", "rtAudience": 94, "rtAudienceLabel": "Upright", "imdb": 8.4, "imdbVotes": 781424, "tmdb": 8.3, "tmdbVotes": 9526}}, {"id": "movie-arrival-2016", "title": "Arrival", "year": 2016, "type": "movie", "runtimeSeconds": 6967, "seasonCount": null, "communityRating": 7.636, "criticRating": 94, "genres": ["Drama", "Science Fiction", "Mystery"], "overview": "Taking place after alien crafts land around the world, an expert linguist is recruited by the military to determine whether they come in peace or are a threat.", "poster": "assets/movie-arrival-2016-poster.jpg", "backdrop": "assets/movie-arrival-2016-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-14T19:54:12.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 329865, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 94, "rtCriticsLabel": "Certified Fresh", "rtAudience": 83, "rtAudienceLabel": "Upright", "imdb": 7.9, "imdbVotes": 883621, "tmdb": 7.638, "tmdbVotes": 20125}}, {"id": "movie-avatar-aang-the-last-airbender-2026", "title": "Avatar Aang: The Last Airbender", "year": 2026, "type": "movie", "runtimeSeconds": 5930, "seasonCount": null, "communityRating": 9.147, "criticRating": 90, "genres": ["Animation", "Adventure", "Fantasy"], "overview": "Avatar Aang, the world's last Airbender, learns of an ancient power that could save his culture from extinction. With the help of his friends, he embarks on a global quest to find it before it falls into the wrong\u2026", "poster": "assets/movie-avatar-aang-the-last-airbender-2026-poster.jpg", "backdrop": "assets/movie-avatar-aang-the-last-airbender-2026-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-25T09:53:09.0000000Z", "lastPlayedAt": "2026-08-02T19:22:33.6155383Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 980431, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 90, "rtCriticsLabel": "Certified Fresh", "rtAudience": 99, "rtAudienceLabel": "Upright", "imdb": 7.7, "imdbVotes": 30792, "tmdb": 9, "tmdbVotes": 1417}}, {"id": "movie-bohemian-rhapsody-2018", "title": "Bohemian Rhapsody", "year": 2018, "type": "movie", "runtimeSeconds": 8077, "seasonCount": null, "communityRating": 7.95, "criticRating": 60, "genres": ["Music", "Drama"], "overview": "Singer Freddie Mercury, guitarist Brian May, drummer Roger Taylor and bass guitarist John Deacon take the music world by storm when they form the rock 'n' roll band Queen in 1970. Hit songs become instant classics.\u2026", "poster": "assets/movie-bohemian-rhapsody-2018-poster.jpg", "backdrop": "assets/movie-bohemian-rhapsody-2018-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:28:20.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 424694, "artwork": {"posterKind": "center_crop", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 60, "rtCriticsLabel": "Fresh", "rtAudience": 85, "rtAudienceLabel": "Upright", "imdb": 7.9, "imdbVotes": 655894, "tmdb": 7.947, "tmdbVotes": 18196}}, {"id": "movie-the-boy-and-the-heron-2023", "title": "The Boy and the Heron", "year": 2023, "type": "movie", "runtimeSeconds": 7437, "seasonCount": null, "communityRating": 7.4, "criticRating": 96, "genres": ["Animation", "Fantasy", "Drama"], "overview": "While the Second World War rages, the teenage Mahito, haunted by his mother's tragic death, is relocated from Tokyo to the serene rural home of his new stepmother Natsuko, a woman who bears a striking resemblance to\u2026", "poster": "assets/movie-the-boy-and-the-heron-2023-poster.jpg", "backdrop": "assets/movie-the-boy-and-the-heron-2023-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:25:15.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 508883, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 96, "rtCriticsLabel": "Certified Fresh", "rtAudience": 88, "rtAudienceLabel": "Upright", "imdb": 7.3, "imdbVotes": 152169, "tmdb": 7.387, "tmdbVotes": 2893}}, {"id": "movie-braveheart-1995", "title": "Braveheart", "year": 1995, "type": "movie", "runtimeSeconds": 10665, "seasonCount": null, "communityRating": 7.945, "criticRating": 74, "genres": ["Drama", "History", "War"], "overview": "Enraged at the slaughter of Murron, his new bride and childhood love, Scottish warrior William Wallace slays a platoon of the local English lord's soldiers. This leads the village to revolt and, eventually, the\u2026", "poster": "assets/movie-braveheart-1995-poster.jpg", "backdrop": "assets/movie-braveheart-1995-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-10T21:24:28.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 197, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 74, "rtCriticsLabel": "Certified Fresh", "rtAudience": 85, "rtAudienceLabel": "Upright", "imdb": 8.3, "imdbVotes": 1174990, "tmdb": 7.947, "tmdbVotes": 11584}}, {"id": "movie-the-breakfast-club-1985", "title": "The Breakfast Club", "year": 1985, "type": "movie", "runtimeSeconds": 5833, "seasonCount": null, "communityRating": 7.7, "criticRating": 87, "genres": ["Comedy", "Drama"], "overview": "Five high school students from different walks of life endure a Saturday detention under a power-hungry principal. The disparate group includes rebel John, princess Claire, outcast Allison, brainy Brian and Andrew,\u2026", "poster": "assets/movie-the-breakfast-club-1985-poster.jpg", "backdrop": "assets/movie-the-breakfast-club-1985-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:25:03.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 2108, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 87, "rtCriticsLabel": "Certified Fresh", "rtAudience": 92, "rtAudienceLabel": "Upright", "imdb": 7.8, "imdbVotes": 478743, "tmdb": 7.713, "tmdbVotes": 8815}}, {"id": "movie-castle-in-the-sky-1986", "title": "Castle in the Sky", "year": 1986, "type": "movie", "runtimeSeconds": 7474, "seasonCount": null, "communityRating": 8, "criticRating": 97, "genres": ["Adventure", "Fantasy", "Animation"], "overview": "A young boy and a girl with a magic crystal must race against pirates and foreign agents in a search for a legendary floating castle.", "poster": "assets/movie-castle-in-the-sky-1986-poster.jpg", "backdrop": "assets/movie-castle-in-the-sky-1986-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:23:09.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 10515, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 96, "rtCriticsLabel": "Fresh", "rtAudience": 91, "rtAudienceLabel": "Upright", "imdb": 8, "imdbVotes": 201545, "tmdb": 7.972, "tmdbVotes": 4868}}, {"id": "movie-citizen-kane-1941", "title": "Citizen Kane", "year": 1941, "type": "movie", "runtimeSeconds": 7173, "seasonCount": null, "communityRating": 7.966, "criticRating": 99, "genres": ["Mystery", "Drama"], "overview": "Newspaper magnate Charles Foster Kane is taken from his mother as a boy and made the ward of a rich industrialist. As a result, every well-meaning, tyrannical or self-destructive move he makes for the rest of his\u2026", "poster": "assets/movie-citizen-kane-1941-poster.jpg", "backdrop": "assets/movie-citizen-kane-1941-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:22:16.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 15, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 99, "rtCriticsLabel": "Certified Fresh", "rtAudience": 98, "rtAudienceLabel": "Upright", "imdb": 8.2, "imdbVotes": 498321, "tmdb": 7.962, "tmdbVotes": 6324}}, {"id": "movie-conclave-2024", "title": "Conclave", "year": 2024, "type": "movie", "runtimeSeconds": 7230, "seasonCount": null, "communityRating": 7.19, "criticRating": 93, "genres": ["Drama", "Thriller"], "overview": "After the unexpected death of the Pope, Cardinal Lawrence is tasked with managing the covert and ancient ritual of electing a new one. Sequestered in the Vatican with the Catholic Church\u2019s most powerful leaders until\u2026", "poster": "assets/movie-conclave-2024-poster.jpg", "backdrop": "assets/movie-conclave-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:30:41.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 974576, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 93, "rtCriticsLabel": "Certified Fresh", "rtAudience": 86, "rtAudienceLabel": "Upright", "imdb": 7.4, "imdbVotes": 290498, "tmdb": 7.19, "tmdbVotes": 3744}}, {"id": "movie-the-dry-2021", "title": "The Dry", "year": 2021, "type": "movie", "runtimeSeconds": 7037, "seasonCount": null, "communityRating": 6.779, "criticRating": null, "genres": ["Crime", "Thriller", "Mystery"], "overview": "Aaron Falk returns to his drought-stricken hometown to attend a tragic funeral. But his return opens a decades-old wound - the unsolved death of a teenage girl.", "poster": "assets/movie-the-dry-2021-poster.jpg", "backdrop": "assets/movie-the-dry-2021-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:29:20.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 567797, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 90, "rtCriticsLabel": "Certified Fresh", "rtAudience": 89, "rtAudienceLabel": "Upright", "imdb": 6.8, "imdbVotes": 41935, "tmdb": 6.8, "tmdbVotes": 892}}, {"id": "movie-easy-a-2010", "title": "Easy A", "year": 2010, "type": "movie", "runtimeSeconds": 5545, "seasonCount": null, "communityRating": 6.8, "criticRating": 85, "genres": ["Comedy"], "overview": "Olive, an average high school student, sees her below-the-radar existence turn around overnight once she decides to use the school's gossip grapevine to advance her social standing. Now her classmates are turning\u2026", "poster": "assets/movie-easy-a-2010-poster.jpg", "backdrop": "assets/movie-easy-a-2010-backdrop.jpg", "played": true, "playCount": 1, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-17T19:59:04.0000000Z", "lastPlayedAt": "2026-10-17T20:02:51.1083337Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 37735, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 85, "rtCriticsLabel": "Certified Fresh", "rtAudience": 77, "rtAudienceLabel": "Upright", "imdb": 7, "imdbVotes": 448780, "tmdb": 6.8, "tmdbVotes": 7619}}, {"id": "movie-el-camino-a-breaking-bad-movie-2019", "title": "El Camino: A Breaking Bad Movie", "year": 2019, "type": "movie", "runtimeSeconds": 7362, "seasonCount": null, "communityRating": 7.055, "criticRating": 92, "genres": ["Crime", "Drama", "Thriller"], "overview": "In the wake of his dramatic escape from captivity, Jesse Pinkman must come to terms with his past in order to forge some kind of future.", "poster": "assets/movie-el-camino-a-breaking-bad-movie-2019-poster.jpg", "backdrop": "assets/movie-el-camino-a-breaking-bad-movie-2019-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-21T23:31:11.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 559969, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 92, "rtCriticsLabel": "Certified Fresh", "rtAudience": 81, "rtAudienceLabel": "Upright", "imdb": 7.3, "imdbVotes": 354488, "tmdb": 7.056, "tmdbVotes": 5950}}, {"id": "movie-emancipation-2022", "title": "Emancipation", "year": 2022, "type": "movie", "runtimeSeconds": 7949, "seasonCount": null, "communityRating": 7.7, "criticRating": 45, "genres": ["Drama", "War", "History"], "overview": "Inspired by the gripping true story of a man who would do anything for his family\u2014and for freedom. When Peter, an enslaved man, risks his life to escape and return to his family, he embarks on a perilous journey of\u2026", "poster": "assets/movie-emancipation-2022-poster.jpg", "backdrop": "assets/movie-emancipation-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-10T20:37:34.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 715931, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 45, "rtCriticsLabel": "Rotten", "rtAudience": 58, "rtAudienceLabel": "Spilled", "imdb": 6.3, "imdbVotes": 32079, "tmdb": 7.744, "tmdbVotes": 1319}}, {"id": "movie-fighting-with-my-family-2019", "title": "Fighting with My Family", "year": 2019, "type": "movie", "runtimeSeconds": 6509, "seasonCount": null, "communityRating": 6.84, "criticRating": 93, "genres": ["Comedy"], "overview": "Born into a tight-knit wrestling family, Paige and her brother Zak are ecstatic when they get the once-in-a-lifetime opportunity to try out for the WWE. But when only Paige earns a spot in the competitive training\u2026", "poster": "assets/movie-fighting-with-my-family-2019-poster.jpg", "backdrop": "assets/movie-fighting-with-my-family-2019-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-22T22:10:59.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 445629, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 93, "rtCriticsLabel": "Certified Fresh", "rtAudience": 86, "rtAudienceLabel": "Upright", "imdb": 7, "imdbVotes": 94576, "tmdb": 6.837, "tmdbVotes": 1952}}, {"id": "movie-force-of-nature-the-dry-2-2024", "title": "Force of Nature: The Dry 2", "year": 2024, "type": "movie", "runtimeSeconds": 6753, "seasonCount": null, "communityRating": 6.3, "criticRating": 66, "genres": ["Mystery", "Thriller"], "overview": "When five women take part in a corporate hiking retreat and only four come out on the other side, Federal Agents Aaron Falk and Carmen Cooper head deep into the Victorian mountain ranges to investigate in the hopes\u2026", "poster": "assets/movie-force-of-nature-the-dry-2-2024-poster.jpg", "backdrop": "assets/movie-force-of-nature-the-dry-2-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:29:49.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 832262, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 66, "rtCriticsLabel": "Fresh", "rtAudience": 43, "rtAudienceLabel": "Spilled", "imdb": 5.9, "imdbVotes": 10343, "tmdb": 6.269, "tmdbVotes": 305}}, {"id": "movie-free-state-of-jones-2016", "title": "Free State of Jones", "year": 2016, "type": "movie", "runtimeSeconds": 8376, "seasonCount": null, "communityRating": 6.79, "criticRating": 48, "genres": ["Action", "Drama", "History"], "overview": "In 1863, Mississippi farmer Newt Knight serves as a medic for the Confederate Army. Opposed to slavery, Knight would rather help the wounded than fight the Union. After his nephew dies in battle, Newt returns home to\u2026", "poster": "assets/movie-free-state-of-jones-2016-poster.jpg", "backdrop": "assets/movie-free-state-of-jones-2016-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-11T17:56:53.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 316152, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 48, "rtCriticsLabel": "Rotten", "rtAudience": 64, "rtAudienceLabel": "Upright", "imdb": 6.9, "imdbVotes": 68931, "tmdb": 6.788, "tmdbVotes": 1730}}, {"id": "movie-from-up-on-poppy-hill-2011", "title": "From Up on Poppy Hill", "year": 2011, "type": "movie", "runtimeSeconds": 5482, "seasonCount": null, "communityRating": 7.5, "criticRating": 87, "genres": ["Animation", "Drama"], "overview": "Yokohama, 1963. Japan is picking itself up from the devastation of World War II and preparing to host the 1964 Olympics\u2014and the mood is one of both optimism and conflict as the young generation struggles to throw off\u2026", "poster": "assets/movie-from-up-on-poppy-hill-2011-poster.jpg", "backdrop": "assets/movie-from-up-on-poppy-hill-2011-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-07T14:41:03.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 83389, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 87, "rtCriticsLabel": "Certified Fresh", "rtAudience": 80, "rtAudienceLabel": "Upright", "imdb": 7.4, "imdbVotes": 60769, "tmdb": 7.509, "tmdbVotes": 2026}}, {"id": "movie-ghost-in-the-shell-1995", "title": "Ghost in the Shell", "year": 1995, "type": "movie", "runtimeSeconds": 4959, "seasonCount": null, "communityRating": 7.929, "criticRating": 95, "genres": ["Action", "Animation", "Science Fiction"], "overview": "In the year 2029, the barriers of our world have been broken down by the net and by cybernetics, but this brings new vulnerability to humans in the form of brain-hacking. When a highly-wanted hacker known as 'The\u2026", "poster": "assets/movie-ghost-in-the-shell-1995-poster.jpg", "backdrop": "assets/movie-ghost-in-the-shell-1995-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:22:57.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 9323, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 97, "rtCriticsLabel": "Certified Fresh", "rtAudience": 90, "rtAudienceLabel": "Upright", "imdb": 7.9, "imdbVotes": 178061, "tmdb": 7.928, "tmdbVotes": 4130}}, {"id": "movie-ghost-in-the-shell-2-innocence-2004", "title": "Ghost in the Shell 2: Innocence", "year": 2004, "type": "movie", "runtimeSeconds": 5950, "seasonCount": null, "communityRating": 7.352, "criticRating": 65, "genres": ["Animation", "Drama", "Science Fiction"], "overview": "Cyborg detective Batou is assigned to investigate a series of murders committed by gynoids\u2014doll-like cyborgs, which all malfunctioned, killed, then self-destructed afterwards. The brains of the gynoids initialize in\u2026", "poster": "assets/movie-ghost-in-the-shell-2-innocence-2004-poster.jpg", "backdrop": "assets/movie-ghost-in-the-shell-2-innocence-2004-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:23:38.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 12140, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 65, "rtCriticsLabel": "Fresh", "rtAudience": 80, "rtAudienceLabel": "Upright", "imdb": 7.4, "imdbVotes": 43280, "tmdb": 7.356, "tmdbVotes": 1049}}, {"id": "movie-the-godfather-1972", "title": "The Godfather", "year": 1972, "type": "movie", "runtimeSeconds": 10603, "seasonCount": null, "communityRating": 8.687, "criticRating": 97, "genres": ["Drama", "Crime"], "overview": "Spanning the years 1945 to 1955, a chronicle of the fictional Italian-American Corleone crime family. When organized crime family patriarch, Vito Corleone barely survives an attempt on his life, his youngest son,\u2026", "poster": "assets/movie-the-godfather-1972-poster.jpg", "backdrop": "assets/movie-the-godfather-1972-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:24:12.0000000Z", "lastPlayedAt": "2026-09-18T19:24:44.9855867Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 238, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 97, "rtCriticsLabel": "Certified Fresh", "rtAudience": 98, "rtAudienceLabel": "Upright", "imdb": 9.2, "imdbVotes": 2262024, "tmdb": 8.688, "tmdbVotes": 23907}}, {"id": "movie-the-godfather-part-ii-1974", "title": "The Godfather Part II", "year": 1974, "type": "movie", "runtimeSeconds": 12031, "seasonCount": null, "communityRating": 8.573, "criticRating": 96, "genres": ["Drama", "Crime"], "overview": "In the continuing saga of the Corleone crime family, a young Vito Corleone grows up in Sicily and in 1910s New York. In the 1950s, Michael Corleone attempts to expand the family business into Las Vegas, Hollywood and\u2026", "poster": "assets/movie-the-godfather-part-ii-1974-poster.jpg", "backdrop": "assets/movie-the-godfather-part-ii-1974-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:24:18.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 240, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 96, "rtCriticsLabel": "Certified Fresh", "rtAudience": 97, "rtAudienceLabel": "Upright", "imdb": 9, "imdbVotes": 1521308, "tmdb": 8.571, "tmdbVotes": 14559}}, {"id": "movie-the-godfather-part-iii-1990", "title": "The Godfather Part III", "year": 1990, "type": "movie", "runtimeSeconds": 9476, "seasonCount": null, "communityRating": 7.416, "criticRating": 67, "genres": ["Crime", "Drama", "Thriller"], "overview": "In the midst of trying to legitimize his business dealings in 1979 New York and Italy, aging mafia don, Michael Corleone seeks forgiveness for his sins while taking a young protege under his wing.", "poster": "assets/movie-the-godfather-part-iii-1990-poster.jpg", "backdrop": "assets/movie-the-godfather-part-iii-1990-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:26:29.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 242, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 68, "rtCriticsLabel": "Fresh", "rtAudience": 68, "rtAudienceLabel": "Upright", "imdb": 7.5, "imdbVotes": 462448, "tmdb": 7.413, "tmdbVotes": 7159}}, {"id": "movie-gosford-park-2001", "title": "Gosford Park", "year": 2001, "type": "movie", "runtimeSeconds": 8247, "seasonCount": null, "communityRating": 6.815, "criticRating": 87, "genres": ["Drama", "Mystery", "Thriller"], "overview": "In 1930s England, a group of pretentious rich and famous gather together for a weekend of relaxation at a hunting resort. But when a murder occurs, each one of these interesting characters becomes a suspect.", "poster": "assets/movie-gosford-park-2001-poster.jpg", "backdrop": "assets/movie-gosford-park-2001-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:25:49.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 5279, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 87, "rtCriticsLabel": "Certified Fresh", "rtAudience": 78, "rtAudienceLabel": "Upright", "imdb": 7.2, "imdbVotes": 103883, "tmdb": 6.822, "tmdbVotes": 1317}}, {"id": "movie-grave-of-the-fireflies-1988", "title": "Grave of the Fireflies", "year": 1988, "type": "movie", "runtimeSeconds": 5313, "seasonCount": null, "communityRating": 8.45, "criticRating": 100, "genres": ["Animation", "Drama", "War"], "overview": "In the final months of World War II, 14-year-old Seita and his sister Setsuko are orphaned when their mother is killed during an air raid in Kobe, Japan. After a falling out with their aunt, they move into an\u2026", "poster": "assets/movie-grave-of-the-fireflies-1988-poster.jpg", "backdrop": "assets/movie-grave-of-the-fireflies-1988-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:24:14.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 12477, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Certified Fresh", "rtAudience": 95, "rtAudienceLabel": "Upright", "imdb": 8.5, "imdbVotes": 399802, "tmdb": 8.45, "tmdbVotes": 6938}}, {"id": "movie-the-great-gatsby-2013", "title": "The Great Gatsby", "year": 2013, "type": "movie", "runtimeSeconds": 8525, "seasonCount": null, "communityRating": 7.346, "criticRating": 49, "genres": ["Drama", "Romance"], "overview": "Nick Carraway arrives in New York in 1922, an era of loose morals, glittering jazz and bootleg kings. Chasing his own American Dream, Nick encounters the mysterious millionaire Gatsby and his bewitching cousin Daisy.\u2026", "poster": "assets/movie-the-great-gatsby-2013-poster.jpg", "backdrop": "assets/movie-the-great-gatsby-2013-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:26:50.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 64682, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 48, "rtCriticsLabel": "Rotten", "rtAudience": 67, "rtAudienceLabel": "Upright", "imdb": 7.2, "imdbVotes": 651882, "tmdb": 7.346, "tmdbVotes": 13290}}, {"id": "movie-the-hours-2002", "title": "The Hours", "year": 2002, "type": "movie", "runtimeSeconds": 6887, "seasonCount": null, "communityRating": 7.25, "criticRating": 79, "genres": ["Drama"], "overview": "The story of three women searching for more potent, meaningful lives. Each is alive at a different time and place, all are linked by their yearnings and their fears. Their stories intertwine, and finally come\u2026", "poster": "assets/movie-the-hours-2002-poster.jpg", "backdrop": "assets/movie-the-hours-2002-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:23:27.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 590, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 79, "rtCriticsLabel": "Certified Fresh", "rtAudience": 84, "rtAudienceLabel": "Upright", "imdb": 7.5, "imdbVotes": 148367, "tmdb": 7.249, "tmdbVotes": 1931}}, {"id": "movie-howl-s-moving-castle-2004", "title": "Howl's Moving Castle", "year": 2004, "type": "movie", "runtimeSeconds": 7149, "seasonCount": null, "communityRating": 8.4, "criticRating": 88, "genres": ["Fantasy", "Animation", "Adventure"], "overview": "Sophie, a young milliner, is turned into an elderly woman by a witch who enters her shop and curses her. She encounters a wizard named Howl and gets caught up in his resistance to fighting for the king.", "poster": "assets/movie-howl-s-moving-castle-2004-poster.jpg", "backdrop": "assets/movie-howl-s-moving-castle-2004-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:22:47.0000000Z", "lastPlayedAt": "2026-05-21T18:46:08.0000000Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 4935, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 87, "rtCriticsLabel": "Certified Fresh", "rtAudience": 93, "rtAudienceLabel": "Upright", "imdb": 8.2, "imdbVotes": 524520, "tmdb": 8.391, "tmdbVotes": 11642}}, {"id": "movie-in-the-heights-2021", "title": "In the Heights", "year": 2021, "type": "movie", "runtimeSeconds": 8579, "seasonCount": null, "communityRating": 7.111, "criticRating": null, "genres": ["Drama", "Romance"], "overview": "The story of Usnavi, a bodega owner who has mixed feelings about closing his store and retiring to the Dominican Republic or staying in Washington Heights.", "poster": "assets/movie-in-the-heights-2021-poster.jpg", "backdrop": "assets/movie-in-the-heights-2021-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:29:11.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 467909, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 94, "rtCriticsLabel": "Certified Fresh", "rtAudience": 94, "rtAudienceLabel": "Upright", "imdb": 7.2, "imdbVotes": 56513, "tmdb": 7.104, "tmdbVotes": 826}}, {"id": "movie-jennifer-s-body-2009", "title": "Jennifer's Body", "year": 2009, "type": "movie", "runtimeSeconds": 6450, "seasonCount": null, "communityRating": 6.012, "criticRating": 47, "genres": ["Horror", "Comedy"], "overview": "Jennifer, a gorgeous, seductive cheerleader takes evil to a whole new level after she's possessed by a sinister demon. Now it's up to her best friend to stop Jennifer's reign of terror before it's too late.", "poster": "assets/movie-jennifer-s-body-2009-poster.jpg", "backdrop": "assets/movie-jennifer-s-body-2009-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-30T03:37:04.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 19994, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 47, "rtCriticsLabel": "Rotten", "rtAudience": 36, "rtAudienceLabel": "Spilled", "imdb": 5.6, "imdbVotes": 179710, "tmdb": 6.013, "tmdbVotes": 4481}}, {"id": "movie-kiki-s-delivery-service-1989", "title": "Kiki's Delivery Service", "year": 1989, "type": "movie", "runtimeSeconds": 6183, "seasonCount": null, "communityRating": 7.839, "criticRating": null, "genres": ["Animation", "Family", "Fantasy"], "overview": "A young witch, on her mandatory year of independent life, finds fitting into a new community difficult while she supports herself by running an air courier service.", "poster": "assets/movie-kiki-s-delivery-service-1989-poster.jpg", "backdrop": "assets/movie-kiki-s-delivery-service-1989-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:24:20.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 16859, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 98, "rtCriticsLabel": "Certified Fresh", "rtAudience": 89, "rtAudienceLabel": "Upright", "imdb": 7.8, "imdbVotes": 192477, "tmdb": 7.839, "tmdbVotes": 4811}}, {"id": "movie-the-last-of-the-mohicans-1992", "title": "The Last of the Mohicans", "year": 1992, "type": "movie", "runtimeSeconds": 6874, "seasonCount": null, "communityRating": 7.388, "criticRating": 88, "genres": ["History", "War", "Drama"], "overview": "In war-torn colonial America, in the midst of a bloody battle between British, the French and Native American allies, the aristocratic daughter of a British Colonel and her party are captured by a group of Huron\u2026", "poster": "assets/movie-the-last-of-the-mohicans-1992-poster.jpg", "backdrop": "assets/movie-the-last-of-the-mohicans-1992-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-10T20:59:22.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 9361, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 88, "rtCriticsLabel": "Certified Fresh", "rtAudience": 88, "rtAudienceLabel": "Upright", "imdb": 7.6, "imdbVotes": 210791, "tmdb": 7.39, "tmdbVotes": 3593}}, {"id": "movie-the-last-samurai-2003", "title": "The Last Samurai", "year": 2003, "type": "movie", "runtimeSeconds": 9251, "seasonCount": null, "communityRating": 7.61, "criticRating": 65, "genres": ["Drama", "Action", "War"], "overview": "Nathan Algren is an American hired to instruct the Japanese army in the ways of modern warfare, which finds him learning to respect the samurai and the honorable principles that rule them. Pressed to destroy the\u2026", "poster": "assets/movie-the-last-samurai-2003-poster.jpg", "backdrop": "assets/movie-the-last-samurai-2003-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-26T05:25:10.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 616, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 66, "rtCriticsLabel": "Fresh", "rtAudience": 83, "rtAudienceLabel": "Upright", "imdb": 7.8, "imdbVotes": 512982, "tmdb": 7.614, "tmdbVotes": 7887}}, {"id": "movie-lemony-snicket-s-a-series-of-unfortunate-events-2004", "title": "Lemony Snicket's A Series of Unfortunate Events", "year": 2004, "type": "movie", "runtimeSeconds": 6479, "seasonCount": null, "communityRating": 6.8, "criticRating": 72, "genres": ["Adventure", "Comedy", "Family"], "overview": "Three wealthy children's parents are killed in a fire. When they are sent to a distant relative, they find out that he is plotting to kill them and seize their fortune.", "poster": "assets/movie-lemony-snicket-s-a-series-of-unfortunate-events-2004-poster.jpg", "backdrop": "assets/movie-lemony-snicket-s-a-series-of-unfortunate-events-2004-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:26:09.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 11774, "artwork": {"posterKind": "center_crop", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 72, "rtCriticsLabel": "Fresh", "rtAudience": 63, "rtAudienceLabel": "Upright", "imdb": 6.8, "imdbVotes": 233636, "tmdb": 6.82, "tmdbVotes": 5447}}, {"id": "movie-the-lighthouse-2019", "title": "The Lighthouse", "year": 2019, "type": "movie", "runtimeSeconds": 6549, "seasonCount": null, "communityRating": 7.463, "criticRating": 90, "genres": ["Drama", "Fantasy", "Thriller"], "overview": "Two lighthouse keepers try to maintain their sanity while living on a remote and mysterious New England island in the 1890s.", "poster": "assets/movie-the-lighthouse-2019-poster.jpg", "backdrop": "assets/movie-the-lighthouse-2019-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:29:01.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 503919, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 90, "rtCriticsLabel": "Certified Fresh", "rtAudience": 72, "rtAudienceLabel": "Upright", "imdb": 7.3, "imdbVotes": 312344, "tmdb": 7.46, "tmdbVotes": 5895}}, {"id": "movie-locke-2014", "title": "Locke", "year": 2014, "type": "movie", "runtimeSeconds": 5090, "seasonCount": null, "communityRating": 6.888, "criticRating": 91, "genres": ["Drama", "Thriller"], "overview": "Ivan Locke has worked hard to craft a good life for himself. Tonight, that life will collapse around him. On the eve of the biggest challenge of his career, Ivan receives a phone call that sets in motion a series of\u2026", "poster": "assets/movie-locke-2014-poster.jpg", "backdrop": "assets/movie-locke-2014-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T17:38:48.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 210479, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 90, "rtCriticsLabel": "Certified Fresh", "rtAudience": 72, "rtAudienceLabel": "Upright", "imdb": 7.1, "imdbVotes": 171265, "tmdb": 6.889, "tmdbVotes": 3088}}, {"id": "movie-the-matrix-1999", "title": "The Matrix", "year": 1999, "type": "movie", "runtimeSeconds": 8179, "seasonCount": null, "communityRating": 8.259, "criticRating": 83, "genres": ["Action", "Science Fiction"], "overview": "Set in the 22nd century, The Matrix tells the story of a computer hacker who joins a group of underground insurgents fighting the vast and powerful computers who now rule the earth.", "poster": "assets/movie-the-matrix-1999-poster.jpg", "backdrop": "assets/movie-the-matrix-1999-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:18:12.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 603, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 83, "rtCriticsLabel": "Certified Fresh", "rtAudience": 85, "rtAudienceLabel": "Upright", "imdb": 8.7, "imdbVotes": 2289808, "tmdb": 8.262, "tmdbVotes": 29153}}, {"id": "movie-the-matrix-reloaded-2003", "title": "The Matrix Reloaded", "year": 2003, "type": "movie", "runtimeSeconds": 8298, "seasonCount": null, "communityRating": 7.085, "criticRating": 74, "genres": ["Adventure", "Action", "Thriller"], "overview": "The Resistance builds in numbers as humans are freed from the Matrix and brought to the city of Zion. Neo discovers his superpowers, including the ability to see the code inside the Matrix. With machine sentinels\u2026", "poster": "assets/movie-the-matrix-reloaded-2003-poster.jpg", "backdrop": "assets/movie-the-matrix-reloaded-2003-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:24:03.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 604, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 73, "rtCriticsLabel": "Certified Fresh", "rtAudience": 72, "rtAudienceLabel": "Upright", "imdb": 7.2, "imdbVotes": 678013, "tmdb": 7.087, "tmdbVotes": 12504}}, {"id": "movie-the-matrix-resurrections-2021", "title": "The Matrix Resurrections", "year": 2021, "type": "movie", "runtimeSeconds": 8875, "seasonCount": null, "communityRating": 6.303, "criticRating": 63, "genres": ["Science Fiction", "Action", "Adventure"], "overview": "Plagued by strange memories, Neo's life takes an unexpected turn when he finds himself back inside the Matrix.", "poster": "assets/movie-the-matrix-resurrections-2021-poster.jpg", "backdrop": "assets/movie-the-matrix-resurrections-2021-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:31:22.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 624860, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 63, "rtCriticsLabel": "Fresh", "rtAudience": 63, "rtAudienceLabel": "Upright", "imdb": 5.6, "imdbVotes": 307780, "tmdb": 6.294, "tmdbVotes": 6842}}, {"id": "movie-the-matrix-revolutions-2003", "title": "The Matrix Revolutions", "year": 2003, "type": "movie", "runtimeSeconds": 7756, "seasonCount": null, "communityRating": 6.758, "criticRating": 33, "genres": ["Adventure", "Action", "Thriller"], "overview": "The human city of Zion defends itself against the massive invasion of the machines as Neo fights to end the war at another front while also opposing the rogue Agent Smith.", "poster": "assets/movie-the-matrix-revolutions-2003-poster.jpg", "backdrop": "assets/movie-the-matrix-revolutions-2003-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:25:47.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 605, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 34, "rtCriticsLabel": "Rotten", "rtAudience": 60, "rtAudienceLabel": "Upright", "imdb": 6.8, "imdbVotes": 581553, "tmdb": 6.762, "tmdbVotes": 11292}}, {"id": "movie-mercy-2026", "title": "Mercy", "year": 2026, "type": "movie", "runtimeSeconds": 5968, "seasonCount": null, "communityRating": 7.077, "criticRating": null, "genres": ["Science Fiction", "Thriller", "Crime"], "overview": "In the near future, a detective stands on trial accused of murdering his wife. He has ninety minutes to prove his innocence to the advanced AI Judge he once championed, before it determines his fate.", "poster": "assets/movie-mercy-2026-poster.jpg", "backdrop": "assets/movie-mercy-2026-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:31:40.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 1236153, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 24, "rtCriticsLabel": "Rotten", "rtAudience": 81, "rtAudienceLabel": "Upright", "imdb": 6.2, "imdbVotes": 83878, "tmdb": 7.054, "tmdbVotes": 1812}}, {"id": "movie-normal-2026", "title": "Normal", "year": 2026, "type": "movie", "runtimeSeconds": 5514, "seasonCount": null, "communityRating": 7.267, "criticRating": 77, "genres": ["Action", "Crime", "Thriller"], "overview": "An unassuming substitute sheriff with a troubled past responds to a bank robbery in a small, sleepy town and unknowingly uncovers something far more explosive.", "poster": "assets/movie-normal-2026-poster.jpg", "backdrop": "assets/movie-normal-2026-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-08-21T08:33:18.0000000Z", "lastPlayedAt": "2026-09-24T23:07:52.1161581Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 1242332, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 78, "rtCriticsLabel": "Certified Fresh", "rtAudience": 75, "rtAudienceLabel": "Upright", "imdb": 6.3, "imdbVotes": 37501, "tmdb": 7.241, "tmdbVotes": 786}}, {"id": "movie-outlaw-king-2018", "title": "Outlaw King", "year": 2018, "type": "movie", "runtimeSeconds": 7297, "seasonCount": null, "communityRating": 6.829, "criticRating": 63, "genres": ["Action", "History", "War"], "overview": "Forced into exile by the English after being crowned King of Scotland, legendary warrior Robert the Bruce fights to reclaim the throne.", "poster": "assets/movie-outlaw-king-2018-poster.jpg", "backdrop": "assets/movie-outlaw-king-2018-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:28:32.0000000Z", "lastPlayedAt": "2026-07-26T22:35:35.0000000Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 454227, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 63, "rtCriticsLabel": "Fresh", "rtAudience": 69, "rtAudienceLabel": "Upright", "imdb": 6.9, "imdbVotes": 89876, "tmdb": 6.844, "tmdbVotes": 1780}}, {"id": "movie-panic-room-2002", "title": "Panic Room", "year": 2002, "type": "movie", "runtimeSeconds": 6717, "seasonCount": null, "communityRating": 6.794, "criticRating": 76, "genres": ["Crime", "Drama", "Thriller"], "overview": "Trapped in their New York brownstone's panic room, a hidden chamber built as a sanctuary in the event of break-ins, newly divorced Meg Altman and her young daughter Sarah play a deadly game of cat-and-mouse with\u2026", "poster": "assets/movie-panic-room-2002-poster.jpg", "backdrop": "assets/movie-panic-room-2002-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-19T20:50:01.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 4547, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 76, "rtCriticsLabel": "Certified Fresh", "rtAudience": 64, "rtAudienceLabel": "Upright", "imdb": 6.8, "imdbVotes": 317468, "tmdb": 6.796, "tmdbVotes": 5653}}, {"id": "movie-the-patriot-2000", "title": "The Patriot", "year": 2000, "type": "movie", "runtimeSeconds": 9887, "seasonCount": null, "communityRating": 7.214, "criticRating": 61, "genres": ["Drama", "History", "War"], "overview": "After proving himself on the field of battle in the French and Indian War, Benjamin Martin wants nothing more to do with such things, preferring the simple life of a farmer. But when his son Gabriel enlists in the\u2026", "poster": "assets/movie-the-patriot-2000-poster.jpg", "backdrop": "assets/movie-the-patriot-2000-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-10T20:50:00.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 2024, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 61, "rtCriticsLabel": "Fresh", "rtAudience": 81, "rtAudienceLabel": "Upright", "imdb": 7.2, "imdbVotes": 317639, "tmdb": 7.215, "tmdbVotes": 4535}}, {"id": "movie-peaky-blinders-the-immortal-man-2026", "title": "Peaky Blinders: The Immortal Man", "year": 2026, "type": "movie", "runtimeSeconds": 6844, "seasonCount": null, "communityRating": 7.187, "criticRating": 90, "genres": ["Crime", "Drama"], "overview": "After his estranged son gets embroiled in a Nazi plot, self-exiled gangster Tommy Shelby must return to Birmingham to save his family \u2014 and his nation.", "poster": "assets/movie-peaky-blinders-the-immortal-man-2026-poster.jpg", "backdrop": "assets/movie-peaky-blinders-the-immortal-man-2026-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:30:01.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 875828, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 90, "rtCriticsLabel": "Certified Fresh", "rtAudience": 87, "rtAudienceLabel": "Upright", "imdb": 6.5, "imdbVotes": 92130, "tmdb": 7.189, "tmdbVotes": 1304}}, {"id": "movie-ponyo-2008", "title": "Ponyo", "year": 2008, "type": "movie", "runtimeSeconds": 6530, "seasonCount": null, "communityRating": 7.763, "criticRating": 91, "genres": ["Animation", "Fantasy", "Family"], "overview": "When Sosuke, a young boy who lives on a clifftop overlooking the sea, rescues a stranded goldfish named Ponyo, he discovers more than he bargained for. Ponyo is a curious, energetic young creature who yearns to be\u2026", "poster": "assets/movie-ponyo-2008-poster.jpg", "backdrop": "assets/movie-ponyo-2008-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:23:53.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 12429, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 91, "rtCriticsLabel": "Certified Fresh", "rtAudience": 84, "rtAudienceLabel": "Upright", "imdb": 7.6, "imdbVotes": 190294, "tmdb": 7.759, "tmdbVotes": 5050}}, {"id": "movie-porco-rosso-1992", "title": "Porco Rosso", "year": 1992, "type": "movie", "runtimeSeconds": 5600, "seasonCount": null, "communityRating": 7.794, "criticRating": 96, "genres": ["Animation", "Adventure", "Fantasy"], "overview": "In Italy in the 1930s, sky pirates in biplanes terrorize wealthy cruise ships as they sail the Adriatic Sea. The only pilot brave enough to stop the scourge is the mysterious Porco Rosso, a former World War I flying\u2026", "poster": "assets/movie-porco-rosso-1992-poster.jpg", "backdrop": "assets/movie-porco-rosso-1992-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:23:22.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 11621, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 96, "rtCriticsLabel": "Fresh", "rtAudience": 87, "rtAudienceLabel": "Upright", "imdb": 7.7, "imdbVotes": 119396, "tmdb": 7.789, "tmdbVotes": 3879}}, {"id": "movie-princess-mononoke-1997", "title": "Princess Mononoke", "year": 1997, "type": "movie", "runtimeSeconds": 8003, "seasonCount": null, "communityRating": 8.321, "criticRating": 93, "genres": ["Adventure", "Fantasy", "Animation"], "overview": "Ashitaka, a prince of the disappearing Emishi people, is cursed by a demonized boar god and must journey to the west to find a cure. Along the way, he encounters San, a young human woman fighting to protect the\u2026", "poster": "assets/movie-princess-mononoke-1997-poster.jpg", "backdrop": "assets/movie-princess-mononoke-1997-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:22:28.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 128, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 94, "rtCriticsLabel": "Certified Fresh", "rtAudience": 99, "rtAudienceLabel": "Upright", "imdb": 8.3, "imdbVotes": 493735, "tmdb": 8.321, "tmdbVotes": 9438}}, {"id": "movie-prisoners-2013", "title": "Prisoners", "year": 2013, "type": "movie", "runtimeSeconds": 9192, "seasonCount": null, "communityRating": 8.106, "criticRating": 81, "genres": ["Drama", "Thriller", "Crime"], "overview": "After his six-year-old daughter is abducted, a desperate father takes justice into his own hands when he loses faith in the detective leading the investigation, setting both men on a collision course as the search\u2026", "poster": "assets/movie-prisoners-2013-poster.jpg", "backdrop": "assets/movie-prisoners-2013-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:27:33.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 146233, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 82, "rtCriticsLabel": "Certified Fresh", "rtAudience": 87, "rtAudienceLabel": "Upright", "imdb": 8.2, "imdbVotes": 963062, "tmdb": 8.105, "tmdbVotes": 13722}}, {"id": "movie-racing-stripes-2005", "title": "Racing Stripes", "year": 2005, "type": "movie", "runtimeSeconds": 6117, "seasonCount": null, "communityRating": 5.945, "criticRating": 35, "genres": ["Comedy", "Family"], "overview": "Shattered illusions are hard to repair -- especially for a good-hearted zebra named Stripes who's spent his life on a Kentucky farm amidst the sorely mistaken notion that he's a debonair thoroughbred. Once he faces\u2026", "poster": "assets/movie-racing-stripes-2005-poster.jpg", "backdrop": "assets/movie-racing-stripes-2005-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:26:02.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 6439, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 35, "rtCriticsLabel": "Rotten", "rtAudience": 42, "rtAudienceLabel": "Spilled", "imdb": 5.3, "imdbVotes": 19569, "tmdb": 5.962, "tmdbVotes": 705}}, {"id": "movie-requiem-for-a-dream-2000", "title": "Requiem for a Dream", "year": 2000, "type": "movie", "runtimeSeconds": 6086, "seasonCount": null, "communityRating": 8.005, "criticRating": 80, "genres": ["Crime", "Drama"], "overview": "The drug-induced utopias of four Coney Island residents are shattered when their addictions run deep.", "poster": "assets/movie-requiem-for-a-dream-2000-poster.jpg", "backdrop": "assets/movie-requiem-for-a-dream-2000-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:24:15.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 641, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 81, "rtCriticsLabel": "Certified Fresh", "rtAudience": 93, "rtAudienceLabel": "Upright", "imdb": 8.3, "imdbVotes": 989090, "tmdb": 8, "tmdbVotes": 11454}}, {"id": "movie-rogue-one-a-star-wars-story-2016", "title": "Rogue One: A Star Wars Story", "year": 2016, "type": "movie", "runtimeSeconds": 8038, "seasonCount": null, "communityRating": 7.51, "criticRating": 84, "genres": ["Action", "Adventure", "Science Fiction"], "overview": "A rogue band of resistance fighters unite for a mission to steal the Death Star plans and bring a new hope to the galaxy.", "poster": "assets/movie-rogue-one-a-star-wars-story-2016-poster.jpg", "backdrop": "assets/movie-rogue-one-a-star-wars-story-2016-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:28:00.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 330459, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 84, "rtCriticsLabel": "Certified Fresh", "rtAudience": 87, "rtAudienceLabel": "Upright", "imdb": 7.8, "imdbVotes": 768365, "tmdb": 7.512, "tmdbVotes": 16937}}, {"id": "movie-secondhand-lions-2003", "title": "Secondhand Lions", "year": 2003, "type": "movie", "runtimeSeconds": 6547, "seasonCount": null, "communityRating": 7.237, "criticRating": 60, "genres": ["Family", "Comedy", "Drama"], "overview": "The comedic adventures of an introverted boy left on the doorstep of a pair of reluctant, eccentric great-uncles, whose exotic remembrances stir the boy's spirit and re-ignite the men's lives.", "poster": "assets/movie-secondhand-lions-2003-poster.jpg", "backdrop": "assets/movie-secondhand-lions-2003-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-12T07:16:13.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 13156, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 59, "rtCriticsLabel": "Rotten", "rtAudience": 84, "rtAudienceLabel": "Upright", "imdb": 7.5, "imdbVotes": 66911, "tmdb": 7.242, "tmdbVotes": 872}}, {"id": "movie-the-secret-life-of-pets-2-2019", "title": "The Secret Life of Pets 2", "year": 2019, "type": "movie", "runtimeSeconds": 5164, "seasonCount": null, "communityRating": 6.95, "criticRating": 60, "genres": ["Animation", "Comedy", "Family"], "overview": "Max the terrier must cope with some major life changes when his owner gets married and has a baby. When the family takes a trip to the countryside, nervous Max has numerous run-ins with canine-intolerant cows,\u2026", "poster": "assets/movie-the-secret-life-of-pets-2-2019-poster.jpg", "backdrop": "assets/movie-the-secret-life-of-pets-2-2019-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-05T07:00:53.0000000Z", "lastPlayedAt": "2026-09-17T18:07:00.7024231Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 412117, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 60, "rtCriticsLabel": "Fresh", "rtAudience": 90, "rtAudienceLabel": "Upright", "imdb": 6.4, "imdbVotes": 80464, "tmdb": 6.948, "tmdbVotes": 3450}}, {"id": "movie-self-reliance-2024", "title": "Self Reliance", "year": 2024, "type": "movie", "runtimeSeconds": 5461, "seasonCount": null, "communityRating": 5.888, "criticRating": 73, "genres": ["Comedy", "Thriller"], "overview": "When a man is offered a million dollars to play a game in which hunters try to kill him, he thinks he has found the perfect loophole: they can only attack when he\u2019s alone. His only problem is that none of his friends\u2026", "poster": "assets/movie-self-reliance-2024-poster.jpg", "backdrop": "assets/movie-self-reliance-2024-backdrop.jpg", "played": false, "playCount": 1, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:31:00.0000000Z", "lastPlayedAt": "2026-10-02T19:59:16.9286868Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 1072876, "artwork": {"posterKind": "center_crop", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 73, "rtCriticsLabel": "Certified Fresh", "rtAudience": 56, "rtAudienceLabel": "Spilled", "imdb": 6, "imdbVotes": 23771, "tmdb": 5.866, "tmdbVotes": 346}}, {"id": "movie-serpico-1973", "title": "Serpico", "year": 1973, "type": "movie", "runtimeSeconds": 7805, "seasonCount": null, "communityRating": 7.5, "criticRating": 93, "genres": ["Crime", "Drama", "History"], "overview": "New York cop Frank Serpico blows the whistle on the rampant corruption in the force only to have his comrades turn against him.", "poster": "assets/movie-serpico-1973-poster.jpg", "backdrop": "assets/movie-serpico-1973-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-10T21:45:02.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 9040, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 93, "rtCriticsLabel": "Certified Fresh", "rtAudience": 88, "rtAudienceLabel": "Upright", "imdb": 7.7, "imdbVotes": 149750, "tmdb": 7.525, "tmdbVotes": 2323}}, {"id": "movie-shaft-2000", "title": "Shaft", "year": 2000, "type": "movie", "runtimeSeconds": 5960, "seasonCount": null, "communityRating": 6.026, "criticRating": 67, "genres": ["Action", "Adventure", "Crime"], "overview": "New York police detective John Shaft arrests Walter Wade Jr. for a racially motivated slaying. But the only eyewitness disappears, and Wade jumps bail for Switzerland. Two years later Wade returns to face trial,\u2026", "poster": "assets/movie-shaft-2000-poster.jpg", "backdrop": "assets/movie-shaft-2000-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-10T21:30:40.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 479, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 68, "rtCriticsLabel": "Fresh", "rtAudience": 40, "rtAudienceLabel": "Spilled", "imdb": 6, "imdbVotes": 86257, "tmdb": 6.024, "tmdbVotes": 1531}}, {"id": "movie-a-silent-voice-the-movie-2016", "title": "A Silent Voice: The Movie", "year": 2016, "type": "movie", "runtimeSeconds": 7804, "seasonCount": null, "communityRating": 8.398, "criticRating": null, "genres": ["Animation", "Drama", "Romance"], "overview": "Shouya Ishida starts bullying the new girl in class, Shouko Nishimiya, because she is deaf. But as the teasing continues, the rest of the class starts to turn on Shouya for his lack of compassion. When they leave\u2026", "poster": "assets/movie-a-silent-voice-the-movie-2016-poster.jpg", "backdrop": "assets/movie-a-silent-voice-the-movie-2016-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T17:39:46.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 378064, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 95, "rtCriticsLabel": "Fresh", "rtAudience": 94, "rtAudienceLabel": "Upright", "imdb": 8.2, "imdbVotes": 151926, "tmdb": 8.396, "tmdbVotes": 4764}}, {"id": "movie-sinbad-legend-of-the-seven-seas-2003", "title": "Sinbad: Legend of the Seven Seas", "year": 2003, "type": "movie", "runtimeSeconds": 5143, "seasonCount": null, "communityRating": 7.067, "criticRating": 44, "genres": ["Action", "Adventure", "Animation"], "overview": "The sailor of legend is framed by the goddess Eris for the theft of the Book of Peace, and must travel to her realm at the end of the world to retrieve it and save the life of his childhood friend Prince Proteus.", "poster": "assets/movie-sinbad-legend-of-the-seven-seas-2003-poster.jpg", "backdrop": "assets/movie-sinbad-legend-of-the-seven-seas-2003-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:26:25.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 14411, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 46, "rtCriticsLabel": "Rotten", "rtAudience": 57, "rtAudienceLabel": "Spilled", "imdb": 6.8, "imdbVotes": 65298, "tmdb": 7.071, "tmdbVotes": 2323}}, {"id": "movie-sleeping-with-other-people-2015", "title": "Sleeping with Other People", "year": 2015, "type": "movie", "runtimeSeconds": 6076, "seasonCount": null, "communityRating": 6.274, "criticRating": 64, "genres": ["Comedy", "Romance"], "overview": "Can two serial cheaters get a second chance at love? After a one-night stand in college, New Yorkers Lainey and Jake meet by chance twelve years later and discover they each have the same problem: because of their\u2026", "poster": "assets/movie-sleeping-with-other-people-2015-poster.jpg", "backdrop": "assets/movie-sleeping-with-other-people-2015-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:27:42.0000000Z", "lastPlayedAt": "2026-07-17T17:50:44.0000000Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 288036, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 64, "rtCriticsLabel": "Fresh", "rtAudience": 56, "rtAudienceLabel": "Spilled", "imdb": 6.5, "imdbVotes": 52275, "tmdb": 6.275, "tmdbVotes": 1020}}, {"id": "movie-snowpiercer-2013", "title": "Snowpiercer", "year": 2013, "type": "movie", "runtimeSeconds": 7573, "seasonCount": null, "communityRating": 6.909, "criticRating": 94, "genres": ["Action", "Science Fiction", "Drama"], "overview": "In a future where a failed global-warming experiment kills off most life on the planet, a class system evolves aboard the Snowpiercer; a train that travels around the globe via a perpetual-motion engine.", "poster": "assets/movie-snowpiercer-2013-poster.jpg", "backdrop": "assets/movie-snowpiercer-2013-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:27:07.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 110415, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 94, "rtCriticsLabel": "Certified Fresh", "rtAudience": 72, "rtAudienceLabel": "Upright", "imdb": 7.1, "imdbVotes": 426976, "tmdb": 6.9, "tmdbVotes": 10807}}, {"id": "movie-spider-man-brand-new-day-2026", "title": "Spider-Man: Brand New Day", "year": 2026, "type": "movie", "runtimeSeconds": 8701, "seasonCount": null, "communityRating": 8.374, "criticRating": 90, "genres": ["Science Fiction", "Action", "Adventure"], "overview": "Fighting crime full-time as Spider-Man in a world that doesn't remember him\u2014and the pressure of seeing his old friends move on without him\u2014sparks a change in Peter Parker he may not have the power to control. But\u2026", "poster": "assets/movie-spider-man-brand-new-day-2026-poster.jpg", "backdrop": "assets/movie-spider-man-brand-new-day-2026-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-10T14:14:27.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 969681, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 90, "rtCriticsLabel": "Certified Fresh", "rtAudience": 97, "rtAudienceLabel": "Upright", "imdb": 7.9, "imdbVotes": 366886, "tmdb": 8.5, "tmdbVotes": 6537}}, {"id": "movie-spider-man-far-from-home-2019", "title": "Spider-Man: Far From Home", "year": 2019, "type": "movie", "runtimeSeconds": 7762, "seasonCount": null, "communityRating": 7.389, "criticRating": 91, "genres": ["Action", "Adventure", "Science Fiction"], "overview": "Peter Parker and his friends go on a summer trip to Europe. However, they will hardly be able to rest - Peter will have to agree to help Nick Fury uncover the mystery of creatures that cause natural disasters and\u2026", "poster": "assets/movie-spider-man-far-from-home-2019-poster.jpg", "backdrop": "assets/movie-spider-man-far-from-home-2019-backdrop.jpg", "played": true, "playCount": 1, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-10T14:25:47.0000000Z", "lastPlayedAt": "2026-10-10T17:50:35.6193119Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 429617, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 91, "rtCriticsLabel": "Certified Fresh", "rtAudience": 95, "rtAudienceLabel": "Upright", "imdb": 7.3, "imdbVotes": 682887, "tmdb": 7.387, "tmdbVotes": 17847}}, {"id": "movie-spider-man-homecoming-2017", "title": "Spider-Man: Homecoming", "year": 2017, "type": "movie", "runtimeSeconds": 8008, "seasonCount": null, "communityRating": 7.333, "criticRating": 92, "genres": ["Action", "Adventure", "Science Fiction"], "overview": "Following the events of Captain America: Civil War, Peter Parker, with the help of his mentor Tony Stark, tries to balance his life as an ordinary high school student in Queens, New York City, with fighting crime as\u2026", "poster": "assets/movie-spider-man-homecoming-2017-poster.jpg", "backdrop": "assets/movie-spider-man-homecoming-2017-backdrop.jpg", "played": true, "playCount": 1, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-10T14:33:13.0000000Z", "lastPlayedAt": "2026-10-10T20:31:23.4444213Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 315635, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 92, "rtCriticsLabel": "Certified Fresh", "rtAudience": 87, "rtAudienceLabel": "Upright", "imdb": 7.4, "imdbVotes": 840974, "tmdb": 7.332, "tmdbVotes": 24183}}, {"id": "movie-spider-man-no-way-home-2021", "title": "Spider-Man: No Way Home", "year": 2021, "type": "movie", "runtimeSeconds": 8890, "seasonCount": null, "communityRating": 7.947, "criticRating": 93, "genres": ["Action", "Adventure", "Science Fiction"], "overview": "Peter Parker is unmasked and no longer able to separate his normal life from the high-stakes of being a super-hero. When he asks for help from Doctor Strange the stakes become even more dangerous, forcing him to\u2026", "poster": "assets/movie-spider-man-no-way-home-2021-poster.jpg", "backdrop": "assets/movie-spider-man-no-way-home-2021-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-10T14:21:05.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 634649, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 93, "rtCriticsLabel": "Certified Fresh", "rtAudience": 97, "rtAudienceLabel": "Upright", "imdb": 8.1, "imdbVotes": 1082660, "tmdb": 7.947, "tmdbVotes": 23505}}, {"id": "movie-spirited-away-2001", "title": "Spirited Away", "year": 2001, "type": "movie", "runtimeSeconds": 7472, "seasonCount": null, "communityRating": 8.535, "criticRating": 96, "genres": ["Animation", "Family", "Fantasy"], "overview": "A young girl, Chihiro, becomes trapped in a strange new world of spirits. When her parents undergo a mysterious transformation, she must call upon the courage she never knew she had to free her family.", "poster": "assets/movie-spirited-away-2001-poster.jpg", "backdrop": "assets/movie-spirited-away-2001-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:22:42.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 129, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 96, "rtCriticsLabel": "Certified Fresh", "rtAudience": 99, "rtAudienceLabel": "Upright", "imdb": 8.6, "imdbVotes": 987658, "tmdb": 8.536, "tmdbVotes": 19176}}, {"id": "movie-the-substance-2024", "title": "The Substance", "year": 2024, "type": "movie", "runtimeSeconds": 8531, "seasonCount": null, "communityRating": 7.135, "criticRating": 89, "genres": ["Horror", "Science Fiction"], "overview": "A fading celebrity decides to use a black market drug, a cell-replicating substance that temporarily creates a younger, better version of herself.", "poster": "assets/movie-the-substance-2024-poster.jpg", "backdrop": "assets/movie-the-substance-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:30:22.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 933260, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 89, "rtCriticsLabel": "Certified Fresh", "rtAudience": 76, "rtAudienceLabel": "Upright", "imdb": 7.2, "imdbVotes": 466542, "tmdb": 7.13, "tmdbVotes": 6746}}, {"id": "movie-the-tale-of-the-princess-kaguya-2013", "title": "The Tale of The Princess Kaguya", "year": 2013, "type": "movie", "runtimeSeconds": 8218, "seasonCount": null, "communityRating": 8.1, "criticRating": 100, "genres": ["Animation", "Drama", "Fantasy"], "overview": "Found inside a shining stalk of bamboo by an old bamboo cutter and his wife, a tiny girl grows rapidly into an exquisite young lady. The mysterious young princess enthrals all who encounter her. But, ultimately, she\u2026", "poster": "assets/movie-the-tale-of-the-princess-kaguya-2013-poster.jpg", "backdrop": "assets/movie-the-tale-of-the-princess-kaguya-2013-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-07T13:40:15.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 149871, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Certified Fresh", "rtAudience": 90, "rtAudienceLabel": "Upright", "imdb": 8, "imdbVotes": 63574, "tmdb": 8.083, "tmdbVotes": 2079}}, {"id": "movie-taxi-driver-1976", "title": "Taxi Driver", "year": 1976, "type": "movie", "runtimeSeconds": 6831, "seasonCount": null, "communityRating": 8.121, "criticRating": 89, "genres": ["Crime", "Drama"], "overview": "Suffering from insomnia, disturbed loner Travis Bickle takes a job as a New York City cabbie, haunting the streets nightly, growing increasingly detached from reality as he dreams of cleaning up the filthy city.", "poster": "assets/movie-taxi-driver-1976-poster.jpg", "backdrop": "assets/movie-taxi-driver-1976-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:23:04.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 103, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 89, "rtCriticsLabel": "Certified Fresh", "rtAudience": 93, "rtAudienceLabel": "Upright", "imdb": 8.2, "imdbVotes": 1046700, "tmdb": 8.116, "tmdbVotes": 13973}}, {"id": "movie-triangle-of-sadness-2022", "title": "Triangle of Sadness", "year": 2022, "type": "movie", "runtimeSeconds": 8833, "seasonCount": null, "communityRating": 7.005, "criticRating": 71, "genres": ["Comedy", "Drama"], "overview": "A celebrity model couple are invited on a luxury cruise for the uber-rich, helmed by an unhinged, alcoholic captain. What first appears Instagrammable ends catastrophically, leaving the survivors stranded on a desert\u2026", "poster": "assets/movie-triangle-of-sadness-2022-poster.jpg", "backdrop": "assets/movie-triangle-of-sadness-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:29:00.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 497828, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 72, "rtCriticsLabel": "Fresh", "rtAudience": 80, "rtAudienceLabel": "Upright", "imdb": 7.2, "imdbVotes": 221069, "tmdb": 7, "tmdbVotes": 3104}}, {"id": "movie-tsotsi-2005", "title": "Tsotsi", "year": 2005, "type": "movie", "runtimeSeconds": 5663, "seasonCount": null, "communityRating": 6.937, "criticRating": 82, "genres": ["Crime", "Drama"], "overview": "A young South African boy from the Johannesburg ghetto named Tsotsi, meaning Gangster, leaves home as a child to get away from his helpless parents. Now a teenage thug, Tsotsi finds a baby in the back seat of a car\u2026", "poster": "assets/movie-tsotsi-2005-poster.jpg", "backdrop": "assets/movie-tsotsi-2005-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:24:58.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 868, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 82, "rtCriticsLabel": "Certified Fresh", "rtAudience": 86, "rtAudienceLabel": "Upright", "imdb": 7.2, "imdbVotes": 32526, "tmdb": 6.941, "tmdbVotes": 372}}, {"id": "movie-unknown-number-the-high-school-catfish-2025", "title": "Unknown Number: The High School Catfish", "year": 2025, "type": "movie", "runtimeSeconds": 5691, "seasonCount": null, "communityRating": 6.746, "criticRating": 85, "genres": ["Documentary"], "overview": "Vulgar, taunting texts blow up the phones of a teen and her boyfriend. Who's sending them \u2014 and why? This twisty documentary reveals the shocking answer.", "poster": "assets/movie-unknown-number-the-high-school-catfish-2025-poster.jpg", "backdrop": "assets/movie-unknown-number-the-high-school-catfish-2025-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:32:10.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 1517772, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 85, "rtCriticsLabel": "Fresh", "rtAudience": 69, "rtAudienceLabel": "Upright", "imdb": 6.7, "imdbVotes": 23039, "tmdb": 6.737, "tmdbVotes": 257}}, {"id": "movie-the-uprising-2026", "title": "The Uprising", "year": 2026, "type": "movie", "runtimeSeconds": 7684, "seasonCount": null, "communityRating": 7.846, "criticRating": 55, "genres": ["Action", "Drama", "History"], "overview": "As war and plague devastate 14th-century England, a humble peasant ignites a rebellion, uniting an army of commoners to defy the King\u2019s might in a desperate fight for justice and survival.", "poster": "assets/movie-the-uprising-2026-poster.jpg", "backdrop": "assets/movie-the-uprising-2026-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-11-02T16:37:03.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 977942, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 57, "rtCriticsLabel": "Rotten", "rtAudience": 65, "rtAudienceLabel": "Upright", "imdb": 6.2, "imdbVotes": 6622, "tmdb": 7.8, "tmdbVotes": 404}}, {"id": "movie-vice-2018", "title": "Vice", "year": 2018, "type": "movie", "runtimeSeconds": 7948, "seasonCount": null, "communityRating": 7.013, "criticRating": 64, "genres": ["Drama", "Comedy", "History"], "overview": "George W. Bush picks Dick Cheney, the CEO of Halliburton Co., to be his Republican running mate in the 2000 presidential election. No stranger to politics, Cheney's impressive r\u00e9sum\u00e9 includes stints as White House\u2026", "poster": "assets/movie-vice-2018-poster.jpg", "backdrop": "assets/movie-vice-2018-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:28:30.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 429197, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 64, "rtCriticsLabel": "Fresh", "rtAudience": 61, "rtAudienceLabel": "Upright", "imdb": 7.2, "imdbVotes": 177299, "tmdb": 7.014, "tmdbVotes": 3723}}, {"id": "movie-warriors-of-the-wind-1984", "title": "Warriors of the Wind", "year": 1984, "type": "movie", "runtimeSeconds": 7022, "seasonCount": null, "communityRating": 7.941, "criticRating": 87, "genres": ["Adventure", "Animation", "Fantasy"], "overview": "After a global war, the seaside kingdom known as the Valley of the Wind remains one of the last strongholds on Earth untouched by a poisonous jungle and the powerful insects that guard it. Led by the courageous\u2026", "poster": "assets/movie-warriors-of-the-wind-1984-poster.jpg", "backdrop": "assets/movie-warriors-of-the-wind-1984-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:22:20.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 81, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 87, "rtCriticsLabel": "Fresh", "rtAudience": 91, "rtAudienceLabel": "Upright", "imdb": 8, "imdbVotes": 200154, "tmdb": 7.94, "tmdbVotes": 4260}}, {"id": "movie-we-bury-the-dead-2026", "title": "We Bury the Dead", "year": 2026, "type": "movie", "runtimeSeconds": 5693, "seasonCount": null, "communityRating": 6.153, "criticRating": 87, "genres": ["Horror", "Thriller"], "overview": "After a catastrophic military disaster reanimates the dead, Ava searches for her missing husband amidst an increasingly hostile world.", "poster": "assets/movie-we-bury-the-dead-2026-poster.jpg", "backdrop": "assets/movie-we-bury-the-dead-2026-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-08-26T05:36:00.4272328Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 1198984, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 87, "rtCriticsLabel": "Certified Fresh", "rtAudience": 45, "rtAudienceLabel": "Spilled", "imdb": 5.6, "imdbVotes": 20693, "tmdb": 6.137, "tmdbVotes": 433}}, {"id": "movie-white-hot-the-rise-fall-of-abercrombie-fitch-2022", "title": "White Hot: The Rise & Fall of Abercrombie & Fitch", "year": 2022, "type": "movie", "runtimeSeconds": 5187, "seasonCount": null, "communityRating": 5.663, "criticRating": 66, "genres": ["Documentary"], "overview": "All the cool kids were wearing it. This documentary explores A&F's pop culture reign in the late '90s and early 2000s and how it thrived on exclusion.", "poster": "assets/movie-white-hot-the-rise-fall-of-abercrombie-fitch-2022-poster.jpg", "backdrop": "assets/movie-white-hot-the-rise-fall-of-abercrombie-fitch-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:30:34.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 953371, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 66, "rtCriticsLabel": "Fresh", "rtAudience": 29, "rtAudienceLabel": "Spilled", "imdb": 5.8, "imdbVotes": 5655, "tmdb": 5.7, "tmdbVotes": 102}}, {"id": "movie-the-wild-robot-2024", "title": "The Wild Robot", "year": 2024, "type": "movie", "runtimeSeconds": 6101, "seasonCount": null, "communityRating": 8.308, "criticRating": 97, "genres": ["Family", "Animation", "Science Fiction"], "overview": "After a shipwreck, an intelligent robot called Roz is stranded on an uninhabited island. To survive the harsh environment, Roz bonds with the island's animals and cares for an orphaned baby goose.", "poster": "assets/movie-the-wild-robot-2024-poster.jpg", "backdrop": "assets/movie-the-wild-robot-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:31:49.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 1184918, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 97, "rtCriticsLabel": "Certified Fresh", "rtAudience": 98, "rtAudienceLabel": "Upright", "imdb": 8.1, "imdbVotes": 241134, "tmdb": 8.309, "tmdbVotes": 6819}}, {"id": "movie-the-wind-rises-2013", "title": "The Wind Rises", "year": 2013, "type": "movie", "runtimeSeconds": 8031, "seasonCount": null, "communityRating": 7.784, "criticRating": 88, "genres": ["Animation", "Drama", "History"], "overview": "A lifelong love of flight inspires Japanese aviation engineer Jiro Horikoshi, whose storied career includes the creation of the A-6M World War II fighter plane.", "poster": "assets/movie-the-wind-rises-2013-poster.jpg", "backdrop": "assets/movie-the-wind-rises-2013-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:24:38.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 149870, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 89, "rtCriticsLabel": "Certified Fresh", "rtAudience": 85, "rtAudienceLabel": "Upright", "imdb": 7.8, "imdbVotes": 115756, "tmdb": 7.783, "tmdbVotes": 3536}}, {"id": "movie-the-witcher-nightmare-of-the-wolf-2021", "title": "The Witcher: Nightmare of the Wolf", "year": 2021, "type": "movie", "runtimeSeconds": 5015, "seasonCount": null, "communityRating": 7.422, "criticRating": 100, "genres": ["Animation", "Action", "Fantasy"], "overview": "Escaping from poverty to become a witcher, Vesemir slays monsters for coin and glory, but when a new menace rises, he must face the demons of his past.", "poster": "assets/movie-the-witcher-nightmare-of-the-wolf-2021-poster.jpg", "backdrop": "assets/movie-the-witcher-nightmare-of-the-wolf-2021-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-26T04:42:03.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 666243, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": 83, "rtAudienceLabel": "Upright", "imdb": 7.2, "imdbVotes": 55759, "tmdb": 7.407, "tmdbVotes": 1159}}, {"id": "movie-the-witcher-sirens-of-the-deep-2025", "title": "The Witcher: Sirens of the Deep", "year": 2025, "type": "movie", "runtimeSeconds": 5517, "seasonCount": null, "communityRating": 6.8, "criticRating": 60, "genres": ["Animation", "Fantasy", "Action"], "overview": "When human sailors are attacked by mysterious creatures of the deep, only one person can stop the war between land and sea: the Witcher, Geralt of Rivia.", "poster": "assets/movie-the-witcher-sirens-of-the-deep-2025-poster.jpg", "backdrop": "assets/movie-the-witcher-sirens-of-the-deep-2025-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-26T05:03:26.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 1203329, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 60, "rtCriticsLabel": "Fresh", "rtAudience": 37, "rtAudienceLabel": "Spilled", "imdb": 6.1, "imdbVotes": 11799, "tmdb": 6.741, "tmdbVotes": 303}}], "shows": [{"id": "tv-adults-2025", "title": "Adults", "year": 2025, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 6.495, "criticRating": null, "genres": ["Comedy"], "overview": "A group of twenty-somethings in New York trying to be good people, despite being neither \"good\" nor \"people\" yet.", "poster": "assets/tv-adults-2025-poster.jpg", "backdrop": "assets/tv-adults-2025-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-23T21:01:41.1814765Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 260458, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 88, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.5, "tmdbVotes": 102}}, {"id": "tv-american-crime-story-2016", "title": "American Crime Story", "year": 2016, "type": "tv", "runtimeSeconds": null, "seasonCount": 3, "communityRating": 7.573, "criticRating": null, "genres": ["Crime", "Drama"], "overview": "An anthology series centered around some of history's most famous criminal investigations.", "poster": "assets/tv-american-crime-story-2016-poster.jpg", "backdrop": "assets/tv-american-crime-story-2016-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0490488Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 64513, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 84, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.585, "tmdbVotes": 1094}}, {"id": "tv-american-primeval-2025", "title": "American Primeval", "year": 2025, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.691, "criticRating": null, "genres": ["Western", "Drama", "Action & Adventure"], "overview": "A mother and son fleeing from their past form a found family while confronting a harsh landscape of freedom and cruelty in the American West.", "poster": "assets/tv-american-primeval-2025-poster.jpg", "backdrop": "assets/tv-american-primeval-2025-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0573221Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 215866, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 71, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.741, "tmdbVotes": 538}}, {"id": "tv-arrested-development-2003", "title": "Arrested Development", "year": 2003, "type": "tv", "runtimeSeconds": null, "seasonCount": 5, "communityRating": 7.925, "criticRating": null, "genres": ["Comedy"], "overview": "The story of a wealthy family that lost everything, and the one son who had no choice but to keep them all together.", "poster": "assets/tv-arrested-development-2003-poster.jpg", "backdrop": "assets/tv-arrested-development-2003-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0513048Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 4589, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 75, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.906, "tmdbVotes": 1793}}, {"id": "tv-avatar-the-last-airbender-2005", "title": "Avatar: The Last Airbender", "year": 2005, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 3, "communityRating": 8.78, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "In a war-torn world of elemental magic, a young boy reawakens to undertake a dangerous mystic quest to fulfill his destiny as the Avatar, and bring peace to the world.", "poster": "assets/tv-avatar-the-last-airbender-2005-poster.jpg", "backdrop": "assets/tv-avatar-the-last-airbender-2005-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0504536Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 246, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.787, "tmdbVotes": 5215}}, {"id": "tv-b-b-vol-liefde-2021", "title": "B&B Vol Liefde", "year": 2021, "type": "tv", "runtimeSeconds": 3720, "seasonCount": 1, "communityRating": 7.2, "criticRating": null, "genres": ["Reality"], "overview": "B&B Vol Liefde is a heartwarming adventure, full of romance, excitement, and unforgettable moments. Adventurous Dutch B&B owners abroad throw their doors wide open and let their hearts speak, determined to find that\u2026", "poster": "assets/tv-b-b-vol-liefde-2021-poster.jpg", "backdrop": "assets/tv-b-b-vol-liefde-2021-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-31T22:30:11.129657Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 124428, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.2, "tmdbVotes": 10}}, {"id": "tv-the-bear-2022", "title": "The Bear", "year": 2022, "type": "tv", "runtimeSeconds": null, "seasonCount": 5, "communityRating": 8.139, "criticRating": null, "genres": ["Drama", "Comedy"], "overview": "Carmy, a young fine-dining chef, comes home to Chicago to run his family sandwich shop. As he fights to transform the shop and himself, he works alongside a rough-around-the-edges crew that ultimately reveal\u2026", "poster": "assets/tv-the-bear-2022-poster.jpg", "backdrop": "assets/tv-the-bear-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0467371Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 136315, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 93, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.158, "tmdbVotes": 2025}}, {"id": "tv-blue-planet-ii-2017", "title": "Blue Planet II", "year": 2017, "type": "tv", "runtimeSeconds": 3600, "seasonCount": 1, "communityRating": 8.64, "criticRating": null, "genres": ["Documentary", "Family"], "overview": "There is nowhere more powerful and unforgiving yet more beautiful and compelling than the ocean. Join us and explore the greatest yet least known parts of our planet.", "poster": "assets/tv-blue-planet-ii-2017-poster.jpg", "backdrop": "assets/tv-blue-planet-ii-2017-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0634849Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 74313, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 97, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.6, "tmdbVotes": 380}}, {"id": "tv-breeders-2020", "title": "Breeders", "year": 2020, "type": "tv", "runtimeSeconds": 1500, "seasonCount": 4, "communityRating": 6.8, "criticRating": null, "genres": ["Comedy"], "overview": "Exposing the parental-paradox that it is possible, in the very same moment, to love your child to the horizon of the universe, while being apoplectically angry enough to want to send them there.", "poster": "assets/tv-breeders-2020-poster.jpg", "backdrop": "assets/tv-breeders-2020-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-28T18:54:21.8885435Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 93290, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 92, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.8, "tmdbVotes": 104}}, {"id": "tv-chernobyl-2019", "title": "Chernobyl", "year": 2019, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 8.718, "criticRating": null, "genres": ["Drama"], "overview": "The true story of one of the worst man-made catastrophes in history: the catastrophic nuclear accident at Chernobyl. A tale of the brave men and women who sacrificed to save Europe from unimaginable disaster.", "poster": "assets/tv-chernobyl-2019-poster.jpg", "backdrop": "assets/tv-chernobyl-2019-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0469903Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 87108, "artwork": {"posterKind": "poster", "backdropKind": "center_crop"}, "ratings": {"rtCritics": 95, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.731, "tmdbVotes": 8496}}, {"id": "tv-community-2009", "title": "Community", "year": 2009, "type": "tv", "runtimeSeconds": null, "seasonCount": 6, "communityRating": 7.997, "criticRating": null, "genres": ["Comedy"], "overview": "Follow the lives of a group of students at what is possibly the world\u2019s worst community college in the fictional locale of Greendale, Colorado.", "poster": "assets/tv-community-2009-poster.jpg", "backdrop": "assets/tv-community-2009-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0531416Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 18347, "artwork": {"posterKind": "center_crop", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 88, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.994, "tmdbVotes": 2226}}, {"id": "tv-dead-end-job-2026", "title": "Dead-End Job", "year": 2026, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.7, "criticRating": null, "genres": ["Mystery"], "overview": "Drowning in debt, a young man takes lucrative part-time jobs from a mysterious agency \u2014 only to find his hustle for more money turning nightmarish.", "poster": "assets/tv-dead-end-job-2026-poster.jpg", "backdrop": "assets/tv-dead-end-job-2026-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-11-04T18:58:01.8352857Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 289517, "artwork": {"posterKind": "center_crop", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.7, "tmdbVotes": 38}}, {"id": "tv-dopesick-2021", "title": "Dopesick", "year": 2021, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 8.015, "criticRating": null, "genres": ["Crime", "Drama"], "overview": "The story of how one company triggered the worst drug epidemic in American history. Look into the epicenter of America's struggle with opioid addiction, from a distressed Virginia mining community, to the hallways of\u2026", "poster": "assets/tv-dopesick-2021-poster.jpg", "backdrop": "assets/tv-dopesick-2021-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0580168Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 110695, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 89, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.035, "tmdbVotes": 565}}, {"id": "tv-dune-prophecy-2024", "title": "Dune: Prophecy", "year": 2024, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.213, "criticRating": null, "genres": ["Sci-Fi & Fantasy", "Drama", "Action & Adventure"], "overview": "Ten thousand years before the ascension of Paul Atreides, sisters Valya and Tula Harkonnen establish the fabled sect and female order that would become known as the Bene Gesserit to control the future of humankind.", "poster": "assets/tv-dune-prophecy-2024-poster.jpg", "backdrop": "assets/tv-dune-prophecy-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-11-04T18:23:01.2908568Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 90228, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 64, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.213, "tmdbVotes": 677}}, {"id": "tv-east-of-eden-2026", "title": "East of Eden", "year": 2026, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.931, "criticRating": null, "genres": ["Drama", "Crime"], "overview": "In the early 1900s, a fiercely independent woman drives a rift between two brothers, igniting a saga that unfolds across two generations.", "poster": "assets/tv-east-of-eden-2026-poster.jpg", "backdrop": "assets/tv-east-of-eden-2026-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-23T18:07:11.3965096Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 258165, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 88, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.982, "tmdbVotes": 139}}, {"id": "tv-fallout-2024", "title": "Fallout", "year": 2024, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 8.124, "criticRating": null, "genres": ["Action & Adventure", "Sci-Fi & Fantasy", "Drama"], "overview": "The story of haves and have-nots in a world in which there's almost nothing left to have. 200 years after the apocalypse, the gentle denizens of luxury fallout shelters are forced to return to the irradiated\u2026", "poster": "assets/tv-fallout-2024-poster.jpg", "backdrop": "assets/tv-fallout-2024-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0669262Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 106379, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 94, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.106, "tmdbVotes": 3233}}, {"id": "tv-fawlty-towers-1975", "title": "Fawlty Towers", "year": 1975, "type": "tv", "runtimeSeconds": 1800, "seasonCount": 2, "communityRating": 8.295, "criticRating": null, "genres": ["Comedy"], "overview": "Owner Basil Fawlty, his wife Sybil, a chambermaid Polly, and Spanish waiter Manuel attempt to run their hotel amidst farcical situations and an array of demanding guests.", "poster": "assets/tv-fawlty-towers-1975-poster.jpg", "backdrop": "assets/tv-fawlty-towers-1975-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-19T23:26:39.7658344Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 2207, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.296, "tmdbVotes": 867}}, {"id": "tv-the-final-problem-2026", "title": "The Final Problem", "year": 2026, "type": "tv", "runtimeSeconds": 3420, "seasonCount": 1, "communityRating": 7.041, "criticRating": null, "genres": ["Crime", "Mystery"], "overview": "In the spring of 1959, a retired actor, who previously played Sherlock Holmes, must investigate a murder at a small hotel on a tiny island near Mallorca.", "poster": "assets/tv-the-final-problem-2026-poster.jpg", "backdrop": "assets/tv-the-final-problem-2026-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-11-04T19:16:12.4822791Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 290720, "artwork": {"posterKind": "center_crop", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.041, "tmdbVotes": 49}}, {"id": "tv-the-four-seasons-2025", "title": "The Four Seasons", "year": 2025, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 6.879, "criticRating": null, "genres": ["Comedy"], "overview": "The decades-long friendship between three married couples is tested when one divorces, complicating their tradition of quarterly weekend getaways.", "poster": "assets/tv-the-four-seasons-2025-poster.jpg", "backdrop": "assets/tv-the-four-seasons-2025-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-28T18:36:42.5326384Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 243316, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 81, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.892, "tmdbVotes": 204}}, {"id": "tv-the-get-down-2016", "title": "The Get Down", "year": 2016, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.3, "criticRating": null, "genres": ["Drama"], "overview": "In 1977 New York City, the talented and soulful youth of the South Bronx chase dreams and breakneck beats to transform music history.", "poster": "assets/tv-the-get-down-2016-poster.jpg", "backdrop": "assets/tv-the-get-down-2016-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0497737Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 65345, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 81, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.3, "tmdbVotes": 196}}, {"id": "tv-how-to-change-your-mind-2022", "title": "How to Change Your Mind", "year": 2022, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.76, "criticRating": null, "genres": ["Documentary", "Mystery"], "overview": "Author Michael Pollan leads the way in this docuseries exploring the history and uses of psychedelics, including LSD, psilocybin, MDMA and mescaline.", "poster": "assets/tv-how-to-change-your-mind-2022-poster.jpg", "backdrop": "assets/tv-how-to-change-your-mind-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0601399Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 204304, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.8, "tmdbVotes": 53}}, {"id": "tv-interrogation-2020", "title": "Interrogation", "year": 2020, "type": "tv", "runtimeSeconds": 2700, "seasonCount": 1, "communityRating": 6.788, "criticRating": null, "genres": ["Drama", "Crime"], "overview": "Based on a true story that spanned more than 30 years in which a young man was charged and convicted of brutally murdering his mother. Each episode is structured around an interrogation taken directly from the real\u2026", "poster": "assets/tv-interrogation-2020-poster.jpg", "backdrop": "assets/tv-interrogation-2020-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-26T05:52:06.1068207Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 96488, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 57, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.9, "tmdbVotes": 54}}, {"id": "tv-it-s-always-sunny-in-philadelphia-2005", "title": "It's Always Sunny in Philadelphia", "year": 2005, "type": "tv", "runtimeSeconds": null, "seasonCount": 18, "communityRating": 8.2, "criticRating": null, "genres": ["Comedy"], "overview": "Four egocentric friends run a neighborhood Irish pub in Philadelphia and try to find their way through the adult world of work and relationships. Unfortunately, their warped views and precarious judgments often lead\u2026", "poster": "assets/tv-it-s-always-sunny-in-philadelphia-2005-poster.jpg", "backdrop": "assets/tv-it-s-always-sunny-in-philadelphia-2005-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0485774Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 2710, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 94, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.253, "tmdbVotes": 1457}}, {"id": "tv-a-knight-of-the-seven-kingdoms-2026", "title": "A Knight of the Seven Kingdoms", "year": 2026, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 8.431, "criticRating": null, "genres": ["Drama", "Sci-Fi & Fantasy", "Action & Adventure"], "overview": "A century before the events of Game of Thrones, two unlikely heroes wandered Westeros: a young, naive but courageous knight, Ser Duncan the Tall, and his diminutive squire, Egg. Set in an age when the Targaryen line\u2026", "poster": "assets/tv-a-knight-of-the-seven-kingdoms-2026-poster.jpg", "backdrop": "assets/tv-a-knight-of-the-seven-kingdoms-2026-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0493344Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 224372, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 94, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.4, "tmdbVotes": 1266}}, {"id": "tv-landman-2024", "title": "Landman", "year": 2024, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 8.194, "criticRating": null, "genres": ["Drama"], "overview": "Set in the proverbial boomtowns of West-Texas and a modern-day tale of fortune-seeking in the world of oil rigs, the series is an upstairs/downstairs story of roughnecks and wildcat billionaires that are fueling a\u2026", "poster": "assets/tv-landman-2024-poster.jpg", "backdrop": "assets/tv-landman-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-22T21:38:18.0973875Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 157741, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 80, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.209, "tmdbVotes": 668}}, {"id": "tv-the-last-of-us-2023", "title": "The Last of Us", "year": 2023, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 8.419, "criticRating": null, "genres": ["Drama"], "overview": "Twenty years after modern civilization has been destroyed, Joel, a hardened survivor, is hired to smuggle Ellie, a 14-year-old girl, out of an oppressive quarantine zone. What starts as a small job soon becomes a\u2026", "poster": "assets/tv-the-last-of-us-2023-poster.jpg", "backdrop": "assets/tv-the-last-of-us-2023-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0640955Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 100088, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 94, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.4, "tmdbVotes": 7485}}, {"id": "tv-last-samurai-standing-2025", "title": "Last Samurai Standing", "year": 2025, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.805, "criticRating": null, "genres": ["Action & Adventure", "Drama"], "overview": "In the early Meiji era, Shujiro, once known as an undefeated samurai, decides to participate in a deadly survival game to save his family and villagers.", "poster": "assets/tv-last-samurai-standing-2025-poster.jpg", "backdrop": "assets/tv-last-samurai-standing-2025-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0622526Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 252193, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.812, "tmdbVotes": 271}}, {"id": "tv-lord-of-the-flies-2026", "title": "Lord of the Flies", "year": 2026, "type": "tv", "runtimeSeconds": 3480, "seasonCount": 1, "communityRating": 7.571, "criticRating": null, "genres": ["Drama", "Action & Adventure"], "overview": "After a plane crash, a group of schoolboys find themselves stranded on a tropical island without adults. Ralph is elected leader and, with the help of the intelligent Piggy, strives to maintain order and\u2026", "poster": "assets/tv-lord-of-the-flies-2026-poster.jpg", "backdrop": "assets/tv-lord-of-the-flies-2026-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0645648Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 270572, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 91, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.58, "tmdbVotes": 138}}, {"id": "tv-the-lord-of-the-rings-the-rings-of-power-2022", "title": "The Lord of the Rings: The Rings of Power", "year": 2022, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 7.233, "criticRating": null, "genres": ["Action & Adventure", "Sci-Fi & Fantasy", "Drama"], "overview": "Beginning in a time of relative peace, we follow an ensemble cast of characters as they confront the re-emergence of evil to Middle-earth. From the darkest depths of the Misty Mountains, to the majestic forests of\u2026", "poster": "assets/tv-the-lord-of-the-rings-the-rings-of-power-2022-poster.jpg", "backdrop": "assets/tv-the-lord-of-the-rings-the-rings-of-power-2022-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0618904Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 84773, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 84, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.211, "tmdbVotes": 4003}}, {"id": "tv-love-death-robots-2019", "title": "Love, Death & Robots", "year": 2019, "type": "tv", "runtimeSeconds": null, "seasonCount": 4, "communityRating": 8.21, "criticRating": null, "genres": ["Animation", "Sci-Fi & Fantasy"], "overview": "Terrifying creatures, wicked surprises and dark comedy converge in this NSFW anthology of animated stories presented by Tim Miller and David Fincher.", "poster": "assets/tv-love-death-robots-2019-poster.jpg", "backdrop": "assets/tv-love-death-robots-2019-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0460964Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 86831, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 89, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.203, "tmdbVotes": 4238}}, {"id": "tv-maniac-2018", "title": "Maniac", "year": 2018, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.338, "criticRating": null, "genres": ["Comedy", "Drama", "Sci-Fi & Fantasy"], "overview": "Two strangers are drawn to a mysterious pharmaceutical trial that will, they're assured, with no complications or side-effects whatsoever, solve all of their problems, permanently. Things do not go as planned.", "poster": "assets/tv-maniac-2018-poster.jpg", "backdrop": "assets/tv-maniac-2018-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-20T19:42:30.955548Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 73411, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 84, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.323, "tmdbVotes": 1073}}, {"id": "tv-mussolini-son-of-the-century-2024", "title": "Mussolini: Son of the Century", "year": 2024, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 8.135, "criticRating": null, "genres": ["Drama", "War & Politics"], "overview": "The rise of Benito Mussolini and the birth of fascism in Italy, chronicling a country\u2019s surrender to dictatorship and the relentless ascent of a man who rose from his ashes time and again.", "poster": "assets/tv-mussolini-son-of-the-century-2024-poster.jpg", "backdrop": "assets/tv-mussolini-son-of-the-century-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0586588Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 212333, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 96, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.1, "tmdbVotes": 179}}, {"id": "tv-the-night-manager-2016", "title": "The Night Manager", "year": 2016, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 7.74, "criticRating": null, "genres": ["Drama", "Mystery", "Crime"], "overview": "Former British soldier Jonathan Pine navigates the shadowy recesses of Whitehall and Washington where an unholy alliance operates between the intelligence community and the secret arms trade. To infiltrate the inner\u2026", "poster": "assets/tv-the-night-manager-2016-poster.jpg", "backdrop": "assets/tv-the-night-manager-2016-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0663672Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 61859, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 88, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.7, "tmdbVotes": 1422}}, {"id": "tv-one-piece-2023", "title": "ONE PIECE", "year": 2023, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 8.118, "criticRating": null, "genres": ["Action & Adventure", "Sci-Fi & Fantasy"], "overview": "With his straw hat and ragtag crew, young pirate Monkey D. Luffy goes on an epic voyage for treasure.", "poster": "assets/tv-one-piece-2023-poster.jpg", "backdrop": "assets/tv-one-piece-2023-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0439378Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 111110, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 93, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.104, "tmdbVotes": 1988}}, {"id": "tv-only-murders-in-the-building-2021", "title": "Only Murders in the Building", "year": 2021, "type": "tv", "runtimeSeconds": null, "seasonCount": 5, "communityRating": 8.438, "criticRating": null, "genres": ["Comedy", "Mystery", "Crime"], "overview": "Three strangers share an obsession with true crime and suddenly find themselves wrapped up in one.", "poster": "assets/tv-only-murders-in-the-building-2021-poster.jpg", "backdrop": "assets/tv-only-murders-in-the-building-2021-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0594614Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 107113, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 95, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.398, "tmdbVotes": 2333}}, {"id": "tv-over-the-garden-wall-2014", "title": "Over the Garden Wall", "year": 2014, "type": "tv", "runtimeSeconds": 720, "seasonCount": 1, "communityRating": 8.6, "criticRating": null, "genres": ["Mystery", "Sci-Fi & Fantasy", "Animation"], "overview": "Two brothers, Wirt and Greg, find themselves lost in the Unknown; a strange forest adrift in time. With the help of a wise old Woodsman and a foul-tempered bluebird named Beatrice, Wirt and Greg must travel across\u2026", "poster": "assets/tv-over-the-garden-wall-2014-poster.jpg", "backdrop": "assets/tv-over-the-garden-wall-2014-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0455757Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 61617, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.6, "tmdbVotes": 1854}}, {"id": "tv-peaky-blinders-2013", "title": "Peaky Blinders", "year": 2013, "type": "tv", "runtimeSeconds": null, "seasonCount": 6, "communityRating": 8.5, "criticRating": null, "genres": ["Drama", "Crime"], "overview": "A gangster family epic set in 1919 Birmingham, England and centered on a gang who sew razor blades in the peaks of their caps, and their fierce boss Tommy Shelby, who means to move up in the world.", "poster": "assets/tv-peaky-blinders-2013-poster.jpg", "backdrop": "assets/tv-peaky-blinders-2013-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0539185Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 60574, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 93, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.5, "tmdbVotes": 11506}}, {"id": "tv-the-pitt-2025", "title": "The Pitt", "year": 2025, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 8.729, "criticRating": null, "genres": ["Drama"], "overview": "The staff of Pittsburgh's Trauma Medical Center work around the clock to save lives in an overcrowded and underfunded emergency department.", "poster": "assets/tv-the-pitt-2025-poster.jpg", "backdrop": "assets/tv-the-pitt-2025-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0582942Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 250307, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 96, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.731, "tmdbVotes": 1046}}, {"id": "tv-pluribus-2025", "title": "Pluribus", "year": 2025, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.936, "criticRating": null, "genres": ["Drama", "Sci-Fi & Fantasy"], "overview": "The most miserable person on Earth must save the world from happiness.", "poster": "assets/tv-pluribus-2025-poster.jpg", "backdrop": "assets/tv-pluribus-2025-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0674462Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 225171, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 99, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.921, "tmdbVotes": 1191}}, {"id": "tv-ripley-2024", "title": "RIPLEY", "year": 2024, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.911, "criticRating": null, "genres": ["Drama"], "overview": "A grifter in 1960s New York is hired to convince a wealthy man's son to return home from Italy and begins a life of deceit, fraud and murder.", "poster": "assets/tv-ripley-2024-poster.jpg", "backdrop": "assets/tv-ripley-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0658942Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 94028, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 86, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.909, "tmdbVotes": 453}}, {"id": "tv-say-nothing-2024", "title": "Say Nothing", "year": 2024, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.462, "criticRating": null, "genres": ["Drama", "Crime"], "overview": "Through the eyes of various Irish Republican Army (IRA) members, explore the extremes some people will go to in the name of their beliefs, the way a deeply divided society can suddenly tip over into armed conflict,\u2026", "poster": "assets/tv-say-nothing-2024-poster.jpg", "backdrop": "assets/tv-say-nothing-2024-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0499752Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 245256, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 94, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.475, "tmdbVotes": 122}}, {"id": "tv-severance-2022", "title": "Severance", "year": 2022, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 8.396, "criticRating": null, "genres": ["Drama", "Mystery", "Sci-Fi & Fantasy"], "overview": "Mark leads a team of office workers whose memories have been surgically divided between their work and personal lives. When a mysterious colleague appears outside of work, it begins a journey to discover the truth\u2026", "poster": "assets/tv-severance-2022-poster.jpg", "backdrop": "assets/tv-severance-2022-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0598478Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 95396, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 95, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.389, "tmdbVotes": 3095}}, {"id": "tv-six-feet-under-2001", "title": "Six Feet Under", "year": 2001, "type": "tv", "runtimeSeconds": null, "seasonCount": 5, "communityRating": 8.115, "criticRating": null, "genres": ["Drama"], "overview": "When death is your business, what is your life? For the Fisher family, the world outside of their family-owned funeral home continues to be at least as challenging as\u2014and far less predictable than\u2014the one inside.", "poster": "assets/tv-six-feet-under-2001-poster.jpg", "backdrop": "assets/tv-six-feet-under-2001-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-26T04:04:05.108787Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 1274, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 81, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.1, "tmdbVotes": 1192}}, {"id": "tv-undercover-2019", "title": "Undercover", "year": 2019, "type": "tv", "runtimeSeconds": 3000, "seasonCount": 3, "communityRating": 7.5, "criticRating": null, "genres": ["Crime", "Drama"], "overview": "Undercover agents infiltrate a drug kingpin's operation by posing as a couple at the campground where he spends his weekends. Inspired by real events.", "poster": "assets/tv-undercover-2019-poster.jpg", "backdrop": "assets/tv-undercover-2019-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0570153Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 86942, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.5, "tmdbVotes": 186}}, {"id": "tv-upload-2020", "title": "Upload", "year": 2020, "type": "tv", "runtimeSeconds": null, "seasonCount": 4, "communityRating": 7.781, "criticRating": null, "genres": ["Comedy", "Sci-Fi & Fantasy", "Drama"], "overview": "In 2033, people who are near death can be \u201cuploaded\u201d into virtual reality hotels run by 6 tech firms. Cash-strapped Nora lives in Brooklyn and works customer service for the luxurious \u201cLakeview\u201d digital afterlife.\u2026", "poster": "assets/tv-upload-2020-poster.jpg", "backdrop": "assets/tv-upload-2020-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0566967Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 86248, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 87, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.757, "tmdbVotes": 1511}}, {"id": "tv-the-walk-in-2022", "title": "The Walk-In", "year": 2022, "type": "tv", "runtimeSeconds": 2760, "seasonCount": 1, "communityRating": 6.9, "criticRating": null, "genres": ["Drama", "Crime"], "overview": "Forced to flee the UK and go into hiding in the 1990s, when he became a mole from within the BNP, Matthew Collins returned to Britain to make a new life for himself, working with young white men in the fight against\u2026", "poster": "assets/tv-the-walk-in-2022-poster.jpg", "backdrop": "assets/tv-the-walk-in-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0654569Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 210664, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 80, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7, "tmdbVotes": 50}}, {"id": "tv-the-white-lotus-2021", "title": "The White Lotus", "year": 2021, "type": "tv", "runtimeSeconds": null, "seasonCount": 3, "communityRating": 7.596, "criticRating": null, "genres": ["Comedy", "Drama", "Mystery"], "overview": "Follow the exploits of various guests and employees at an exclusive tropical resort over the span of a week as with each passing day, a darker complexity emerges in these picture-perfect travelers, the hotel\u2019s\u2026", "poster": "assets/tv-the-white-lotus-2021-poster.jpg", "backdrop": "assets/tv-the-white-lotus-2021-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0546438Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 111803, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 90, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.599, "tmdbVotes": 1604}}, {"id": "tv-widow-s-bay-2026", "title": "Widow's Bay", "year": 2026, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 8.172, "criticRating": null, "genres": ["Drama", "Comedy", "Mystery"], "overview": "The mayor of a New England town resolves to turn it into the next tourist hot spot, despite local warnings that it's cursed.", "poster": "assets/tv-widow-s-bay-2026-poster.jpg", "backdrop": "assets/tv-widow-s-bay-2026-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-20T10:27:12.7003979Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 270476, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 98, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8, "tmdbVotes": 585}}, {"id": "tv-the-witcher-2019", "title": "The Witcher", "year": 2019, "type": "tv", "runtimeSeconds": null, "seasonCount": 4, "communityRating": 7.905, "criticRating": null, "genres": ["Drama", "Action & Adventure"], "overview": "Geralt of Rivia, a mutated monster-hunter for hire, journeys toward his destiny in a turbulent world where people often prove more wicked than beasts.", "poster": "assets/tv-the-witcher-2019-poster.jpg", "backdrop": "assets/tv-the-witcher-2019-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0552369Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 71912, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 75, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.88, "tmdbVotes": 6997}}, {"id": "tv-yellowjackets-2021", "title": "Yellowjackets", "year": 2021, "type": "tv", "runtimeSeconds": null, "seasonCount": 3, "communityRating": 7.449, "criticRating": null, "genres": ["Drama", "Mystery"], "overview": "This equal parts survival epic, psychological horror story and coming-of-age drama tells the saga of a team of wildly talented high school girls soccer players who become the (un)lucky survivors of a plane crash deep\u2026", "poster": "assets/tv-yellowjackets-2021-poster.jpg", "backdrop": "assets/tv-yellowjackets-2021-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0577612Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 117488, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 92, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.431, "tmdbVotes": 1070}}], "anime": [], "resume": [{"id": "episode-episode-1-2025-s1e1", "title": "Episode 1", "year": 2025, "type": "episode", "runtimeSeconds": 3618, "seasonCount": null, "communityRating": 7.357, "criticRating": null, "genres": ["Biography", "Drama", "History"], "overview": "Mussolini had just founded the Fasci di Combattimento when D'Annunzio took Fiume. Mussolini hoped to capitalize on the poet's achievement in the electoral arena.", "poster": "assets/episode-episode-1-2025-s1e1-poster.jpg", "backdrop": "assets/episode-episode-1-2025-s1e1-backdrop.jpg", "played": false, "playCount": 1, "resumeSeconds": 489.55, "progress": 0.1353, "addedAt": "2026-07-17T13:52:48.0000000Z", "lastPlayedAt": "2026-11-04T19:01:49.9731435Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "Mussolini: Son of the Century", "seasonNumber": 1, "episodeNumber": 1, "artwork": {"posterKind": "center_crop", "backdropKind": "backdrop"}}], "nextUp": [{"id": "episode-no-honeymoon-2020-s1e8", "title": "No Honeymoon", "year": 2020, "type": "episode", "runtimeSeconds": 1499, "seasonCount": null, "communityRating": 6.7, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "Paul attempts to focus on his upcoming wedding but is constantly thwarted by his family; Ally is distracted by work, Luke is misbehaving at school and Leah (Stella Gonet) doesn\u2019t understand why they are even\u2026", "poster": "assets/episode-no-honeymoon-2020-s1e8-poster.jpg", "backdrop": "assets/episode-no-honeymoon-2020-s1e8-backdrop.jpg", "played": false, "playCount": 1, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-28T19:00:50.0000000Z", "lastPlayedAt": "2026-11-03T20:57:58.3314947Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "Breeders", "seasonNumber": 1, "episodeNumber": 8, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}, {"id": "episode-notapusy-2005-s3e4", "title": "Notapusy", "year": 2005, "type": "episode", "runtimeSeconds": 1334, "seasonCount": null, "communityRating": 8.125, "criticRating": null, "genres": ["Comedy"], "overview": "To prove he's a man to Rita, Michael and his nephew Steve Holt compete in the Church And State Fair's annual triathlon. George Sr. tries to scare some at-risk youth, and Tobias tries to help George-Michael's\u2026", "poster": "assets/episode-notapusy-2005-s3e4-poster.jpg", "backdrop": "assets/episode-notapusy-2005-s3e4-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T12:17:58.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "Arrested Development", "seasonNumber": 3, "episodeNumber": 4, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}, {"id": "episode-the-man-who-would-be-vogue-2018-s2e1", "title": "The Man Who Would Be Vogue", "year": 2018, "type": "episode", "runtimeSeconds": 3203, "seasonCount": null, "communityRating": 7.459, "criticRating": null, "genres": ["Biography", "Crime", "Drama"], "overview": "The murder of Gianni Versace turns the eyes of the world onto Miami Beach.", "poster": "assets/episode-the-man-who-would-be-vogue-2018-s2e1-poster.jpg", "backdrop": "assets/episode-the-man-who-would-be-vogue-2018-s2e1-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T13:29:35.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "American Crime Story", "seasonNumber": 2, "episodeNumber": 1, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}, {"id": "episode-tomorrow-2024-s3e1", "title": "Tomorrow", "year": 2024, "type": "episode", "runtimeSeconds": 2207, "seasonCount": null, "communityRating": 7.6, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "The next day and the days that led to it.", "poster": "assets/episode-tomorrow-2024-s3e1-poster.jpg", "backdrop": "assets/episode-tomorrow-2024-s3e1-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T13:46:39.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "The Bear", "seasonNumber": 3, "episodeNumber": 1, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}, {"id": "episode-episode-1-2025-s1e1", "title": "Episode 1", "year": 2025, "type": "episode", "runtimeSeconds": 3618, "seasonCount": null, "communityRating": 7.357, "criticRating": null, "genres": ["Biography", "Drama", "History"], "overview": "Mussolini had just founded the Fasci di Combattimento when D'Annunzio took Fiume. Mussolini hoped to capitalize on the poet's achievement in the electoral arena.", "poster": "assets/episode-episode-1-2025-s1e1-poster.jpg", "backdrop": "assets/episode-episode-1-2025-s1e1-backdrop.jpg", "played": false, "playCount": 1, "resumeSeconds": 489.55, "progress": 0.1353, "addedAt": "2026-07-17T13:52:48.0000000Z", "lastPlayedAt": "2026-11-04T19:01:49.9731435Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "Mussolini: Son of the Century", "seasonNumber": 1, "episodeNumber": 1, "artwork": {"posterKind": "center_crop", "backdropKind": "backdrop"}}], "recentlyAdded": [{"id": "tv-the-final-problem-2026", "title": "The Final Problem", "year": 2026, "type": "tv", "runtimeSeconds": 3420, "seasonCount": 1, "communityRating": 7.041, "criticRating": null, "genres": ["Crime", "Mystery"], "overview": "In the spring of 1959, a retired actor, who previously played Sherlock Holmes, must investigate a murder at a small hotel on a tiny island near Mallorca.", "poster": "assets/tv-the-final-problem-2026-poster.jpg", "backdrop": "assets/tv-the-final-problem-2026-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-11-04T19:16:12.4822791Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 290720, "artwork": {"posterKind": "center_crop", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.041, "tmdbVotes": 49}}, {"id": "tv-dead-end-job-2026", "title": "Dead-End Job", "year": 2026, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.7, "criticRating": null, "genres": ["Mystery"], "overview": "Drowning in debt, a young man takes lucrative part-time jobs from a mysterious agency \u2014 only to find his hustle for more money turning nightmarish.", "poster": "assets/tv-dead-end-job-2026-poster.jpg", "backdrop": "assets/tv-dead-end-job-2026-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-11-04T18:58:01.8352857Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 289517, "artwork": {"posterKind": "center_crop", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.7, "tmdbVotes": 38}}, {"id": "tv-dune-prophecy-2024", "title": "Dune: Prophecy", "year": 2024, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.213, "criticRating": null, "genres": ["Sci-Fi & Fantasy", "Drama", "Action & Adventure"], "overview": "Ten thousand years before the ascension of Paul Atreides, sisters Valya and Tula Harkonnen establish the fabled sect and female order that would become known as the Bene Gesserit to control the future of humankind.", "poster": "assets/tv-dune-prophecy-2024-poster.jpg", "backdrop": "assets/tv-dune-prophecy-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-11-04T18:23:01.2908568Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 90228, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 64, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.213, "tmdbVotes": 677}}, {"id": "movie-the-uprising-2026", "title": "The Uprising", "year": 2026, "type": "movie", "runtimeSeconds": 7684, "seasonCount": null, "communityRating": 7.846, "criticRating": 55, "genres": ["Action", "Drama", "History"], "overview": "As war and plague devastate 14th-century England, a humble peasant ignites a rebellion, uniting an army of commoners to defy the King\u2019s might in a desperate fight for justice and survival.", "poster": "assets/movie-the-uprising-2026-poster.jpg", "backdrop": "assets/movie-the-uprising-2026-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-11-02T16:37:03.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 977942, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 57, "rtCriticsLabel": "Rotten", "rtAudience": 65, "rtAudienceLabel": "Upright", "imdb": 6.2, "imdbVotes": 6622, "tmdb": 7.8, "tmdbVotes": 404}}, {"id": "movie-jennifer-s-body-2009", "title": "Jennifer's Body", "year": 2009, "type": "movie", "runtimeSeconds": 6450, "seasonCount": null, "communityRating": 6.012, "criticRating": 47, "genres": ["Horror", "Comedy"], "overview": "Jennifer, a gorgeous, seductive cheerleader takes evil to a whole new level after she's possessed by a sinister demon. Now it's up to her best friend to stop Jennifer's reign of terror before it's too late.", "poster": "assets/movie-jennifer-s-body-2009-poster.jpg", "backdrop": "assets/movie-jennifer-s-body-2009-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-30T03:37:04.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 19994, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 47, "rtCriticsLabel": "Rotten", "rtAudience": 36, "rtAudienceLabel": "Spilled", "imdb": 5.6, "imdbVotes": 179710, "tmdb": 6.013, "tmdbVotes": 4481}}, {"id": "tv-breeders-2020", "title": "Breeders", "year": 2020, "type": "tv", "runtimeSeconds": 1500, "seasonCount": 4, "communityRating": 6.8, "criticRating": null, "genres": ["Comedy"], "overview": "Exposing the parental-paradox that it is possible, in the very same moment, to love your child to the horizon of the universe, while being apoplectically angry enough to want to send them there.", "poster": "assets/tv-breeders-2020-poster.jpg", "backdrop": "assets/tv-breeders-2020-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-28T18:54:21.8885435Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 93290, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 92, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.8, "tmdbVotes": 104}}, {"id": "tv-the-four-seasons-2025", "title": "The Four Seasons", "year": 2025, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 6.879, "criticRating": null, "genres": ["Comedy"], "overview": "The decades-long friendship between three married couples is tested when one divorces, complicating their tradition of quarterly weekend getaways.", "poster": "assets/tv-the-four-seasons-2025-poster.jpg", "backdrop": "assets/tv-the-four-seasons-2025-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-28T18:36:42.5326384Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 243316, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 81, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.892, "tmdbVotes": 204}}, {"id": "tv-adults-2025", "title": "Adults", "year": 2025, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 6.495, "criticRating": null, "genres": ["Comedy"], "overview": "A group of twenty-somethings in New York trying to be good people, despite being neither \"good\" nor \"people\" yet.", "poster": "assets/tv-adults-2025-poster.jpg", "backdrop": "assets/tv-adults-2025-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-23T21:01:41.1814765Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 260458, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 88, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.5, "tmdbVotes": 102}}, {"id": "tv-east-of-eden-2026", "title": "East of Eden", "year": 2026, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.931, "criticRating": null, "genres": ["Drama", "Crime"], "overview": "In the early 1900s, a fiercely independent woman drives a rift between two brothers, igniting a saga that unfolds across two generations.", "poster": "assets/tv-east-of-eden-2026-poster.jpg", "backdrop": "assets/tv-east-of-eden-2026-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-23T18:07:11.3965096Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 258165, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 88, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.982, "tmdbVotes": 139}}, {"id": "movie-fighting-with-my-family-2019", "title": "Fighting with My Family", "year": 2019, "type": "movie", "runtimeSeconds": 6509, "seasonCount": null, "communityRating": 6.84, "criticRating": 93, "genres": ["Comedy"], "overview": "Born into a tight-knit wrestling family, Paige and her brother Zak are ecstatic when they get the once-in-a-lifetime opportunity to try out for the WWE. But when only Paige earns a spot in the competitive training\u2026", "poster": "assets/movie-fighting-with-my-family-2019-poster.jpg", "backdrop": "assets/movie-fighting-with-my-family-2019-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-22T22:10:59.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 445629, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 93, "rtCriticsLabel": "Certified Fresh", "rtAudience": 86, "rtAudienceLabel": "Upright", "imdb": 7, "imdbVotes": 94576, "tmdb": 6.837, "tmdbVotes": 1952}}, {"id": "tv-landman-2024", "title": "Landman", "year": 2024, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 8.194, "criticRating": null, "genres": ["Drama"], "overview": "Set in the proverbial boomtowns of West-Texas and a modern-day tale of fortune-seeking in the world of oil rigs, the series is an upstairs/downstairs story of roughnecks and wildcat billionaires that are fueling a\u2026", "poster": "assets/tv-landman-2024-poster.jpg", "backdrop": "assets/tv-landman-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-22T21:38:18.0973875Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 157741, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 80, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.209, "tmdbVotes": 668}}, {"id": "tv-fawlty-towers-1975", "title": "Fawlty Towers", "year": 1975, "type": "tv", "runtimeSeconds": 1800, "seasonCount": 2, "communityRating": 8.295, "criticRating": null, "genres": ["Comedy"], "overview": "Owner Basil Fawlty, his wife Sybil, a chambermaid Polly, and Spanish waiter Manuel attempt to run their hotel amidst farcical situations and an array of demanding guests.", "poster": "assets/tv-fawlty-towers-1975-poster.jpg", "backdrop": "assets/tv-fawlty-towers-1975-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-19T23:26:39.7658344Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 2207, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.296, "tmdbVotes": 867}}], "discover": [{"id": "movie-12-years-a-slave-2013", "title": "12 Years a Slave", "year": 2013, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "History"], "overview": "In the pre-Civil War United States, Solomon Northup, a free black man from upstate New York, is abducted and sold into slavery. Facing cruelty as well as unexpected kindnesses Solomon struggles not only to stay\u2026", "poster": "assets/movie-12-years-a-slave-2013-poster.jpg", "backdrop": "assets/movie-12-years-a-slave-2013-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 76203, "ratings": {"rtCritics": 95, "rtCriticsLabel": "Certified Fresh", "rtAudience": 90, "rtAudienceLabel": "Upright", "imdb": 8.1, "imdbVotes": 796313, "tmdb": 7.927, "tmdbVotes": 12335}}, {"id": "movie-101-dalmatians-ii-patch-s-london-adventure-2003", "title": "101 Dalmatians II: Patch's London Adventure", "year": 2003, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Family", "Animation", "Adventure"], "overview": "Being one of 101 takes its toll on Patch, who doesn't feel unique. When he's accidentally left behind on moving day, he meets his idol, Thunderbolt, who enlists him on a publicity campaign.", "poster": "assets/movie-101-dalmatians-ii-patch-s-london-adventure-2003-poster.jpg", "backdrop": "assets/movie-101-dalmatians-ii-patch-s-london-adventure-2003-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 13654, "ratings": {"rtCritics": 67, "rtCriticsLabel": "Fresh", "rtAudience": 41, "rtAudienceLabel": "Spilled", "imdb": 5.7, "imdbVotes": 12343, "tmdb": 6.025, "tmdbVotes": 1274}}, {"id": "movie-absolution-2024", "title": "Absolution", "year": 2024, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Crime", "Thriller"], "overview": "An aging ex-boxer gangster working as muscle for a Boston crime boss receives an upsetting diagnosis. Despite a faltering memory, he attempts to rectify the sins of his past and reconnect with his estranged\u2026", "poster": "assets/movie-absolution-2024-poster.jpg", "backdrop": "assets/movie-absolution-2024-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 974453, "ratings": {"rtCritics": 56, "rtCriticsLabel": "Rotten", "rtAudience": 32, "rtAudienceLabel": "Spilled", "imdb": 5.2, "imdbVotes": 11413, "tmdb": 5.906, "tmdbVotes": 564}}, {"id": "movie-addicted-2014", "title": "Addicted", "year": 2014, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Thriller"], "overview": "A gallerist risks her family and flourishing career when she enters into an affair with a talented painter and slowly loses control of her life.", "poster": "assets/movie-addicted-2014-poster.jpg", "backdrop": "assets/movie-addicted-2014-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 235271, "ratings": {"rtCritics": 7, "rtCriticsLabel": "Rotten", "rtAudience": 51, "rtAudienceLabel": "Spilled", "imdb": 5, "imdbVotes": 13131, "tmdb": 5.928, "tmdbVotes": 466}}, {"id": "movie-all-the-money-in-the-world-2017", "title": "All the Money in the World", "year": 2017, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Crime", "History", "Thriller"], "overview": "The story of the kidnapping of 16-year-old John Paul Getty III and the desperate attempt by his devoted mother to convince his billionaire grandfather Jean Paul Getty to pay the ransom.", "poster": "assets/movie-all-the-money-in-the-world-2017-poster.jpg", "backdrop": "assets/movie-all-the-money-in-the-world-2017-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 446791, "ratings": {"rtCritics": 79, "rtCriticsLabel": "Certified Fresh", "rtAudience": 66, "rtAudienceLabel": "Upright", "imdb": 6.8, "imdbVotes": 97448, "tmdb": 6.493, "tmdbVotes": 2493}}, {"id": "movie-the-alto-knights-2025", "title": "The Alto Knights", "year": 2025, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Crime", "Drama"], "overview": "Two of New York's most notorious organized crime bosses, Frank Costello and Vito Genovese, vie for control of the city's streets. Once the best of friends, petty jealousies and a series of betrayals place them on a\u2026", "poster": "assets/movie-the-alto-knights-2025-poster.jpg", "backdrop": "assets/movie-the-alto-knights-2025-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 1013601, "ratings": {"rtCritics": 39, "rtCriticsLabel": "Rotten", "rtAudience": 71, "rtAudienceLabel": "Upright", "imdb": 5.9, "imdbVotes": 23017, "tmdb": 6.223, "tmdbVotes": 485}}, {"id": "movie-the-amazing-spider-man-2-2014", "title": "The Amazing Spider-Man 2", "year": 2014, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Adventure", "Science Fiction"], "overview": "For Peter Parker, life is busy. Between taking out the bad guys as Spider-Man and spending time with the person he loves, Gwen Stacy, high school graduation cannot come quickly enough. Peter has not forgotten about\u2026", "poster": "assets/movie-the-amazing-spider-man-2-2014-poster.jpg", "backdrop": "assets/movie-the-amazing-spider-man-2-2014-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 102382, "ratings": {"rtCritics": 51, "rtCriticsLabel": "Rotten", "rtAudience": 64, "rtAudienceLabel": "Upright", "imdb": 6.6, "imdbVotes": 611981, "tmdb": 6.567, "tmdbVotes": 14919}}, {"id": "movie-ant-man-and-the-wasp-quantumania-2023", "title": "Ant-Man and the Wasp: Quantumania", "year": 2023, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Adventure", "Science Fiction"], "overview": "Super-Hero partners Scott Lang and Hope van Dyne, along with with Hope's parents Janet van Dyne and Hank Pym, and Scott's daughter Cassie Lang, find themselves exploring the Quantum Realm, interacting with strange\u2026", "poster": "assets/movie-ant-man-and-the-wasp-quantumania-2023-poster.jpg", "backdrop": "assets/movie-ant-man-and-the-wasp-quantumania-2023-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 640146, "ratings": {"rtCritics": 46, "rtCriticsLabel": "Rotten", "rtAudience": 81, "rtAudienceLabel": "Upright", "imdb": 6, "imdbVotes": 281738, "tmdb": 6.197, "tmdbVotes": 6236}}, {"id": "movie-arctic-dogs-2019", "title": "Arctic Dogs", "year": 2019, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Animation", "Adventure", "Comedy"], "overview": "Animals band together to save the day when the evil Otto Von Walrus hatches a sinister scheme to accelerate global warming and melt the Arctic Circle.", "poster": "assets/movie-arctic-dogs-2019-poster.jpg", "backdrop": "assets/movie-arctic-dogs-2019-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 416153, "ratings": {"rtCritics": 13, "rtCriticsLabel": "Rotten", "rtAudience": 60, "rtAudienceLabel": "Upright", "imdb": 4.9, "imdbVotes": 2580, "tmdb": 5.9, "tmdbVotes": 148}}, {"id": "movie-the-avengers-2012", "title": "The Avengers", "year": 2012, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Science Fiction", "Action", "Adventure"], "overview": "When an unexpected enemy emerges and threatens global safety and security, Nick Fury, director of the international peacekeeping agency known as S.H.I.E.L.D., finds himself in need of a team to pull the world back\u2026", "poster": "assets/movie-the-avengers-2012-poster.jpg", "backdrop": "assets/movie-the-avengers-2012-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 24428, "ratings": {"rtCritics": 91, "rtCriticsLabel": "Certified Fresh", "rtAudience": 91, "rtAudienceLabel": "Upright", "imdb": 8, "imdbVotes": 1590693, "tmdb": 8.084, "tmdbVotes": 40262}}, {"id": "movie-black-bear-2020", "title": "Black Bear", "year": 2020, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Thriller"], "overview": "At a remote lake house in the Adirondack Mountains, a couple entertains an out-of-town guest looking for inspiration in her filmmaking. The group quickly falls into a calculated game of desire, manipulation, and\u2026", "poster": "assets/movie-black-bear-2020-poster.jpg", "backdrop": "assets/movie-black-bear-2020-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 624788, "ratings": {"rtCritics": 89, "rtCriticsLabel": "Certified Fresh", "rtAudience": 64, "rtAudienceLabel": "Upright", "imdb": 6.5, "imdbVotes": 18408, "tmdb": 6.337, "tmdbVotes": 315}}, {"id": "movie-black-panther-2018", "title": "Black Panther", "year": 2018, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Adventure", "Science Fiction"], "overview": "King T'Challa returns home to the reclusive, technologically advanced African nation of Wakanda to serve as his country's new leader. However, T'Challa soon finds that he is challenged for the throne by factions\u2026", "poster": "assets/movie-black-panther-2018-poster.jpg", "backdrop": "assets/movie-black-panther-2018-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 284054, "ratings": {"rtCritics": 96, "rtCriticsLabel": "Certified Fresh", "rtAudience": null, "rtAudienceLabel": "Spilled", "imdb": 7.3, "imdbVotes": 931120, "tmdb": 7.36, "tmdbVotes": 24190}}, {"id": "movie-a-bronx-tale-1993", "title": "A Bronx Tale", "year": 1993, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Crime"], "overview": "Set in the Bronx during the tumultuous 1960s, an adolescent boy is torn between his honest, working-class father and a violent yet charismatic crime boss. Complicating matters is the youngster's growing attraction -\u2026", "poster": "assets/movie-a-bronx-tale-1993-poster.jpg", "backdrop": "assets/movie-a-bronx-tale-1993-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 1607, "ratings": {"rtCritics": 97, "rtCriticsLabel": "Fresh", "rtAudience": 92, "rtAudienceLabel": "Upright", "imdb": 7.8, "imdbVotes": 191930, "tmdb": 7.913, "tmdbVotes": 3075}}, {"id": "movie-by-any-means-2026", "title": "By Any Means", "year": 2026, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Thriller", "History", "Crime"], "overview": "In 1960s Mississippi, a young Black FBI agent is forced to partner with a notorious mafia hitman to investigate a wave of brutal civil rights murders.", "poster": "assets/movie-by-any-means-2026-poster.jpg", "backdrop": "assets/movie-by-any-means-2026-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 1380417, "ratings": {"rtCritics": 49, "rtCriticsLabel": "Rotten", "rtAudience": 88, "rtAudienceLabel": "Upright", "imdb": 6.8, "imdbVotes": 3534, "tmdb": 6.6, "tmdbVotes": 92}}, {"id": "movie-captain-america-civil-war-2016", "title": "Captain America: Civil War", "year": 2016, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Adventure", "Action", "Science Fiction"], "overview": "Following the events of Age of Ultron, the collective governments of the world pass an act designed to regulate all superhuman activity. This polarizes opinion amongst the Avengers, causing two factions to side with\u2026", "poster": "assets/movie-captain-america-civil-war-2016-poster.jpg", "backdrop": "assets/movie-captain-america-civil-war-2016-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 271110, "ratings": {"rtCritics": 90, "rtCriticsLabel": "Certified Fresh", "rtAudience": 89, "rtAudienceLabel": "Upright", "imdb": 7.8, "imdbVotes": 954943, "tmdb": 7.455, "tmdbVotes": 24808}}, {"id": "movie-captain-america-the-first-avenger-2011", "title": "Captain America: The First Avenger", "year": 2011, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Adventure", "Science Fiction"], "overview": "During World War II, Steve Rogers is a sickly man from Brooklyn who's transformed into super-soldier Captain America to aid in the war effort. Rogers must stop the Red Skull \u2013 Adolf Hitler's ruthless head of\u2026", "poster": "assets/movie-captain-america-the-first-avenger-2011-poster.jpg", "backdrop": "assets/movie-captain-america-the-first-avenger-2011-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 1771, "ratings": {"rtCritics": 80, "rtCriticsLabel": "Certified Fresh", "rtAudience": 75, "rtAudienceLabel": "Upright", "imdb": 6.9, "imdbVotes": 993453, "tmdb": 7.012, "tmdbVotes": 23473}}, {"id": "movie-captain-america-the-winter-soldier-2014", "title": "Captain America: The Winter Soldier", "year": 2014, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Adventure", "Science Fiction"], "overview": "After the cataclysmic events in New York with The Avengers, Steve Rogers, aka Captain America is living quietly in Washington, D.C. and trying to adjust to the modern world. But when a S.H.I.E.L.D. colleague comes\u2026", "poster": "assets/movie-captain-america-the-winter-soldier-2014-poster.jpg", "backdrop": "assets/movie-captain-america-the-winter-soldier-2014-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 100402, "ratings": {"rtCritics": 90, "rtCriticsLabel": "Certified Fresh", "rtAudience": 92, "rtAudienceLabel": "Upright", "imdb": 7.7, "imdbVotes": 995514, "tmdb": 7.654, "tmdbVotes": 20688}}, {"id": "movie-careful-what-you-wish-for-2015", "title": "Careful What You Wish For", "year": 2015, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Thriller"], "overview": "A guy gets more than he bargained for after entering into an affair with the wife of an investment banker. Soon, a suspicious death and substantial life insurance policy embroil him in a scandal.", "poster": "assets/movie-careful-what-you-wish-for-2015-poster.jpg", "backdrop": "assets/movie-careful-what-you-wish-for-2015-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 317744, "ratings": {"rtCritics": 17, "rtCriticsLabel": "Rotten", "rtAudience": 61, "rtAudienceLabel": "Upright", "imdb": 5.6, "imdbVotes": 9452, "tmdb": 6.129, "tmdbVotes": 446}}, {"id": "tv-12-monkeys-2015", "title": "12 Monkeys", "year": 2015, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Sci-Fi & Fantasy", "Drama"], "overview": "The provocative story of Cole, a time traveler from a decimated future in a high-stakes race against the clock. Utilizing a dangerous and untested method of time travel, he journeys from 2043 to the present day on a\u2026", "poster": "assets/tv-12-monkeys-2015-poster.jpg", "backdrop": "assets/tv-12-monkeys-2015-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 60948, "ratings": {"rtCritics": 88, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.441, "tmdbVotes": 1245}}, {"id": "tv-90-day-fiance-before-the-90-days-2017", "title": "90 Day Fianc\u00e9: Before the 90 Days", "year": 2017, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Reality"], "overview": "Follow couples who have an existing relationship online, but haven't met in person. We'll experience their journey as they travel to the other's foreign country for the first time in an attempt to establish an\u2026", "poster": "assets/tv-90-day-fiance-before-the-90-days-2017-poster.jpg", "backdrop": "assets/tv-90-day-fiance-before-the-90-days-2017-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 73319, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.5, "tmdbVotes": 45}}, {"id": "tv-90-day-fiance-the-other-way-2019", "title": "90 Day Fianc\u00e9: The Other Way", "year": 2019, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Reality"], "overview": "Americans move to a foreign country all in the name of love. Will uprooting their lives in the United States pay off, or will they be packing up and moving back to America brokenhearted?", "poster": "assets/tv-90-day-fiance-the-other-way-2019-poster.jpg", "backdrop": "assets/tv-90-day-fiance-the-other-way-2019-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 90046, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.8, "tmdbVotes": 34}}, {"id": "tv-the-100-2014", "title": "The 100", "year": 2014, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Sci-Fi & Fantasy", "Action & Adventure", "Drama"], "overview": "100 years in the future, when the Earth has been abandoned due to radioactivity, the last surviving humans live on an ark orbiting the planet \u2014 but the ark won't last forever. So the repressive regime picks 100\u2026", "poster": "assets/tv-the-100-2014-poster.jpg", "backdrop": "assets/tv-the-100-2014-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 48866, "ratings": {"rtCritics": 93, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.878, "tmdbVotes": 8855}}, {"id": "tv-accused-2023", "title": "Accused", "year": 2023, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Crime", "Drama"], "overview": "In this crime anthology series, viewers discover how an ordinary person got caught up in an extraordinary situation, ultimately revealing how one wrong turn leads to another, until it\u2019s too late to turn back. Told\u2026", "poster": "assets/tv-accused-2023-poster.jpg", "backdrop": "assets/tv-accused-2023-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 202097, "ratings": {"rtCritics": 61, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.851, "tmdbVotes": 87}}, {"id": "tv-after-life-2019", "title": "After Life", "year": 2019, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "Tony had a perfect life. But after his wife Lisa suddenly dies, Tony changes. After contemplating taking his own life, he decides instead to live long enough to punish the world by saying and doing whatever he likes\u2026", "poster": "assets/tv-after-life-2019-poster.jpg", "backdrop": "assets/tv-after-life-2019-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 79410, "ratings": {"rtCritics": 72, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.902, "tmdbVotes": 1169}}, {"id": "tv-all-creatures-great-and-small-1978", "title": "All Creatures Great and Small", "year": 1978, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "The trials and misadventures of the staff at a country veterinary office in Yorkshire. James Herriot, a young animal surgeon, moves to a small Yorkshire town to begin his first job.", "poster": "assets/tv-all-creatures-great-and-small-1978-poster.jpg", "backdrop": "assets/tv-all-creatures-great-and-small-1978-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "center_crop", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 7406, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.8, "tmdbVotes": 50}}, {"id": "tv-american-housewife-2016", "title": "American Housewife", "year": 2016, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Comedy"], "overview": "A family comedy narrated by Katie, a strong-willed mother, raising her flawed family in a wealthy town filled with perfect wives and their perfect offspring.", "poster": "assets/tv-american-housewife-2016-poster.jpg", "backdrop": "assets/tv-american-housewife-2016-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 67117, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.7, "tmdbVotes": 187}}, {"id": "tv-atlantis-2013", "title": "Atlantis", "year": 2013, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action & Adventure", "Sci-Fi & Fantasy"], "overview": "A fantasy drama set in a world of legendary heroes and mythical creatures. Far from home and desperate for answers, Jason washes up on the shores of an ancient land. A mysterious place; a world of bull leaping, of\u2026", "poster": "assets/tv-atlantis-2013-poster.jpg", "backdrop": "assets/tv-atlantis-2013-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 47054, "ratings": {"rtCritics": 62, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.4, "tmdbVotes": 155}}, {"id": "tv-avatar-the-last-airbender-2024", "title": "Avatar: The Last Airbender", "year": 2024, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action & Adventure", "Sci-Fi & Fantasy", "Drama"], "overview": "A young boy known as the Avatar must master the four elemental powers to save a world at war \u2014 and fight a ruthless enemy bent on stopping him.", "poster": "assets/tv-avatar-the-last-airbender-2024-poster.jpg", "backdrop": "assets/tv-avatar-the-last-airbender-2024-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 82452, "ratings": {"rtCritics": 66, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.8, "tmdbVotes": 1406}}, {"id": "tv-avatar-seven-havens-2026", "title": "Avatar: Seven Havens", "year": 2026, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "In a world shattered by a devastating cataclysm, a young Earthbender discovers she's the new Avatar after Korra. But in this dangerous era, that title marks her as humanity's destroyer, not its savior. Hunted by both\u2026", "poster": "assets/tv-avatar-seven-havens-2026-poster.jpg", "backdrop": "assets/tv-avatar-seven-havens-2026-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 284833, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.9, "tmdbVotes": 183}}, {"id": "tv-batman-the-brave-and-the-bold-2008", "title": "Batman: The Brave and the Bold", "year": 2008, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "The Caped Crusader is teamed up with Blue Beetle, Green Arrow, Aquaman and countless others in his quest to uphold justice.", "poster": "assets/tv-batman-the-brave-and-the-bold-2008-poster.jpg", "backdrop": "assets/tv-batman-the-brave-and-the-bold-2008-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 15804, "ratings": {"rtCritics": 96, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.6, "tmdbVotes": 295}}, {"id": "tv-beat-bobby-flay-2013", "title": "Beat Bobby Flay", "year": 2013, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Reality"], "overview": "Two talented chefs go head-to-head for the chance to Beat Bobby Flay. To get to Bobby the chefs must first face off against each other, creating a spectacular dish with a secret ingredient of Bobby's choice. Judges\u2026", "poster": "assets/tv-beat-bobby-flay-2013-poster.jpg", "backdrop": "assets/tv-beat-bobby-flay-2013-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 62481, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.5, "tmdbVotes": 31}}, {"id": "tv-benson-1979", "title": "Benson", "year": 1979, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Comedy"], "overview": "A butler deals with life at the governor's mansion.", "poster": "assets/tv-benson-1979-poster.jpg", "backdrop": "assets/tv-benson-1979-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 1849, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7, "tmdbVotes": 46}}, {"id": "tv-black-rabbit-2025", "title": "Black Rabbit", "year": 2025, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Crime"], "overview": "A rising-star restaurateur is forced into New York's criminal underworld when his chaotic brother returns to town with loan sharks on his trail.", "poster": "assets/tv-black-rabbit-2025-poster.jpg", "backdrop": "assets/tv-black-rabbit-2025-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 249039, "ratings": {"rtCritics": 66, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.983, "tmdbVotes": 240}}, {"id": "tv-black-ish-2014", "title": "black-ish", "year": 2014, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Comedy"], "overview": "A family man struggles to gain a sense of cultural identity while raising his kids in a predominantly white, upper-middle-class neighborhood.", "poster": "assets/tv-black-ish-2014-poster.jpg", "backdrop": "assets/tv-black-ish-2014-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 61381, "ratings": {"rtCritics": 92, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.1, "tmdbVotes": 352}}, {"id": "tv-breaking-bad-2008", "title": "Breaking Bad", "year": 2008, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Crime"], "overview": "Walter White, a New Mexico chemistry teacher, is diagnosed with Stage III cancer and given a prognosis of only two years left to live. He becomes filled with a sense of fearlessness and an unrelenting desire to\u2026", "poster": "assets/tv-breaking-bad-2008-poster.jpg", "backdrop": "assets/tv-breaking-bad-2008-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "center_crop", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 1396, "ratings": {"rtCritics": 96, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.959, "tmdbVotes": 19030}}, {"id": "tv-the-capture-2019", "title": "The Capture", "year": 2019, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Crime", "Drama", "Mystery"], "overview": "When soldier Shaun Emery's conviction for a murder in Afghanistan is overturned due to flawed video evidence, he returns to life as a free man with his young daughter. But when damning CCTV footage from a night out\u2026", "poster": "assets/tv-the-capture-2019-poster.jpg", "backdrop": "assets/tv-the-capture-2019-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 93166, "ratings": {"rtCritics": 97, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.634, "tmdbVotes": 361}}], "discoverySource": {"state": "materialized_snapshot", "rankKnown": false, "selection": "First 18 alphabetically indexed movie stubs and first 18 series stubs, not ranked feed order", "observedAt": "2026-11-04T20:42:12.030108+00:00"}}], "notes": {"real": ["Profile keys and library membership derive from deployed Conductarr configuration and viewer-scoped Jellyfin GET responses.", "Names, years, overviews, library ratings, genres, season-container counts and watch/resume data come from actual Jellyfin metadata.", "All local artwork is copied from Jellyfin; discovery posters are the real materialized Conductarr recommendation artwork.", "Discover contains 36 real materialized personal recommendations per profile, with profile-scoped availability from read-only retained membership/request state.", "Justin has no playable resume items. Mindel and Shared each have one. No progress or watched flags were invented."], "approximations": ["Default profile initials and display capitalization are frontend presentation choices; colours reuse the existing PWA palette, not live user appearance configuration.", "Conductarr catalogue and recommendation GET endpoints both returned 401. Authentication was not bypassed. SQLite contains no retained recommendation feed/candidate snapshot tables; the source keeps ranked feeds in memory. Discovery therefore reads existing per-profile Jellyfin recommendation stubs as metadata only.", "Discovery selection is 18 alphabetically indexed movie suggestions plus 18 series suggestions, not personal rank order; rank is null. Materialization age and original feed ordering are unknown.", "Discovery stub runtime is synthetic (two seconds), so runtime, season count, rating and watch state are null rather than presenting stub values as real media facts.", "93 selected identities lack original landscape art in the materialized metadata; their backdrop is a marked crop of the real poster, not an original film backdrop. Existing artwork is reused; new artwork is centre-cropped to the required dimensions.", "Jellyfin server-side resizing requests maxWidth/maxHeight/quality. When the result had the wrong aspect ratio, sequential single-thread ffmpeg center crops produce exact 300x450 and 1280x720 JPEGs.", "Series seasonCount is Jellyfin ChildCount, including season containers for specials when present.", "Discover availability is a retained profile-membership/request snapshot, not a fresh playback or acquisition guarantee. not_here means absent from that profile in retained state, not absent from the entire household."], "missing": ["Justin Movies has seven live viewer-scoped films, not 200. No titles are invented or borrowed across profile boundaries.", "Discovery community/critic ratings, reliable runtime/season counts, personal feed ranking/freshness and 93 original landscape backdrops are unavailable through the permitted read paths."], "unmetRequirements": ["At least 200 real profile-scoped Justin Movies"], "mustNotBeShown": ["Recs Movies", "Recs Shows", "Justin Movies", "Justin Shows", "Mindel Movies", "Mindel Shows", "Shared Movies", "Shared Shows", "DGiC", "Stored names padded with spaces", "Internal bucket and acquisition-instance names"], "privacy": ["Includes actual watched and resume history for Justin, Mindel and Shared independently; Shared is not a union.", "No Jellyfin item/user IDs, source paths, service URLs, credentials, request actor identities, or private hotlinks are included. tmdbId is a public external title identity.", "Only the seven configured playable libraries are selected. Extra observed DGiC views and all recommendation stubs are excluded from playable collections.", "No profile is assumed to be a child or age-restricted. Each playable list and episode shelf is limited to that profile\u2019s configured libraries."], "sourceReadPaths": ["GET /Users (allowlisted mapping only)", "GET /Users/{userId}/Views", "GET /Users/{userId}/Items (parent-scoped playable libraries and metadata-only recommendation libraries)", "GET /Users/{userId}/Items/Resume", "GET /Shows/NextUp?UserId=...", "GET /Items/{itemId}/Images/Primary", "GET /Items/{itemId}/Images/Backdrop/0", "Conductarr GET /catalogue/discover?page=1&media_type=movie (401)", "Conductarr GET /api/v1/recommendations/profiles/justin/feed (401)", "SQLite file URI mode=ro plus PRAGMA query_only=ON: catalogue_metadata, bucket_memberships, native_requests; sqlite_master for schema inspection"], "productionMutations": 0, "artwork": {"files": 632, "bytes": 35201423, "posterDimensions": [300, 450], "backdropDimensions": [1280, 720], "posterQualityRequested": 70, "backdropQualityRequested": 65, "posterDerivedBackdropCount": 93}, "consumerContract": ["profiles is an array in Justin/Mindel/Shared order. Every profile owns movies/shows/anime/resume/nextUp/recentlyAdded/discover item arrays; empty shelves are intentional.", "Item paths are relative to fixture.json. Resolve assets against the fixture directory, not against the outer prototype document.", "Use destinations for visible navigation. No raw library display/stored names belong in UI.", "Deduplicate library/resume/discover by stable local id or (type,tmdbId). Never treat a materialized recommendation as a playable library item.", "played=null and watchStateKnown=false mean unknown, not unwatched. Null ratings are missing, not zero; communityRating source is Jellyfin, not an asserted TMDB/IMDb score.", "availability values are in_library/requested/not_here. No requested examples were observed in the selected discover set; do not fabricate them.", "Render artwork.backdropKind=poster_crop as a poster-derived landscape; prefer the poster over pretending it is original landscape art.", "This static fixture preserves actual current collection sizes, viewer-scoped watch state and unknown discovery ranking; request and playback demonstrations must remain labelled simulations."]}} \ No newline at end of file diff --git a/wolfskin-design/src/test/resources/wolfskin-home-presses.json b/wolfskin-design/src/test/resources/wolfskin-home-presses.json new file mode 100644 index 00000000..700edd60 --- /dev/null +++ b/wolfskin-design/src/test/resources/wolfskin-home-presses.json @@ -0,0 +1,126 @@ +[ + { + "goal": "Resume (Mindel, from launch)", + "keys": [ + "RIGHT", + "OK", + "OK" + ], + "presses": 3, + "note": "", + "start": "app launch: Who's watching with Justin focused (index.html:357)", + "replayed": "PASS", + "source": "index.html:2152-2174 PRESSES, replayed 2026-11-05" + }, + { + "goal": "Play next (Justin has no resume)", + "keys": [ + "OK", + "OK" + ], + "presses": 2, + "note": "", + "start": "app launch: Who's watching with Justin focused (index.html:357)", + "replayed": "PASS", + "source": "index.html:2152-2174 PRESSES, replayed 2026-11-05" + }, + { + "goal": "Round 3 \u00b7 Home hero \u2192 Trending (D2 split)", + "keys": [ + "OK", + "DOWN", + "DOWN", + "DOWN", + "DOWN", + "DOWN", + "DOWN", + "DOWN", + "DOWN", + "DOWN", + "DOWN", + "DOWN", + "DOWN" + ], + "presses": 13, + "note": "12 Downs after the launch OK", + "start": "app launch: Who's watching with Justin focused (index.html:357)", + "replayed": "PASS", + "source": "index.html:2152-2174 PRESSES, replayed 2026-11-05" + }, + { + "goal": "Round 3 \u00b7 Trending \u2192 its See all card (D3: 20 cards)", + "keys": [ + "OK", + "DOWN", + "DOWN", + "DOWN", + "DOWN", + "DOWN", + "DOWN", + "DOWN", + "DOWN", + "DOWN", + "DOWN", + "DOWN", + "DOWN", + "RIGHT", + "RIGHT", + "RIGHT", + "RIGHT", + "RIGHT", + "RIGHT", + "RIGHT", + "RIGHT", + "RIGHT", + "RIGHT", + "RIGHT", + "RIGHT", + "RIGHT", + "RIGHT", + "RIGHT", + "RIGHT", + "RIGHT", + "RIGHT", + "RIGHT", + "RIGHT" + ], + "presses": 33, + "note": "20 Rights from Trending", + "start": "app launch: Who's watching with Justin focused (index.html:357)", + "replayed": "PASS", + "source": "index.html:2152-2174 PRESSES, replayed 2026-11-05" + }, + { + "goal": "Round 3 \u00b7 deep Home row: Back to the nav, Down to the same card", + "keys": [ + "OK", + "DOWN", + "DOWN", + "DOWN", + "DOWN", + "RIGHT", + "RIGHT", + "BACK", + "DOWN" + ], + "presses": 9, + "note": "2: Back, Down", + "start": "app launch: Who's watching with Justin focused (index.html:357)", + "replayed": "PASS", + "source": "index.html:2152-2174 PRESSES, replayed 2026-11-05" + }, + { + "goal": "Open Discover", + "keys": [ + "OK", + "UP", + "RIGHT", + "OK" + ], + "presses": 4, + "note": "", + "start": "app launch: Who's watching with Justin focused (index.html:357)", + "replayed": "PASS", + "source": "index.html:2152-2174 PRESSES, replayed 2026-11-05" + } +] diff --git a/wolfskin-design/src/test/resources/wolfskin-library.json b/wolfskin-design/src/test/resources/wolfskin-library.json new file mode 100644 index 00000000..76617014 --- /dev/null +++ b/wolfskin-design/src/test/resources/wolfskin-library.json @@ -0,0 +1 @@ +{"profiles":[{"key":"justin","movies":[{"id":"movie-the-girl-who-leapt-through-time-2006","title":"The Girl Who Leapt Through Time","year":2006,"type":"movie","runtimeSeconds":5886,"seasonCount":null,"communityRating":7.772,"criticRating":87,"genres":["Fantasy","Animation","Drama"],"overview":"When 17-year-old Makoto Konno gains the ability to 'leap' backwards through time, she immediately sets about improving her grades and preventing personal mishaps. However, she soon realises that changing the past\u2026","poster":"assets/movie-the-girl-who-leapt-through-time-2006-poster.jpg","backdrop":"assets/movie-the-girl-who-leapt-through-time-2006-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T17:39:11.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":14069,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":87,"rtCriticsLabel":"Fresh","rtAudience":90,"rtAudienceLabel":"Upright","imdb":7.6,"imdbVotes":76917,"tmdb":7.775,"tmdbVotes":2345}},{"id":"movie-locke-2014","title":"Locke","year":2014,"type":"movie","runtimeSeconds":5090,"seasonCount":null,"communityRating":6.891,"criticRating":91,"genres":["Drama","Thriller"],"overview":"Ivan Locke has worked hard to craft a good life for himself. Tonight, that life will collapse around him. On the eve of the biggest challenge of his career, Ivan receives a phone call that sets in motion a series of\u2026","poster":"assets/movie-locke-2014-poster.jpg","backdrop":"assets/movie-locke-2014-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T17:38:48.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":210479,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":90,"rtCriticsLabel":"Certified Fresh","rtAudience":72,"rtAudienceLabel":"Upright","imdb":7.1,"imdbVotes":171265,"tmdb":6.889,"tmdbVotes":3088}},{"id":"movie-look-who-s-back-2015","title":"Look Who's Back","year":2015,"type":"movie","runtimeSeconds":6936,"seasonCount":null,"communityRating":6.793,"criticRating":null,"genres":["Comedy"],"overview":"When Adolf Hitler reawakens at the site of his former bunker in present-day Berlin, he is mistaken for a comedian and quickly becomes a media phenomenon.","poster":"assets/movie-look-who-s-back-2015-poster.jpg","backdrop":"assets/movie-look-who-s-back-2015-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:27:48.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":318917,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":81,"rtAudienceLabel":"Upright","imdb":7,"imdbVotes":54672,"tmdb":6.796,"tmdbVotes":1990}},{"id":"movie-memories-1995","title":"Memories","year":1995,"type":"movie","runtimeSeconds":6805,"seasonCount":null,"communityRating":7.465,"criticRating":null,"genres":["Fantasy","Animation","Science Fiction"],"overview":"Three back-to-back anime films by three different directors make up this sci-fi trilogy three years in the making.","poster":"assets/movie-memories-1995-poster.jpg","backdrop":"assets/movie-memories-1995-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-30T21:36:03.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":42994,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":82,"rtAudienceLabel":"Upright","imdb":7.5,"imdbVotes":26638,"tmdb":7.459,"tmdbVotes":622}},{"id":"movie-reincarnated-2012","title":"Reincarnated","year":2012,"type":"movie","runtimeSeconds":5759,"seasonCount":null,"communityRating":6.145,"criticRating":28,"genres":["Music","Documentary"],"overview":"Evolution as an artist is often times what separates legends from the more mundane. After being heavily influenced by his experience in Jamaica \u2013 and his subsequent name change from Snoop Dogg to Snoop Lion \u2013 the LBC\u2026","poster":"assets/movie-reincarnated-2012-poster.jpg","backdrop":"assets/movie-reincarnated-2012-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:27:26.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":123431,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":28,"rtCriticsLabel":"Rotten","rtAudience":41,"rtAudienceLabel":"Spilled","imdb":6.2,"imdbVotes":2563,"tmdb":6.1,"tmdbVotes":69}},{"id":"movie-shipwrecked-nightmare-at-sea-2026","title":"Shipwrecked: Nightmare at Sea","year":2026,"type":"movie","runtimeSeconds":5385,"seasonCount":null,"communityRating":7.181,"criticRating":null,"genres":["Documentary"],"overview":"Never-before-seen footage and survivor accounts trace the 2012 shipwreck of a luxury cruise and the disaster that ensued, in this immersive documentary.","poster":"assets/movie-shipwrecked-nightmare-at-sea-2026-poster.jpg","backdrop":"assets/movie-shipwrecked-nightmare-at-sea-2026-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-20T20:11:43.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":1715492,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":6.5,"imdbVotes":5080,"tmdb":7.172,"tmdbVotes":87}},{"id":"movie-steins-gate-the-movie-load-region-of-deja-vu-2013","title":"Steins;Gate: The Movie - Load Region of D\u00e9j\u00e0 Vu","year":2013,"type":"movie","runtimeSeconds":5403,"seasonCount":null,"communityRating":7.8,"criticRating":null,"genres":["Animation","Mystery","Sci-Fi"],"overview":"After the events of the anime, Rintarou begins to feel the repercussions of extensive time travel, and eventually completely fades from reality. Kurisu, being the only companion to remember him, now must find a way\u2026","poster":"assets/movie-steins-gate-the-movie-load-region-of-deja-vu-2013-poster.jpg","backdrop":"assets/movie-steins-gate-the-movie-load-region-of-deja-vu-2013-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-22T14:55:54.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","artwork":{"posterKind":"poster","backdropKind":"backdrop"}}],"shows":[{"id":"tv-andor-2022","title":"Andor","year":2022,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":8.293,"criticRating":null,"genres":["Sci-Fi & Fantasy","Action & Adventure","Drama"],"overview":"In an era filled with danger, deception and intrigue, Cassian Andor will discover the difference he can make in the struggle against the tyrannical Galactic Empire. He embarks on a path that is destined to turn him\u2026","poster":"assets/tv-andor-2022-poster.jpg","backdrop":"assets/tv-andor-2022-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0432996Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":83867,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":96,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.321,"tmdbVotes":2312}},{"id":"tv-the-boys-2019","title":"The Boys","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":5,"communityRating":8.443,"criticRating":null,"genres":["Sci-Fi & Fantasy","Action & Adventure"],"overview":"A group of vigilantes known informally as \u201cThe Boys\u201d set out to take down corrupt superheroes with no more than blue-collar grit and a willingness to fight dirty.","poster":"assets/tv-the-boys-2019-poster.jpg","backdrop":"assets/tv-the-boys-2019-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0410131Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":76479,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":92,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.4,"tmdbVotes":13628}},{"id":"tv-californication-2007","title":"Californication","year":2007,"type":"tv","runtimeSeconds":1680,"seasonCount":7,"communityRating":7.632,"criticRating":null,"genres":["Comedy","Drama"],"overview":"A self-loathing, alcoholic writer attempts to repair his damaged relationships with his daughter and her mother while combating sex addiction, a budding drug problem, and the seeming inability to avoid making bad\u2026","poster":"assets/tv-californication-2007-poster.jpg","backdrop":"assets/tv-californication-2007-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0428037Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":1215,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":57,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.62,"tmdbVotes":1406}},{"id":"tv-firefly-2002","title":"Firefly","year":2002,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":8.347,"criticRating":null,"genres":["Drama","Action & Adventure","Sci-Fi & Fantasy"],"overview":"In the year 2517, the universe is a vast frontier where lawlessness reigns and adventure awaits! Join Captain Malcolm Reynolds and his ragtag crew aboard the spaceship Serenity as they navigate the fringes of\u2026","poster":"assets/tv-firefly-2002-poster.jpg","backdrop":"assets/tv-firefly-2002-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-20T11:40:08.4202369Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":1437,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":77,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.351,"tmdbVotes":2550}},{"id":"tv-fleabag-2016","title":"Fleabag","year":2016,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":8.278,"criticRating":null,"genres":["Comedy","Drama"],"overview":"A portrait into the mind of a dry-witted, sexual, angry, porn-watching, grief-riddled woman, trying to make sense of the world. As she hurls herself headlong at modern living, Fleabag is thrown roughly up against the\u2026","poster":"assets/tv-fleabag-2016-poster.jpg","backdrop":"assets/tv-fleabag-2016-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T17:42:25.4848214Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":67070,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.3,"tmdbVotes":1993}},{"id":"tv-gravity-falls-2012","title":"Gravity Falls","year":2012,"type":"tv","runtimeSeconds":1320,"seasonCount":2,"communityRating":8.619,"criticRating":null,"genres":["Action & Adventure","Animation","Comedy"],"overview":"Twin brother and sister Dipper and Mabel Pines are in for an unexpected adventure when they spend the summer helping their great uncle Stan run a tourist trap in the mysterious town of Gravity Falls, Oregon.","poster":"assets/tv-gravity-falls-2012-poster.jpg","backdrop":"assets/tv-gravity-falls-2012-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0435827Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":40075,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.624,"tmdbVotes":3647}},{"id":"tv-invincible-2021","title":"INVINCIBLE","year":2021,"type":"tv","runtimeSeconds":null,"seasonCount":4,"communityRating":8.638,"criticRating":null,"genres":["Animation","Drama","Sci-Fi & Fantasy"],"overview":"Mark Grayson is a normal teenager except for the fact that his father is the most powerful superhero on the planet. Shortly after his seventeenth birthday, Mark begins to develop powers of his own and enters into his\u2026","poster":"assets/tv-invincible-2021-poster.jpg","backdrop":"assets/tv-invincible-2021-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.040159Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":95557,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":99,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.6,"tmdbVotes":6155}},{"id":"tv-the-legend-of-korra-2012","title":"The Legend of Korra","year":2012,"type":"tv","runtimeSeconds":1380,"seasonCount":4,"communityRating":8.2,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"Avatar Korra, a headstrong, rebellious, feisty young woman who continually challenges and breaks with tradition, is on her quest to become a fully realized Avatar. In this story, the Avatar struggles to find balance\u2026","poster":"assets/tv-the-legend-of-korra-2012-poster.jpg","backdrop":"assets/tv-the-legend-of-korra-2012-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-20T00:19:23.2494945Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":33880,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":88,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.221,"tmdbVotes":2482}},{"id":"tv-love-death-robots-2019","title":"Love, Death & Robots","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":4,"communityRating":8.205,"criticRating":null,"genres":["Animation","Sci-Fi & Fantasy"],"overview":"Terrifying creatures, wicked surprises and dark comedy converge in this NSFW anthology of animated stories presented by Tim Miller and David Fincher.","poster":"assets/tv-love-death-robots-2019-poster.jpg","backdrop":"assets/tv-love-death-robots-2019-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-20T11:31:20.8818757Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":86831,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":89,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.203,"tmdbVotes":4238}},{"id":"tv-norsemen-2016","title":"Norsemen","year":2016,"type":"tv","runtimeSeconds":1800,"seasonCount":3,"communityRating":7.177,"criticRating":null,"genres":["Comedy","Drama"],"overview":"Norsemen is an epic and humorous drama series set in the Viking Age. The residents of an 8th-century Viking village experience political rivalry, social change and innovations that upend their culture and way of life.","poster":"assets/tv-norsemen-2016-poster.jpg","backdrop":"assets/tv-norsemen-2016-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.034182Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":68126,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.2,"tmdbVotes":233}},{"id":"tv-rick-and-morty-2013","title":"Rick and Morty","year":2013,"type":"tv","runtimeSeconds":null,"seasonCount":9,"communityRating":8.679,"criticRating":null,"genres":["Animation","Comedy","Sci-Fi & Fantasy"],"overview":"Follows a sociopathic genius scientist who drags his inherently timid grandson on adventures across the universe.","poster":"assets/tv-rick-and-morty-2013-poster.jpg","backdrop":"assets/tv-rick-and-morty-2013-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0419581Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":60625,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":91,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.7,"tmdbVotes":11548}},{"id":"tv-secret-level-2024","title":"Secret Level","year":2024,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.411,"criticRating":null,"genres":["Animation","Sci-Fi & Fantasy","Action & Adventure"],"overview":"Adult animated series of original short stories which are set within the worlds of beloved video games. Each episode serves as a gateway to a new adventure, unlocking exciting worlds from beloved gaming classics and\u2026","poster":"assets/tv-secret-level-2024-poster.jpg","backdrop":"assets/tv-secret-level-2024-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0336417Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":261579,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":65,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.392,"tmdbVotes":612}},{"id":"tv-silo-2023","title":"Silo","year":2023,"type":"tv","runtimeSeconds":null,"seasonCount":3,"communityRating":8.178,"criticRating":null,"genres":["Sci-Fi & Fantasy","Drama"],"overview":"In a ruined and toxic future, thousands live in a giant silo deep underground. After its sheriff breaks a cardinal rule and residents die mysteriously, engineer Juliette starts to uncover shocking secrets and the\u2026","poster":"assets/tv-silo-2023-poster.jpg","backdrop":"assets/tv-silo-2023-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0346526Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":125988,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":92,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.232,"tmdbVotes":2881}},{"id":"tv-south-park-1997","title":"South Park","year":1997,"type":"tv","runtimeSeconds":null,"seasonCount":29,"communityRating":8.336,"criticRating":null,"genres":["Animation","Comedy"],"overview":"Follow the misadventures of four irreverent grade-schoolers in the quiet, dysfunctional town of South Park, Colorado.","poster":"assets/tv-south-park-1997-poster.jpg","backdrop":"assets/tv-south-park-1997-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0456124Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":2190,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":80,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.332,"tmdbVotes":5250}}],"anime":[{"id":"tv-bleach-2004","title":"Bleach","year":2004,"type":"tv","runtimeSeconds":1440,"seasonCount":17,"communityRating":8.363,"criticRating":null,"genres":["Action & Adventure","Animation","Sci-Fi & Fantasy"],"overview":"For as long as he can remember, Ichigo Kurosaki has been able to see ghosts. But when he meets Rukia, a Soul Reaper who battles evil spirits known as Hollows, he finds his life is changed forever. Now, with a\u2026","poster":"assets/tv-bleach-2004-poster.jpg","backdrop":"assets/tv-bleach-2004-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0359827Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":30984,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.4,"tmdbVotes":2331}},{"id":"tv-chainsaw-man-2022","title":"Chainsaw Man","year":2022,"type":"tv","runtimeSeconds":1440,"seasonCount":1,"communityRating":8.515,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"Denji has a simple dream\u2014to live a happy and peaceful life, spending time with a girl he likes. This is a far cry from reality, however, as Denji is forced by the yakuza into killing devils in order to pay off his\u2026","poster":"assets/tv-chainsaw-man-2022-poster.jpg","backdrop":"assets/tv-chainsaw-man-2022-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-20T22:28:13.2095501Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":114410,"artwork":{"posterKind":"center_crop","backdropKind":"backdrop"},"ratings":{"rtCritics":97,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.508,"tmdbVotes":2364}},{"id":"tv-cyberpunk-edgerunners-2022","title":"Cyberpunk: Edgerunners","year":2022,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":8.466,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"In a dystopia riddled with corruption and cybernetic implants, a talented but reckless street kid strives to become a mercenary outlaw \u2014 an edgerunner.","poster":"assets/tv-cyberpunk-edgerunners-2022-poster.jpg","backdrop":"assets/tv-cyberpunk-edgerunners-2022-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-20T22:28:13.2093107Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":105248,"artwork":{"posterKind":"center_crop","backdropKind":"backdrop"},"ratings":{"rtCritics":89,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.507,"tmdbVotes":2132}},{"id":"tv-dan-da-dan-2024","title":"Dan Da Dan","year":2024,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":8.538,"criticRating":null,"genres":["Animation","Action & Adventure","Comedy"],"overview":"In a bet to prove whether ghosts or aliens exist, two high schoolers face terrifying paranormal threats, gain superpowers and maybe even fall in love?!","poster":"assets/tv-dan-da-dan-2024-poster.jpg","backdrop":"assets/tv-dan-da-dan-2024-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-22T10:31:16.2652252Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":240411,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.517,"tmdbVotes":986}},{"id":"tv-dr-stone-2019","title":"Dr. STONE","year":2019,"type":"tv","runtimeSeconds":1440,"seasonCount":4,"communityRating":8.541,"criticRating":null,"genres":["Animation","Action & Adventure","Comedy"],"overview":"One fateful day, all of humanity was petrified by a blinding flash of light. After several millennia, high schooler Taiju awakens and finds himself lost in a world of statues. However, he\u2019s not alone! His\u2026","poster":"assets/tv-dr-stone-2019-poster.jpg","backdrop":"assets/tv-dr-stone-2019-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-20T22:28:13.2091579Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":86031,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.53,"tmdbVotes":1814}},{"id":"tv-frieren-beyond-journey-s-end-2023","title":"Frieren: Beyond Journey's End","year":2023,"type":"tv","runtimeSeconds":1500,"seasonCount":2,"communityRating":8.803,"criticRating":null,"genres":["Animation","Action & Adventure","Drama"],"overview":"After the party of heroes defeated the Demon King, they restored peace to the land and returned to lives of solitude. Generations pass, and the elven mage Frieren comes face to face with humanity\u2019s mortality. She\u2026","poster":"assets/tv-frieren-beyond-journey-s-end-2023-poster.jpg","backdrop":"assets/tv-frieren-beyond-journey-s-end-2023-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-20T22:28:13.2094435Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":209867,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.826,"tmdbVotes":1016}},{"id":"tv-fullmetal-alchemist-brotherhood-2009","title":"Fullmetal Alchemist: Brotherhood","year":2009,"type":"tv","runtimeSeconds":1500,"seasonCount":1,"communityRating":8.7,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"Disregard for alchemy\u2019s laws ripped half of Edward Elric\u2019s limbs from his body and left his brother Alphonse\u2019s soul clinging to a suit of armor. To restore what was lost, the brothers seek the Philosopher\u2019s Stone.\u2026","poster":"assets/tv-fullmetal-alchemist-brotherhood-2009-poster.jpg","backdrop":"assets/tv-fullmetal-alchemist-brotherhood-2009-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-20T10:27:12.3573939Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":31911,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.7,"tmdbVotes":2633}},{"id":"tv-jujutsu-kaisen-2020","title":"JUJUTSU KAISEN","year":2020,"type":"tv","runtimeSeconds":1440,"seasonCount":3,"communityRating":8.583,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"Yuji Itadori is a boy with tremendous physical strength, though he lives a completely ordinary high school life. One day, to save a classmate who has been attacked by curses, he eats the finger of Ryomen Sukuna,\u2026","poster":"assets/tv-jujutsu-kaisen-2020-poster.jpg","backdrop":"assets/tv-jujutsu-kaisen-2020-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-20T22:28:13.207573Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":95479,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.574,"tmdbVotes":4735}},{"id":"tv-kaiju-no-8-2024","title":"Kaiju No. 8","year":2024,"type":"tv","runtimeSeconds":1440,"seasonCount":2,"communityRating":8.436,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"In a world plagued by creatures known as Kaiju, Kafka Hibino aspired to enlist in The Defense Force. He makes a promise to enlist with his childhood friend, Mina Ashiro. Soon, life takes them in separate ways. While\u2026","poster":"assets/tv-kaiju-no-8-2024-poster.jpg","backdrop":"assets/tv-kaiju-no-8-2024-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-22T10:31:16.2646566Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":207468,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.4,"tmdbVotes":836}},{"id":"tv-my-hero-academia-2016","title":"My Hero Academia","year":2016,"type":"tv","runtimeSeconds":1440,"seasonCount":8,"communityRating":8.607,"criticRating":null,"genres":["Action & Adventure","Animation","Sci-Fi & Fantasy"],"overview":"After he saves a bully from a Villain, a normal student is granted a superpower that allows him to attend a high school training academy for Heroes.","poster":"assets/tv-my-hero-academia-2016-poster.jpg","backdrop":"assets/tv-my-hero-academia-2016-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-20T22:28:13.2089315Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":65930,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.6,"tmdbVotes":5426}},{"id":"tv-neon-genesis-evangelion-1995","title":"Neon Genesis Evangelion","year":1995,"type":"tv","runtimeSeconds":1500,"seasonCount":1,"communityRating":8.568,"criticRating":null,"genres":["Sci-Fi & Fantasy","Animation","Drama"],"overview":"At the turn of the century, the Angels returned to Earth, seeking to wipe out humanity in an apocalyptic fury. Devastated, mankind's last remnants moved underground to wait for the day when the Angels would come back\u2026","poster":"assets/tv-neon-genesis-evangelion-1995-poster.jpg","backdrop":"assets/tv-neon-genesis-evangelion-1995-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0340629Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":890,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.56,"tmdbVotes":2205}},{"id":"tv-record-of-ragnarok-2021","title":"Record of Ragnarok","year":2021,"type":"tv","runtimeSeconds":null,"seasonCount":3,"communityRating":8.402,"criticRating":null,"genres":["Action & Adventure","Animation","Sci-Fi & Fantasy"],"overview":"Before eradicating humankind from the world, the gods give them one last chance to prove themselves worthy of survival. Let the Ragnarok battles begin.","poster":"assets/tv-record-of-ragnarok-2021-poster.jpg","backdrop":"assets/tv-record-of-ragnarok-2021-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-20T22:28:13.2085766Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":114868,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.403,"tmdbVotes":1929}},{"id":"tv-sakamoto-days-2025","title":"SAKAMOTO DAYS","year":2025,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":8.071,"criticRating":null,"genres":["Animation","Comedy","Action & Adventure"],"overview":"Once the greatest hitman of all, Taro Sakamoto retired in the name of love. But when his past catches up, he must fight to protect his beloved family.","poster":"assets/tv-sakamoto-days-2025-poster.jpg","backdrop":"assets/tv-sakamoto-days-2025-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-22T10:31:16.266066Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":207332,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.057,"tmdbVotes":375}},{"id":"tv-sentenced-to-be-a-hero-2025","title":"Sentenced to Be a Hero","year":2025,"type":"tv","runtimeSeconds":1440,"seasonCount":1,"communityRating":8.338,"criticRating":null,"genres":["Animation","Sci-Fi & Fantasy","Action & Adventure"],"overview":"In a world where heroism is a punishment, Xylo Forbartz, a condemned goddess killer, battles endless hordes of monstrous abominations as part of Penal Hero Unit 9004. Death is no escape, only a cycle of resurrection\u2026","poster":"assets/tv-sentenced-to-be-a-hero-2025-poster.jpg","backdrop":"assets/tv-sentenced-to-be-a-hero-2025-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-22T10:31:16.2666791Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":249907,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.244,"tmdbVotes":129}},{"id":"tv-spy-x-family-2022","title":"SPY x FAMILY","year":2022,"type":"tv","runtimeSeconds":1440,"seasonCount":3,"communityRating":8.506,"criticRating":null,"genres":["Animation","Action & Adventure","Comedy"],"overview":"A spy, an assassin and a telepath come together to pose as a family, each for their own reasons, while hiding their true identities from each other.","poster":"assets/tv-spy-x-family-2022-poster.jpg","backdrop":"assets/tv-spy-x-family-2022-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-22T10:31:16.2670364Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":120089,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.5,"tmdbVotes":2382}},{"id":"tv-the-tatami-galaxy-2010","title":"The Tatami Galaxy","year":2010,"type":"tv","runtimeSeconds":1500,"seasonCount":2,"communityRating":8.271,"criticRating":null,"genres":["Animation","Comedy","Drama"],"overview":"When a college dropout stops for a late night bite at a mysterious ramen stand, he crosses paths with a self-proclaimed deity of matrimony. This bizarre meeting sends the young man hurtling through a horrifying\u2026","poster":"assets/tv-the-tatami-galaxy-2010-poster.jpg","backdrop":"assets/tv-the-tatami-galaxy-2010-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-18T05:52:51.0023256Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":36243,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.3,"tmdbVotes":152}},{"id":"tv-vinland-saga-2019","title":"Vinland Saga","year":2019,"type":"tv","runtimeSeconds":1500,"seasonCount":2,"communityRating":8.527,"criticRating":null,"genres":["Animation","War & Politics","Action & Adventure"],"overview":"For a thousand years, the Vikings have made quite a name and reputation for themselves as the strongest families with a thirst for violence. Thorfinn, the son of one of the Vikings' greatest warriors, spends his\u2026","poster":"assets/tv-vinland-saga-2019-poster.jpg","backdrop":"assets/tv-vinland-saga-2019-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-20T22:28:13.207389Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":88803,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.509,"tmdbVotes":1064}},{"id":"tv-yu-yu-hakusho-1992","title":"Yu Yu Hakusho","year":1992,"type":"tv","runtimeSeconds":1440,"seasonCount":4,"communityRating":8.5,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"After dying to save a boy, delinquent tough guy Yusuke Urameshi is granted another chance at life by redeeming himself as a \"Spirit Detective.\"","poster":"assets/tv-yu-yu-hakusho-1992-poster.jpg","backdrop":"assets/tv-yu-yu-hakusho-1992-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-19T21:46:01.5733049Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":30669,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.475,"tmdbVotes":519}}],"discover":[{"id":"movie-the-amazing-spider-man-2012","title":"The Amazing Spider-Man","year":2012,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"A teenage Peter Parker grapples with both high school and amazing super-human crises as his alter-ego Spider-Man.","poster":"assets/movie-the-amazing-spider-man-2012-poster.jpg","backdrop":"assets/movie-the-amazing-spider-man-2012-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1930,"ratings":{"rtCritics":71,"rtCriticsLabel":"Certified Fresh","rtAudience":77,"rtAudienceLabel":"Upright","imdb":7,"imdbVotes":791938,"tmdb":6.768,"tmdbVotes":19312}},{"id":"movie-the-amazing-spider-man-2-2014","title":"The Amazing Spider-Man 2","year":2014,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"For Peter Parker, life is busy. Between taking out the bad guys as Spider-Man and spending time with the person he loves, Gwen Stacy, high school graduation cannot come quickly enough. Peter has not forgotten about\u2026","poster":"assets/movie-the-amazing-spider-man-2-2014-poster.jpg","backdrop":"assets/movie-the-amazing-spider-man-2-2014-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":102382,"ratings":{"rtCritics":51,"rtCriticsLabel":"Rotten","rtAudience":64,"rtAudienceLabel":"Upright","imdb":6.6,"imdbVotes":611981,"tmdb":6.567,"tmdbVotes":14919}},{"id":"movie-the-animatrix-2003","title":"The Animatrix","year":2003,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Science Fiction"],"overview":"Straight from the creators of the groundbreaking Matrix trilogy, this collection of short animated films from the world's leading anime directors fuses computer graphics and Japanese anime to provide the background\u2026","poster":"assets/movie-the-animatrix-2003-poster.jpg","backdrop":"assets/movie-the-animatrix-2003-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":55931,"ratings":{"rtCritics":86,"rtCriticsLabel":"Fresh","rtAudience":78,"rtAudienceLabel":"Upright","imdb":7.3,"imdbVotes":91929,"tmdb":7.221,"tmdbVotes":1872}},{"id":"movie-ant-man-and-the-wasp-quantumania-2023","title":"Ant-Man and the Wasp: Quantumania","year":2023,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"Super-Hero partners Scott Lang and Hope van Dyne, along with with Hope's parents Janet van Dyne and Hank Pym, and Scott's daughter Cassie Lang, find themselves exploring the Quantum Realm, interacting with strange\u2026","poster":"assets/movie-ant-man-and-the-wasp-quantumania-2023-poster.jpg","backdrop":"assets/movie-ant-man-and-the-wasp-quantumania-2023-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":640146,"ratings":{"rtCritics":46,"rtCriticsLabel":"Rotten","rtAudience":81,"rtAudienceLabel":"Upright","imdb":6,"imdbVotes":281738,"tmdb":6.197,"tmdbVotes":6236}},{"id":"movie-appleseed-alpha-2014","title":"Appleseed Alpha","year":2014,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Science Fiction","Action"],"overview":"The Third World War (2099\u20132126) devastated part of the Earth. Former nations are reforming while new powers emerge, and groups of humans struggle to survive in ruined cities. The story follows Deunan Knute and her\u2026","poster":"assets/movie-appleseed-alpha-2014-poster.jpg","backdrop":"assets/movie-appleseed-alpha-2014-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"center_crop","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":269650,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":59,"rtAudienceLabel":"Spilled","imdb":6.5,"imdbVotes":9689,"tmdb":6.808,"tmdbVotes":436}},{"id":"movie-aquaman-and-the-lost-kingdom-2023","title":"Aquaman and the Lost Kingdom","year":2023,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Fantasy"],"overview":"Black Manta seeks revenge on Aquaman for his father's death. Wielding the Black Trident's power, he becomes a formidable foe. To defend Atlantis, Arthur (Aquaman) forges an alliance with his imprisoned brother. They\u2026","poster":"assets/movie-aquaman-and-the-lost-kingdom-2023-poster.jpg","backdrop":"assets/movie-aquaman-and-the-lost-kingdom-2023-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":572802,"ratings":{"rtCritics":33,"rtCriticsLabel":"Rotten","rtAudience":79,"rtAudienceLabel":"Upright","imdb":5.6,"imdbVotes":122422,"tmdb":6.48,"tmdbVotes":3748}},{"id":"movie-the-avengers-2012","title":"The Avengers","year":2012,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Science Fiction","Action","Adventure"],"overview":"When an unexpected enemy emerges and threatens global safety and security, Nick Fury, director of the international peacekeeping agency known as S.H.I.E.L.D., finds himself in need of a team to pull the world back\u2026","poster":"assets/movie-the-avengers-2012-poster.jpg","backdrop":"assets/movie-the-avengers-2012-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":24428,"ratings":{"rtCritics":91,"rtCriticsLabel":"Certified Fresh","rtAudience":91,"rtAudienceLabel":"Upright","imdb":8,"imdbVotes":1590693,"tmdb":8.084,"tmdbVotes":40262}},{"id":"movie-avengers-confidential-black-widow-punisher-2014","title":"Avengers Confidential: Black Widow & Punisher","year":2014,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Science Fiction","Action"],"overview":"When the Punisher takes out a black-market weapons dealer, he stumbles upon a far-reaching terrorist plot devised by a group known as Leviathan.","poster":"assets/movie-avengers-confidential-black-widow-punisher-2014-poster.jpg","backdrop":"assets/movie-avengers-confidential-black-widow-punisher-2014-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":257346,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":55,"rtAudienceLabel":"Spilled","imdb":5.7,"imdbVotes":5166,"tmdb":6.368,"tmdbVotes":303}},{"id":"movie-avengers-age-of-ultron-2015","title":"Avengers: Age of Ultron","year":2015,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"When Tony Stark tries to jumpstart a dormant peacekeeping program, things go awry and Earth\u2019s Mightiest Heroes are put to the ultimate test as the fate of the planet hangs in the balance. As the villainous Ultron\u2026","poster":"assets/movie-avengers-age-of-ultron-2015-poster.jpg","backdrop":"assets/movie-avengers-age-of-ultron-2015-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":99861,"ratings":{"rtCritics":75,"rtCriticsLabel":"Certified Fresh","rtAudience":82,"rtAudienceLabel":"Upright","imdb":7.3,"imdbVotes":1025876,"tmdb":7.278,"tmdbVotes":25289}},{"id":"movie-avengers-infinity-war-2018","title":"Avengers: Infinity War","year":2018,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Adventure","Action","Science Fiction"],"overview":"As the Avengers and their allies have continued to protect the world from threats too large for any one hero to handle, a new danger has emerged from the cosmic shadows: Thanos. A despot of intergalactic infamy, his\u2026","poster":"assets/movie-avengers-infinity-war-2018-poster.jpg","backdrop":"assets/movie-avengers-infinity-war-2018-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":299536,"ratings":{"rtCritics":85,"rtCriticsLabel":"Certified Fresh","rtAudience":92,"rtAudienceLabel":"Upright","imdb":8.4,"imdbVotes":1421954,"tmdb":8.246,"tmdbVotes":33331}},{"id":"movie-batman-ninja-2018","title":"Batman Ninja","year":2018,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Action","Science Fiction"],"overview":"Batman, along with many of his allies and adversaries, finds himself transported to feudal Japan by Gorilla Grodd's time displacement machine.","poster":"assets/movie-batman-ninja-2018-poster.jpg","backdrop":"assets/movie-batman-ninja-2018-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":485942,"ratings":{"rtCritics":84,"rtCriticsLabel":"Fresh","rtAudience":60,"rtAudienceLabel":"Upright","imdb":5.6,"imdbVotes":24579,"tmdb":5.9,"tmdbVotes":1106}},{"id":"movie-batman-v-superman-dawn-of-justice-2016","title":"Batman v Superman: Dawn of Justice","year":2016,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Fantasy"],"overview":"Fearing the actions of a god-like Super Hero left unchecked, Gotham City\u2019s own formidable, forceful vigilante takes on Metropolis\u2019s most revered, modern-day savior, while the world wrestles with what sort of hero it\u2026","poster":"assets/movie-batman-v-superman-dawn-of-justice-2016-poster.jpg","backdrop":"assets/movie-batman-v-superman-dawn-of-justice-2016-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":209112,"ratings":{"rtCritics":28,"rtCriticsLabel":"Rotten","rtAudience":63,"rtAudienceLabel":"Upright","imdb":6.4,"imdbVotes":801382,"tmdb":6.014,"tmdbVotes":19527}},{"id":"movie-batman-the-dark-knight-returns-part-1-2012","title":"Batman: The Dark Knight Returns, Part 1","year":2012,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Science Fiction","Action","Animation"],"overview":"Batman has not been seen for ten years. A new breed of criminal ravages Gotham City, forcing 55-year-old Bruce Wayne back into the cape and cowl. But, does he still have what it takes to fight crime in a new era?","poster":"assets/movie-batman-the-dark-knight-returns-part-1-2012-poster.jpg","backdrop":"assets/movie-batman-the-dark-knight-returns-part-1-2012-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":123025,"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":93,"rtAudienceLabel":"Upright","imdb":7.9,"imdbVotes":68943,"tmdb":7.742,"tmdbVotes":1766}},{"id":"movie-black-adam-2022","title":"Black Adam","year":2022,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"Nearly 5,000 years after he was bestowed with the almighty powers of the Egyptian gods\u2014and imprisoned just as quickly\u2014Black Adam is freed from his earthly tomb, ready to unleash his unique form of justice on the\u2026","poster":"assets/movie-black-adam-2022-poster.jpg","backdrop":"assets/movie-black-adam-2022-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":436270,"ratings":{"rtCritics":39,"rtCriticsLabel":"Rotten","rtAudience":87,"rtAudienceLabel":"Upright","imdb":6.1,"imdbVotes":309624,"tmdb":6.747,"tmdbVotes":7370}},{"id":"movie-black-panther-2018","title":"Black Panther","year":2018,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"King T'Challa returns home to the reclusive, technologically advanced African nation of Wakanda to serve as his country's new leader. However, T'Challa soon finds that he is challenged for the throne by factions\u2026","poster":"assets/movie-black-panther-2018-poster.jpg","backdrop":"assets/movie-black-panther-2018-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":284054,"ratings":{"rtCritics":96,"rtCriticsLabel":"Certified Fresh","rtAudience":null,"rtAudienceLabel":"Spilled","imdb":7.3,"imdbVotes":931120,"tmdb":7.36,"tmdbVotes":24190}},{"id":"movie-black-panther-wakanda-forever-2022","title":"Black Panther: Wakanda Forever","year":2022,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"Queen Ramonda, Shuri, M\u2019Baku, Okoye and the Dora Milaje fight to protect their nation from intervening world powers in the wake of King T\u2019Challa\u2019s death. As the Wakandans strive to embrace their next chapter, the\u2026","poster":"assets/movie-black-panther-wakanda-forever-2022-poster.jpg","backdrop":"assets/movie-black-panther-wakanda-forever-2022-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":505642,"ratings":{"rtCritics":84,"rtCriticsLabel":"Certified Fresh","rtAudience":93,"rtAudienceLabel":"Upright","imdb":6.6,"imdbVotes":405088,"tmdb":6.984,"tmdbVotes":7920}},{"id":"movie-blame-2017","title":"BLAME!","year":2017,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Drama","Science Fiction"],"overview":"In the distant technological future, civilization has reached its ultimate Net-based form. An \"infection\" in the past caused the automated systems to spiral out of order, resulting in a multi-leveled city structure\u2026","poster":"assets/movie-blame-2017-poster.jpg","backdrop":"assets/movie-blame-2017-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":409421,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":61,"rtAudienceLabel":"Upright","imdb":6.6,"imdbVotes":10258,"tmdb":6.523,"tmdbVotes":370}},{"id":"movie-bleach-the-movie-fade-to-black-2008","title":"Bleach the Movie: Fade to Black","year":2008,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Animation","Fantasy"],"overview":"A strange disturbance in the World of the Living sends Ichigo and Kon to the Soul Society, where the Seireitei has been devastated by an explosion of reishi and Rukia is missing. Stranger still, none of the Soul\u2026","poster":"assets/movie-bleach-the-movie-fade-to-black-2008-poster.jpg","backdrop":"assets/movie-bleach-the-movie-fade-to-black-2008-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":17165,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":67,"rtAudienceLabel":"Upright","imdb":7.1,"imdbVotes":3693,"tmdb":7.1,"tmdbVotes":206}},{"id":"tv-90-day-fiance-before-the-90-days-2017","title":"90 Day Fianc\u00e9: Before the 90 Days","year":2017,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Reality"],"overview":"Follow couples who have an existing relationship online, but haven't met in person. We'll experience their journey as they travel to the other's foreign country for the first time in an attempt to establish an\u2026","poster":"assets/tv-90-day-fiance-before-the-90-days-2017-poster.jpg","backdrop":"assets/tv-90-day-fiance-before-the-90-days-2017-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":73319,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.5,"tmdbVotes":45}},{"id":"tv-90-day-fiance-the-other-way-2019","title":"90 Day Fianc\u00e9: The Other Way","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Reality"],"overview":"Americans move to a foreign country all in the name of love. Will uprooting their lives in the United States pay off, or will they be packing up and moving back to America brokenhearted?","poster":"assets/tv-90-day-fiance-the-other-way-2019-poster.jpg","backdrop":"assets/tv-90-day-fiance-the-other-way-2019-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":90046,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.8,"tmdbVotes":34}},{"id":"tv-the-100-2014","title":"The 100","year":2014,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Sci-Fi & Fantasy","Action & Adventure","Drama"],"overview":"100 years in the future, when the Earth has been abandoned due to radioactivity, the last surviving humans live on an ark orbiting the planet \u2014 but the ark won't last forever. So the repressive regime picks 100\u2026","poster":"assets/tv-the-100-2014-poster.jpg","backdrop":"assets/tv-the-100-2014-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":48866,"ratings":{"rtCritics":93,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.878,"tmdbVotes":8855}},{"id":"tv-the-abbott-and-costello-show-1952","title":"The Abbott and Costello Show","year":1952,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy"],"overview":"Bud and Lou are unemployed actors living in Mr. Fields\u2019 boarding house. Lou\u2019s girlfriend Hillary lives across the hall. Many situations arise leading to slapstick and puns.","poster":"assets/tv-the-abbott-and-costello-show-1952-poster.jpg","backdrop":"assets/tv-the-abbott-and-costello-show-1952-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":11821,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.2,"tmdbVotes":22}},{"id":"tv-accel-world-2012","title":"Accel World","year":2012,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Action & Adventure","Drama"],"overview":"A short, overweight student uses the Neuro Link, an evolution of the Nerve Gear, to escape himself from the reality. But his skills in the virtual world brings him to the attention of Kuroyukihime, a popular student\u2026","poster":"assets/tv-accel-world-2012-poster.jpg","backdrop":"assets/tv-accel-world-2012-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"center_crop","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":43167,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.429,"tmdbVotes":163}},{"id":"tv-ahsoka-2023","title":"Ahsoka","year":2023,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Sci-Fi & Fantasy","Action & Adventure","Drama"],"overview":"Former Jedi Knight Ahsoka Tano investigates an emerging threat to a vulnerable galaxy.","poster":"assets/tv-ahsoka-2023-poster.jpg","backdrop":"assets/tv-ahsoka-2023-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":114461,"ratings":{"rtCritics":85,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.4,"tmdbVotes":1351}},{"id":"tv-american-dad-2005","title":"American Dad!","year":2005,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Comedy"],"overview":"The series focuses on an eccentric motley crew that is the Smith family and their three housemates: Father, husband, and breadwinner Stan Smith; his better half housewife, Francine Smith; their college-aged daughter,\u2026","poster":"assets/tv-american-dad-2005-poster.jpg","backdrop":"assets/tv-american-dad-2005-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1433,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.022,"tmdbVotes":2481}},{"id":"tv-american-gods-2017","title":"American Gods","year":2017,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Mystery","Sci-Fi & Fantasy"],"overview":"An ex-con becomes the traveling partner of a conman who turns out to be one of the older gods trying to recruit troops to battle the upstart deities. Based on Neil Gaiman's fantasy novel.","poster":"assets/tv-american-gods-2017-poster.jpg","backdrop":"assets/tv-american-gods-2017-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":46639,"ratings":{"rtCritics":75,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.1,"tmdbVotes":1852}},{"id":"tv-andromeda-2000","title":"Andromeda","year":2000,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action & Adventure","Drama","Sci-Fi & Fantasy"],"overview":"Captain Dylan Hunt and his crew quest to restore a government that once presided over an extended peace and prosperity.","poster":"assets/tv-andromeda-2000-poster.jpg","backdrop":"assets/tv-andromeda-2000-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":2985,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.068,"tmdbVotes":315}},{"id":"tv-aqua-teen-hunger-force-2000","title":"Aqua Teen Hunger Force","year":2000,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Comedy"],"overview":"The surreal adventures of three anthropomorphic fast food items: Master Shake, Frylock and Meatwad, and their human nextdoor neighbor, Carl Brutananadilewski.","poster":"assets/tv-aqua-teen-hunger-force-2000-poster.jpg","backdrop":"assets/tv-aqua-teen-hunger-force-2000-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":251,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.5,"tmdbVotes":272}},{"id":"tv-arifureta-from-commonplace-to-world-s-strongest-2019","title":"Arifureta: From Commonplace to World's Strongest","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"Hajime Nagumo and his high school class are suddenly summoned to a fantastical land as heroes. But while most of his classmates have powerful stats and abilities, Hajime does not. Underappreciated and unprepared, he\u2026","poster":"assets/tv-arifureta-from-commonplace-to-world-s-strongest-2019-poster.jpg","backdrop":"assets/tv-arifureta-from-commonplace-to-world-s-strongest-2019-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":86034,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.382,"tmdbVotes":662}},{"id":"tv-arrow-2012","title":"Arrow","year":2012,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Crime","Drama","Action & Adventure"],"overview":"Spoiled billionaire playboy Oliver Queen is missing and presumed dead when his yacht is lost at sea. He returns five years later a changed man, determined to clean up the city as a hooded vigilante armed with a bow.","poster":"assets/tv-arrow-2012-poster.jpg","backdrop":"assets/tv-arrow-2012-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1412,"ratings":{"rtCritics":86,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.834,"tmdbVotes":6537}},{"id":"tv-avatar-the-last-airbender-2024","title":"Avatar: The Last Airbender","year":2024,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action & Adventure","Sci-Fi & Fantasy","Drama"],"overview":"A young boy known as the Avatar must master the four elemental powers to save a world at war \u2014 and fight a ruthless enemy bent on stopping him.","poster":"assets/tv-avatar-the-last-airbender-2024-poster.jpg","backdrop":"assets/tv-avatar-the-last-airbender-2024-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":82452,"ratings":{"rtCritics":66,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.8,"tmdbVotes":1406}},{"id":"tv-avatar-seven-havens-2026","title":"Avatar: Seven Havens","year":2026,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"In a world shattered by a devastating cataclysm, a young Earthbender discovers she's the new Avatar after Korra. But in this dangerous era, that title marks her as humanity's destroyer, not its savior. Hunted by both\u2026","poster":"assets/tv-avatar-seven-havens-2026-poster.jpg","backdrop":"assets/tv-avatar-seven-havens-2026-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":284833,"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.9,"tmdbVotes":183}},{"id":"tv-batman-the-brave-and-the-bold-2008","title":"Batman: The Brave and the Bold","year":2008,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"The Caped Crusader is teamed up with Blue Beetle, Green Arrow, Aquaman and countless others in his quest to uphold justice.","poster":"assets/tv-batman-the-brave-and-the-bold-2008-poster.jpg","backdrop":"assets/tv-batman-the-brave-and-the-bold-2008-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":15804,"ratings":{"rtCritics":96,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.6,"tmdbVotes":295}},{"id":"tv-batwoman-2019","title":"Batwoman","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Sci-Fi & Fantasy","Action & Adventure"],"overview":"Kate Kane, armed with a passion for social justice and a flair for speaking her mind, soars onto the streets of Gotham as Batwoman, an out lesbian and highly trained street fighter primed to snuff out the failing\u2026","poster":"assets/tv-batwoman-2019-poster.jpg","backdrop":"assets/tv-batwoman-2019-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":89247,"ratings":{"rtCritics":83,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.8,"tmdbVotes":1619}},{"id":"tv-big-mouth-2017","title":"Big Mouth","year":2017,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy","Animation"],"overview":"Teenage friends find their lives upended by the wonders and horrors of puberty in this edgy comedy from real-life pals Nick Kroll and Andrew Goldberg.","poster":"assets/tv-big-mouth-2017-poster.jpg","backdrop":"assets/tv-big-mouth-2017-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":74204,"ratings":{"rtCritics":95,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.591,"tmdbVotes":1274}},{"id":"tv-black-lightning-2018","title":"Black Lightning","year":2018,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action & Adventure","Sci-Fi & Fantasy","Drama"],"overview":"Jefferson Pierce is a man wrestling with a secret. As the father of two daughters and principal of a charter high school that also serves as a safe haven for young people in a New Orleans neighborhood overrun by gang\u2026","poster":"assets/tv-black-lightning-2018-poster.jpg","backdrop":"assets/tv-black-lightning-2018-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":71663,"ratings":{"rtCritics":92,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.907,"tmdbVotes":991}}]},{"key":"mindel","movies":[{"id":"movie-chicago-2002","title":"Chicago","year":2002,"type":"movie","runtimeSeconds":6808,"seasonCount":null,"communityRating":7.131,"criticRating":87,"genres":["Comedy","Crime","Drama"],"overview":"Murderesses Velma Kelly and Roxie Hart find themselves on death row together and fight for the fame that will keep them from the gallows in 1920s Chicago.","poster":"assets/movie-chicago-2002-poster.jpg","backdrop":"assets/movie-chicago-2002-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:24:53.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":1574,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":87,"rtCriticsLabel":"Certified Fresh","rtAudience":83,"rtAudienceLabel":"Upright","imdb":7.2,"imdbVotes":259272,"tmdb":7.135,"tmdbVotes":3025}},{"id":"movie-coherence-2014","title":"Coherence","year":2014,"type":"movie","runtimeSeconds":5286,"seasonCount":null,"communityRating":7.21,"criticRating":89,"genres":["Thriller","Science Fiction"],"overview":"Four couples gather for dinner the night a mysterious and powerful comet passes overhead.","poster":"assets/movie-coherence-2014-poster.jpg","backdrop":"assets/movie-coherence-2014-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2017-08-12T23:49:00.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":220289,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":89,"rtCriticsLabel":"Certified Fresh","rtAudience":81,"rtAudienceLabel":"Upright","imdb":7.1,"imdbVotes":180354,"tmdb":7.208,"tmdbVotes":3702}},{"id":"movie-the-killing-of-a-sacred-deer-2017","title":"The Killing of a Sacred Deer","year":2017,"type":"movie","runtimeSeconds":7262,"seasonCount":null,"communityRating":7.003,"criticRating":79,"genres":["Drama","Thriller","Mystery"],"overview":"Dr. Steven Murphy is a renowned cardiovascular surgeon who presides over a spotless household with his wife and two children. Lurking at the margins of his idyllic suburban existence is Martin, a fatherless teen who\u2026","poster":"assets/movie-the-killing-of-a-sacred-deer-2017-poster.jpg","backdrop":"assets/movie-the-killing-of-a-sacred-deer-2017-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-22T21:49:26.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":399057,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":79,"rtCriticsLabel":"Certified Fresh","rtAudience":63,"rtAudienceLabel":"Upright","imdb":7,"imdbVotes":219488,"tmdb":7.001,"tmdbVotes":4640}},{"id":"movie-night-is-short-walk-on-girl-2017","title":"Night Is Short, Walk On Girl","year":2017,"type":"movie","runtimeSeconds":5548,"seasonCount":null,"communityRating":7.421,"criticRating":90,"genres":["Comedy","Romance","Animation"],"overview":"As a group of university students go out for a night on the town, a sophomore known only as \"The Girl with Black Hair\" experiences a series of surreal encounters with the local nightlife \u2013 all the while unaware of\u2026","poster":"assets/movie-night-is-short-walk-on-girl-2017-poster.jpg","backdrop":"assets/movie-night-is-short-walk-on-girl-2017-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T17:39:14.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":430214,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":90,"rtCriticsLabel":"Fresh","rtAudience":84,"rtAudienceLabel":"Upright","imdb":7.5,"imdbVotes":7640,"tmdb":7.4,"tmdbVotes":267}},{"id":"movie-thelma-louise-1991","title":"Thelma & Louise","year":1991,"type":"movie","runtimeSeconds":7786,"seasonCount":null,"communityRating":7.596,"criticRating":87,"genres":["Drama","Crime","Adventure"],"overview":"Taking a break from their dreary lives, close friends Thelma and Louise embark on a short weekend trip that ends in unforeseen incriminating circumstances. As fugitives, both women rediscover the strength of their\u2026","poster":"assets/movie-thelma-louise-1991-poster.jpg","backdrop":"assets/movie-thelma-louise-1991-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:24:40.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":1541,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":87,"rtCriticsLabel":"Certified Fresh","rtAudience":82,"rtAudienceLabel":"Upright","imdb":7.6,"imdbVotes":197947,"tmdb":7.594,"tmdbVotes":4063}},{"id":"movie-vicky-cristina-barcelona-2008","title":"Vicky Cristina Barcelona","year":2008,"type":"movie","runtimeSeconds":5804,"seasonCount":null,"communityRating":6.798,"criticRating":80,"genres":["Drama","Romance"],"overview":"Two girlfriends on a summer holiday in Spain become enamored with the same painter, unaware that his ex-wife, with whom he has a tempestuous relationship, is about to re-enter the picture.","poster":"assets/movie-vicky-cristina-barcelona-2008-poster.jpg","backdrop":"assets/movie-vicky-cristina-barcelona-2008-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:25:34.0000000Z","lastPlayedAt":"2026-08-11T20:12:57.3064089Z","availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":5038,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":80,"rtCriticsLabel":"Certified Fresh","rtAudience":74,"rtAudienceLabel":"Upright","imdb":7.1,"imdbVotes":283286,"tmdb":6.797,"tmdbVotes":4164}}],"shows":[{"id":"tv-all-creatures-great-small-2020","title":"All Creatures Great & Small","year":2020,"type":"tv","runtimeSeconds":3000,"seasonCount":7,"communityRating":8.2,"criticRating":null,"genres":["Drama","Comedy"],"overview":"The heartwarming and humorous adventures of a young country vet in the Yorkshire Dales in the 1930s. A remake of the 1978 series.","poster":"assets/tv-all-creatures-great-small-2020-poster.jpg","backdrop":"assets/tv-all-creatures-great-small-2020-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-11-04T20:13:03.4266156Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":108255,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":99,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.2,"tmdbVotes":168}},{"id":"tv-back-to-life-2019","title":"Back to Life","year":2019,"type":"tv","runtimeSeconds":1500,"seasonCount":2,"communityRating":7.367,"criticRating":null,"genres":["Comedy","Drama"],"overview":"When Miri Matteson returns home after eighteen years, can she integrate back into her old life? With a terrible event from her past hanging over her, it won\u2019t be easy.","poster":"assets/tv-back-to-life-2019-poster.jpg","backdrop":"assets/tv-back-to-life-2019-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0688758Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":88510,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.4,"tmdbVotes":65}},{"id":"tv-big-boys-2022","title":"Big Boys","year":2022,"type":"tv","runtimeSeconds":1440,"seasonCount":3,"communityRating":7.6,"criticRating":null,"genres":["Comedy","Drama"],"overview":"Two boys from very different ends of the 'spectrum of masculinity' become best friends at Brent University freshers' week; in their first year of uni, they explore, experiment and try to discover themselves, helping\u2026","poster":"assets/tv-big-boys-2022-poster.jpg","backdrop":"assets/tv-big-boys-2022-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-11-04T20:25:58.2156837Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":202851,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.6,"tmdbVotes":49}},{"id":"tv-call-the-midwife-2012","title":"Call the Midwife","year":2012,"type":"tv","runtimeSeconds":3120,"seasonCount":15,"communityRating":7.5,"criticRating":null,"genres":["Drama"],"overview":"Drama following the lives of a group of midwives working in the poverty-stricken East End of London during the 1950s, based on the best-selling memoirs of Jennifer Worth.","poster":"assets/tv-call-the-midwife-2012-poster.jpg","backdrop":"assets/tv-call-the-midwife-2012-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0377216Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":39793,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.577,"tmdbVotes":267}},{"id":"tv-derry-girls-2018","title":"Derry Girls","year":2018,"type":"tv","runtimeSeconds":1380,"seasonCount":3,"communityRating":8.144,"criticRating":null,"genres":["Comedy","Drama"],"overview":"Amidst the political conflict of Northern Ireland in the 1990s, five secondary school students square off with the universal challenges of being a teenager.","poster":"assets/tv-derry-girls-2018-poster.jpg","backdrop":"assets/tv-derry-girls-2018-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-02T20:01:55.8955537Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":76148,"artwork":{"posterKind":"center_crop","backdropKind":"backdrop"},"ratings":{"rtCritics":99,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.138,"tmdbVotes":454}},{"id":"tv-desperate-housewives-2004","title":"Desperate Housewives","year":2004,"type":"tv","runtimeSeconds":2520,"seasonCount":8,"communityRating":7.896,"criticRating":null,"genres":["Mystery","Drama","Comedy"],"overview":"Looking down on her friends and family isn't a way of life for Mary Alice Young... it's a way of death. One day, in her perfect house, in the loveliest of suburbs, Mary Alice ended it all. Now she's taking us into\u2026","poster":"assets/tv-desperate-housewives-2004-poster.jpg","backdrop":"assets/tv-desperate-housewives-2004-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-29T20:56:40.3505596Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":693,"artwork":{"posterKind":"poster","backdropKind":"center_crop"},"ratings":{"rtCritics":72,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.896,"tmdbVotes":2171}},{"id":"tv-the-diplomat-2023","title":"The Diplomat","year":2023,"type":"tv","runtimeSeconds":null,"seasonCount":4,"communityRating":7.569,"criticRating":null,"genres":["Drama","War & Politics"],"overview":"Amid an international crisis, a US diplomat contends with her high-profile job as ambassador to the UK and her strained marriage to a political star.","poster":"assets/tv-the-diplomat-2023-poster.jpg","backdrop":"assets/tv-the-diplomat-2023-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0640348Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":203857,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":93,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.652,"tmdbVotes":521}},{"id":"tv-the-durrells-2016","title":"The Durrells","year":2016,"type":"tv","runtimeSeconds":2760,"seasonCount":4,"communityRating":7.402,"criticRating":null,"genres":["Drama","Comedy","Family"],"overview":"In 1935, financially strapped widow Louisa Durrell, whose life has fallen apart, decides to move from England, with her four children (three sons, one daughter), to the island of Corfu, Greece. Once there, the family\u2026","poster":"assets/tv-the-durrells-2016-poster.jpg","backdrop":"assets/tv-the-durrells-2016-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0619436Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":66134,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":94,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.4,"tmdbVotes":127}},{"id":"tv-etoile-2025","title":"\u00c9toile","year":2025,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.197,"criticRating":null,"genres":["Comedy","Drama"],"overview":"In an ambitious gambit to save their storied institutions, two world-renowned ballet companies in New York City and Paris swap their most talented stars.","poster":"assets/tv-etoile-2025-poster.jpg","backdrop":"assets/tv-etoile-2025-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0601383Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":225383,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":86,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.3,"tmdbVotes":40}},{"id":"tv-gilmore-girls-2000","title":"Gilmore Girls","year":2000,"type":"tv","runtimeSeconds":2640,"seasonCount":7,"communityRating":7.893,"criticRating":null,"genres":["Comedy","Drama"],"overview":"Set in the charming town of Stars Hollow, Connecticut, the series follows the captivating lives of Lorelai and Rory Gilmore, a mother/daughter pair who have a relationship most people only dream of.","poster":"assets/tv-gilmore-girls-2000-poster.jpg","backdrop":"assets/tv-gilmore-girls-2000-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0631035Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":4586,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.9,"tmdbVotes":1339}},{"id":"tv-ginny-georgia-2021","title":"Ginny & Georgia","year":2021,"type":"tv","runtimeSeconds":null,"seasonCount":3,"communityRating":8.026,"criticRating":null,"genres":["Comedy","Drama"],"overview":"Free-spirited Georgia and her two kids, Ginny and Austin, move north in search of a fresh start but find that the road to new beginnings can be bumpy.","poster":"assets/tv-ginny-georgia-2021-poster.jpg","backdrop":"assets/tv-ginny-georgia-2021-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0694828Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":117581,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":68,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8,"tmdbVotes":1840}},{"id":"tv-hellcats-2010","title":"Hellcats","year":2010,"type":"tv","runtimeSeconds":2460,"seasonCount":1,"communityRating":7.5,"criticRating":null,"genres":["Drama"],"overview":"Pre-law student Marti Perkins' world is flipped upside down when she loses her scholarship, and realizes the only way she can stay in school is by reigniting her dormant teen gymnastic skills to win a place on Lancer\u2026","poster":"assets/tv-hellcats-2010-poster.jpg","backdrop":"assets/tv-hellcats-2010-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-22T20:39:02.7719318Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":32504,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":65,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.5,"tmdbVotes":116}},{"id":"tv-high-potential-2024","title":"High Potential","year":2024,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":8.201,"criticRating":null,"genres":["Drama","Crime","Mystery"],"overview":"Morgan is a single mom with an exceptional mind, whose unconventional knack for solving crimes leads to an unusual and unstoppable partnership with a by-the-book seasoned detective.","poster":"assets/tv-high-potential-2024-poster.jpg","backdrop":"assets/tv-high-potential-2024-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0686091Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":226637,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":98,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.3,"tmdbVotes":707}},{"id":"tv-how-to-get-to-heaven-from-belfast-2026","title":"How to Get to Heaven from Belfast","year":2026,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.027,"criticRating":null,"genres":["Mystery","Comedy","Drama"],"overview":"Three lifelong pals embark on a chaotic quest to solve the mystery of their old friend's suspicious death and keep their own dark secret under wraps.","poster":"assets/tv-how-to-get-to-heaven-from-belfast-2026-poster.jpg","backdrop":"assets/tv-how-to-get-to-heaven-from-belfast-2026-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0683192Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":233079,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":95,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.063,"tmdbVotes":95}},{"id":"tv-the-hunting-wives-2025","title":"The Hunting Wives","year":2025,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":6.932,"criticRating":null,"genres":["Drama","Mystery"],"overview":"Sophie trades New England for East Texas and falls into a wealthy socialite's magnetic orbit \u2014 where a clique of housewives hide deadly secrets.","poster":"assets/tv-the-hunting-wives-2025-poster.jpg","backdrop":"assets/tv-the-hunting-wives-2025-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-20T19:42:46.3227892Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":244447,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":81,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.964,"tmdbVotes":112}},{"id":"tv-the-lost-flowers-of-alice-hart-2023","title":"The Lost Flowers of Alice Hart","year":2023,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.634,"criticRating":null,"genres":["Drama","Mystery"],"overview":"After losing her parents in a mysterious fire, nine-year-old Alice Hart is raised by her grandmother June on a flower farm where she learns there are secrets within secrets. But years on, an unearthed betrayal sees\u2026","poster":"assets/tv-the-lost-flowers-of-alice-hart-2023-poster.jpg","backdrop":"assets/tv-the-lost-flowers-of-alice-hart-2023-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0607236Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":125816,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":82,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.64,"tmdbVotes":161}},{"id":"tv-love-is-blind-2020","title":"Love Is Blind","year":2020,"type":"tv","runtimeSeconds":null,"seasonCount":11,"communityRating":6.261,"criticRating":null,"genres":["Reality"],"overview":"Nick and Vanessa Lachey host this social experiment where single men and women look for love and get engaged, all before meeting in person.","poster":"assets/tv-love-is-blind-2020-poster.jpg","backdrop":"assets/tv-love-is-blind-2020-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-22T17:11:08.2447226Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":99353,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":66,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.233,"tmdbVotes":118}},{"id":"tv-maximum-pleasure-guaranteed-2026","title":"Maximum Pleasure Guaranteed","year":2026,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":6.676,"criticRating":null,"genres":["Comedy"],"overview":"A newly divorced mom falls down a dangerous rabbit hole of blackmail, murder, and youth soccer.","poster":"assets/tv-maximum-pleasure-guaranteed-2026-poster.jpg","backdrop":"assets/tv-maximum-pleasure-guaranteed-2026-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-07T21:04:02.8277793Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":285404,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":93,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.799,"tmdbVotes":92}},{"id":"tv-minx-2022","title":"Minx","year":2022,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":6.833,"criticRating":null,"genres":["Comedy","Drama"],"overview":"An earnest young feminist joins forces with a low-rent publisher to make the world's first erotic magazine for women.","poster":"assets/tv-minx-2022-poster.jpg","backdrop":"assets/tv-minx-2022-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0653613Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":118303,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":93,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.9,"tmdbVotes":123}},{"id":"tv-offspring-2010","title":"Offspring","year":2010,"type":"tv","runtimeSeconds":3600,"seasonCount":7,"communityRating":6.9,"criticRating":null,"genres":["Drama"],"overview":"An exuberant drama set in Melbourne's Fitzroy, centering on Nina Proudman and her struggle to deal with her fabulously messy family, her hunt for a decent love life and her tendency to overthink and fly off into fantasy.","poster":"assets/tv-offspring-2010-poster.jpg","backdrop":"assets/tv-offspring-2010-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0664581Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":33155,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.9,"tmdbVotes":27}},{"id":"tv-only-murders-in-the-building-2021","title":"Only Murders in the Building","year":2021,"type":"tv","runtimeSeconds":null,"seasonCount":5,"communityRating":8.398,"criticRating":null,"genres":["Comedy","Mystery","Crime"],"overview":"Three strangers share an obsession with true crime and suddenly find themselves wrapped up in one.","poster":"assets/tv-only-murders-in-the-building-2021-poster.jpg","backdrop":"assets/tv-only-murders-in-the-building-2021-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-11-04T20:25:58.2163301Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":107113,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":95,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.398,"tmdbVotes":2333}},{"id":"tv-outlander-blood-of-my-blood-2025","title":"Outlander: Blood of My Blood","year":2025,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":8.542,"criticRating":null,"genres":["Drama"],"overview":"From the battlefields of World War I to the rugged Highlands of 18th century Scotland, two fated couples must defy the forces that seek to tear them apart, unfolding in surprising and unforeseen ways in this romantic\u2026","poster":"assets/tv-outlander-blood-of-my-blood-2025-poster.jpg","backdrop":"assets/tv-outlander-blood-of-my-blood-2025-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0691373Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":207484,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":94,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.738,"tmdbVotes":124}},{"id":"tv-rivals-2024","title":"Rivals","year":2024,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":7.44,"criticRating":null,"genres":["Drama"],"overview":"It is 1986 and Britain is booming. \"Rivals\" dives headfirst into the cutthroat world of TV where hair-dos are big and ambitions are even bigger. Deals are brokered in boardrooms, as well as bedrooms. Nobody can be\u2026","poster":"assets/tv-rivals-2024-poster.jpg","backdrop":"assets/tv-rivals-2024-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0645815Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":208921,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":96,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.4,"tmdbVotes":93}},{"id":"tv-rooster-2026","title":"Rooster","year":2026,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.431,"criticRating":null,"genres":["Comedy"],"overview":"On a college campus, an author navigates a complicated relationship with his daughter.","poster":"assets/tv-rooster-2026-poster.jpg","backdrop":"assets/tv-rooster-2026-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.063389Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":254953,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":88,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.423,"tmdbVotes":144}},{"id":"tv-sex-and-the-city-1998","title":"Sex and the City","year":1998,"type":"tv","runtimeSeconds":null,"seasonCount":6,"communityRating":7.365,"criticRating":null,"genres":["Drama","Comedy"],"overview":"Based on the bestselling book by Candace Bushnell, Sex and the City tells the story of four best friends, all single and in their late thirties, as they pursue their careers and talk about their sex lives, all while\u2026","poster":"assets/tv-sex-and-the-city-1998-poster.jpg","backdrop":"assets/tv-sex-and-the-city-1998-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0679324Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":105,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":70,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.351,"tmdbVotes":2220}},{"id":"tv-skymed-2022","title":"SkyMed","year":2022,"type":"tv","runtimeSeconds":2640,"seasonCount":4,"communityRating":6.665,"criticRating":null,"genres":["Drama"],"overview":"Life, death and drama at 20,000 feet. The series weaves together intense character journeys and high-stakes medical rescues, as we follow the triumphs, heartbreaks and tribulations of budding nurses and pilots flying\u2026","poster":"assets/tv-skymed-2022-poster.jpg","backdrop":"assets/tv-skymed-2022-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-22T19:44:29.2431354Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":133908,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.699,"tmdbVotes":98}},{"id":"tv-so-you-think-you-can-dance-2008","title":"So You Think You Can Dance","year":2008,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":9.8,"criticRating":null,"genres":["Family","Reality"],"overview":"So You Think You Can Dance is a joint Belgian-Dutch dance competition show broadcast on RTL 5 and vtm television stations. The first season was broadcast only in the Netherlands, whereas starting with season 2, the\u2026","poster":"assets/tv-so-you-think-you-can-dance-2008-poster.jpg","backdrop":"assets/tv-so-you-think-you-can-dance-2008-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-23T11:11:35.4124418Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":40231,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":9.8,"tmdbVotes":4}},{"id":"tv-the-white-lotus-2021","title":"The White Lotus","year":2021,"type":"tv","runtimeSeconds":null,"seasonCount":3,"communityRating":7.597,"criticRating":null,"genres":["Comedy","Drama","Mystery"],"overview":"Follow the exploits of various guests and employees at an exclusive tropical resort over the span of a week as with each passing day, a darker complexity emerges in these picture-perfect travelers, the hotel\u2019s\u2026","poster":"assets/tv-the-white-lotus-2021-poster.jpg","backdrop":"assets/tv-the-white-lotus-2021-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-20T02:45:52.8742674Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":111803,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":90,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.599,"tmdbVotes":1604}},{"id":"tv-yellowjackets-2021","title":"Yellowjackets","year":2021,"type":"tv","runtimeSeconds":null,"seasonCount":3,"communityRating":7.431,"criticRating":null,"genres":["Drama","Mystery"],"overview":"This equal parts survival epic, psychological horror story and coming-of-age drama tells the saga of a team of wildly talented high school girls soccer players who become the (un)lucky survivors of a plane crash deep\u2026","poster":"assets/tv-yellowjackets-2021-poster.jpg","backdrop":"assets/tv-yellowjackets-2021-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-11-04T20:31:47.4592756Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":117488,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":92,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.431,"tmdbVotes":1070}}],"anime":[],"discover":[{"id":"movie-across-the-hall-2009","title":"Across the Hall","year":2009,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Thriller","Mystery"],"overview":"Convinced that his fiancee is cheating on him, a man follows her to a hotel and calls his best friend to help him avoid a calamity.","poster":"assets/movie-across-the-hall-2009-poster.jpg","backdrop":"assets/movie-across-the-hall-2009-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":21469,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":39,"rtAudienceLabel":"Spilled","imdb":5.8,"imdbVotes":5130,"tmdb":6,"tmdbVotes":129}},{"id":"movie-addicted-2014","title":"Addicted","year":2014,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Thriller"],"overview":"A gallerist risks her family and flourishing career when she enters into an affair with a talented painter and slowly loses control of her life.","poster":"assets/movie-addicted-2014-poster.jpg","backdrop":"assets/movie-addicted-2014-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":235271,"ratings":{"rtCritics":7,"rtCriticsLabel":"Rotten","rtAudience":51,"rtAudienceLabel":"Spilled","imdb":5,"imdbVotes":13131,"tmdb":5.928,"tmdbVotes":466}},{"id":"movie-adulterers-2016","title":"Adulterers","year":2016,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Thriller","Drama"],"overview":"On a steamy New Orleans afternoon, Sam Deuprey decides to surprise his beautiful wife, Ashley, for their anniversary and comes home early. But Ashley is not alone in the bedroom. She and her lover, Damien are about\u2026","poster":"assets/movie-adulterers-2016-poster.jpg","backdrop":"assets/movie-adulterers-2016-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":376004,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":37,"rtAudienceLabel":"Spilled","imdb":5.1,"imdbVotes":2144,"tmdb":6.2,"tmdbVotes":116}},{"id":"movie-alone-with-her-2006","title":"Alone With Her","year":2006,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Crime","Drama","Thriller"],"overview":"The harrowing story of a disturbed young man's attempts to win the affections of an unsuspecting young woman. When Doug first sees Amy, he instantly falls for her and begins to watch her every move, going so far as\u2026","poster":"assets/movie-alone-with-her-2006-poster.jpg","backdrop":"assets/movie-alone-with-her-2006-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":18206,"ratings":{"rtCritics":70,"rtCriticsLabel":"Fresh","rtAudience":46,"rtAudienceLabel":"Spilled","imdb":6,"imdbVotes":4359,"tmdb":5.9,"tmdbVotes":94}},{"id":"movie-awake-2007","title":"Awake","year":2007,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Thriller","Crime","Mystery"],"overview":"While undergoing heart surgery, a man experiences a phenomenon called \u2018anesthetic awareness\u2019, which leaves him awake but paralyzed throughout the operation. As various obstacles present themselves, his wife must make\u2026","poster":"assets/movie-awake-2007-poster.jpg","backdrop":"assets/movie-awake-2007-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":13483,"ratings":{"rtCritics":23,"rtCriticsLabel":"Rotten","rtAudience":55,"rtAudienceLabel":"Spilled","imdb":6.5,"imdbVotes":89389,"tmdb":6.586,"tmdbVotes":1649}},{"id":"movie-black-bear-2020","title":"Black Bear","year":2020,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Thriller"],"overview":"At a remote lake house in the Adirondack Mountains, a couple entertains an out-of-town guest looking for inspiration in her filmmaking. The group quickly falls into a calculated game of desire, manipulation, and\u2026","poster":"assets/movie-black-bear-2020-poster.jpg","backdrop":"assets/movie-black-bear-2020-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":624788,"ratings":{"rtCritics":89,"rtCriticsLabel":"Certified Fresh","rtAudience":64,"rtAudienceLabel":"Upright","imdb":6.5,"imdbVotes":18408,"tmdb":6.337,"tmdbVotes":315}},{"id":"movie-butterfly-on-a-wheel-2007","title":"Butterfly on a Wheel","year":2007,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Crime","Drama","Thriller"],"overview":"A sociopathic kidnapper methodically pushes a desperate pair of parents to their absolute breaking point.","poster":"assets/movie-butterfly-on-a-wheel-2007-poster.jpg","backdrop":"assets/movie-butterfly-on-a-wheel-2007-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":13250,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":61,"rtAudienceLabel":"Upright","imdb":6.6,"imdbVotes":35409,"tmdb":6.457,"tmdbVotes":595}},{"id":"movie-careful-what-you-wish-for-2015","title":"Careful What You Wish For","year":2015,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Thriller"],"overview":"A guy gets more than he bargained for after entering into an affair with the wife of an investment banker. Soon, a suspicious death and substantial life insurance policy embroil him in a scandal.","poster":"assets/movie-careful-what-you-wish-for-2015-poster.jpg","backdrop":"assets/movie-careful-what-you-wish-for-2015-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":317744,"ratings":{"rtCritics":17,"rtCriticsLabel":"Rotten","rtAudience":61,"rtAudienceLabel":"Upright","imdb":5.6,"imdbVotes":9452,"tmdb":6.129,"tmdbVotes":446}},{"id":"movie-crime-of-passion-1956","title":"Crime of Passion","year":1956,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Crime","Thriller"],"overview":"Kathy leaves the newspaper business to marry homicide detective Bill, but is frustrated by his lack of ambition and the banality of life in the suburbs. Her drive to advance Bill's career soon takes her down a\u2026","poster":"assets/movie-crime-of-passion-1956-poster.jpg","backdrop":"assets/movie-crime-of-passion-1956-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"center_crop","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1920,"ratings":{"rtCritics":80,"rtCriticsLabel":"Fresh","rtAudience":34,"rtAudienceLabel":"Spilled","imdb":6.4,"imdbVotes":3750,"tmdb":6.1,"tmdbVotes":57}},{"id":"movie-deep-water-2022","title":"Deep Water","year":2022,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Mystery","Thriller"],"overview":"A well-to-do husband who allows his wife to have affairs in order to avoid a divorce becomes a prime suspect in the disappearance of her lovers.","poster":"assets/movie-deep-water-2022-poster.jpg","backdrop":"assets/movie-deep-water-2022-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":619979,"ratings":{"rtCritics":35,"rtCriticsLabel":"Rotten","rtAudience":25,"rtAudienceLabel":"Spilled","imdb":5.5,"imdbVotes":74098,"tmdb":5.834,"tmdbVotes":1755}},{"id":"movie-derailed-2005","title":"Derailed","year":2005,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Thriller"],"overview":"When two married business executives having an affair are blackmailed by a violent criminal, they are forced to turn the tables on him to save their families.","poster":"assets/movie-derailed-2005-poster.jpg","backdrop":"assets/movie-derailed-2005-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":8999,"ratings":{"rtCritics":20,"rtCriticsLabel":"Rotten","rtAudience":58,"rtAudienceLabel":"Spilled","imdb":6.6,"imdbVotes":79563,"tmdb":6.405,"tmdbVotes":1193}},{"id":"movie-die-my-love-2025","title":"Die My Love","year":2025,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama"],"overview":"After inheriting a remote Montana house, Jackson moves there from New York with his partner Grace, and the couple soon welcome a child. As Jackson becomes increasingly absent and rural isolation sets in, Grace\u2026","poster":"assets/movie-die-my-love-2025-poster.jpg","backdrop":"assets/movie-die-my-love-2025-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1033148,"ratings":{"rtCritics":74,"rtCriticsLabel":"Certified Fresh","rtAudience":45,"rtAudienceLabel":"Spilled","imdb":6,"imdbVotes":50664,"tmdb":6.1,"tmdbVotes":683}},{"id":"movie-dream-house-2011","title":"Dream House","year":2011,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Mystery","Drama","Thriller"],"overview":"Publisher Will Atenton quits a lucrative job in New York to relocate his wife, Libby, and their daughters to a quaint town in New England. However, as they settle into their home the Atentons discover that a woman\u2026","poster":"assets/movie-dream-house-2011-poster.jpg","backdrop":"assets/movie-dream-house-2011-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":69668,"ratings":{"rtCritics":7,"rtCriticsLabel":"Rotten","rtAudience":36,"rtAudienceLabel":"Spilled","imdb":6,"imdbVotes":71386,"tmdb":6.212,"tmdbVotes":1480}},{"id":"movie-dream-lover-1994","title":"Dream Lover","year":1994,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Mystery","Thriller"],"overview":"Not long after they cross paths at an art gallery, architect Ray Reardon and hypnotically sensual Lena are married with children. But as strange incidents occur, Ray begins to realize he may not really know the woman\u2026","poster":"assets/movie-dream-lover-1994-poster.jpg","backdrop":"assets/movie-dream-lover-1994-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":18190,"ratings":{"rtCritics":57,"rtCriticsLabel":"Rotten","rtAudience":38,"rtAudienceLabel":"Spilled","imdb":6.2,"imdbVotes":7865,"tmdb":6.844,"tmdbVotes":167}},{"id":"movie-drop-2025","title":"Drop","year":2025,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Mystery","Thriller"],"overview":"Violet, a widowed mother on her first date in years, arrives at an upscale restaurant where she is relieved that her date, Henry, is more charming and handsome than she expected. But their chemistry begins to curdle\u2026","poster":"assets/movie-drop-2025-poster.jpg","backdrop":"assets/movie-drop-2025-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1249213,"ratings":{"rtCritics":83,"rtCriticsLabel":"Certified Fresh","rtAudience":79,"rtAudienceLabel":"Upright","imdb":6.1,"imdbVotes":61018,"tmdb":6.275,"tmdbVotes":1023}},{"id":"movie-eastern-promises-2007","title":"Eastern Promises","year":2007,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Thriller","Crime","Mystery"],"overview":"A Russian teenager living in London dies during childbirth but leaves clues in her diary that could tie her child to a rape involving a violent Russian mob family.","poster":"assets/movie-eastern-promises-2007-poster.jpg","backdrop":"assets/movie-eastern-promises-2007-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":2252,"ratings":{"rtCritics":89,"rtCriticsLabel":"Certified Fresh","rtAudience":83,"rtAudienceLabel":"Upright","imdb":7.6,"imdbVotes":281015,"tmdb":7.349,"tmdbVotes":3900}},{"id":"movie-faces-1968","title":"Faces","year":1968,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama"],"overview":"Middle-aged suburban husband Richard abruptly tells his wife, Maria, that he wants a divorce. As Richard takes up with a younger woman, Maria enjoys a night on the town with her friends and meets a younger man. As\u2026","poster":"assets/movie-faces-1968-poster.jpg","backdrop":"assets/movie-faces-1968-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"center_crop","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":753,"ratings":{"rtCritics":83,"rtCriticsLabel":"Fresh","rtAudience":86,"rtAudienceLabel":"Upright","imdb":7.4,"imdbVotes":12950,"tmdb":7.093,"tmdbVotes":227}},{"id":"movie-fatale-2020","title":"Fatale","year":2020,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Thriller"],"overview":"After a wild one-night stand, successful sports agent Derrick Tyler watches his perfect life slowly disappear when he discovers the sexy and mysterious woman he risked everything for is determined police detective\u2026","poster":"assets/movie-fatale-2020-poster.jpg","backdrop":"assets/movie-fatale-2020-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":549514,"ratings":{"rtCritics":46,"rtCriticsLabel":"Rotten","rtAudience":87,"rtAudienceLabel":"Upright","imdb":5.5,"imdbVotes":16392,"tmdb":6,"tmdbVotes":401}},{"id":"tv-56-days-2026","title":"56 Days","year":2026,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Crime","Mystery"],"overview":"When an unidentified body is found in a luxury apartment linked to Oliver Kennedy and his girlfriend Ciara Wyse, Detectives Lee Reardon and Karl Connolly reconstruct the couple's deadly romance across the past 56 days.","poster":"assets/tv-56-days-2026-poster.jpg","backdrop":"assets/tv-56-days-2026-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":241372,"ratings":{"rtCritics":60,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.388,"tmdbVotes":245}},{"id":"tv-90-day-fiance-before-the-90-days-2017","title":"90 Day Fianc\u00e9: Before the 90 Days","year":2017,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Reality"],"overview":"Follow couples who have an existing relationship online, but haven't met in person. We'll experience their journey as they travel to the other's foreign country for the first time in an attempt to establish an\u2026","poster":"assets/tv-90-day-fiance-before-the-90-days-2017-poster.jpg","backdrop":"assets/tv-90-day-fiance-before-the-90-days-2017-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":73319,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.5,"tmdbVotes":45}},{"id":"tv-90-day-fiance-the-other-way-2019","title":"90 Day Fianc\u00e9: The Other Way","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Reality"],"overview":"Americans move to a foreign country all in the name of love. Will uprooting their lives in the United States pay off, or will they be packing up and moving back to America brokenhearted?","poster":"assets/tv-90-day-fiance-the-other-way-2019-poster.jpg","backdrop":"assets/tv-90-day-fiance-the-other-way-2019-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":90046,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.8,"tmdbVotes":34}},{"id":"tv-absolutely-fabulous-1992","title":"Absolutely Fabulous","year":1992,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy"],"overview":"Set in the world of fashion and PR, immature fun-loving mother Edina Monsoon and her best friend Patsy drive Eddie's sensible daughter, Saffron, up the wall with their constant drug abuse and outrageous selfishness.\u2026","poster":"assets/tv-absolutely-fabulous-1992-poster.jpg","backdrop":"assets/tv-absolutely-fabulous-1992-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":4493,"ratings":{"rtCritics":96,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.6,"tmdbVotes":198}},{"id":"tv-the-afterparty-2022","title":"The Afterparty","year":2022,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Mystery","Comedy","Crime"],"overview":"Nothing is as it seems in this hilarious murder mystery where each suspect's story is told through a different film genre.","poster":"assets/tv-the-afterparty-2022-poster.jpg","backdrop":"assets/tv-the-afterparty-2022-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":106454,"ratings":{"rtCritics":92,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.071,"tmdbVotes":245}},{"id":"tv-agatha-christie-s-marple-2004","title":"Agatha Christie's Marple","year":2004,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Crime","Mystery"],"overview":"The adventures of Miss Jane Marple, an elderly spinster living in the quiet little village of St Mary Mead. During her many visits to friends and relatives in other villages, Miss Marple often stumbles upon\u2026","poster":"assets/tv-agatha-christie-s-marple-2004-poster.jpg","backdrop":"assets/tv-agatha-christie-s-marple-2004-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":6127,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.9,"tmdbVotes":152}},{"id":"tv-all-american-homecoming-2022","title":"All American: Homecoming","year":2022,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama"],"overview":"Follow the journeys of Simone Hicks, a tennis hopeful from Beverly Hills, and elite baseball player Damon Sims from Chicago, as they navigate life at the prestigious HBCU Bringston University.","poster":"assets/tv-all-american-homecoming-2022-poster.jpg","backdrop":"assets/tv-all-american-homecoming-2022-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":153723,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.3,"tmdbVotes":70}},{"id":"tv-and-just-like-that-2021","title":"And Just Like That\u2026","year":2021,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy","Drama"],"overview":"This new chapter of \u201cSex and the City\u201d follows Carrie, Miranda and Charlotte as they navigate the journey from the complicated reality of life and friendship in their 30s to the even more complicated reality of life\u2026","poster":"assets/tv-and-just-like-that-2021-poster.jpg","backdrop":"assets/tv-and-just-like-that-2021-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":116450,"ratings":{"rtCritics":52,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.211,"tmdbVotes":556}},{"id":"tv-anne-with-an-e-2017","title":"Anne with an E","year":2017,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Family"],"overview":"A coming-of-age story about an outsider who, against all odds and numerous challenges, fights for love and acceptance and for her place in the world. The series centers on a young orphaned girl in the late 1890s,\u2026","poster":"assets/tv-anne-with-an-e-2017-poster.jpg","backdrop":"assets/tv-anne-with-an-e-2017-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":70785,"ratings":{"rtCritics":63,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.7,"tmdbVotes":5019}},{"id":"tv-awkward-2011","title":"Awkward.","year":2011,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy","Drama"],"overview":"An irreverent look at the conflict, chaos and humor that defines teenage life through the eyes of 15-year-old Jenna Hamilton whose life begins to change when a simple accident becomes an epic misunderstanding and is\u2026","poster":"assets/tv-awkward-2011-poster.jpg","backdrop":"assets/tv-awkward-2011-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":39859,"ratings":{"rtCritics":78,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.4,"tmdbVotes":427}},{"id":"tv-the-baby-sitters-club-2020","title":"The Baby-Sitters Club","year":2020,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Kids","Drama"],"overview":"In this contemporary take on the beloved book series, five best friends launch a baby-sitting business that's big on fun and adventure.","poster":"assets/tv-the-baby-sitters-club-2020-poster.jpg","backdrop":"assets/tv-the-baby-sitters-club-2020-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":100412,"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.9,"tmdbVotes":506}},{"id":"tv-ballers-2015","title":"Ballers","year":2015,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Comedy"],"overview":"Looking at the lives of former and current football players, the show follows former superstar Spencer Strasmore as he gets his life on track in retirement while mentoring other current and former players through the\u2026","poster":"assets/tv-ballers-2015-poster.jpg","backdrop":"assets/tv-ballers-2015-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":62704,"ratings":{"rtCritics":72,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.809,"tmdbVotes":820}},{"id":"tv-baretta-1975","title":"Baretta","year":1975,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Crime","Drama","Mystery"],"overview":"Baretta is an American detective television series which ran on ABC from 1975 to 1978. The show was a milder version of a successful 1973\u201374 ABC series, Toma, starring Tony Musante as chameleon-like, real-life New\u2026","poster":"assets/tv-baretta-1975-poster.jpg","backdrop":"assets/tv-baretta-1975-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":3216,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.5,"tmdbVotes":43}},{"id":"tv-based-on-a-true-story-2023","title":"Based on a True Story","year":2023,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy","Drama"],"overview":"The lives of a realtor, a plumber and a former tennis star unexpectedly collide, exposing America\u2019s obsession with true crime, murder and the slow-close toilet seat.","poster":"assets/tv-based-on-a-true-story-2023-poster.jpg","backdrop":"assets/tv-based-on-a-true-story-2023-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":197373,"ratings":{"rtCritics":79,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7,"tmdbVotes":160}},{"id":"tv-the-beast-in-me-2025","title":"The Beast in Me","year":2025,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Mystery"],"overview":"A famous author is pulled into a twisted mind game with her rich, powerful new neighbor \u2014 who might be a murderer.","poster":"assets/tv-the-beast-in-me-2025-poster.jpg","backdrop":"assets/tv-the-beast-in-me-2025-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":250504,"ratings":{"rtCritics":85,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.072,"tmdbVotes":334}},{"id":"tv-big-city-greens-2018","title":"Big City Greens","year":2018,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Family","Animation","Comedy"],"overview":"The offbeat adventures of 10-year-old Cricket Green, a mischievous and optimistic country boy who moves to the big city with his wildly out of place family \u2013 older sister Tilly, father Bill and Gramma Alice.","poster":"assets/tv-big-city-greens-2018-poster.jpg","backdrop":"assets/tv-big-city-greens-2018-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":80587,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.555,"tmdbVotes":82}},{"id":"tv-the-bold-type-2017","title":"The Bold Type","year":2017,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Comedy"],"overview":"A glimpse into the outrageous lives of Jane, Kat and Sutton, who are working at the nation's top women's magazine, Scarlet, while navigating their careers, identities and individual voices.","poster":"assets/tv-the-bold-type-2017-poster.jpg","backdrop":"assets/tv-the-bold-type-2017-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":69630,"ratings":{"rtCritics":89,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.7,"tmdbVotes":204}},{"id":"tv-brassic-2019","title":"Brassic","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy","Drama","Crime"],"overview":"A group of working-class friends finding unconventional ways to win at life in suburban northern England. These lads have dealt, scammed, bribed and conned their way through adolescence, but now, their dealing and\u2026","poster":"assets/tv-brassic-2019-poster.jpg","backdrop":"assets/tv-brassic-2019-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":92621,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.336,"tmdbVotes":263}}]},{"key":"shared","movies":[{"id":"movie-american-gangster-2007","title":"American Gangster","year":2007,"type":"movie","runtimeSeconds":10546,"seasonCount":null,"communityRating":7.595,"criticRating":81,"genres":["Drama","Crime"],"overview":"Loosely based on the criminal career of Frank Lucas, a gangster from La Grange, North Carolina, who smuggled heroin into the United States on American service planes returning from the Vietnam War, before being\u2026","poster":"assets/movie-american-gangster-2007-poster.jpg","backdrop":"assets/movie-american-gangster-2007-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-08-23T10:22:36.0000000Z","lastPlayedAt":"2026-08-30T21:05:39.7952451Z","availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":4982,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":81,"rtCriticsLabel":"Certified Fresh","rtAudience":90,"rtAudienceLabel":"Upright","imdb":7.8,"imdbVotes":495656,"tmdb":7.599,"tmdbVotes":6350}},{"id":"movie-annie-hall-1977","title":"Annie Hall","year":1977,"type":"movie","runtimeSeconds":5604,"seasonCount":null,"communityRating":7.712,"criticRating":97,"genres":["Comedy","Drama","Romance"],"overview":"New York comedian Alvy Singer falls in love with the ditsy Annie Hall.","poster":"assets/movie-annie-hall-1977-poster.jpg","backdrop":"assets/movie-annie-hall-1977-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:24:23.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":703,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":97,"rtCriticsLabel":"Certified Fresh","rtAudience":92,"rtAudienceLabel":"Upright","imdb":7.9,"imdbVotes":293349,"tmdb":7.71,"tmdbVotes":4391}},{"id":"movie-apocalypse-now-1979","title":"Apocalypse Now","year":1979,"type":"movie","runtimeSeconds":8835,"seasonCount":null,"communityRating":8.261,"criticRating":91,"genres":["Drama","War"],"overview":"At the height of the Vietnam war, Captain Benjamin Willard is sent on a dangerous mission that, officially, \"does not exist, nor will it ever exist.\" His goal is to locate - and eliminate - a mysterious Green Beret\u2026","poster":"assets/movie-apocalypse-now-1979-poster.jpg","backdrop":"assets/movie-apocalypse-now-1979-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:22:25.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":28,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":91,"rtCriticsLabel":"Certified Fresh","rtAudience":94,"rtAudienceLabel":"Upright","imdb":8.4,"imdbVotes":781424,"tmdb":8.3,"tmdbVotes":9526}},{"id":"movie-arrival-2016","title":"Arrival","year":2016,"type":"movie","runtimeSeconds":6967,"seasonCount":null,"communityRating":7.636,"criticRating":94,"genres":["Drama","Science Fiction","Mystery"],"overview":"Taking place after alien crafts land around the world, an expert linguist is recruited by the military to determine whether they come in peace or are a threat.","poster":"assets/movie-arrival-2016-poster.jpg","backdrop":"assets/movie-arrival-2016-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-14T19:54:12.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":329865,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":94,"rtCriticsLabel":"Certified Fresh","rtAudience":83,"rtAudienceLabel":"Upright","imdb":7.9,"imdbVotes":883621,"tmdb":7.638,"tmdbVotes":20125}},{"id":"movie-avatar-aang-the-last-airbender-2026","title":"Avatar Aang: The Last Airbender","year":2026,"type":"movie","runtimeSeconds":5930,"seasonCount":null,"communityRating":9.147,"criticRating":90,"genres":["Animation","Adventure","Fantasy"],"overview":"Avatar Aang, the world's last Airbender, learns of an ancient power that could save his culture from extinction. With the help of his friends, he embarks on a global quest to find it before it falls into the wrong\u2026","poster":"assets/movie-avatar-aang-the-last-airbender-2026-poster.jpg","backdrop":"assets/movie-avatar-aang-the-last-airbender-2026-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-25T09:53:09.0000000Z","lastPlayedAt":"2026-08-02T19:22:33.6155383Z","availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":980431,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":90,"rtCriticsLabel":"Certified Fresh","rtAudience":99,"rtAudienceLabel":"Upright","imdb":7.7,"imdbVotes":30792,"tmdb":9,"tmdbVotes":1417}},{"id":"movie-bohemian-rhapsody-2018","title":"Bohemian Rhapsody","year":2018,"type":"movie","runtimeSeconds":8077,"seasonCount":null,"communityRating":7.95,"criticRating":60,"genres":["Music","Drama"],"overview":"Singer Freddie Mercury, guitarist Brian May, drummer Roger Taylor and bass guitarist John Deacon take the music world by storm when they form the rock 'n' roll band Queen in 1970. Hit songs become instant classics.\u2026","poster":"assets/movie-bohemian-rhapsody-2018-poster.jpg","backdrop":"assets/movie-bohemian-rhapsody-2018-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:28:20.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":424694,"artwork":{"posterKind":"center_crop","backdropKind":"backdrop"},"ratings":{"rtCritics":60,"rtCriticsLabel":"Fresh","rtAudience":85,"rtAudienceLabel":"Upright","imdb":7.9,"imdbVotes":655894,"tmdb":7.947,"tmdbVotes":18196}},{"id":"movie-the-boy-and-the-heron-2023","title":"The Boy and the Heron","year":2023,"type":"movie","runtimeSeconds":7437,"seasonCount":null,"communityRating":7.4,"criticRating":96,"genres":["Animation","Fantasy","Drama"],"overview":"While the Second World War rages, the teenage Mahito, haunted by his mother's tragic death, is relocated from Tokyo to the serene rural home of his new stepmother Natsuko, a woman who bears a striking resemblance to\u2026","poster":"assets/movie-the-boy-and-the-heron-2023-poster.jpg","backdrop":"assets/movie-the-boy-and-the-heron-2023-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:25:15.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":508883,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":96,"rtCriticsLabel":"Certified Fresh","rtAudience":88,"rtAudienceLabel":"Upright","imdb":7.3,"imdbVotes":152169,"tmdb":7.387,"tmdbVotes":2893}},{"id":"movie-braveheart-1995","title":"Braveheart","year":1995,"type":"movie","runtimeSeconds":10665,"seasonCount":null,"communityRating":7.945,"criticRating":74,"genres":["Drama","History","War"],"overview":"Enraged at the slaughter of Murron, his new bride and childhood love, Scottish warrior William Wallace slays a platoon of the local English lord's soldiers. This leads the village to revolt and, eventually, the\u2026","poster":"assets/movie-braveheart-1995-poster.jpg","backdrop":"assets/movie-braveheart-1995-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-10T21:24:28.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":197,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":74,"rtCriticsLabel":"Certified Fresh","rtAudience":85,"rtAudienceLabel":"Upright","imdb":8.3,"imdbVotes":1174990,"tmdb":7.947,"tmdbVotes":11584}},{"id":"movie-the-breakfast-club-1985","title":"The Breakfast Club","year":1985,"type":"movie","runtimeSeconds":5833,"seasonCount":null,"communityRating":7.7,"criticRating":87,"genres":["Comedy","Drama"],"overview":"Five high school students from different walks of life endure a Saturday detention under a power-hungry principal. The disparate group includes rebel John, princess Claire, outcast Allison, brainy Brian and Andrew,\u2026","poster":"assets/movie-the-breakfast-club-1985-poster.jpg","backdrop":"assets/movie-the-breakfast-club-1985-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:25:03.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":2108,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":87,"rtCriticsLabel":"Certified Fresh","rtAudience":92,"rtAudienceLabel":"Upright","imdb":7.8,"imdbVotes":478743,"tmdb":7.713,"tmdbVotes":8815}},{"id":"movie-castle-in-the-sky-1986","title":"Castle in the Sky","year":1986,"type":"movie","runtimeSeconds":7474,"seasonCount":null,"communityRating":8,"criticRating":97,"genres":["Adventure","Fantasy","Animation"],"overview":"A young boy and a girl with a magic crystal must race against pirates and foreign agents in a search for a legendary floating castle.","poster":"assets/movie-castle-in-the-sky-1986-poster.jpg","backdrop":"assets/movie-castle-in-the-sky-1986-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:23:09.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":10515,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":96,"rtCriticsLabel":"Fresh","rtAudience":91,"rtAudienceLabel":"Upright","imdb":8,"imdbVotes":201545,"tmdb":7.972,"tmdbVotes":4868}},{"id":"movie-citizen-kane-1941","title":"Citizen Kane","year":1941,"type":"movie","runtimeSeconds":7173,"seasonCount":null,"communityRating":7.966,"criticRating":99,"genres":["Mystery","Drama"],"overview":"Newspaper magnate Charles Foster Kane is taken from his mother as a boy and made the ward of a rich industrialist. As a result, every well-meaning, tyrannical or self-destructive move he makes for the rest of his\u2026","poster":"assets/movie-citizen-kane-1941-poster.jpg","backdrop":"assets/movie-citizen-kane-1941-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:22:16.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":15,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":99,"rtCriticsLabel":"Certified Fresh","rtAudience":98,"rtAudienceLabel":"Upright","imdb":8.2,"imdbVotes":498321,"tmdb":7.962,"tmdbVotes":6324}},{"id":"movie-conclave-2024","title":"Conclave","year":2024,"type":"movie","runtimeSeconds":7230,"seasonCount":null,"communityRating":7.19,"criticRating":93,"genres":["Drama","Thriller"],"overview":"After the unexpected death of the Pope, Cardinal Lawrence is tasked with managing the covert and ancient ritual of electing a new one. Sequestered in the Vatican with the Catholic Church\u2019s most powerful leaders until\u2026","poster":"assets/movie-conclave-2024-poster.jpg","backdrop":"assets/movie-conclave-2024-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:30:41.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":974576,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":93,"rtCriticsLabel":"Certified Fresh","rtAudience":86,"rtAudienceLabel":"Upright","imdb":7.4,"imdbVotes":290498,"tmdb":7.19,"tmdbVotes":3744}},{"id":"movie-the-dry-2021","title":"The Dry","year":2021,"type":"movie","runtimeSeconds":7037,"seasonCount":null,"communityRating":6.779,"criticRating":null,"genres":["Crime","Thriller","Mystery"],"overview":"Aaron Falk returns to his drought-stricken hometown to attend a tragic funeral. But his return opens a decades-old wound - the unsolved death of a teenage girl.","poster":"assets/movie-the-dry-2021-poster.jpg","backdrop":"assets/movie-the-dry-2021-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:29:20.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":567797,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":90,"rtCriticsLabel":"Certified Fresh","rtAudience":89,"rtAudienceLabel":"Upright","imdb":6.8,"imdbVotes":41935,"tmdb":6.8,"tmdbVotes":892}},{"id":"movie-easy-a-2010","title":"Easy A","year":2010,"type":"movie","runtimeSeconds":5545,"seasonCount":null,"communityRating":6.8,"criticRating":85,"genres":["Comedy"],"overview":"Olive, an average high school student, sees her below-the-radar existence turn around overnight once she decides to use the school's gossip grapevine to advance her social standing. Now her classmates are turning\u2026","poster":"assets/movie-easy-a-2010-poster.jpg","backdrop":"assets/movie-easy-a-2010-backdrop.jpg","played":true,"playCount":1,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-17T19:59:04.0000000Z","lastPlayedAt":"2026-10-17T20:02:51.1083337Z","availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":37735,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":85,"rtCriticsLabel":"Certified Fresh","rtAudience":77,"rtAudienceLabel":"Upright","imdb":7,"imdbVotes":448780,"tmdb":6.8,"tmdbVotes":7619}},{"id":"movie-el-camino-a-breaking-bad-movie-2019","title":"El Camino: A Breaking Bad Movie","year":2019,"type":"movie","runtimeSeconds":7362,"seasonCount":null,"communityRating":7.055,"criticRating":92,"genres":["Crime","Drama","Thriller"],"overview":"In the wake of his dramatic escape from captivity, Jesse Pinkman must come to terms with his past in order to forge some kind of future.","poster":"assets/movie-el-camino-a-breaking-bad-movie-2019-poster.jpg","backdrop":"assets/movie-el-camino-a-breaking-bad-movie-2019-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-21T23:31:11.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":559969,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":92,"rtCriticsLabel":"Certified Fresh","rtAudience":81,"rtAudienceLabel":"Upright","imdb":7.3,"imdbVotes":354488,"tmdb":7.056,"tmdbVotes":5950}},{"id":"movie-emancipation-2022","title":"Emancipation","year":2022,"type":"movie","runtimeSeconds":7949,"seasonCount":null,"communityRating":7.7,"criticRating":45,"genres":["Drama","War","History"],"overview":"Inspired by the gripping true story of a man who would do anything for his family\u2014and for freedom. When Peter, an enslaved man, risks his life to escape and return to his family, he embarks on a perilous journey of\u2026","poster":"assets/movie-emancipation-2022-poster.jpg","backdrop":"assets/movie-emancipation-2022-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-10T20:37:34.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":715931,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":45,"rtCriticsLabel":"Rotten","rtAudience":58,"rtAudienceLabel":"Spilled","imdb":6.3,"imdbVotes":32079,"tmdb":7.744,"tmdbVotes":1319}},{"id":"movie-fighting-with-my-family-2019","title":"Fighting with My Family","year":2019,"type":"movie","runtimeSeconds":6509,"seasonCount":null,"communityRating":6.84,"criticRating":93,"genres":["Comedy"],"overview":"Born into a tight-knit wrestling family, Paige and her brother Zak are ecstatic when they get the once-in-a-lifetime opportunity to try out for the WWE. But when only Paige earns a spot in the competitive training\u2026","poster":"assets/movie-fighting-with-my-family-2019-poster.jpg","backdrop":"assets/movie-fighting-with-my-family-2019-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-22T22:10:59.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":445629,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":93,"rtCriticsLabel":"Certified Fresh","rtAudience":86,"rtAudienceLabel":"Upright","imdb":7,"imdbVotes":94576,"tmdb":6.837,"tmdbVotes":1952}},{"id":"movie-force-of-nature-the-dry-2-2024","title":"Force of Nature: The Dry 2","year":2024,"type":"movie","runtimeSeconds":6753,"seasonCount":null,"communityRating":6.3,"criticRating":66,"genres":["Mystery","Thriller"],"overview":"When five women take part in a corporate hiking retreat and only four come out on the other side, Federal Agents Aaron Falk and Carmen Cooper head deep into the Victorian mountain ranges to investigate in the hopes\u2026","poster":"assets/movie-force-of-nature-the-dry-2-2024-poster.jpg","backdrop":"assets/movie-force-of-nature-the-dry-2-2024-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:29:49.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":832262,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":66,"rtCriticsLabel":"Fresh","rtAudience":43,"rtAudienceLabel":"Spilled","imdb":5.9,"imdbVotes":10343,"tmdb":6.269,"tmdbVotes":305}},{"id":"movie-free-state-of-jones-2016","title":"Free State of Jones","year":2016,"type":"movie","runtimeSeconds":8376,"seasonCount":null,"communityRating":6.79,"criticRating":48,"genres":["Action","Drama","History"],"overview":"In 1863, Mississippi farmer Newt Knight serves as a medic for the Confederate Army. Opposed to slavery, Knight would rather help the wounded than fight the Union. After his nephew dies in battle, Newt returns home to\u2026","poster":"assets/movie-free-state-of-jones-2016-poster.jpg","backdrop":"assets/movie-free-state-of-jones-2016-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-11T17:56:53.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":316152,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":48,"rtCriticsLabel":"Rotten","rtAudience":64,"rtAudienceLabel":"Upright","imdb":6.9,"imdbVotes":68931,"tmdb":6.788,"tmdbVotes":1730}},{"id":"movie-from-up-on-poppy-hill-2011","title":"From Up on Poppy Hill","year":2011,"type":"movie","runtimeSeconds":5482,"seasonCount":null,"communityRating":7.5,"criticRating":87,"genres":["Animation","Drama"],"overview":"Yokohama, 1963. Japan is picking itself up from the devastation of World War II and preparing to host the 1964 Olympics\u2014and the mood is one of both optimism and conflict as the young generation struggles to throw off\u2026","poster":"assets/movie-from-up-on-poppy-hill-2011-poster.jpg","backdrop":"assets/movie-from-up-on-poppy-hill-2011-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-07T14:41:03.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":83389,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":87,"rtCriticsLabel":"Certified Fresh","rtAudience":80,"rtAudienceLabel":"Upright","imdb":7.4,"imdbVotes":60769,"tmdb":7.509,"tmdbVotes":2026}},{"id":"movie-ghost-in-the-shell-1995","title":"Ghost in the Shell","year":1995,"type":"movie","runtimeSeconds":4959,"seasonCount":null,"communityRating":7.929,"criticRating":95,"genres":["Action","Animation","Science Fiction"],"overview":"In the year 2029, the barriers of our world have been broken down by the net and by cybernetics, but this brings new vulnerability to humans in the form of brain-hacking. When a highly-wanted hacker known as 'The\u2026","poster":"assets/movie-ghost-in-the-shell-1995-poster.jpg","backdrop":"assets/movie-ghost-in-the-shell-1995-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:22:57.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":9323,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":97,"rtCriticsLabel":"Certified Fresh","rtAudience":90,"rtAudienceLabel":"Upright","imdb":7.9,"imdbVotes":178061,"tmdb":7.928,"tmdbVotes":4130}},{"id":"movie-ghost-in-the-shell-2-innocence-2004","title":"Ghost in the Shell 2: Innocence","year":2004,"type":"movie","runtimeSeconds":5950,"seasonCount":null,"communityRating":7.352,"criticRating":65,"genres":["Animation","Drama","Science Fiction"],"overview":"Cyborg detective Batou is assigned to investigate a series of murders committed by gynoids\u2014doll-like cyborgs, which all malfunctioned, killed, then self-destructed afterwards. The brains of the gynoids initialize in\u2026","poster":"assets/movie-ghost-in-the-shell-2-innocence-2004-poster.jpg","backdrop":"assets/movie-ghost-in-the-shell-2-innocence-2004-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:23:38.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":12140,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":65,"rtCriticsLabel":"Fresh","rtAudience":80,"rtAudienceLabel":"Upright","imdb":7.4,"imdbVotes":43280,"tmdb":7.356,"tmdbVotes":1049}},{"id":"movie-the-godfather-1972","title":"The Godfather","year":1972,"type":"movie","runtimeSeconds":10603,"seasonCount":null,"communityRating":8.687,"criticRating":97,"genres":["Drama","Crime"],"overview":"Spanning the years 1945 to 1955, a chronicle of the fictional Italian-American Corleone crime family. When organized crime family patriarch, Vito Corleone barely survives an attempt on his life, his youngest son,\u2026","poster":"assets/movie-the-godfather-1972-poster.jpg","backdrop":"assets/movie-the-godfather-1972-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:24:12.0000000Z","lastPlayedAt":"2026-09-18T19:24:44.9855867Z","availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":238,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":97,"rtCriticsLabel":"Certified Fresh","rtAudience":98,"rtAudienceLabel":"Upright","imdb":9.2,"imdbVotes":2262024,"tmdb":8.688,"tmdbVotes":23907}},{"id":"movie-the-godfather-part-ii-1974","title":"The Godfather Part II","year":1974,"type":"movie","runtimeSeconds":12031,"seasonCount":null,"communityRating":8.573,"criticRating":96,"genres":["Drama","Crime"],"overview":"In the continuing saga of the Corleone crime family, a young Vito Corleone grows up in Sicily and in 1910s New York. In the 1950s, Michael Corleone attempts to expand the family business into Las Vegas, Hollywood and\u2026","poster":"assets/movie-the-godfather-part-ii-1974-poster.jpg","backdrop":"assets/movie-the-godfather-part-ii-1974-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:24:18.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":240,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":96,"rtCriticsLabel":"Certified Fresh","rtAudience":97,"rtAudienceLabel":"Upright","imdb":9,"imdbVotes":1521308,"tmdb":8.571,"tmdbVotes":14559}},{"id":"movie-the-godfather-part-iii-1990","title":"The Godfather Part III","year":1990,"type":"movie","runtimeSeconds":9476,"seasonCount":null,"communityRating":7.416,"criticRating":67,"genres":["Crime","Drama","Thriller"],"overview":"In the midst of trying to legitimize his business dealings in 1979 New York and Italy, aging mafia don, Michael Corleone seeks forgiveness for his sins while taking a young protege under his wing.","poster":"assets/movie-the-godfather-part-iii-1990-poster.jpg","backdrop":"assets/movie-the-godfather-part-iii-1990-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:26:29.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":242,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":68,"rtCriticsLabel":"Fresh","rtAudience":68,"rtAudienceLabel":"Upright","imdb":7.5,"imdbVotes":462448,"tmdb":7.413,"tmdbVotes":7159}},{"id":"movie-gosford-park-2001","title":"Gosford Park","year":2001,"type":"movie","runtimeSeconds":8247,"seasonCount":null,"communityRating":6.815,"criticRating":87,"genres":["Drama","Mystery","Thriller"],"overview":"In 1930s England, a group of pretentious rich and famous gather together for a weekend of relaxation at a hunting resort. But when a murder occurs, each one of these interesting characters becomes a suspect.","poster":"assets/movie-gosford-park-2001-poster.jpg","backdrop":"assets/movie-gosford-park-2001-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:25:49.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":5279,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":87,"rtCriticsLabel":"Certified Fresh","rtAudience":78,"rtAudienceLabel":"Upright","imdb":7.2,"imdbVotes":103883,"tmdb":6.822,"tmdbVotes":1317}},{"id":"movie-grave-of-the-fireflies-1988","title":"Grave of the Fireflies","year":1988,"type":"movie","runtimeSeconds":5313,"seasonCount":null,"communityRating":8.45,"criticRating":100,"genres":["Animation","Drama","War"],"overview":"In the final months of World War II, 14-year-old Seita and his sister Setsuko are orphaned when their mother is killed during an air raid in Kobe, Japan. After a falling out with their aunt, they move into an\u2026","poster":"assets/movie-grave-of-the-fireflies-1988-poster.jpg","backdrop":"assets/movie-grave-of-the-fireflies-1988-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:24:14.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":12477,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Certified Fresh","rtAudience":95,"rtAudienceLabel":"Upright","imdb":8.5,"imdbVotes":399802,"tmdb":8.45,"tmdbVotes":6938}},{"id":"movie-the-great-gatsby-2013","title":"The Great Gatsby","year":2013,"type":"movie","runtimeSeconds":8525,"seasonCount":null,"communityRating":7.346,"criticRating":49,"genres":["Drama","Romance"],"overview":"Nick Carraway arrives in New York in 1922, an era of loose morals, glittering jazz and bootleg kings. Chasing his own American Dream, Nick encounters the mysterious millionaire Gatsby and his bewitching cousin Daisy.\u2026","poster":"assets/movie-the-great-gatsby-2013-poster.jpg","backdrop":"assets/movie-the-great-gatsby-2013-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:26:50.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":64682,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":48,"rtCriticsLabel":"Rotten","rtAudience":67,"rtAudienceLabel":"Upright","imdb":7.2,"imdbVotes":651882,"tmdb":7.346,"tmdbVotes":13290}},{"id":"movie-the-hours-2002","title":"The Hours","year":2002,"type":"movie","runtimeSeconds":6887,"seasonCount":null,"communityRating":7.25,"criticRating":79,"genres":["Drama"],"overview":"The story of three women searching for more potent, meaningful lives. Each is alive at a different time and place, all are linked by their yearnings and their fears. Their stories intertwine, and finally come\u2026","poster":"assets/movie-the-hours-2002-poster.jpg","backdrop":"assets/movie-the-hours-2002-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:23:27.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":590,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":79,"rtCriticsLabel":"Certified Fresh","rtAudience":84,"rtAudienceLabel":"Upright","imdb":7.5,"imdbVotes":148367,"tmdb":7.249,"tmdbVotes":1931}},{"id":"movie-howl-s-moving-castle-2004","title":"Howl's Moving Castle","year":2004,"type":"movie","runtimeSeconds":7149,"seasonCount":null,"communityRating":8.4,"criticRating":88,"genres":["Fantasy","Animation","Adventure"],"overview":"Sophie, a young milliner, is turned into an elderly woman by a witch who enters her shop and curses her. She encounters a wizard named Howl and gets caught up in his resistance to fighting for the king.","poster":"assets/movie-howl-s-moving-castle-2004-poster.jpg","backdrop":"assets/movie-howl-s-moving-castle-2004-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:22:47.0000000Z","lastPlayedAt":"2026-05-21T18:46:08.0000000Z","availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":4935,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":87,"rtCriticsLabel":"Certified Fresh","rtAudience":93,"rtAudienceLabel":"Upright","imdb":8.2,"imdbVotes":524520,"tmdb":8.391,"tmdbVotes":11642}},{"id":"movie-in-the-heights-2021","title":"In the Heights","year":2021,"type":"movie","runtimeSeconds":8579,"seasonCount":null,"communityRating":7.111,"criticRating":null,"genres":["Drama","Romance"],"overview":"The story of Usnavi, a bodega owner who has mixed feelings about closing his store and retiring to the Dominican Republic or staying in Washington Heights.","poster":"assets/movie-in-the-heights-2021-poster.jpg","backdrop":"assets/movie-in-the-heights-2021-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:29:11.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":467909,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":94,"rtCriticsLabel":"Certified Fresh","rtAudience":94,"rtAudienceLabel":"Upright","imdb":7.2,"imdbVotes":56513,"tmdb":7.104,"tmdbVotes":826}},{"id":"movie-jennifer-s-body-2009","title":"Jennifer's Body","year":2009,"type":"movie","runtimeSeconds":6450,"seasonCount":null,"communityRating":6.012,"criticRating":47,"genres":["Horror","Comedy"],"overview":"Jennifer, a gorgeous, seductive cheerleader takes evil to a whole new level after she's possessed by a sinister demon. Now it's up to her best friend to stop Jennifer's reign of terror before it's too late.","poster":"assets/movie-jennifer-s-body-2009-poster.jpg","backdrop":"assets/movie-jennifer-s-body-2009-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-30T03:37:04.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":19994,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":47,"rtCriticsLabel":"Rotten","rtAudience":36,"rtAudienceLabel":"Spilled","imdb":5.6,"imdbVotes":179710,"tmdb":6.013,"tmdbVotes":4481}},{"id":"movie-kiki-s-delivery-service-1989","title":"Kiki's Delivery Service","year":1989,"type":"movie","runtimeSeconds":6183,"seasonCount":null,"communityRating":7.839,"criticRating":null,"genres":["Animation","Family","Fantasy"],"overview":"A young witch, on her mandatory year of independent life, finds fitting into a new community difficult while she supports herself by running an air courier service.","poster":"assets/movie-kiki-s-delivery-service-1989-poster.jpg","backdrop":"assets/movie-kiki-s-delivery-service-1989-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:24:20.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":16859,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":98,"rtCriticsLabel":"Certified Fresh","rtAudience":89,"rtAudienceLabel":"Upright","imdb":7.8,"imdbVotes":192477,"tmdb":7.839,"tmdbVotes":4811}},{"id":"movie-the-last-of-the-mohicans-1992","title":"The Last of the Mohicans","year":1992,"type":"movie","runtimeSeconds":6874,"seasonCount":null,"communityRating":7.388,"criticRating":88,"genres":["History","War","Drama"],"overview":"In war-torn colonial America, in the midst of a bloody battle between British, the French and Native American allies, the aristocratic daughter of a British Colonel and her party are captured by a group of Huron\u2026","poster":"assets/movie-the-last-of-the-mohicans-1992-poster.jpg","backdrop":"assets/movie-the-last-of-the-mohicans-1992-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-10T20:59:22.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":9361,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":88,"rtCriticsLabel":"Certified Fresh","rtAudience":88,"rtAudienceLabel":"Upright","imdb":7.6,"imdbVotes":210791,"tmdb":7.39,"tmdbVotes":3593}},{"id":"movie-the-last-samurai-2003","title":"The Last Samurai","year":2003,"type":"movie","runtimeSeconds":9251,"seasonCount":null,"communityRating":7.61,"criticRating":65,"genres":["Drama","Action","War"],"overview":"Nathan Algren is an American hired to instruct the Japanese army in the ways of modern warfare, which finds him learning to respect the samurai and the honorable principles that rule them. Pressed to destroy the\u2026","poster":"assets/movie-the-last-samurai-2003-poster.jpg","backdrop":"assets/movie-the-last-samurai-2003-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-26T05:25:10.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":616,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":66,"rtCriticsLabel":"Fresh","rtAudience":83,"rtAudienceLabel":"Upright","imdb":7.8,"imdbVotes":512982,"tmdb":7.614,"tmdbVotes":7887}},{"id":"movie-lemony-snicket-s-a-series-of-unfortunate-events-2004","title":"Lemony Snicket's A Series of Unfortunate Events","year":2004,"type":"movie","runtimeSeconds":6479,"seasonCount":null,"communityRating":6.8,"criticRating":72,"genres":["Adventure","Comedy","Family"],"overview":"Three wealthy children's parents are killed in a fire. When they are sent to a distant relative, they find out that he is plotting to kill them and seize their fortune.","poster":"assets/movie-lemony-snicket-s-a-series-of-unfortunate-events-2004-poster.jpg","backdrop":"assets/movie-lemony-snicket-s-a-series-of-unfortunate-events-2004-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:26:09.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":11774,"artwork":{"posterKind":"center_crop","backdropKind":"backdrop"},"ratings":{"rtCritics":72,"rtCriticsLabel":"Fresh","rtAudience":63,"rtAudienceLabel":"Upright","imdb":6.8,"imdbVotes":233636,"tmdb":6.82,"tmdbVotes":5447}},{"id":"movie-the-lighthouse-2019","title":"The Lighthouse","year":2019,"type":"movie","runtimeSeconds":6549,"seasonCount":null,"communityRating":7.463,"criticRating":90,"genres":["Drama","Fantasy","Thriller"],"overview":"Two lighthouse keepers try to maintain their sanity while living on a remote and mysterious New England island in the 1890s.","poster":"assets/movie-the-lighthouse-2019-poster.jpg","backdrop":"assets/movie-the-lighthouse-2019-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:29:01.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":503919,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":90,"rtCriticsLabel":"Certified Fresh","rtAudience":72,"rtAudienceLabel":"Upright","imdb":7.3,"imdbVotes":312344,"tmdb":7.46,"tmdbVotes":5895}},{"id":"movie-locke-2014","title":"Locke","year":2014,"type":"movie","runtimeSeconds":5090,"seasonCount":null,"communityRating":6.888,"criticRating":91,"genres":["Drama","Thriller"],"overview":"Ivan Locke has worked hard to craft a good life for himself. Tonight, that life will collapse around him. On the eve of the biggest challenge of his career, Ivan receives a phone call that sets in motion a series of\u2026","poster":"assets/movie-locke-2014-poster.jpg","backdrop":"assets/movie-locke-2014-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T17:38:48.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":210479,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":90,"rtCriticsLabel":"Certified Fresh","rtAudience":72,"rtAudienceLabel":"Upright","imdb":7.1,"imdbVotes":171265,"tmdb":6.889,"tmdbVotes":3088}},{"id":"movie-the-matrix-1999","title":"The Matrix","year":1999,"type":"movie","runtimeSeconds":8179,"seasonCount":null,"communityRating":8.259,"criticRating":83,"genres":["Action","Science Fiction"],"overview":"Set in the 22nd century, The Matrix tells the story of a computer hacker who joins a group of underground insurgents fighting the vast and powerful computers who now rule the earth.","poster":"assets/movie-the-matrix-1999-poster.jpg","backdrop":"assets/movie-the-matrix-1999-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:18:12.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":603,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":83,"rtCriticsLabel":"Certified Fresh","rtAudience":85,"rtAudienceLabel":"Upright","imdb":8.7,"imdbVotes":2289808,"tmdb":8.262,"tmdbVotes":29153}},{"id":"movie-the-matrix-reloaded-2003","title":"The Matrix Reloaded","year":2003,"type":"movie","runtimeSeconds":8298,"seasonCount":null,"communityRating":7.085,"criticRating":74,"genres":["Adventure","Action","Thriller"],"overview":"The Resistance builds in numbers as humans are freed from the Matrix and brought to the city of Zion. Neo discovers his superpowers, including the ability to see the code inside the Matrix. With machine sentinels\u2026","poster":"assets/movie-the-matrix-reloaded-2003-poster.jpg","backdrop":"assets/movie-the-matrix-reloaded-2003-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:24:03.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":604,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":73,"rtCriticsLabel":"Certified Fresh","rtAudience":72,"rtAudienceLabel":"Upright","imdb":7.2,"imdbVotes":678013,"tmdb":7.087,"tmdbVotes":12504}},{"id":"movie-the-matrix-resurrections-2021","title":"The Matrix Resurrections","year":2021,"type":"movie","runtimeSeconds":8875,"seasonCount":null,"communityRating":6.303,"criticRating":63,"genres":["Science Fiction","Action","Adventure"],"overview":"Plagued by strange memories, Neo's life takes an unexpected turn when he finds himself back inside the Matrix.","poster":"assets/movie-the-matrix-resurrections-2021-poster.jpg","backdrop":"assets/movie-the-matrix-resurrections-2021-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:31:22.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":624860,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":63,"rtCriticsLabel":"Fresh","rtAudience":63,"rtAudienceLabel":"Upright","imdb":5.6,"imdbVotes":307780,"tmdb":6.294,"tmdbVotes":6842}},{"id":"movie-the-matrix-revolutions-2003","title":"The Matrix Revolutions","year":2003,"type":"movie","runtimeSeconds":7756,"seasonCount":null,"communityRating":6.758,"criticRating":33,"genres":["Adventure","Action","Thriller"],"overview":"The human city of Zion defends itself against the massive invasion of the machines as Neo fights to end the war at another front while also opposing the rogue Agent Smith.","poster":"assets/movie-the-matrix-revolutions-2003-poster.jpg","backdrop":"assets/movie-the-matrix-revolutions-2003-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:25:47.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":605,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":34,"rtCriticsLabel":"Rotten","rtAudience":60,"rtAudienceLabel":"Upright","imdb":6.8,"imdbVotes":581553,"tmdb":6.762,"tmdbVotes":11292}},{"id":"movie-mercy-2026","title":"Mercy","year":2026,"type":"movie","runtimeSeconds":5968,"seasonCount":null,"communityRating":7.077,"criticRating":null,"genres":["Science Fiction","Thriller","Crime"],"overview":"In the near future, a detective stands on trial accused of murdering his wife. He has ninety minutes to prove his innocence to the advanced AI Judge he once championed, before it determines his fate.","poster":"assets/movie-mercy-2026-poster.jpg","backdrop":"assets/movie-mercy-2026-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:31:40.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":1236153,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":24,"rtCriticsLabel":"Rotten","rtAudience":81,"rtAudienceLabel":"Upright","imdb":6.2,"imdbVotes":83878,"tmdb":7.054,"tmdbVotes":1812}},{"id":"movie-normal-2026","title":"Normal","year":2026,"type":"movie","runtimeSeconds":5514,"seasonCount":null,"communityRating":7.267,"criticRating":77,"genres":["Action","Crime","Thriller"],"overview":"An unassuming substitute sheriff with a troubled past responds to a bank robbery in a small, sleepy town and unknowingly uncovers something far more explosive.","poster":"assets/movie-normal-2026-poster.jpg","backdrop":"assets/movie-normal-2026-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-08-21T08:33:18.0000000Z","lastPlayedAt":"2026-09-24T23:07:52.1161581Z","availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":1242332,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":78,"rtCriticsLabel":"Certified Fresh","rtAudience":75,"rtAudienceLabel":"Upright","imdb":6.3,"imdbVotes":37501,"tmdb":7.241,"tmdbVotes":786}},{"id":"movie-outlaw-king-2018","title":"Outlaw King","year":2018,"type":"movie","runtimeSeconds":7297,"seasonCount":null,"communityRating":6.829,"criticRating":63,"genres":["Action","History","War"],"overview":"Forced into exile by the English after being crowned King of Scotland, legendary warrior Robert the Bruce fights to reclaim the throne.","poster":"assets/movie-outlaw-king-2018-poster.jpg","backdrop":"assets/movie-outlaw-king-2018-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:28:32.0000000Z","lastPlayedAt":"2026-07-26T22:35:35.0000000Z","availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":454227,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":63,"rtCriticsLabel":"Fresh","rtAudience":69,"rtAudienceLabel":"Upright","imdb":6.9,"imdbVotes":89876,"tmdb":6.844,"tmdbVotes":1780}},{"id":"movie-panic-room-2002","title":"Panic Room","year":2002,"type":"movie","runtimeSeconds":6717,"seasonCount":null,"communityRating":6.794,"criticRating":76,"genres":["Crime","Drama","Thriller"],"overview":"Trapped in their New York brownstone's panic room, a hidden chamber built as a sanctuary in the event of break-ins, newly divorced Meg Altman and her young daughter Sarah play a deadly game of cat-and-mouse with\u2026","poster":"assets/movie-panic-room-2002-poster.jpg","backdrop":"assets/movie-panic-room-2002-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-19T20:50:01.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":4547,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":76,"rtCriticsLabel":"Certified Fresh","rtAudience":64,"rtAudienceLabel":"Upright","imdb":6.8,"imdbVotes":317468,"tmdb":6.796,"tmdbVotes":5653}},{"id":"movie-the-patriot-2000","title":"The Patriot","year":2000,"type":"movie","runtimeSeconds":9887,"seasonCount":null,"communityRating":7.214,"criticRating":61,"genres":["Drama","History","War"],"overview":"After proving himself on the field of battle in the French and Indian War, Benjamin Martin wants nothing more to do with such things, preferring the simple life of a farmer. But when his son Gabriel enlists in the\u2026","poster":"assets/movie-the-patriot-2000-poster.jpg","backdrop":"assets/movie-the-patriot-2000-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-10T20:50:00.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":2024,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":61,"rtCriticsLabel":"Fresh","rtAudience":81,"rtAudienceLabel":"Upright","imdb":7.2,"imdbVotes":317639,"tmdb":7.215,"tmdbVotes":4535}},{"id":"movie-peaky-blinders-the-immortal-man-2026","title":"Peaky Blinders: The Immortal Man","year":2026,"type":"movie","runtimeSeconds":6844,"seasonCount":null,"communityRating":7.187,"criticRating":90,"genres":["Crime","Drama"],"overview":"After his estranged son gets embroiled in a Nazi plot, self-exiled gangster Tommy Shelby must return to Birmingham to save his family \u2014 and his nation.","poster":"assets/movie-peaky-blinders-the-immortal-man-2026-poster.jpg","backdrop":"assets/movie-peaky-blinders-the-immortal-man-2026-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:30:01.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":875828,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":90,"rtCriticsLabel":"Certified Fresh","rtAudience":87,"rtAudienceLabel":"Upright","imdb":6.5,"imdbVotes":92130,"tmdb":7.189,"tmdbVotes":1304}},{"id":"movie-ponyo-2008","title":"Ponyo","year":2008,"type":"movie","runtimeSeconds":6530,"seasonCount":null,"communityRating":7.763,"criticRating":91,"genres":["Animation","Fantasy","Family"],"overview":"When Sosuke, a young boy who lives on a clifftop overlooking the sea, rescues a stranded goldfish named Ponyo, he discovers more than he bargained for. Ponyo is a curious, energetic young creature who yearns to be\u2026","poster":"assets/movie-ponyo-2008-poster.jpg","backdrop":"assets/movie-ponyo-2008-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:23:53.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":12429,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":91,"rtCriticsLabel":"Certified Fresh","rtAudience":84,"rtAudienceLabel":"Upright","imdb":7.6,"imdbVotes":190294,"tmdb":7.759,"tmdbVotes":5050}},{"id":"movie-porco-rosso-1992","title":"Porco Rosso","year":1992,"type":"movie","runtimeSeconds":5600,"seasonCount":null,"communityRating":7.794,"criticRating":96,"genres":["Animation","Adventure","Fantasy"],"overview":"In Italy in the 1930s, sky pirates in biplanes terrorize wealthy cruise ships as they sail the Adriatic Sea. The only pilot brave enough to stop the scourge is the mysterious Porco Rosso, a former World War I flying\u2026","poster":"assets/movie-porco-rosso-1992-poster.jpg","backdrop":"assets/movie-porco-rosso-1992-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:23:22.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":11621,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":96,"rtCriticsLabel":"Fresh","rtAudience":87,"rtAudienceLabel":"Upright","imdb":7.7,"imdbVotes":119396,"tmdb":7.789,"tmdbVotes":3879}},{"id":"movie-princess-mononoke-1997","title":"Princess Mononoke","year":1997,"type":"movie","runtimeSeconds":8003,"seasonCount":null,"communityRating":8.321,"criticRating":93,"genres":["Adventure","Fantasy","Animation"],"overview":"Ashitaka, a prince of the disappearing Emishi people, is cursed by a demonized boar god and must journey to the west to find a cure. Along the way, he encounters San, a young human woman fighting to protect the\u2026","poster":"assets/movie-princess-mononoke-1997-poster.jpg","backdrop":"assets/movie-princess-mononoke-1997-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:22:28.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":128,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":94,"rtCriticsLabel":"Certified Fresh","rtAudience":99,"rtAudienceLabel":"Upright","imdb":8.3,"imdbVotes":493735,"tmdb":8.321,"tmdbVotes":9438}},{"id":"movie-prisoners-2013","title":"Prisoners","year":2013,"type":"movie","runtimeSeconds":9192,"seasonCount":null,"communityRating":8.106,"criticRating":81,"genres":["Drama","Thriller","Crime"],"overview":"After his six-year-old daughter is abducted, a desperate father takes justice into his own hands when he loses faith in the detective leading the investigation, setting both men on a collision course as the search\u2026","poster":"assets/movie-prisoners-2013-poster.jpg","backdrop":"assets/movie-prisoners-2013-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:27:33.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":146233,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":82,"rtCriticsLabel":"Certified Fresh","rtAudience":87,"rtAudienceLabel":"Upright","imdb":8.2,"imdbVotes":963062,"tmdb":8.105,"tmdbVotes":13722}},{"id":"movie-racing-stripes-2005","title":"Racing Stripes","year":2005,"type":"movie","runtimeSeconds":6117,"seasonCount":null,"communityRating":5.945,"criticRating":35,"genres":["Comedy","Family"],"overview":"Shattered illusions are hard to repair -- especially for a good-hearted zebra named Stripes who's spent his life on a Kentucky farm amidst the sorely mistaken notion that he's a debonair thoroughbred. Once he faces\u2026","poster":"assets/movie-racing-stripes-2005-poster.jpg","backdrop":"assets/movie-racing-stripes-2005-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:26:02.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":6439,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":35,"rtCriticsLabel":"Rotten","rtAudience":42,"rtAudienceLabel":"Spilled","imdb":5.3,"imdbVotes":19569,"tmdb":5.962,"tmdbVotes":705}},{"id":"movie-requiem-for-a-dream-2000","title":"Requiem for a Dream","year":2000,"type":"movie","runtimeSeconds":6086,"seasonCount":null,"communityRating":8.005,"criticRating":80,"genres":["Crime","Drama"],"overview":"The drug-induced utopias of four Coney Island residents are shattered when their addictions run deep.","poster":"assets/movie-requiem-for-a-dream-2000-poster.jpg","backdrop":"assets/movie-requiem-for-a-dream-2000-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:24:15.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":641,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":81,"rtCriticsLabel":"Certified Fresh","rtAudience":93,"rtAudienceLabel":"Upright","imdb":8.3,"imdbVotes":989090,"tmdb":8,"tmdbVotes":11454}},{"id":"movie-rogue-one-a-star-wars-story-2016","title":"Rogue One: A Star Wars Story","year":2016,"type":"movie","runtimeSeconds":8038,"seasonCount":null,"communityRating":7.51,"criticRating":84,"genres":["Action","Adventure","Science Fiction"],"overview":"A rogue band of resistance fighters unite for a mission to steal the Death Star plans and bring a new hope to the galaxy.","poster":"assets/movie-rogue-one-a-star-wars-story-2016-poster.jpg","backdrop":"assets/movie-rogue-one-a-star-wars-story-2016-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:28:00.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":330459,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":84,"rtCriticsLabel":"Certified Fresh","rtAudience":87,"rtAudienceLabel":"Upright","imdb":7.8,"imdbVotes":768365,"tmdb":7.512,"tmdbVotes":16937}},{"id":"movie-secondhand-lions-2003","title":"Secondhand Lions","year":2003,"type":"movie","runtimeSeconds":6547,"seasonCount":null,"communityRating":7.237,"criticRating":60,"genres":["Family","Comedy","Drama"],"overview":"The comedic adventures of an introverted boy left on the doorstep of a pair of reluctant, eccentric great-uncles, whose exotic remembrances stir the boy's spirit and re-ignite the men's lives.","poster":"assets/movie-secondhand-lions-2003-poster.jpg","backdrop":"assets/movie-secondhand-lions-2003-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-12T07:16:13.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":13156,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":59,"rtCriticsLabel":"Rotten","rtAudience":84,"rtAudienceLabel":"Upright","imdb":7.5,"imdbVotes":66911,"tmdb":7.242,"tmdbVotes":872}},{"id":"movie-the-secret-life-of-pets-2-2019","title":"The Secret Life of Pets 2","year":2019,"type":"movie","runtimeSeconds":5164,"seasonCount":null,"communityRating":6.95,"criticRating":60,"genres":["Animation","Comedy","Family"],"overview":"Max the terrier must cope with some major life changes when his owner gets married and has a baby. When the family takes a trip to the countryside, nervous Max has numerous run-ins with canine-intolerant cows,\u2026","poster":"assets/movie-the-secret-life-of-pets-2-2019-poster.jpg","backdrop":"assets/movie-the-secret-life-of-pets-2-2019-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-05T07:00:53.0000000Z","lastPlayedAt":"2026-09-17T18:07:00.7024231Z","availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":412117,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":60,"rtCriticsLabel":"Fresh","rtAudience":90,"rtAudienceLabel":"Upright","imdb":6.4,"imdbVotes":80464,"tmdb":6.948,"tmdbVotes":3450}},{"id":"movie-self-reliance-2024","title":"Self Reliance","year":2024,"type":"movie","runtimeSeconds":5461,"seasonCount":null,"communityRating":5.888,"criticRating":73,"genres":["Comedy","Thriller"],"overview":"When a man is offered a million dollars to play a game in which hunters try to kill him, he thinks he has found the perfect loophole: they can only attack when he\u2019s alone. His only problem is that none of his friends\u2026","poster":"assets/movie-self-reliance-2024-poster.jpg","backdrop":"assets/movie-self-reliance-2024-backdrop.jpg","played":false,"playCount":1,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:31:00.0000000Z","lastPlayedAt":"2026-10-02T19:59:16.9286868Z","availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":1072876,"artwork":{"posterKind":"center_crop","backdropKind":"backdrop"},"ratings":{"rtCritics":73,"rtCriticsLabel":"Certified Fresh","rtAudience":56,"rtAudienceLabel":"Spilled","imdb":6,"imdbVotes":23771,"tmdb":5.866,"tmdbVotes":346}},{"id":"movie-serpico-1973","title":"Serpico","year":1973,"type":"movie","runtimeSeconds":7805,"seasonCount":null,"communityRating":7.5,"criticRating":93,"genres":["Crime","Drama","History"],"overview":"New York cop Frank Serpico blows the whistle on the rampant corruption in the force only to have his comrades turn against him.","poster":"assets/movie-serpico-1973-poster.jpg","backdrop":"assets/movie-serpico-1973-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-10T21:45:02.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":9040,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":93,"rtCriticsLabel":"Certified Fresh","rtAudience":88,"rtAudienceLabel":"Upright","imdb":7.7,"imdbVotes":149750,"tmdb":7.525,"tmdbVotes":2323}},{"id":"movie-shaft-2000","title":"Shaft","year":2000,"type":"movie","runtimeSeconds":5960,"seasonCount":null,"communityRating":6.026,"criticRating":67,"genres":["Action","Adventure","Crime"],"overview":"New York police detective John Shaft arrests Walter Wade Jr. for a racially motivated slaying. But the only eyewitness disappears, and Wade jumps bail for Switzerland. Two years later Wade returns to face trial,\u2026","poster":"assets/movie-shaft-2000-poster.jpg","backdrop":"assets/movie-shaft-2000-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-10T21:30:40.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":479,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":68,"rtCriticsLabel":"Fresh","rtAudience":40,"rtAudienceLabel":"Spilled","imdb":6,"imdbVotes":86257,"tmdb":6.024,"tmdbVotes":1531}},{"id":"movie-a-silent-voice-the-movie-2016","title":"A Silent Voice: The Movie","year":2016,"type":"movie","runtimeSeconds":7804,"seasonCount":null,"communityRating":8.398,"criticRating":null,"genres":["Animation","Drama","Romance"],"overview":"Shouya Ishida starts bullying the new girl in class, Shouko Nishimiya, because she is deaf. But as the teasing continues, the rest of the class starts to turn on Shouya for his lack of compassion. When they leave\u2026","poster":"assets/movie-a-silent-voice-the-movie-2016-poster.jpg","backdrop":"assets/movie-a-silent-voice-the-movie-2016-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T17:39:46.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":378064,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":95,"rtCriticsLabel":"Fresh","rtAudience":94,"rtAudienceLabel":"Upright","imdb":8.2,"imdbVotes":151926,"tmdb":8.396,"tmdbVotes":4764}},{"id":"movie-sinbad-legend-of-the-seven-seas-2003","title":"Sinbad: Legend of the Seven Seas","year":2003,"type":"movie","runtimeSeconds":5143,"seasonCount":null,"communityRating":7.067,"criticRating":44,"genres":["Action","Adventure","Animation"],"overview":"The sailor of legend is framed by the goddess Eris for the theft of the Book of Peace, and must travel to her realm at the end of the world to retrieve it and save the life of his childhood friend Prince Proteus.","poster":"assets/movie-sinbad-legend-of-the-seven-seas-2003-poster.jpg","backdrop":"assets/movie-sinbad-legend-of-the-seven-seas-2003-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:26:25.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":14411,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":46,"rtCriticsLabel":"Rotten","rtAudience":57,"rtAudienceLabel":"Spilled","imdb":6.8,"imdbVotes":65298,"tmdb":7.071,"tmdbVotes":2323}},{"id":"movie-sleeping-with-other-people-2015","title":"Sleeping with Other People","year":2015,"type":"movie","runtimeSeconds":6076,"seasonCount":null,"communityRating":6.274,"criticRating":64,"genres":["Comedy","Romance"],"overview":"Can two serial cheaters get a second chance at love? After a one-night stand in college, New Yorkers Lainey and Jake meet by chance twelve years later and discover they each have the same problem: because of their\u2026","poster":"assets/movie-sleeping-with-other-people-2015-poster.jpg","backdrop":"assets/movie-sleeping-with-other-people-2015-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:27:42.0000000Z","lastPlayedAt":"2026-07-17T17:50:44.0000000Z","availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":288036,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":64,"rtCriticsLabel":"Fresh","rtAudience":56,"rtAudienceLabel":"Spilled","imdb":6.5,"imdbVotes":52275,"tmdb":6.275,"tmdbVotes":1020}},{"id":"movie-snowpiercer-2013","title":"Snowpiercer","year":2013,"type":"movie","runtimeSeconds":7573,"seasonCount":null,"communityRating":6.909,"criticRating":94,"genres":["Action","Science Fiction","Drama"],"overview":"In a future where a failed global-warming experiment kills off most life on the planet, a class system evolves aboard the Snowpiercer; a train that travels around the globe via a perpetual-motion engine.","poster":"assets/movie-snowpiercer-2013-poster.jpg","backdrop":"assets/movie-snowpiercer-2013-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:27:07.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":110415,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":94,"rtCriticsLabel":"Certified Fresh","rtAudience":72,"rtAudienceLabel":"Upright","imdb":7.1,"imdbVotes":426976,"tmdb":6.9,"tmdbVotes":10807}},{"id":"movie-spider-man-brand-new-day-2026","title":"Spider-Man: Brand New Day","year":2026,"type":"movie","runtimeSeconds":8701,"seasonCount":null,"communityRating":8.374,"criticRating":90,"genres":["Science Fiction","Action","Adventure"],"overview":"Fighting crime full-time as Spider-Man in a world that doesn't remember him\u2014and the pressure of seeing his old friends move on without him\u2014sparks a change in Peter Parker he may not have the power to control. But\u2026","poster":"assets/movie-spider-man-brand-new-day-2026-poster.jpg","backdrop":"assets/movie-spider-man-brand-new-day-2026-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-10T14:14:27.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":969681,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":90,"rtCriticsLabel":"Certified Fresh","rtAudience":97,"rtAudienceLabel":"Upright","imdb":7.9,"imdbVotes":366886,"tmdb":8.5,"tmdbVotes":6537}},{"id":"movie-spider-man-far-from-home-2019","title":"Spider-Man: Far From Home","year":2019,"type":"movie","runtimeSeconds":7762,"seasonCount":null,"communityRating":7.389,"criticRating":91,"genres":["Action","Adventure","Science Fiction"],"overview":"Peter Parker and his friends go on a summer trip to Europe. However, they will hardly be able to rest - Peter will have to agree to help Nick Fury uncover the mystery of creatures that cause natural disasters and\u2026","poster":"assets/movie-spider-man-far-from-home-2019-poster.jpg","backdrop":"assets/movie-spider-man-far-from-home-2019-backdrop.jpg","played":true,"playCount":1,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-10T14:25:47.0000000Z","lastPlayedAt":"2026-10-10T17:50:35.6193119Z","availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":429617,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":91,"rtCriticsLabel":"Certified Fresh","rtAudience":95,"rtAudienceLabel":"Upright","imdb":7.3,"imdbVotes":682887,"tmdb":7.387,"tmdbVotes":17847}},{"id":"movie-spider-man-homecoming-2017","title":"Spider-Man: Homecoming","year":2017,"type":"movie","runtimeSeconds":8008,"seasonCount":null,"communityRating":7.333,"criticRating":92,"genres":["Action","Adventure","Science Fiction"],"overview":"Following the events of Captain America: Civil War, Peter Parker, with the help of his mentor Tony Stark, tries to balance his life as an ordinary high school student in Queens, New York City, with fighting crime as\u2026","poster":"assets/movie-spider-man-homecoming-2017-poster.jpg","backdrop":"assets/movie-spider-man-homecoming-2017-backdrop.jpg","played":true,"playCount":1,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-10T14:33:13.0000000Z","lastPlayedAt":"2026-10-10T20:31:23.4444213Z","availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":315635,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":92,"rtCriticsLabel":"Certified Fresh","rtAudience":87,"rtAudienceLabel":"Upright","imdb":7.4,"imdbVotes":840974,"tmdb":7.332,"tmdbVotes":24183}},{"id":"movie-spider-man-no-way-home-2021","title":"Spider-Man: No Way Home","year":2021,"type":"movie","runtimeSeconds":8890,"seasonCount":null,"communityRating":7.947,"criticRating":93,"genres":["Action","Adventure","Science Fiction"],"overview":"Peter Parker is unmasked and no longer able to separate his normal life from the high-stakes of being a super-hero. When he asks for help from Doctor Strange the stakes become even more dangerous, forcing him to\u2026","poster":"assets/movie-spider-man-no-way-home-2021-poster.jpg","backdrop":"assets/movie-spider-man-no-way-home-2021-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-10T14:21:05.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":634649,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":93,"rtCriticsLabel":"Certified Fresh","rtAudience":97,"rtAudienceLabel":"Upright","imdb":8.1,"imdbVotes":1082660,"tmdb":7.947,"tmdbVotes":23505}},{"id":"movie-spirited-away-2001","title":"Spirited Away","year":2001,"type":"movie","runtimeSeconds":7472,"seasonCount":null,"communityRating":8.535,"criticRating":96,"genres":["Animation","Family","Fantasy"],"overview":"A young girl, Chihiro, becomes trapped in a strange new world of spirits. When her parents undergo a mysterious transformation, she must call upon the courage she never knew she had to free her family.","poster":"assets/movie-spirited-away-2001-poster.jpg","backdrop":"assets/movie-spirited-away-2001-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:22:42.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":129,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":96,"rtCriticsLabel":"Certified Fresh","rtAudience":99,"rtAudienceLabel":"Upright","imdb":8.6,"imdbVotes":987658,"tmdb":8.536,"tmdbVotes":19176}},{"id":"movie-the-substance-2024","title":"The Substance","year":2024,"type":"movie","runtimeSeconds":8531,"seasonCount":null,"communityRating":7.135,"criticRating":89,"genres":["Horror","Science Fiction"],"overview":"A fading celebrity decides to use a black market drug, a cell-replicating substance that temporarily creates a younger, better version of herself.","poster":"assets/movie-the-substance-2024-poster.jpg","backdrop":"assets/movie-the-substance-2024-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:30:22.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":933260,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":89,"rtCriticsLabel":"Certified Fresh","rtAudience":76,"rtAudienceLabel":"Upright","imdb":7.2,"imdbVotes":466542,"tmdb":7.13,"tmdbVotes":6746}},{"id":"movie-the-tale-of-the-princess-kaguya-2013","title":"The Tale of The Princess Kaguya","year":2013,"type":"movie","runtimeSeconds":8218,"seasonCount":null,"communityRating":8.1,"criticRating":100,"genres":["Animation","Drama","Fantasy"],"overview":"Found inside a shining stalk of bamboo by an old bamboo cutter and his wife, a tiny girl grows rapidly into an exquisite young lady. The mysterious young princess enthrals all who encounter her. But, ultimately, she\u2026","poster":"assets/movie-the-tale-of-the-princess-kaguya-2013-poster.jpg","backdrop":"assets/movie-the-tale-of-the-princess-kaguya-2013-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-07T13:40:15.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":149871,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Certified Fresh","rtAudience":90,"rtAudienceLabel":"Upright","imdb":8,"imdbVotes":63574,"tmdb":8.083,"tmdbVotes":2079}},{"id":"movie-taxi-driver-1976","title":"Taxi Driver","year":1976,"type":"movie","runtimeSeconds":6831,"seasonCount":null,"communityRating":8.121,"criticRating":89,"genres":["Crime","Drama"],"overview":"Suffering from insomnia, disturbed loner Travis Bickle takes a job as a New York City cabbie, haunting the streets nightly, growing increasingly detached from reality as he dreams of cleaning up the filthy city.","poster":"assets/movie-taxi-driver-1976-poster.jpg","backdrop":"assets/movie-taxi-driver-1976-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:23:04.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":103,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":89,"rtCriticsLabel":"Certified Fresh","rtAudience":93,"rtAudienceLabel":"Upright","imdb":8.2,"imdbVotes":1046700,"tmdb":8.116,"tmdbVotes":13973}},{"id":"movie-triangle-of-sadness-2022","title":"Triangle of Sadness","year":2022,"type":"movie","runtimeSeconds":8833,"seasonCount":null,"communityRating":7.005,"criticRating":71,"genres":["Comedy","Drama"],"overview":"A celebrity model couple are invited on a luxury cruise for the uber-rich, helmed by an unhinged, alcoholic captain. What first appears Instagrammable ends catastrophically, leaving the survivors stranded on a desert\u2026","poster":"assets/movie-triangle-of-sadness-2022-poster.jpg","backdrop":"assets/movie-triangle-of-sadness-2022-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:29:00.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":497828,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":72,"rtCriticsLabel":"Fresh","rtAudience":80,"rtAudienceLabel":"Upright","imdb":7.2,"imdbVotes":221069,"tmdb":7,"tmdbVotes":3104}},{"id":"movie-tsotsi-2005","title":"Tsotsi","year":2005,"type":"movie","runtimeSeconds":5663,"seasonCount":null,"communityRating":6.937,"criticRating":82,"genres":["Crime","Drama"],"overview":"A young South African boy from the Johannesburg ghetto named Tsotsi, meaning Gangster, leaves home as a child to get away from his helpless parents. Now a teenage thug, Tsotsi finds a baby in the back seat of a car\u2026","poster":"assets/movie-tsotsi-2005-poster.jpg","backdrop":"assets/movie-tsotsi-2005-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:24:58.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":868,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":82,"rtCriticsLabel":"Certified Fresh","rtAudience":86,"rtAudienceLabel":"Upright","imdb":7.2,"imdbVotes":32526,"tmdb":6.941,"tmdbVotes":372}},{"id":"movie-unknown-number-the-high-school-catfish-2025","title":"Unknown Number: The High School Catfish","year":2025,"type":"movie","runtimeSeconds":5691,"seasonCount":null,"communityRating":6.746,"criticRating":85,"genres":["Documentary"],"overview":"Vulgar, taunting texts blow up the phones of a teen and her boyfriend. Who's sending them \u2014 and why? This twisty documentary reveals the shocking answer.","poster":"assets/movie-unknown-number-the-high-school-catfish-2025-poster.jpg","backdrop":"assets/movie-unknown-number-the-high-school-catfish-2025-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:32:10.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":1517772,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":85,"rtCriticsLabel":"Fresh","rtAudience":69,"rtAudienceLabel":"Upright","imdb":6.7,"imdbVotes":23039,"tmdb":6.737,"tmdbVotes":257}},{"id":"movie-the-uprising-2026","title":"The Uprising","year":2026,"type":"movie","runtimeSeconds":7684,"seasonCount":null,"communityRating":7.846,"criticRating":55,"genres":["Action","Drama","History"],"overview":"As war and plague devastate 14th-century England, a humble peasant ignites a rebellion, uniting an army of commoners to defy the King\u2019s might in a desperate fight for justice and survival.","poster":"assets/movie-the-uprising-2026-poster.jpg","backdrop":"assets/movie-the-uprising-2026-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-11-02T16:37:03.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":977942,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":57,"rtCriticsLabel":"Rotten","rtAudience":65,"rtAudienceLabel":"Upright","imdb":6.2,"imdbVotes":6622,"tmdb":7.8,"tmdbVotes":404}},{"id":"movie-vice-2018","title":"Vice","year":2018,"type":"movie","runtimeSeconds":7948,"seasonCount":null,"communityRating":7.013,"criticRating":64,"genres":["Drama","Comedy","History"],"overview":"George W. Bush picks Dick Cheney, the CEO of Halliburton Co., to be his Republican running mate in the 2000 presidential election. No stranger to politics, Cheney's impressive r\u00e9sum\u00e9 includes stints as White House\u2026","poster":"assets/movie-vice-2018-poster.jpg","backdrop":"assets/movie-vice-2018-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:28:30.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":429197,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":64,"rtCriticsLabel":"Fresh","rtAudience":61,"rtAudienceLabel":"Upright","imdb":7.2,"imdbVotes":177299,"tmdb":7.014,"tmdbVotes":3723}},{"id":"movie-warriors-of-the-wind-1984","title":"Warriors of the Wind","year":1984,"type":"movie","runtimeSeconds":7022,"seasonCount":null,"communityRating":7.941,"criticRating":87,"genres":["Adventure","Animation","Fantasy"],"overview":"After a global war, the seaside kingdom known as the Valley of the Wind remains one of the last strongholds on Earth untouched by a poisonous jungle and the powerful insects that guard it. Led by the courageous\u2026","poster":"assets/movie-warriors-of-the-wind-1984-poster.jpg","backdrop":"assets/movie-warriors-of-the-wind-1984-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:22:20.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":81,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":87,"rtCriticsLabel":"Fresh","rtAudience":91,"rtAudienceLabel":"Upright","imdb":8,"imdbVotes":200154,"tmdb":7.94,"tmdbVotes":4260}},{"id":"movie-we-bury-the-dead-2026","title":"We Bury the Dead","year":2026,"type":"movie","runtimeSeconds":5693,"seasonCount":null,"communityRating":6.153,"criticRating":87,"genres":["Horror","Thriller"],"overview":"After a catastrophic military disaster reanimates the dead, Ava searches for her missing husband amidst an increasingly hostile world.","poster":"assets/movie-we-bury-the-dead-2026-poster.jpg","backdrop":"assets/movie-we-bury-the-dead-2026-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-08-26T05:36:00.4272328Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":1198984,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":87,"rtCriticsLabel":"Certified Fresh","rtAudience":45,"rtAudienceLabel":"Spilled","imdb":5.6,"imdbVotes":20693,"tmdb":6.137,"tmdbVotes":433}},{"id":"movie-white-hot-the-rise-fall-of-abercrombie-fitch-2022","title":"White Hot: The Rise & Fall of Abercrombie & Fitch","year":2022,"type":"movie","runtimeSeconds":5187,"seasonCount":null,"communityRating":5.663,"criticRating":66,"genres":["Documentary"],"overview":"All the cool kids were wearing it. This documentary explores A&F's pop culture reign in the late '90s and early 2000s and how it thrived on exclusion.","poster":"assets/movie-white-hot-the-rise-fall-of-abercrombie-fitch-2022-poster.jpg","backdrop":"assets/movie-white-hot-the-rise-fall-of-abercrombie-fitch-2022-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:30:34.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":953371,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":66,"rtCriticsLabel":"Fresh","rtAudience":29,"rtAudienceLabel":"Spilled","imdb":5.8,"imdbVotes":5655,"tmdb":5.7,"tmdbVotes":102}},{"id":"movie-the-wild-robot-2024","title":"The Wild Robot","year":2024,"type":"movie","runtimeSeconds":6101,"seasonCount":null,"communityRating":8.308,"criticRating":97,"genres":["Family","Animation","Science Fiction"],"overview":"After a shipwreck, an intelligent robot called Roz is stranded on an uninhabited island. To survive the harsh environment, Roz bonds with the island's animals and cares for an orphaned baby goose.","poster":"assets/movie-the-wild-robot-2024-poster.jpg","backdrop":"assets/movie-the-wild-robot-2024-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:31:49.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":1184918,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":97,"rtCriticsLabel":"Certified Fresh","rtAudience":98,"rtAudienceLabel":"Upright","imdb":8.1,"imdbVotes":241134,"tmdb":8.309,"tmdbVotes":6819}},{"id":"movie-the-wind-rises-2013","title":"The Wind Rises","year":2013,"type":"movie","runtimeSeconds":8031,"seasonCount":null,"communityRating":7.784,"criticRating":88,"genres":["Animation","Drama","History"],"overview":"A lifelong love of flight inspires Japanese aviation engineer Jiro Horikoshi, whose storied career includes the creation of the A-6M World War II fighter plane.","poster":"assets/movie-the-wind-rises-2013-poster.jpg","backdrop":"assets/movie-the-wind-rises-2013-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:24:38.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":149870,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":89,"rtCriticsLabel":"Certified Fresh","rtAudience":85,"rtAudienceLabel":"Upright","imdb":7.8,"imdbVotes":115756,"tmdb":7.783,"tmdbVotes":3536}},{"id":"movie-the-witcher-nightmare-of-the-wolf-2021","title":"The Witcher: Nightmare of the Wolf","year":2021,"type":"movie","runtimeSeconds":5015,"seasonCount":null,"communityRating":7.422,"criticRating":100,"genres":["Animation","Action","Fantasy"],"overview":"Escaping from poverty to become a witcher, Vesemir slays monsters for coin and glory, but when a new menace rises, he must face the demons of his past.","poster":"assets/movie-the-witcher-nightmare-of-the-wolf-2021-poster.jpg","backdrop":"assets/movie-the-witcher-nightmare-of-the-wolf-2021-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-26T04:42:03.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":666243,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":83,"rtAudienceLabel":"Upright","imdb":7.2,"imdbVotes":55759,"tmdb":7.407,"tmdbVotes":1159}},{"id":"movie-the-witcher-sirens-of-the-deep-2025","title":"The Witcher: Sirens of the Deep","year":2025,"type":"movie","runtimeSeconds":5517,"seasonCount":null,"communityRating":6.8,"criticRating":60,"genres":["Animation","Fantasy","Action"],"overview":"When human sailors are attacked by mysterious creatures of the deep, only one person can stop the war between land and sea: the Witcher, Geralt of Rivia.","poster":"assets/movie-the-witcher-sirens-of-the-deep-2025-poster.jpg","backdrop":"assets/movie-the-witcher-sirens-of-the-deep-2025-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-26T05:03:26.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":1203329,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":60,"rtCriticsLabel":"Fresh","rtAudience":37,"rtAudienceLabel":"Spilled","imdb":6.1,"imdbVotes":11799,"tmdb":6.741,"tmdbVotes":303}}],"shows":[{"id":"tv-adults-2025","title":"Adults","year":2025,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":6.495,"criticRating":null,"genres":["Comedy"],"overview":"A group of twenty-somethings in New York trying to be good people, despite being neither \"good\" nor \"people\" yet.","poster":"assets/tv-adults-2025-poster.jpg","backdrop":"assets/tv-adults-2025-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-23T21:01:41.1814765Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":260458,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":88,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.5,"tmdbVotes":102}},{"id":"tv-american-crime-story-2016","title":"American Crime Story","year":2016,"type":"tv","runtimeSeconds":null,"seasonCount":3,"communityRating":7.573,"criticRating":null,"genres":["Crime","Drama"],"overview":"An anthology series centered around some of history's most famous criminal investigations.","poster":"assets/tv-american-crime-story-2016-poster.jpg","backdrop":"assets/tv-american-crime-story-2016-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0490488Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":64513,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":84,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.585,"tmdbVotes":1094}},{"id":"tv-american-primeval-2025","title":"American Primeval","year":2025,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.691,"criticRating":null,"genres":["Western","Drama","Action & Adventure"],"overview":"A mother and son fleeing from their past form a found family while confronting a harsh landscape of freedom and cruelty in the American West.","poster":"assets/tv-american-primeval-2025-poster.jpg","backdrop":"assets/tv-american-primeval-2025-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0573221Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":215866,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":71,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.741,"tmdbVotes":538}},{"id":"tv-arrested-development-2003","title":"Arrested Development","year":2003,"type":"tv","runtimeSeconds":null,"seasonCount":5,"communityRating":7.925,"criticRating":null,"genres":["Comedy"],"overview":"The story of a wealthy family that lost everything, and the one son who had no choice but to keep them all together.","poster":"assets/tv-arrested-development-2003-poster.jpg","backdrop":"assets/tv-arrested-development-2003-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0513048Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":4589,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":75,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.906,"tmdbVotes":1793}},{"id":"tv-avatar-the-last-airbender-2005","title":"Avatar: The Last Airbender","year":2005,"type":"tv","runtimeSeconds":1440,"seasonCount":3,"communityRating":8.78,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"In a war-torn world of elemental magic, a young boy reawakens to undertake a dangerous mystic quest to fulfill his destiny as the Avatar, and bring peace to the world.","poster":"assets/tv-avatar-the-last-airbender-2005-poster.jpg","backdrop":"assets/tv-avatar-the-last-airbender-2005-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0504536Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":246,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.787,"tmdbVotes":5215}},{"id":"tv-b-b-vol-liefde-2021","title":"B&B Vol Liefde","year":2021,"type":"tv","runtimeSeconds":3720,"seasonCount":1,"communityRating":7.2,"criticRating":null,"genres":["Reality"],"overview":"B&B Vol Liefde is a heartwarming adventure, full of romance, excitement, and unforgettable moments. Adventurous Dutch B&B owners abroad throw their doors wide open and let their hearts speak, determined to find that\u2026","poster":"assets/tv-b-b-vol-liefde-2021-poster.jpg","backdrop":"assets/tv-b-b-vol-liefde-2021-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-31T22:30:11.129657Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":124428,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.2,"tmdbVotes":10}},{"id":"tv-the-bear-2022","title":"The Bear","year":2022,"type":"tv","runtimeSeconds":null,"seasonCount":5,"communityRating":8.139,"criticRating":null,"genres":["Drama","Comedy"],"overview":"Carmy, a young fine-dining chef, comes home to Chicago to run his family sandwich shop. As he fights to transform the shop and himself, he works alongside a rough-around-the-edges crew that ultimately reveal\u2026","poster":"assets/tv-the-bear-2022-poster.jpg","backdrop":"assets/tv-the-bear-2022-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0467371Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":136315,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":93,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.158,"tmdbVotes":2025}},{"id":"tv-blue-planet-ii-2017","title":"Blue Planet II","year":2017,"type":"tv","runtimeSeconds":3600,"seasonCount":1,"communityRating":8.64,"criticRating":null,"genres":["Documentary","Family"],"overview":"There is nowhere more powerful and unforgiving yet more beautiful and compelling than the ocean. Join us and explore the greatest yet least known parts of our planet.","poster":"assets/tv-blue-planet-ii-2017-poster.jpg","backdrop":"assets/tv-blue-planet-ii-2017-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0634849Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":74313,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":97,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.6,"tmdbVotes":380}},{"id":"tv-breeders-2020","title":"Breeders","year":2020,"type":"tv","runtimeSeconds":1500,"seasonCount":4,"communityRating":6.8,"criticRating":null,"genres":["Comedy"],"overview":"Exposing the parental-paradox that it is possible, in the very same moment, to love your child to the horizon of the universe, while being apoplectically angry enough to want to send them there.","poster":"assets/tv-breeders-2020-poster.jpg","backdrop":"assets/tv-breeders-2020-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-28T18:54:21.8885435Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":93290,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":92,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.8,"tmdbVotes":104}},{"id":"tv-chernobyl-2019","title":"Chernobyl","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":8.718,"criticRating":null,"genres":["Drama"],"overview":"The true story of one of the worst man-made catastrophes in history: the catastrophic nuclear accident at Chernobyl. A tale of the brave men and women who sacrificed to save Europe from unimaginable disaster.","poster":"assets/tv-chernobyl-2019-poster.jpg","backdrop":"assets/tv-chernobyl-2019-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0469903Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":87108,"artwork":{"posterKind":"poster","backdropKind":"center_crop"},"ratings":{"rtCritics":95,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.731,"tmdbVotes":8496}},{"id":"tv-community-2009","title":"Community","year":2009,"type":"tv","runtimeSeconds":null,"seasonCount":6,"communityRating":7.997,"criticRating":null,"genres":["Comedy"],"overview":"Follow the lives of a group of students at what is possibly the world\u2019s worst community college in the fictional locale of Greendale, Colorado.","poster":"assets/tv-community-2009-poster.jpg","backdrop":"assets/tv-community-2009-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0531416Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":18347,"artwork":{"posterKind":"center_crop","backdropKind":"backdrop"},"ratings":{"rtCritics":88,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.994,"tmdbVotes":2226}},{"id":"tv-dead-end-job-2026","title":"Dead-End Job","year":2026,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.7,"criticRating":null,"genres":["Mystery"],"overview":"Drowning in debt, a young man takes lucrative part-time jobs from a mysterious agency \u2014 only to find his hustle for more money turning nightmarish.","poster":"assets/tv-dead-end-job-2026-poster.jpg","backdrop":"assets/tv-dead-end-job-2026-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-11-04T18:58:01.8352857Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":289517,"artwork":{"posterKind":"center_crop","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.7,"tmdbVotes":38}},{"id":"tv-dopesick-2021","title":"Dopesick","year":2021,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":8.015,"criticRating":null,"genres":["Crime","Drama"],"overview":"The story of how one company triggered the worst drug epidemic in American history. Look into the epicenter of America's struggle with opioid addiction, from a distressed Virginia mining community, to the hallways of\u2026","poster":"assets/tv-dopesick-2021-poster.jpg","backdrop":"assets/tv-dopesick-2021-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0580168Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":110695,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":89,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.035,"tmdbVotes":565}},{"id":"tv-dune-prophecy-2024","title":"Dune: Prophecy","year":2024,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.213,"criticRating":null,"genres":["Sci-Fi & Fantasy","Drama","Action & Adventure"],"overview":"Ten thousand years before the ascension of Paul Atreides, sisters Valya and Tula Harkonnen establish the fabled sect and female order that would become known as the Bene Gesserit to control the future of humankind.","poster":"assets/tv-dune-prophecy-2024-poster.jpg","backdrop":"assets/tv-dune-prophecy-2024-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-11-04T18:23:01.2908568Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":90228,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":64,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.213,"tmdbVotes":677}},{"id":"tv-east-of-eden-2026","title":"East of Eden","year":2026,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.931,"criticRating":null,"genres":["Drama","Crime"],"overview":"In the early 1900s, a fiercely independent woman drives a rift between two brothers, igniting a saga that unfolds across two generations.","poster":"assets/tv-east-of-eden-2026-poster.jpg","backdrop":"assets/tv-east-of-eden-2026-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-23T18:07:11.3965096Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":258165,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":88,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.982,"tmdbVotes":139}},{"id":"tv-fallout-2024","title":"Fallout","year":2024,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":8.124,"criticRating":null,"genres":["Action & Adventure","Sci-Fi & Fantasy","Drama"],"overview":"The story of haves and have-nots in a world in which there's almost nothing left to have. 200 years after the apocalypse, the gentle denizens of luxury fallout shelters are forced to return to the irradiated\u2026","poster":"assets/tv-fallout-2024-poster.jpg","backdrop":"assets/tv-fallout-2024-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0669262Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":106379,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":94,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.106,"tmdbVotes":3233}},{"id":"tv-fawlty-towers-1975","title":"Fawlty Towers","year":1975,"type":"tv","runtimeSeconds":1800,"seasonCount":2,"communityRating":8.295,"criticRating":null,"genres":["Comedy"],"overview":"Owner Basil Fawlty, his wife Sybil, a chambermaid Polly, and Spanish waiter Manuel attempt to run their hotel amidst farcical situations and an array of demanding guests.","poster":"assets/tv-fawlty-towers-1975-poster.jpg","backdrop":"assets/tv-fawlty-towers-1975-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-19T23:26:39.7658344Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":2207,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.296,"tmdbVotes":867}},{"id":"tv-the-final-problem-2026","title":"The Final Problem","year":2026,"type":"tv","runtimeSeconds":3420,"seasonCount":1,"communityRating":7.041,"criticRating":null,"genres":["Crime","Mystery"],"overview":"In the spring of 1959, a retired actor, who previously played Sherlock Holmes, must investigate a murder at a small hotel on a tiny island near Mallorca.","poster":"assets/tv-the-final-problem-2026-poster.jpg","backdrop":"assets/tv-the-final-problem-2026-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-11-04T19:16:12.4822791Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":290720,"artwork":{"posterKind":"center_crop","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.041,"tmdbVotes":49}},{"id":"tv-the-four-seasons-2025","title":"The Four Seasons","year":2025,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":6.879,"criticRating":null,"genres":["Comedy"],"overview":"The decades-long friendship between three married couples is tested when one divorces, complicating their tradition of quarterly weekend getaways.","poster":"assets/tv-the-four-seasons-2025-poster.jpg","backdrop":"assets/tv-the-four-seasons-2025-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-28T18:36:42.5326384Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":243316,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":81,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.892,"tmdbVotes":204}},{"id":"tv-the-get-down-2016","title":"The Get Down","year":2016,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.3,"criticRating":null,"genres":["Drama"],"overview":"In 1977 New York City, the talented and soulful youth of the South Bronx chase dreams and breakneck beats to transform music history.","poster":"assets/tv-the-get-down-2016-poster.jpg","backdrop":"assets/tv-the-get-down-2016-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0497737Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":65345,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":81,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.3,"tmdbVotes":196}},{"id":"tv-how-to-change-your-mind-2022","title":"How to Change Your Mind","year":2022,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.76,"criticRating":null,"genres":["Documentary","Mystery"],"overview":"Author Michael Pollan leads the way in this docuseries exploring the history and uses of psychedelics, including LSD, psilocybin, MDMA and mescaline.","poster":"assets/tv-how-to-change-your-mind-2022-poster.jpg","backdrop":"assets/tv-how-to-change-your-mind-2022-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0601399Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":204304,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.8,"tmdbVotes":53}},{"id":"tv-interrogation-2020","title":"Interrogation","year":2020,"type":"tv","runtimeSeconds":2700,"seasonCount":1,"communityRating":6.788,"criticRating":null,"genres":["Drama","Crime"],"overview":"Based on a true story that spanned more than 30 years in which a young man was charged and convicted of brutally murdering his mother. Each episode is structured around an interrogation taken directly from the real\u2026","poster":"assets/tv-interrogation-2020-poster.jpg","backdrop":"assets/tv-interrogation-2020-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-26T05:52:06.1068207Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":96488,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":57,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.9,"tmdbVotes":54}},{"id":"tv-it-s-always-sunny-in-philadelphia-2005","title":"It's Always Sunny in Philadelphia","year":2005,"type":"tv","runtimeSeconds":null,"seasonCount":18,"communityRating":8.2,"criticRating":null,"genres":["Comedy"],"overview":"Four egocentric friends run a neighborhood Irish pub in Philadelphia and try to find their way through the adult world of work and relationships. Unfortunately, their warped views and precarious judgments often lead\u2026","poster":"assets/tv-it-s-always-sunny-in-philadelphia-2005-poster.jpg","backdrop":"assets/tv-it-s-always-sunny-in-philadelphia-2005-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0485774Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":2710,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":94,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.253,"tmdbVotes":1457}},{"id":"tv-a-knight-of-the-seven-kingdoms-2026","title":"A Knight of the Seven Kingdoms","year":2026,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":8.431,"criticRating":null,"genres":["Drama","Sci-Fi & Fantasy","Action & Adventure"],"overview":"A century before the events of Game of Thrones, two unlikely heroes wandered Westeros: a young, naive but courageous knight, Ser Duncan the Tall, and his diminutive squire, Egg. Set in an age when the Targaryen line\u2026","poster":"assets/tv-a-knight-of-the-seven-kingdoms-2026-poster.jpg","backdrop":"assets/tv-a-knight-of-the-seven-kingdoms-2026-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0493344Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":224372,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":94,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.4,"tmdbVotes":1266}},{"id":"tv-landman-2024","title":"Landman","year":2024,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":8.194,"criticRating":null,"genres":["Drama"],"overview":"Set in the proverbial boomtowns of West-Texas and a modern-day tale of fortune-seeking in the world of oil rigs, the series is an upstairs/downstairs story of roughnecks and wildcat billionaires that are fueling a\u2026","poster":"assets/tv-landman-2024-poster.jpg","backdrop":"assets/tv-landman-2024-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-22T21:38:18.0973875Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":157741,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":80,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.209,"tmdbVotes":668}},{"id":"tv-the-last-of-us-2023","title":"The Last of Us","year":2023,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":8.419,"criticRating":null,"genres":["Drama"],"overview":"Twenty years after modern civilization has been destroyed, Joel, a hardened survivor, is hired to smuggle Ellie, a 14-year-old girl, out of an oppressive quarantine zone. What starts as a small job soon becomes a\u2026","poster":"assets/tv-the-last-of-us-2023-poster.jpg","backdrop":"assets/tv-the-last-of-us-2023-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0640955Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":100088,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":94,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.4,"tmdbVotes":7485}},{"id":"tv-last-samurai-standing-2025","title":"Last Samurai Standing","year":2025,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.805,"criticRating":null,"genres":["Action & Adventure","Drama"],"overview":"In the early Meiji era, Shujiro, once known as an undefeated samurai, decides to participate in a deadly survival game to save his family and villagers.","poster":"assets/tv-last-samurai-standing-2025-poster.jpg","backdrop":"assets/tv-last-samurai-standing-2025-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0622526Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":252193,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.812,"tmdbVotes":271}},{"id":"tv-lord-of-the-flies-2026","title":"Lord of the Flies","year":2026,"type":"tv","runtimeSeconds":3480,"seasonCount":1,"communityRating":7.571,"criticRating":null,"genres":["Drama","Action & Adventure"],"overview":"After a plane crash, a group of schoolboys find themselves stranded on a tropical island without adults. Ralph is elected leader and, with the help of the intelligent Piggy, strives to maintain order and\u2026","poster":"assets/tv-lord-of-the-flies-2026-poster.jpg","backdrop":"assets/tv-lord-of-the-flies-2026-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0645648Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":270572,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":91,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.58,"tmdbVotes":138}},{"id":"tv-the-lord-of-the-rings-the-rings-of-power-2022","title":"The Lord of the Rings: The Rings of Power","year":2022,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":7.233,"criticRating":null,"genres":["Action & Adventure","Sci-Fi & Fantasy","Drama"],"overview":"Beginning in a time of relative peace, we follow an ensemble cast of characters as they confront the re-emergence of evil to Middle-earth. From the darkest depths of the Misty Mountains, to the majestic forests of\u2026","poster":"assets/tv-the-lord-of-the-rings-the-rings-of-power-2022-poster.jpg","backdrop":"assets/tv-the-lord-of-the-rings-the-rings-of-power-2022-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0618904Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":84773,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":84,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.211,"tmdbVotes":4003}},{"id":"tv-love-death-robots-2019","title":"Love, Death & Robots","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":4,"communityRating":8.21,"criticRating":null,"genres":["Animation","Sci-Fi & Fantasy"],"overview":"Terrifying creatures, wicked surprises and dark comedy converge in this NSFW anthology of animated stories presented by Tim Miller and David Fincher.","poster":"assets/tv-love-death-robots-2019-poster.jpg","backdrop":"assets/tv-love-death-robots-2019-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0460964Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":86831,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":89,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.203,"tmdbVotes":4238}},{"id":"tv-maniac-2018","title":"Maniac","year":2018,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.338,"criticRating":null,"genres":["Comedy","Drama","Sci-Fi & Fantasy"],"overview":"Two strangers are drawn to a mysterious pharmaceutical trial that will, they're assured, with no complications or side-effects whatsoever, solve all of their problems, permanently. Things do not go as planned.","poster":"assets/tv-maniac-2018-poster.jpg","backdrop":"assets/tv-maniac-2018-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-20T19:42:30.955548Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":73411,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":84,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.323,"tmdbVotes":1073}},{"id":"tv-mussolini-son-of-the-century-2024","title":"Mussolini: Son of the Century","year":2024,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":8.135,"criticRating":null,"genres":["Drama","War & Politics"],"overview":"The rise of Benito Mussolini and the birth of fascism in Italy, chronicling a country\u2019s surrender to dictatorship and the relentless ascent of a man who rose from his ashes time and again.","poster":"assets/tv-mussolini-son-of-the-century-2024-poster.jpg","backdrop":"assets/tv-mussolini-son-of-the-century-2024-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0586588Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":212333,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":96,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.1,"tmdbVotes":179}},{"id":"tv-the-night-manager-2016","title":"The Night Manager","year":2016,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":7.74,"criticRating":null,"genres":["Drama","Mystery","Crime"],"overview":"Former British soldier Jonathan Pine navigates the shadowy recesses of Whitehall and Washington where an unholy alliance operates between the intelligence community and the secret arms trade. To infiltrate the inner\u2026","poster":"assets/tv-the-night-manager-2016-poster.jpg","backdrop":"assets/tv-the-night-manager-2016-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0663672Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":61859,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":88,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.7,"tmdbVotes":1422}},{"id":"tv-one-piece-2023","title":"ONE PIECE","year":2023,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":8.118,"criticRating":null,"genres":["Action & Adventure","Sci-Fi & Fantasy"],"overview":"With his straw hat and ragtag crew, young pirate Monkey D. Luffy goes on an epic voyage for treasure.","poster":"assets/tv-one-piece-2023-poster.jpg","backdrop":"assets/tv-one-piece-2023-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0439378Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":111110,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":93,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.104,"tmdbVotes":1988}},{"id":"tv-only-murders-in-the-building-2021","title":"Only Murders in the Building","year":2021,"type":"tv","runtimeSeconds":null,"seasonCount":5,"communityRating":8.438,"criticRating":null,"genres":["Comedy","Mystery","Crime"],"overview":"Three strangers share an obsession with true crime and suddenly find themselves wrapped up in one.","poster":"assets/tv-only-murders-in-the-building-2021-poster.jpg","backdrop":"assets/tv-only-murders-in-the-building-2021-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0594614Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":107113,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":95,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.398,"tmdbVotes":2333}},{"id":"tv-over-the-garden-wall-2014","title":"Over the Garden Wall","year":2014,"type":"tv","runtimeSeconds":720,"seasonCount":1,"communityRating":8.6,"criticRating":null,"genres":["Mystery","Sci-Fi & Fantasy","Animation"],"overview":"Two brothers, Wirt and Greg, find themselves lost in the Unknown; a strange forest adrift in time. With the help of a wise old Woodsman and a foul-tempered bluebird named Beatrice, Wirt and Greg must travel across\u2026","poster":"assets/tv-over-the-garden-wall-2014-poster.jpg","backdrop":"assets/tv-over-the-garden-wall-2014-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0455757Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":61617,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.6,"tmdbVotes":1854}},{"id":"tv-peaky-blinders-2013","title":"Peaky Blinders","year":2013,"type":"tv","runtimeSeconds":null,"seasonCount":6,"communityRating":8.5,"criticRating":null,"genres":["Drama","Crime"],"overview":"A gangster family epic set in 1919 Birmingham, England and centered on a gang who sew razor blades in the peaks of their caps, and their fierce boss Tommy Shelby, who means to move up in the world.","poster":"assets/tv-peaky-blinders-2013-poster.jpg","backdrop":"assets/tv-peaky-blinders-2013-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0539185Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":60574,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":93,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.5,"tmdbVotes":11506}},{"id":"tv-the-pitt-2025","title":"The Pitt","year":2025,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":8.729,"criticRating":null,"genres":["Drama"],"overview":"The staff of Pittsburgh's Trauma Medical Center work around the clock to save lives in an overcrowded and underfunded emergency department.","poster":"assets/tv-the-pitt-2025-poster.jpg","backdrop":"assets/tv-the-pitt-2025-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0582942Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":250307,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":96,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.731,"tmdbVotes":1046}},{"id":"tv-pluribus-2025","title":"Pluribus","year":2025,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.936,"criticRating":null,"genres":["Drama","Sci-Fi & Fantasy"],"overview":"The most miserable person on Earth must save the world from happiness.","poster":"assets/tv-pluribus-2025-poster.jpg","backdrop":"assets/tv-pluribus-2025-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0674462Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":225171,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":99,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.921,"tmdbVotes":1191}},{"id":"tv-ripley-2024","title":"RIPLEY","year":2024,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.911,"criticRating":null,"genres":["Drama"],"overview":"A grifter in 1960s New York is hired to convince a wealthy man's son to return home from Italy and begins a life of deceit, fraud and murder.","poster":"assets/tv-ripley-2024-poster.jpg","backdrop":"assets/tv-ripley-2024-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0658942Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":94028,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":86,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.909,"tmdbVotes":453}},{"id":"tv-say-nothing-2024","title":"Say Nothing","year":2024,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.462,"criticRating":null,"genres":["Drama","Crime"],"overview":"Through the eyes of various Irish Republican Army (IRA) members, explore the extremes some people will go to in the name of their beliefs, the way a deeply divided society can suddenly tip over into armed conflict,\u2026","poster":"assets/tv-say-nothing-2024-poster.jpg","backdrop":"assets/tv-say-nothing-2024-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0499752Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":245256,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":94,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.475,"tmdbVotes":122}},{"id":"tv-severance-2022","title":"Severance","year":2022,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":8.396,"criticRating":null,"genres":["Drama","Mystery","Sci-Fi & Fantasy"],"overview":"Mark leads a team of office workers whose memories have been surgically divided between their work and personal lives. When a mysterious colleague appears outside of work, it begins a journey to discover the truth\u2026","poster":"assets/tv-severance-2022-poster.jpg","backdrop":"assets/tv-severance-2022-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0598478Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":95396,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":95,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.389,"tmdbVotes":3095}},{"id":"tv-six-feet-under-2001","title":"Six Feet Under","year":2001,"type":"tv","runtimeSeconds":null,"seasonCount":5,"communityRating":8.115,"criticRating":null,"genres":["Drama"],"overview":"When death is your business, what is your life? For the Fisher family, the world outside of their family-owned funeral home continues to be at least as challenging as\u2014and far less predictable than\u2014the one inside.","poster":"assets/tv-six-feet-under-2001-poster.jpg","backdrop":"assets/tv-six-feet-under-2001-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-26T04:04:05.108787Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":1274,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":81,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.1,"tmdbVotes":1192}},{"id":"tv-undercover-2019","title":"Undercover","year":2019,"type":"tv","runtimeSeconds":3000,"seasonCount":3,"communityRating":7.5,"criticRating":null,"genres":["Crime","Drama"],"overview":"Undercover agents infiltrate a drug kingpin's operation by posing as a couple at the campground where he spends his weekends. Inspired by real events.","poster":"assets/tv-undercover-2019-poster.jpg","backdrop":"assets/tv-undercover-2019-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0570153Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":86942,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.5,"tmdbVotes":186}},{"id":"tv-upload-2020","title":"Upload","year":2020,"type":"tv","runtimeSeconds":null,"seasonCount":4,"communityRating":7.781,"criticRating":null,"genres":["Comedy","Sci-Fi & Fantasy","Drama"],"overview":"In 2033, people who are near death can be \u201cuploaded\u201d into virtual reality hotels run by 6 tech firms. Cash-strapped Nora lives in Brooklyn and works customer service for the luxurious \u201cLakeview\u201d digital afterlife.\u2026","poster":"assets/tv-upload-2020-poster.jpg","backdrop":"assets/tv-upload-2020-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0566967Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":86248,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":87,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.757,"tmdbVotes":1511}},{"id":"tv-the-walk-in-2022","title":"The Walk-In","year":2022,"type":"tv","runtimeSeconds":2760,"seasonCount":1,"communityRating":6.9,"criticRating":null,"genres":["Drama","Crime"],"overview":"Forced to flee the UK and go into hiding in the 1990s, when he became a mole from within the BNP, Matthew Collins returned to Britain to make a new life for himself, working with young white men in the fight against\u2026","poster":"assets/tv-the-walk-in-2022-poster.jpg","backdrop":"assets/tv-the-walk-in-2022-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0654569Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":210664,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":80,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7,"tmdbVotes":50}},{"id":"tv-the-white-lotus-2021","title":"The White Lotus","year":2021,"type":"tv","runtimeSeconds":null,"seasonCount":3,"communityRating":7.596,"criticRating":null,"genres":["Comedy","Drama","Mystery"],"overview":"Follow the exploits of various guests and employees at an exclusive tropical resort over the span of a week as with each passing day, a darker complexity emerges in these picture-perfect travelers, the hotel\u2019s\u2026","poster":"assets/tv-the-white-lotus-2021-poster.jpg","backdrop":"assets/tv-the-white-lotus-2021-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0546438Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":111803,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":90,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.599,"tmdbVotes":1604}},{"id":"tv-widow-s-bay-2026","title":"Widow's Bay","year":2026,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":8.172,"criticRating":null,"genres":["Drama","Comedy","Mystery"],"overview":"The mayor of a New England town resolves to turn it into the next tourist hot spot, despite local warnings that it's cursed.","poster":"assets/tv-widow-s-bay-2026-poster.jpg","backdrop":"assets/tv-widow-s-bay-2026-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-20T10:27:12.7003979Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":270476,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":98,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8,"tmdbVotes":585}},{"id":"tv-the-witcher-2019","title":"The Witcher","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":4,"communityRating":7.905,"criticRating":null,"genres":["Drama","Action & Adventure"],"overview":"Geralt of Rivia, a mutated monster-hunter for hire, journeys toward his destiny in a turbulent world where people often prove more wicked than beasts.","poster":"assets/tv-the-witcher-2019-poster.jpg","backdrop":"assets/tv-the-witcher-2019-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0552369Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":71912,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":75,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.88,"tmdbVotes":6997}},{"id":"tv-yellowjackets-2021","title":"Yellowjackets","year":2021,"type":"tv","runtimeSeconds":null,"seasonCount":3,"communityRating":7.449,"criticRating":null,"genres":["Drama","Mystery"],"overview":"This equal parts survival epic, psychological horror story and coming-of-age drama tells the saga of a team of wildly talented high school girls soccer players who become the (un)lucky survivors of a plane crash deep\u2026","poster":"assets/tv-yellowjackets-2021-poster.jpg","backdrop":"assets/tv-yellowjackets-2021-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0577612Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":117488,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":92,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.431,"tmdbVotes":1070}}],"anime":[],"discover":[{"id":"movie-12-years-a-slave-2013","title":"12 Years a Slave","year":2013,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","History"],"overview":"In the pre-Civil War United States, Solomon Northup, a free black man from upstate New York, is abducted and sold into slavery. Facing cruelty as well as unexpected kindnesses Solomon struggles not only to stay\u2026","poster":"assets/movie-12-years-a-slave-2013-poster.jpg","backdrop":"assets/movie-12-years-a-slave-2013-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":76203,"ratings":{"rtCritics":95,"rtCriticsLabel":"Certified Fresh","rtAudience":90,"rtAudienceLabel":"Upright","imdb":8.1,"imdbVotes":796313,"tmdb":7.927,"tmdbVotes":12335}},{"id":"movie-101-dalmatians-ii-patch-s-london-adventure-2003","title":"101 Dalmatians II: Patch's London Adventure","year":2003,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Family","Animation","Adventure"],"overview":"Being one of 101 takes its toll on Patch, who doesn't feel unique. When he's accidentally left behind on moving day, he meets his idol, Thunderbolt, who enlists him on a publicity campaign.","poster":"assets/movie-101-dalmatians-ii-patch-s-london-adventure-2003-poster.jpg","backdrop":"assets/movie-101-dalmatians-ii-patch-s-london-adventure-2003-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":13654,"ratings":{"rtCritics":67,"rtCriticsLabel":"Fresh","rtAudience":41,"rtAudienceLabel":"Spilled","imdb":5.7,"imdbVotes":12343,"tmdb":6.025,"tmdbVotes":1274}},{"id":"movie-absolution-2024","title":"Absolution","year":2024,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Crime","Thriller"],"overview":"An aging ex-boxer gangster working as muscle for a Boston crime boss receives an upsetting diagnosis. Despite a faltering memory, he attempts to rectify the sins of his past and reconnect with his estranged\u2026","poster":"assets/movie-absolution-2024-poster.jpg","backdrop":"assets/movie-absolution-2024-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":974453,"ratings":{"rtCritics":56,"rtCriticsLabel":"Rotten","rtAudience":32,"rtAudienceLabel":"Spilled","imdb":5.2,"imdbVotes":11413,"tmdb":5.906,"tmdbVotes":564}},{"id":"movie-addicted-2014","title":"Addicted","year":2014,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Thriller"],"overview":"A gallerist risks her family and flourishing career when she enters into an affair with a talented painter and slowly loses control of her life.","poster":"assets/movie-addicted-2014-poster.jpg","backdrop":"assets/movie-addicted-2014-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":235271,"ratings":{"rtCritics":7,"rtCriticsLabel":"Rotten","rtAudience":51,"rtAudienceLabel":"Spilled","imdb":5,"imdbVotes":13131,"tmdb":5.928,"tmdbVotes":466}},{"id":"movie-all-the-money-in-the-world-2017","title":"All the Money in the World","year":2017,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Crime","History","Thriller"],"overview":"The story of the kidnapping of 16-year-old John Paul Getty III and the desperate attempt by his devoted mother to convince his billionaire grandfather Jean Paul Getty to pay the ransom.","poster":"assets/movie-all-the-money-in-the-world-2017-poster.jpg","backdrop":"assets/movie-all-the-money-in-the-world-2017-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":446791,"ratings":{"rtCritics":79,"rtCriticsLabel":"Certified Fresh","rtAudience":66,"rtAudienceLabel":"Upright","imdb":6.8,"imdbVotes":97448,"tmdb":6.493,"tmdbVotes":2493}},{"id":"movie-the-alto-knights-2025","title":"The Alto Knights","year":2025,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Crime","Drama"],"overview":"Two of New York's most notorious organized crime bosses, Frank Costello and Vito Genovese, vie for control of the city's streets. Once the best of friends, petty jealousies and a series of betrayals place them on a\u2026","poster":"assets/movie-the-alto-knights-2025-poster.jpg","backdrop":"assets/movie-the-alto-knights-2025-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1013601,"ratings":{"rtCritics":39,"rtCriticsLabel":"Rotten","rtAudience":71,"rtAudienceLabel":"Upright","imdb":5.9,"imdbVotes":23017,"tmdb":6.223,"tmdbVotes":485}},{"id":"movie-the-amazing-spider-man-2-2014","title":"The Amazing Spider-Man 2","year":2014,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"For Peter Parker, life is busy. Between taking out the bad guys as Spider-Man and spending time with the person he loves, Gwen Stacy, high school graduation cannot come quickly enough. Peter has not forgotten about\u2026","poster":"assets/movie-the-amazing-spider-man-2-2014-poster.jpg","backdrop":"assets/movie-the-amazing-spider-man-2-2014-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":102382,"ratings":{"rtCritics":51,"rtCriticsLabel":"Rotten","rtAudience":64,"rtAudienceLabel":"Upright","imdb":6.6,"imdbVotes":611981,"tmdb":6.567,"tmdbVotes":14919}},{"id":"movie-ant-man-and-the-wasp-quantumania-2023","title":"Ant-Man and the Wasp: Quantumania","year":2023,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"Super-Hero partners Scott Lang and Hope van Dyne, along with with Hope's parents Janet van Dyne and Hank Pym, and Scott's daughter Cassie Lang, find themselves exploring the Quantum Realm, interacting with strange\u2026","poster":"assets/movie-ant-man-and-the-wasp-quantumania-2023-poster.jpg","backdrop":"assets/movie-ant-man-and-the-wasp-quantumania-2023-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":640146,"ratings":{"rtCritics":46,"rtCriticsLabel":"Rotten","rtAudience":81,"rtAudienceLabel":"Upright","imdb":6,"imdbVotes":281738,"tmdb":6.197,"tmdbVotes":6236}},{"id":"movie-arctic-dogs-2019","title":"Arctic Dogs","year":2019,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Adventure","Comedy"],"overview":"Animals band together to save the day when the evil Otto Von Walrus hatches a sinister scheme to accelerate global warming and melt the Arctic Circle.","poster":"assets/movie-arctic-dogs-2019-poster.jpg","backdrop":"assets/movie-arctic-dogs-2019-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":416153,"ratings":{"rtCritics":13,"rtCriticsLabel":"Rotten","rtAudience":60,"rtAudienceLabel":"Upright","imdb":4.9,"imdbVotes":2580,"tmdb":5.9,"tmdbVotes":148}},{"id":"movie-the-avengers-2012","title":"The Avengers","year":2012,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Science Fiction","Action","Adventure"],"overview":"When an unexpected enemy emerges and threatens global safety and security, Nick Fury, director of the international peacekeeping agency known as S.H.I.E.L.D., finds himself in need of a team to pull the world back\u2026","poster":"assets/movie-the-avengers-2012-poster.jpg","backdrop":"assets/movie-the-avengers-2012-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":24428,"ratings":{"rtCritics":91,"rtCriticsLabel":"Certified Fresh","rtAudience":91,"rtAudienceLabel":"Upright","imdb":8,"imdbVotes":1590693,"tmdb":8.084,"tmdbVotes":40262}},{"id":"movie-black-bear-2020","title":"Black Bear","year":2020,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Thriller"],"overview":"At a remote lake house in the Adirondack Mountains, a couple entertains an out-of-town guest looking for inspiration in her filmmaking. The group quickly falls into a calculated game of desire, manipulation, and\u2026","poster":"assets/movie-black-bear-2020-poster.jpg","backdrop":"assets/movie-black-bear-2020-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":624788,"ratings":{"rtCritics":89,"rtCriticsLabel":"Certified Fresh","rtAudience":64,"rtAudienceLabel":"Upright","imdb":6.5,"imdbVotes":18408,"tmdb":6.337,"tmdbVotes":315}},{"id":"movie-black-panther-2018","title":"Black Panther","year":2018,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"King T'Challa returns home to the reclusive, technologically advanced African nation of Wakanda to serve as his country's new leader. However, T'Challa soon finds that he is challenged for the throne by factions\u2026","poster":"assets/movie-black-panther-2018-poster.jpg","backdrop":"assets/movie-black-panther-2018-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":284054,"ratings":{"rtCritics":96,"rtCriticsLabel":"Certified Fresh","rtAudience":null,"rtAudienceLabel":"Spilled","imdb":7.3,"imdbVotes":931120,"tmdb":7.36,"tmdbVotes":24190}},{"id":"movie-a-bronx-tale-1993","title":"A Bronx Tale","year":1993,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Crime"],"overview":"Set in the Bronx during the tumultuous 1960s, an adolescent boy is torn between his honest, working-class father and a violent yet charismatic crime boss. Complicating matters is the youngster's growing attraction -\u2026","poster":"assets/movie-a-bronx-tale-1993-poster.jpg","backdrop":"assets/movie-a-bronx-tale-1993-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1607,"ratings":{"rtCritics":97,"rtCriticsLabel":"Fresh","rtAudience":92,"rtAudienceLabel":"Upright","imdb":7.8,"imdbVotes":191930,"tmdb":7.913,"tmdbVotes":3075}},{"id":"movie-by-any-means-2026","title":"By Any Means","year":2026,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Thriller","History","Crime"],"overview":"In 1960s Mississippi, a young Black FBI agent is forced to partner with a notorious mafia hitman to investigate a wave of brutal civil rights murders.","poster":"assets/movie-by-any-means-2026-poster.jpg","backdrop":"assets/movie-by-any-means-2026-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1380417,"ratings":{"rtCritics":49,"rtCriticsLabel":"Rotten","rtAudience":88,"rtAudienceLabel":"Upright","imdb":6.8,"imdbVotes":3534,"tmdb":6.6,"tmdbVotes":92}},{"id":"movie-captain-america-civil-war-2016","title":"Captain America: Civil War","year":2016,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Adventure","Action","Science Fiction"],"overview":"Following the events of Age of Ultron, the collective governments of the world pass an act designed to regulate all superhuman activity. This polarizes opinion amongst the Avengers, causing two factions to side with\u2026","poster":"assets/movie-captain-america-civil-war-2016-poster.jpg","backdrop":"assets/movie-captain-america-civil-war-2016-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":271110,"ratings":{"rtCritics":90,"rtCriticsLabel":"Certified Fresh","rtAudience":89,"rtAudienceLabel":"Upright","imdb":7.8,"imdbVotes":954943,"tmdb":7.455,"tmdbVotes":24808}},{"id":"movie-captain-america-the-first-avenger-2011","title":"Captain America: The First Avenger","year":2011,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"During World War II, Steve Rogers is a sickly man from Brooklyn who's transformed into super-soldier Captain America to aid in the war effort. Rogers must stop the Red Skull \u2013 Adolf Hitler's ruthless head of\u2026","poster":"assets/movie-captain-america-the-first-avenger-2011-poster.jpg","backdrop":"assets/movie-captain-america-the-first-avenger-2011-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1771,"ratings":{"rtCritics":80,"rtCriticsLabel":"Certified Fresh","rtAudience":75,"rtAudienceLabel":"Upright","imdb":6.9,"imdbVotes":993453,"tmdb":7.012,"tmdbVotes":23473}},{"id":"movie-captain-america-the-winter-soldier-2014","title":"Captain America: The Winter Soldier","year":2014,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"After the cataclysmic events in New York with The Avengers, Steve Rogers, aka Captain America is living quietly in Washington, D.C. and trying to adjust to the modern world. But when a S.H.I.E.L.D. colleague comes\u2026","poster":"assets/movie-captain-america-the-winter-soldier-2014-poster.jpg","backdrop":"assets/movie-captain-america-the-winter-soldier-2014-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":100402,"ratings":{"rtCritics":90,"rtCriticsLabel":"Certified Fresh","rtAudience":92,"rtAudienceLabel":"Upright","imdb":7.7,"imdbVotes":995514,"tmdb":7.654,"tmdbVotes":20688}},{"id":"movie-careful-what-you-wish-for-2015","title":"Careful What You Wish For","year":2015,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Thriller"],"overview":"A guy gets more than he bargained for after entering into an affair with the wife of an investment banker. Soon, a suspicious death and substantial life insurance policy embroil him in a scandal.","poster":"assets/movie-careful-what-you-wish-for-2015-poster.jpg","backdrop":"assets/movie-careful-what-you-wish-for-2015-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":317744,"ratings":{"rtCritics":17,"rtCriticsLabel":"Rotten","rtAudience":61,"rtAudienceLabel":"Upright","imdb":5.6,"imdbVotes":9452,"tmdb":6.129,"tmdbVotes":446}},{"id":"tv-12-monkeys-2015","title":"12 Monkeys","year":2015,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Sci-Fi & Fantasy","Drama"],"overview":"The provocative story of Cole, a time traveler from a decimated future in a high-stakes race against the clock. Utilizing a dangerous and untested method of time travel, he journeys from 2043 to the present day on a\u2026","poster":"assets/tv-12-monkeys-2015-poster.jpg","backdrop":"assets/tv-12-monkeys-2015-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":60948,"ratings":{"rtCritics":88,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.441,"tmdbVotes":1245}},{"id":"tv-90-day-fiance-before-the-90-days-2017","title":"90 Day Fianc\u00e9: Before the 90 Days","year":2017,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Reality"],"overview":"Follow couples who have an existing relationship online, but haven't met in person. We'll experience their journey as they travel to the other's foreign country for the first time in an attempt to establish an\u2026","poster":"assets/tv-90-day-fiance-before-the-90-days-2017-poster.jpg","backdrop":"assets/tv-90-day-fiance-before-the-90-days-2017-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":73319,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.5,"tmdbVotes":45}},{"id":"tv-90-day-fiance-the-other-way-2019","title":"90 Day Fianc\u00e9: The Other Way","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Reality"],"overview":"Americans move to a foreign country all in the name of love. Will uprooting their lives in the United States pay off, or will they be packing up and moving back to America brokenhearted?","poster":"assets/tv-90-day-fiance-the-other-way-2019-poster.jpg","backdrop":"assets/tv-90-day-fiance-the-other-way-2019-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":90046,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.8,"tmdbVotes":34}},{"id":"tv-the-100-2014","title":"The 100","year":2014,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Sci-Fi & Fantasy","Action & Adventure","Drama"],"overview":"100 years in the future, when the Earth has been abandoned due to radioactivity, the last surviving humans live on an ark orbiting the planet \u2014 but the ark won't last forever. So the repressive regime picks 100\u2026","poster":"assets/tv-the-100-2014-poster.jpg","backdrop":"assets/tv-the-100-2014-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":48866,"ratings":{"rtCritics":93,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.878,"tmdbVotes":8855}},{"id":"tv-accused-2023","title":"Accused","year":2023,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Crime","Drama"],"overview":"In this crime anthology series, viewers discover how an ordinary person got caught up in an extraordinary situation, ultimately revealing how one wrong turn leads to another, until it\u2019s too late to turn back. Told\u2026","poster":"assets/tv-accused-2023-poster.jpg","backdrop":"assets/tv-accused-2023-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":202097,"ratings":{"rtCritics":61,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.851,"tmdbVotes":87}},{"id":"tv-after-life-2019","title":"After Life","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy","Drama"],"overview":"Tony had a perfect life. But after his wife Lisa suddenly dies, Tony changes. After contemplating taking his own life, he decides instead to live long enough to punish the world by saying and doing whatever he likes\u2026","poster":"assets/tv-after-life-2019-poster.jpg","backdrop":"assets/tv-after-life-2019-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":79410,"ratings":{"rtCritics":72,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.902,"tmdbVotes":1169}},{"id":"tv-all-creatures-great-and-small-1978","title":"All Creatures Great and Small","year":1978,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy","Drama"],"overview":"The trials and misadventures of the staff at a country veterinary office in Yorkshire. James Herriot, a young animal surgeon, moves to a small Yorkshire town to begin his first job.","poster":"assets/tv-all-creatures-great-and-small-1978-poster.jpg","backdrop":"assets/tv-all-creatures-great-and-small-1978-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"center_crop","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":7406,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.8,"tmdbVotes":50}},{"id":"tv-american-housewife-2016","title":"American Housewife","year":2016,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy"],"overview":"A family comedy narrated by Katie, a strong-willed mother, raising her flawed family in a wealthy town filled with perfect wives and their perfect offspring.","poster":"assets/tv-american-housewife-2016-poster.jpg","backdrop":"assets/tv-american-housewife-2016-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":67117,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.7,"tmdbVotes":187}},{"id":"tv-atlantis-2013","title":"Atlantis","year":2013,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action & Adventure","Sci-Fi & Fantasy"],"overview":"A fantasy drama set in a world of legendary heroes and mythical creatures. Far from home and desperate for answers, Jason washes up on the shores of an ancient land. A mysterious place; a world of bull leaping, of\u2026","poster":"assets/tv-atlantis-2013-poster.jpg","backdrop":"assets/tv-atlantis-2013-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":47054,"ratings":{"rtCritics":62,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.4,"tmdbVotes":155}},{"id":"tv-avatar-the-last-airbender-2024","title":"Avatar: The Last Airbender","year":2024,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action & Adventure","Sci-Fi & Fantasy","Drama"],"overview":"A young boy known as the Avatar must master the four elemental powers to save a world at war \u2014 and fight a ruthless enemy bent on stopping him.","poster":"assets/tv-avatar-the-last-airbender-2024-poster.jpg","backdrop":"assets/tv-avatar-the-last-airbender-2024-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":82452,"ratings":{"rtCritics":66,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.8,"tmdbVotes":1406}},{"id":"tv-avatar-seven-havens-2026","title":"Avatar: Seven Havens","year":2026,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"In a world shattered by a devastating cataclysm, a young Earthbender discovers she's the new Avatar after Korra. But in this dangerous era, that title marks her as humanity's destroyer, not its savior. Hunted by both\u2026","poster":"assets/tv-avatar-seven-havens-2026-poster.jpg","backdrop":"assets/tv-avatar-seven-havens-2026-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":284833,"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.9,"tmdbVotes":183}},{"id":"tv-batman-the-brave-and-the-bold-2008","title":"Batman: The Brave and the Bold","year":2008,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"The Caped Crusader is teamed up with Blue Beetle, Green Arrow, Aquaman and countless others in his quest to uphold justice.","poster":"assets/tv-batman-the-brave-and-the-bold-2008-poster.jpg","backdrop":"assets/tv-batman-the-brave-and-the-bold-2008-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":15804,"ratings":{"rtCritics":96,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.6,"tmdbVotes":295}},{"id":"tv-beat-bobby-flay-2013","title":"Beat Bobby Flay","year":2013,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Reality"],"overview":"Two talented chefs go head-to-head for the chance to Beat Bobby Flay. To get to Bobby the chefs must first face off against each other, creating a spectacular dish with a secret ingredient of Bobby's choice. Judges\u2026","poster":"assets/tv-beat-bobby-flay-2013-poster.jpg","backdrop":"assets/tv-beat-bobby-flay-2013-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":62481,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.5,"tmdbVotes":31}},{"id":"tv-benson-1979","title":"Benson","year":1979,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy"],"overview":"A butler deals with life at the governor's mansion.","poster":"assets/tv-benson-1979-poster.jpg","backdrop":"assets/tv-benson-1979-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1849,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7,"tmdbVotes":46}},{"id":"tv-black-rabbit-2025","title":"Black Rabbit","year":2025,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Crime"],"overview":"A rising-star restaurateur is forced into New York's criminal underworld when his chaotic brother returns to town with loan sharks on his trail.","poster":"assets/tv-black-rabbit-2025-poster.jpg","backdrop":"assets/tv-black-rabbit-2025-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":249039,"ratings":{"rtCritics":66,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.983,"tmdbVotes":240}},{"id":"tv-black-ish-2014","title":"black-ish","year":2014,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy"],"overview":"A family man struggles to gain a sense of cultural identity while raising his kids in a predominantly white, upper-middle-class neighborhood.","poster":"assets/tv-black-ish-2014-poster.jpg","backdrop":"assets/tv-black-ish-2014-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":61381,"ratings":{"rtCritics":92,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.1,"tmdbVotes":352}},{"id":"tv-breaking-bad-2008","title":"Breaking Bad","year":2008,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Crime"],"overview":"Walter White, a New Mexico chemistry teacher, is diagnosed with Stage III cancer and given a prognosis of only two years left to live. He becomes filled with a sense of fearlessness and an unrelenting desire to\u2026","poster":"assets/tv-breaking-bad-2008-poster.jpg","backdrop":"assets/tv-breaking-bad-2008-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"center_crop","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1396,"ratings":{"rtCritics":96,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.959,"tmdbVotes":19030}},{"id":"tv-the-capture-2019","title":"The Capture","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Crime","Drama","Mystery"],"overview":"When soldier Shaun Emery's conviction for a murder in Afghanistan is overturned due to flawed video evidence, he returns to life as a free man with his young daughter. But when damning CCTV footage from a night out\u2026","poster":"assets/tv-the-capture-2019-poster.jpg","backdrop":"assets/tv-the-capture-2019-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":93166,"ratings":{"rtCritics":97,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.634,"tmdbVotes":361}}]}],"seeAll":{"trending":[{"id":"tv-american-gods-2017","title":"American Gods","year":2017,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Mystery","Sci-Fi & Fantasy"],"overview":"An ex-con becomes the traveling partner of a conman who turns out to be one of the older gods trying to recruit troops to battle the upstart deities. Based on Neil Gaiman's fantasy novel.","poster":"assets/tv-american-gods-2017-poster.jpg","backdrop":"assets/tv-american-gods-2017-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":46639,"ratings":{"rtCritics":75,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.1,"tmdbVotes":1852}},{"id":"tv-avatar-seven-havens-2026","title":"Avatar: Seven Havens","year":2026,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"In a world shattered by a devastating cataclysm, a young Earthbender discovers she's the new Avatar after Korra. But in this dangerous era, that title marks her as humanity's destroyer, not its savior. Hunted by both\u2026","poster":"assets/tv-avatar-seven-havens-2026-poster.jpg","backdrop":"assets/tv-avatar-seven-havens-2026-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":284833,"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.9,"tmdbVotes":183}},{"id":"tv-batman-the-brave-and-the-bold-2008","title":"Batman: The Brave and the Bold","year":2008,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"The Caped Crusader is teamed up with Blue Beetle, Green Arrow, Aquaman and countless others in his quest to uphold justice.","poster":"assets/tv-batman-the-brave-and-the-bold-2008-poster.jpg","backdrop":"assets/tv-batman-the-brave-and-the-bold-2008-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":15804,"ratings":{"rtCritics":96,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.6,"tmdbVotes":295}},{"id":"tv-batwoman-2019","title":"Batwoman","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Sci-Fi & Fantasy","Action & Adventure"],"overview":"Kate Kane, armed with a passion for social justice and a flair for speaking her mind, soars onto the streets of Gotham as Batwoman, an out lesbian and highly trained street fighter primed to snuff out the failing\u2026","poster":"assets/tv-batwoman-2019-poster.jpg","backdrop":"assets/tv-batwoman-2019-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":89247,"ratings":{"rtCritics":83,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.8,"tmdbVotes":1619}},{"id":"tv-big-mouth-2017","title":"Big Mouth","year":2017,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy","Animation"],"overview":"Teenage friends find their lives upended by the wonders and horrors of puberty in this edgy comedy from real-life pals Nick Kroll and Andrew Goldberg.","poster":"assets/tv-big-mouth-2017-poster.jpg","backdrop":"assets/tv-big-mouth-2017-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":74204,"ratings":{"rtCritics":95,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.591,"tmdbVotes":1274}},{"id":"tv-black-lightning-2018","title":"Black Lightning","year":2018,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action & Adventure","Sci-Fi & Fantasy","Drama"],"overview":"Jefferson Pierce is a man wrestling with a secret. As the father of two daughters and principal of a charter high school that also serves as a safe haven for young people in a New Orleans neighborhood overrun by gang\u2026","poster":"assets/tv-black-lightning-2018-poster.jpg","backdrop":"assets/tv-black-lightning-2018-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":71663,"ratings":{"rtCritics":92,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.907,"tmdbVotes":991}},{"id":"movie-across-the-hall-2009","title":"Across the Hall","year":2009,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Thriller","Mystery"],"overview":"Convinced that his fiancee is cheating on him, a man follows her to a hotel and calls his best friend to help him avoid a calamity.","poster":"assets/movie-across-the-hall-2009-poster.jpg","backdrop":"assets/movie-across-the-hall-2009-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":21469,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":39,"rtAudienceLabel":"Spilled","imdb":5.8,"imdbVotes":5130,"tmdb":6,"tmdbVotes":129}},{"id":"movie-addicted-2014","title":"Addicted","year":2014,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Thriller"],"overview":"A gallerist risks her family and flourishing career when she enters into an affair with a talented painter and slowly loses control of her life.","poster":"assets/movie-addicted-2014-poster.jpg","backdrop":"assets/movie-addicted-2014-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":235271,"ratings":{"rtCritics":7,"rtCriticsLabel":"Rotten","rtAudience":51,"rtAudienceLabel":"Spilled","imdb":5,"imdbVotes":13131,"tmdb":5.928,"tmdbVotes":466}},{"id":"movie-adulterers-2016","title":"Adulterers","year":2016,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Thriller","Drama"],"overview":"On a steamy New Orleans afternoon, Sam Deuprey decides to surprise his beautiful wife, Ashley, for their anniversary and comes home early. But Ashley is not alone in the bedroom. She and her lover, Damien are about\u2026","poster":"assets/movie-adulterers-2016-poster.jpg","backdrop":"assets/movie-adulterers-2016-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":376004,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":37,"rtAudienceLabel":"Spilled","imdb":5.1,"imdbVotes":2144,"tmdb":6.2,"tmdbVotes":116}},{"id":"movie-alone-with-her-2006","title":"Alone With Her","year":2006,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Crime","Drama","Thriller"],"overview":"The harrowing story of a disturbed young man's attempts to win the affections of an unsuspecting young woman. When Doug first sees Amy, he instantly falls for her and begins to watch her every move, going so far as\u2026","poster":"assets/movie-alone-with-her-2006-poster.jpg","backdrop":"assets/movie-alone-with-her-2006-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":18206,"ratings":{"rtCritics":70,"rtCriticsLabel":"Fresh","rtAudience":46,"rtAudienceLabel":"Spilled","imdb":6,"imdbVotes":4359,"tmdb":5.9,"tmdbVotes":94}},{"id":"movie-awake-2007","title":"Awake","year":2007,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Thriller","Crime","Mystery"],"overview":"While undergoing heart surgery, a man experiences a phenomenon called \u2018anesthetic awareness\u2019, which leaves him awake but paralyzed throughout the operation. As various obstacles present themselves, his wife must make\u2026","poster":"assets/movie-awake-2007-poster.jpg","backdrop":"assets/movie-awake-2007-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":13483,"ratings":{"rtCritics":23,"rtCriticsLabel":"Rotten","rtAudience":55,"rtAudienceLabel":"Spilled","imdb":6.5,"imdbVotes":89389,"tmdb":6.586,"tmdbVotes":1649}},{"id":"movie-black-bear-2020","title":"Black Bear","year":2020,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Thriller"],"overview":"At a remote lake house in the Adirondack Mountains, a couple entertains an out-of-town guest looking for inspiration in her filmmaking. The group quickly falls into a calculated game of desire, manipulation, and\u2026","poster":"assets/movie-black-bear-2020-poster.jpg","backdrop":"assets/movie-black-bear-2020-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":624788,"ratings":{"rtCritics":89,"rtCriticsLabel":"Certified Fresh","rtAudience":64,"rtAudienceLabel":"Upright","imdb":6.5,"imdbVotes":18408,"tmdb":6.337,"tmdbVotes":315}},{"id":"movie-butterfly-on-a-wheel-2007","title":"Butterfly on a Wheel","year":2007,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Crime","Drama","Thriller"],"overview":"A sociopathic kidnapper methodically pushes a desperate pair of parents to their absolute breaking point.","poster":"assets/movie-butterfly-on-a-wheel-2007-poster.jpg","backdrop":"assets/movie-butterfly-on-a-wheel-2007-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":13250,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":61,"rtAudienceLabel":"Upright","imdb":6.6,"imdbVotes":35409,"tmdb":6.457,"tmdbVotes":595}},{"id":"movie-careful-what-you-wish-for-2015","title":"Careful What You Wish For","year":2015,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Thriller"],"overview":"A guy gets more than he bargained for after entering into an affair with the wife of an investment banker. Soon, a suspicious death and substantial life insurance policy embroil him in a scandal.","poster":"assets/movie-careful-what-you-wish-for-2015-poster.jpg","backdrop":"assets/movie-careful-what-you-wish-for-2015-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":317744,"ratings":{"rtCritics":17,"rtCriticsLabel":"Rotten","rtAudience":61,"rtAudienceLabel":"Upright","imdb":5.6,"imdbVotes":9452,"tmdb":6.129,"tmdbVotes":446}},{"id":"movie-crime-of-passion-1956","title":"Crime of Passion","year":1956,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Crime","Thriller"],"overview":"Kathy leaves the newspaper business to marry homicide detective Bill, but is frustrated by his lack of ambition and the banality of life in the suburbs. Her drive to advance Bill's career soon takes her down a\u2026","poster":"assets/movie-crime-of-passion-1956-poster.jpg","backdrop":"assets/movie-crime-of-passion-1956-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"center_crop","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1920,"ratings":{"rtCritics":80,"rtCriticsLabel":"Fresh","rtAudience":34,"rtAudienceLabel":"Spilled","imdb":6.4,"imdbVotes":3750,"tmdb":6.1,"tmdbVotes":57}},{"id":"movie-deep-water-2022","title":"Deep Water","year":2022,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Mystery","Thriller"],"overview":"A well-to-do husband who allows his wife to have affairs in order to avoid a divorce becomes a prime suspect in the disappearance of her lovers.","poster":"assets/movie-deep-water-2022-poster.jpg","backdrop":"assets/movie-deep-water-2022-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":619979,"ratings":{"rtCritics":35,"rtCriticsLabel":"Rotten","rtAudience":25,"rtAudienceLabel":"Spilled","imdb":5.5,"imdbVotes":74098,"tmdb":5.834,"tmdbVotes":1755}},{"id":"movie-derailed-2005","title":"Derailed","year":2005,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Thriller"],"overview":"When two married business executives having an affair are blackmailed by a violent criminal, they are forced to turn the tables on him to save their families.","poster":"assets/movie-derailed-2005-poster.jpg","backdrop":"assets/movie-derailed-2005-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":8999,"ratings":{"rtCritics":20,"rtCriticsLabel":"Rotten","rtAudience":58,"rtAudienceLabel":"Spilled","imdb":6.6,"imdbVotes":79563,"tmdb":6.405,"tmdbVotes":1193}},{"id":"movie-die-my-love-2025","title":"Die My Love","year":2025,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama"],"overview":"After inheriting a remote Montana house, Jackson moves there from New York with his partner Grace, and the couple soon welcome a child. As Jackson becomes increasingly absent and rural isolation sets in, Grace\u2026","poster":"assets/movie-die-my-love-2025-poster.jpg","backdrop":"assets/movie-die-my-love-2025-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1033148,"ratings":{"rtCritics":74,"rtCriticsLabel":"Certified Fresh","rtAudience":45,"rtAudienceLabel":"Spilled","imdb":6,"imdbVotes":50664,"tmdb":6.1,"tmdbVotes":683}},{"id":"movie-dream-house-2011","title":"Dream House","year":2011,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Mystery","Drama","Thriller"],"overview":"Publisher Will Atenton quits a lucrative job in New York to relocate his wife, Libby, and their daughters to a quaint town in New England. However, as they settle into their home the Atentons discover that a woman\u2026","poster":"assets/movie-dream-house-2011-poster.jpg","backdrop":"assets/movie-dream-house-2011-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":69668,"ratings":{"rtCritics":7,"rtCriticsLabel":"Rotten","rtAudience":36,"rtAudienceLabel":"Spilled","imdb":6,"imdbVotes":71386,"tmdb":6.212,"tmdbVotes":1480}},{"id":"movie-dream-lover-1994","title":"Dream Lover","year":1994,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Mystery","Thriller"],"overview":"Not long after they cross paths at an art gallery, architect Ray Reardon and hypnotically sensual Lena are married with children. But as strange incidents occur, Ray begins to realize he may not really know the woman\u2026","poster":"assets/movie-dream-lover-1994-poster.jpg","backdrop":"assets/movie-dream-lover-1994-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":18190,"ratings":{"rtCritics":57,"rtCriticsLabel":"Rotten","rtAudience":38,"rtAudienceLabel":"Spilled","imdb":6.2,"imdbVotes":7865,"tmdb":6.844,"tmdbVotes":167}},{"id":"movie-drop-2025","title":"Drop","year":2025,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Mystery","Thriller"],"overview":"Violet, a widowed mother on her first date in years, arrives at an upscale restaurant where she is relieved that her date, Henry, is more charming and handsome than she expected. But their chemistry begins to curdle\u2026","poster":"assets/movie-drop-2025-poster.jpg","backdrop":"assets/movie-drop-2025-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1249213,"ratings":{"rtCritics":83,"rtCriticsLabel":"Certified Fresh","rtAudience":79,"rtAudienceLabel":"Upright","imdb":6.1,"imdbVotes":61018,"tmdb":6.275,"tmdbVotes":1023}},{"id":"movie-eastern-promises-2007","title":"Eastern Promises","year":2007,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Thriller","Crime","Mystery"],"overview":"A Russian teenager living in London dies during childbirth but leaves clues in her diary that could tie her child to a rape involving a violent Russian mob family.","poster":"assets/movie-eastern-promises-2007-poster.jpg","backdrop":"assets/movie-eastern-promises-2007-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":2252,"ratings":{"rtCritics":89,"rtCriticsLabel":"Certified Fresh","rtAudience":83,"rtAudienceLabel":"Upright","imdb":7.6,"imdbVotes":281015,"tmdb":7.349,"tmdbVotes":3900}},{"id":"movie-faces-1968","title":"Faces","year":1968,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama"],"overview":"Middle-aged suburban husband Richard abruptly tells his wife, Maria, that he wants a divorce. As Richard takes up with a younger woman, Maria enjoys a night on the town with her friends and meets a younger man. As\u2026","poster":"assets/movie-faces-1968-poster.jpg","backdrop":"assets/movie-faces-1968-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"center_crop","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":753,"ratings":{"rtCritics":83,"rtCriticsLabel":"Fresh","rtAudience":86,"rtAudienceLabel":"Upright","imdb":7.4,"imdbVotes":12950,"tmdb":7.093,"tmdbVotes":227}},{"id":"movie-fatale-2020","title":"Fatale","year":2020,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Thriller"],"overview":"After a wild one-night stand, successful sports agent Derrick Tyler watches his perfect life slowly disappear when he discovers the sexy and mysterious woman he risked everything for is determined police detective\u2026","poster":"assets/movie-fatale-2020-poster.jpg","backdrop":"assets/movie-fatale-2020-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":549514,"ratings":{"rtCritics":46,"rtCriticsLabel":"Rotten","rtAudience":87,"rtAudienceLabel":"Upright","imdb":5.5,"imdbVotes":16392,"tmdb":6,"tmdbVotes":401}},{"id":"tv-56-days-2026","title":"56 Days","year":2026,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Crime","Mystery"],"overview":"When an unidentified body is found in a luxury apartment linked to Oliver Kennedy and his girlfriend Ciara Wyse, Detectives Lee Reardon and Karl Connolly reconstruct the couple's deadly romance across the past 56 days.","poster":"assets/tv-56-days-2026-poster.jpg","backdrop":"assets/tv-56-days-2026-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":241372,"ratings":{"rtCritics":60,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.388,"tmdbVotes":245}},{"id":"tv-absolutely-fabulous-1992","title":"Absolutely Fabulous","year":1992,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy"],"overview":"Set in the world of fashion and PR, immature fun-loving mother Edina Monsoon and her best friend Patsy drive Eddie's sensible daughter, Saffron, up the wall with their constant drug abuse and outrageous selfishness.\u2026","poster":"assets/tv-absolutely-fabulous-1992-poster.jpg","backdrop":"assets/tv-absolutely-fabulous-1992-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":4493,"ratings":{"rtCritics":96,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.6,"tmdbVotes":198}},{"id":"tv-the-afterparty-2022","title":"The Afterparty","year":2022,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Mystery","Comedy","Crime"],"overview":"Nothing is as it seems in this hilarious murder mystery where each suspect's story is told through a different film genre.","poster":"assets/tv-the-afterparty-2022-poster.jpg","backdrop":"assets/tv-the-afterparty-2022-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":106454,"ratings":{"rtCritics":92,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.071,"tmdbVotes":245}},{"id":"tv-agatha-christie-s-marple-2004","title":"Agatha Christie's Marple","year":2004,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Crime","Mystery"],"overview":"The adventures of Miss Jane Marple, an elderly spinster living in the quiet little village of St Mary Mead. During her many visits to friends and relatives in other villages, Miss Marple often stumbles upon\u2026","poster":"assets/tv-agatha-christie-s-marple-2004-poster.jpg","backdrop":"assets/tv-agatha-christie-s-marple-2004-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":6127,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.9,"tmdbVotes":152}},{"id":"tv-all-american-homecoming-2022","title":"All American: Homecoming","year":2022,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama"],"overview":"Follow the journeys of Simone Hicks, a tennis hopeful from Beverly Hills, and elite baseball player Damon Sims from Chicago, as they navigate life at the prestigious HBCU Bringston University.","poster":"assets/tv-all-american-homecoming-2022-poster.jpg","backdrop":"assets/tv-all-american-homecoming-2022-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":153723,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.3,"tmdbVotes":70}},{"id":"tv-and-just-like-that-2021","title":"And Just Like That\u2026","year":2021,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy","Drama"],"overview":"This new chapter of \u201cSex and the City\u201d follows Carrie, Miranda and Charlotte as they navigate the journey from the complicated reality of life and friendship in their 30s to the even more complicated reality of life\u2026","poster":"assets/tv-and-just-like-that-2021-poster.jpg","backdrop":"assets/tv-and-just-like-that-2021-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":116450,"ratings":{"rtCritics":52,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.211,"tmdbVotes":556}},{"id":"tv-anne-with-an-e-2017","title":"Anne with an E","year":2017,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Family"],"overview":"A coming-of-age story about an outsider who, against all odds and numerous challenges, fights for love and acceptance and for her place in the world. The series centers on a young orphaned girl in the late 1890s,\u2026","poster":"assets/tv-anne-with-an-e-2017-poster.jpg","backdrop":"assets/tv-anne-with-an-e-2017-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":70785,"ratings":{"rtCritics":63,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.7,"tmdbVotes":5019}},{"id":"tv-awkward-2011","title":"Awkward.","year":2011,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy","Drama"],"overview":"An irreverent look at the conflict, chaos and humor that defines teenage life through the eyes of 15-year-old Jenna Hamilton whose life begins to change when a simple accident becomes an epic misunderstanding and is\u2026","poster":"assets/tv-awkward-2011-poster.jpg","backdrop":"assets/tv-awkward-2011-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":39859,"ratings":{"rtCritics":78,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.4,"tmdbVotes":427}},{"id":"tv-the-baby-sitters-club-2020","title":"The Baby-Sitters Club","year":2020,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Kids","Drama"],"overview":"In this contemporary take on the beloved book series, five best friends launch a baby-sitting business that's big on fun and adventure.","poster":"assets/tv-the-baby-sitters-club-2020-poster.jpg","backdrop":"assets/tv-the-baby-sitters-club-2020-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":100412,"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.9,"tmdbVotes":506}},{"id":"tv-ballers-2015","title":"Ballers","year":2015,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Comedy"],"overview":"Looking at the lives of former and current football players, the show follows former superstar Spencer Strasmore as he gets his life on track in retirement while mentoring other current and former players through the\u2026","poster":"assets/tv-ballers-2015-poster.jpg","backdrop":"assets/tv-ballers-2015-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":62704,"ratings":{"rtCritics":72,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.809,"tmdbVotes":820}},{"id":"tv-baretta-1975","title":"Baretta","year":1975,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Crime","Drama","Mystery"],"overview":"Baretta is an American detective television series which ran on ABC from 1975 to 1978. The show was a milder version of a successful 1973\u201374 ABC series, Toma, starring Tony Musante as chameleon-like, real-life New\u2026","poster":"assets/tv-baretta-1975-poster.jpg","backdrop":"assets/tv-baretta-1975-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":3216,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.5,"tmdbVotes":43}},{"id":"tv-based-on-a-true-story-2023","title":"Based on a True Story","year":2023,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy","Drama"],"overview":"The lives of a realtor, a plumber and a former tennis star unexpectedly collide, exposing America\u2019s obsession with true crime, murder and the slow-close toilet seat.","poster":"assets/tv-based-on-a-true-story-2023-poster.jpg","backdrop":"assets/tv-based-on-a-true-story-2023-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":197373,"ratings":{"rtCritics":79,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7,"tmdbVotes":160}},{"id":"tv-the-beast-in-me-2025","title":"The Beast in Me","year":2025,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Mystery"],"overview":"A famous author is pulled into a twisted mind game with her rich, powerful new neighbor \u2014 who might be a murderer.","poster":"assets/tv-the-beast-in-me-2025-poster.jpg","backdrop":"assets/tv-the-beast-in-me-2025-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":250504,"ratings":{"rtCritics":85,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.072,"tmdbVotes":334}},{"id":"tv-big-city-greens-2018","title":"Big City Greens","year":2018,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Family","Animation","Comedy"],"overview":"The offbeat adventures of 10-year-old Cricket Green, a mischievous and optimistic country boy who moves to the big city with his wildly out of place family \u2013 older sister Tilly, father Bill and Gramma Alice.","poster":"assets/tv-big-city-greens-2018-poster.jpg","backdrop":"assets/tv-big-city-greens-2018-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":80587,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.555,"tmdbVotes":82}},{"id":"tv-the-bold-type-2017","title":"The Bold Type","year":2017,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Comedy"],"overview":"A glimpse into the outrageous lives of Jane, Kat and Sutton, who are working at the nation's top women's magazine, Scarlet, while navigating their careers, identities and individual voices.","poster":"assets/tv-the-bold-type-2017-poster.jpg","backdrop":"assets/tv-the-bold-type-2017-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":69630,"ratings":{"rtCritics":89,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.7,"tmdbVotes":204}},{"id":"tv-brassic-2019","title":"Brassic","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy","Drama","Crime"],"overview":"A group of working-class friends finding unconventional ways to win at life in suburban northern England. These lads have dealt, scammed, bribed and conned their way through adolescence, but now, their dealing and\u2026","poster":"assets/tv-brassic-2019-poster.jpg","backdrop":"assets/tv-brassic-2019-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":92621,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.336,"tmdbVotes":263}},{"id":"movie-12-years-a-slave-2013","title":"12 Years a Slave","year":2013,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","History"],"overview":"In the pre-Civil War United States, Solomon Northup, a free black man from upstate New York, is abducted and sold into slavery. Facing cruelty as well as unexpected kindnesses Solomon struggles not only to stay\u2026","poster":"assets/movie-12-years-a-slave-2013-poster.jpg","backdrop":"assets/movie-12-years-a-slave-2013-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":76203,"ratings":{"rtCritics":95,"rtCriticsLabel":"Certified Fresh","rtAudience":90,"rtAudienceLabel":"Upright","imdb":8.1,"imdbVotes":796313,"tmdb":7.927,"tmdbVotes":12335}},{"id":"movie-101-dalmatians-ii-patch-s-london-adventure-2003","title":"101 Dalmatians II: Patch's London Adventure","year":2003,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Family","Animation","Adventure"],"overview":"Being one of 101 takes its toll on Patch, who doesn't feel unique. When he's accidentally left behind on moving day, he meets his idol, Thunderbolt, who enlists him on a publicity campaign.","poster":"assets/movie-101-dalmatians-ii-patch-s-london-adventure-2003-poster.jpg","backdrop":"assets/movie-101-dalmatians-ii-patch-s-london-adventure-2003-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":13654,"ratings":{"rtCritics":67,"rtCriticsLabel":"Fresh","rtAudience":41,"rtAudienceLabel":"Spilled","imdb":5.7,"imdbVotes":12343,"tmdb":6.025,"tmdbVotes":1274}},{"id":"movie-absolution-2024","title":"Absolution","year":2024,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Crime","Thriller"],"overview":"An aging ex-boxer gangster working as muscle for a Boston crime boss receives an upsetting diagnosis. Despite a faltering memory, he attempts to rectify the sins of his past and reconnect with his estranged\u2026","poster":"assets/movie-absolution-2024-poster.jpg","backdrop":"assets/movie-absolution-2024-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":974453,"ratings":{"rtCritics":56,"rtCriticsLabel":"Rotten","rtAudience":32,"rtAudienceLabel":"Spilled","imdb":5.2,"imdbVotes":11413,"tmdb":5.906,"tmdbVotes":564}},{"id":"movie-all-the-money-in-the-world-2017","title":"All the Money in the World","year":2017,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Crime","History","Thriller"],"overview":"The story of the kidnapping of 16-year-old John Paul Getty III and the desperate attempt by his devoted mother to convince his billionaire grandfather Jean Paul Getty to pay the ransom.","poster":"assets/movie-all-the-money-in-the-world-2017-poster.jpg","backdrop":"assets/movie-all-the-money-in-the-world-2017-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":446791,"ratings":{"rtCritics":79,"rtCriticsLabel":"Certified Fresh","rtAudience":66,"rtAudienceLabel":"Upright","imdb":6.8,"imdbVotes":97448,"tmdb":6.493,"tmdbVotes":2493}},{"id":"movie-the-alto-knights-2025","title":"The Alto Knights","year":2025,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Crime","Drama"],"overview":"Two of New York's most notorious organized crime bosses, Frank Costello and Vito Genovese, vie for control of the city's streets. Once the best of friends, petty jealousies and a series of betrayals place them on a\u2026","poster":"assets/movie-the-alto-knights-2025-poster.jpg","backdrop":"assets/movie-the-alto-knights-2025-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1013601,"ratings":{"rtCritics":39,"rtCriticsLabel":"Rotten","rtAudience":71,"rtAudienceLabel":"Upright","imdb":5.9,"imdbVotes":23017,"tmdb":6.223,"tmdbVotes":485}},{"id":"movie-arctic-dogs-2019","title":"Arctic Dogs","year":2019,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Adventure","Comedy"],"overview":"Animals band together to save the day when the evil Otto Von Walrus hatches a sinister scheme to accelerate global warming and melt the Arctic Circle.","poster":"assets/movie-arctic-dogs-2019-poster.jpg","backdrop":"assets/movie-arctic-dogs-2019-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":416153,"ratings":{"rtCritics":13,"rtCriticsLabel":"Rotten","rtAudience":60,"rtAudienceLabel":"Upright","imdb":4.9,"imdbVotes":2580,"tmdb":5.9,"tmdbVotes":148}},{"id":"movie-a-bronx-tale-1993","title":"A Bronx Tale","year":1993,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Crime"],"overview":"Set in the Bronx during the tumultuous 1960s, an adolescent boy is torn between his honest, working-class father and a violent yet charismatic crime boss. Complicating matters is the youngster's growing attraction -\u2026","poster":"assets/movie-a-bronx-tale-1993-poster.jpg","backdrop":"assets/movie-a-bronx-tale-1993-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1607,"ratings":{"rtCritics":97,"rtCriticsLabel":"Fresh","rtAudience":92,"rtAudienceLabel":"Upright","imdb":7.8,"imdbVotes":191930,"tmdb":7.913,"tmdbVotes":3075}},{"id":"movie-by-any-means-2026","title":"By Any Means","year":2026,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Thriller","History","Crime"],"overview":"In 1960s Mississippi, a young Black FBI agent is forced to partner with a notorious mafia hitman to investigate a wave of brutal civil rights murders.","poster":"assets/movie-by-any-means-2026-poster.jpg","backdrop":"assets/movie-by-any-means-2026-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1380417,"ratings":{"rtCritics":49,"rtCriticsLabel":"Rotten","rtAudience":88,"rtAudienceLabel":"Upright","imdb":6.8,"imdbVotes":3534,"tmdb":6.6,"tmdbVotes":92}},{"id":"movie-captain-america-civil-war-2016","title":"Captain America: Civil War","year":2016,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Adventure","Action","Science Fiction"],"overview":"Following the events of Age of Ultron, the collective governments of the world pass an act designed to regulate all superhuman activity. This polarizes opinion amongst the Avengers, causing two factions to side with\u2026","poster":"assets/movie-captain-america-civil-war-2016-poster.jpg","backdrop":"assets/movie-captain-america-civil-war-2016-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":271110,"ratings":{"rtCritics":90,"rtCriticsLabel":"Certified Fresh","rtAudience":89,"rtAudienceLabel":"Upright","imdb":7.8,"imdbVotes":954943,"tmdb":7.455,"tmdbVotes":24808}},{"id":"movie-captain-america-the-first-avenger-2011","title":"Captain America: The First Avenger","year":2011,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"During World War II, Steve Rogers is a sickly man from Brooklyn who's transformed into super-soldier Captain America to aid in the war effort. Rogers must stop the Red Skull \u2013 Adolf Hitler's ruthless head of\u2026","poster":"assets/movie-captain-america-the-first-avenger-2011-poster.jpg","backdrop":"assets/movie-captain-america-the-first-avenger-2011-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1771,"ratings":{"rtCritics":80,"rtCriticsLabel":"Certified Fresh","rtAudience":75,"rtAudienceLabel":"Upright","imdb":6.9,"imdbVotes":993453,"tmdb":7.012,"tmdbVotes":23473}},{"id":"movie-captain-america-the-winter-soldier-2014","title":"Captain America: The Winter Soldier","year":2014,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"After the cataclysmic events in New York with The Avengers, Steve Rogers, aka Captain America is living quietly in Washington, D.C. and trying to adjust to the modern world. But when a S.H.I.E.L.D. colleague comes\u2026","poster":"assets/movie-captain-america-the-winter-soldier-2014-poster.jpg","backdrop":"assets/movie-captain-america-the-winter-soldier-2014-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":100402,"ratings":{"rtCritics":90,"rtCriticsLabel":"Certified Fresh","rtAudience":92,"rtAudienceLabel":"Upright","imdb":7.7,"imdbVotes":995514,"tmdb":7.654,"tmdbVotes":20688}},{"id":"tv-12-monkeys-2015","title":"12 Monkeys","year":2015,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Sci-Fi & Fantasy","Drama"],"overview":"The provocative story of Cole, a time traveler from a decimated future in a high-stakes race against the clock. Utilizing a dangerous and untested method of time travel, he journeys from 2043 to the present day on a\u2026","poster":"assets/tv-12-monkeys-2015-poster.jpg","backdrop":"assets/tv-12-monkeys-2015-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":60948,"ratings":{"rtCritics":88,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.441,"tmdbVotes":1245}},{"id":"tv-accused-2023","title":"Accused","year":2023,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Crime","Drama"],"overview":"In this crime anthology series, viewers discover how an ordinary person got caught up in an extraordinary situation, ultimately revealing how one wrong turn leads to another, until it\u2019s too late to turn back. Told\u2026","poster":"assets/tv-accused-2023-poster.jpg","backdrop":"assets/tv-accused-2023-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":202097,"ratings":{"rtCritics":61,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.851,"tmdbVotes":87}},{"id":"tv-after-life-2019","title":"After Life","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy","Drama"],"overview":"Tony had a perfect life. But after his wife Lisa suddenly dies, Tony changes. After contemplating taking his own life, he decides instead to live long enough to punish the world by saying and doing whatever he likes\u2026","poster":"assets/tv-after-life-2019-poster.jpg","backdrop":"assets/tv-after-life-2019-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":79410,"ratings":{"rtCritics":72,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.902,"tmdbVotes":1169}},{"id":"tv-all-creatures-great-and-small-1978","title":"All Creatures Great and Small","year":1978,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy","Drama"],"overview":"The trials and misadventures of the staff at a country veterinary office in Yorkshire. James Herriot, a young animal surgeon, moves to a small Yorkshire town to begin his first job.","poster":"assets/tv-all-creatures-great-and-small-1978-poster.jpg","backdrop":"assets/tv-all-creatures-great-and-small-1978-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"center_crop","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":7406,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.8,"tmdbVotes":50}},{"id":"tv-american-housewife-2016","title":"American Housewife","year":2016,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy"],"overview":"A family comedy narrated by Katie, a strong-willed mother, raising her flawed family in a wealthy town filled with perfect wives and their perfect offspring.","poster":"assets/tv-american-housewife-2016-poster.jpg","backdrop":"assets/tv-american-housewife-2016-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":67117,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.7,"tmdbVotes":187}},{"id":"tv-atlantis-2013","title":"Atlantis","year":2013,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action & Adventure","Sci-Fi & Fantasy"],"overview":"A fantasy drama set in a world of legendary heroes and mythical creatures. Far from home and desperate for answers, Jason washes up on the shores of an ancient land. A mysterious place; a world of bull leaping, of\u2026","poster":"assets/tv-atlantis-2013-poster.jpg","backdrop":"assets/tv-atlantis-2013-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":47054,"ratings":{"rtCritics":62,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.4,"tmdbVotes":155}},{"id":"tv-beat-bobby-flay-2013","title":"Beat Bobby Flay","year":2013,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Reality"],"overview":"Two talented chefs go head-to-head for the chance to Beat Bobby Flay. To get to Bobby the chefs must first face off against each other, creating a spectacular dish with a secret ingredient of Bobby's choice. Judges\u2026","poster":"assets/tv-beat-bobby-flay-2013-poster.jpg","backdrop":"assets/tv-beat-bobby-flay-2013-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":62481,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.5,"tmdbVotes":31}},{"id":"tv-benson-1979","title":"Benson","year":1979,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy"],"overview":"A butler deals with life at the governor's mansion.","poster":"assets/tv-benson-1979-poster.jpg","backdrop":"assets/tv-benson-1979-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1849,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7,"tmdbVotes":46}},{"id":"tv-black-rabbit-2025","title":"Black Rabbit","year":2025,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Crime"],"overview":"A rising-star restaurateur is forced into New York's criminal underworld when his chaotic brother returns to town with loan sharks on his trail.","poster":"assets/tv-black-rabbit-2025-poster.jpg","backdrop":"assets/tv-black-rabbit-2025-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":249039,"ratings":{"rtCritics":66,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.983,"tmdbVotes":240}},{"id":"tv-black-ish-2014","title":"black-ish","year":2014,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy"],"overview":"A family man struggles to gain a sense of cultural identity while raising his kids in a predominantly white, upper-middle-class neighborhood.","poster":"assets/tv-black-ish-2014-poster.jpg","backdrop":"assets/tv-black-ish-2014-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":61381,"ratings":{"rtCritics":92,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.1,"tmdbVotes":352}},{"id":"tv-breaking-bad-2008","title":"Breaking Bad","year":2008,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Crime"],"overview":"Walter White, a New Mexico chemistry teacher, is diagnosed with Stage III cancer and given a prognosis of only two years left to live. He becomes filled with a sense of fearlessness and an unrelenting desire to\u2026","poster":"assets/tv-breaking-bad-2008-poster.jpg","backdrop":"assets/tv-breaking-bad-2008-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"center_crop","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1396,"ratings":{"rtCritics":96,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.959,"tmdbVotes":19030}},{"id":"tv-the-capture-2019","title":"The Capture","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Crime","Drama","Mystery"],"overview":"When soldier Shaun Emery's conviction for a murder in Afghanistan is overturned due to flawed video evidence, he returns to life as a free man with his young daughter. But when damning CCTV footage from a night out\u2026","poster":"assets/tv-the-capture-2019-poster.jpg","backdrop":"assets/tv-the-capture-2019-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":93166,"ratings":{"rtCritics":97,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.634,"tmdbVotes":361}}],"duplicate":[{"id":"movie-the-amazing-spider-man-2012","title":"The Amazing Spider-Man","year":2012,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"A teenage Peter Parker grapples with both high school and amazing super-human crises as his alter-ego Spider-Man.","poster":"assets/movie-the-amazing-spider-man-2012-poster.jpg","backdrop":"assets/movie-the-amazing-spider-man-2012-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1930,"ratings":{"rtCritics":71,"rtCriticsLabel":"Certified Fresh","rtAudience":77,"rtAudienceLabel":"Upright","imdb":7,"imdbVotes":791938,"tmdb":6.768,"tmdbVotes":19312}},{"id":"movie-the-amazing-spider-man-2-2014","title":"The Amazing Spider-Man 2","year":2014,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"For Peter Parker, life is busy. Between taking out the bad guys as Spider-Man and spending time with the person he loves, Gwen Stacy, high school graduation cannot come quickly enough. Peter has not forgotten about\u2026","poster":"assets/movie-the-amazing-spider-man-2-2014-poster.jpg","backdrop":"assets/movie-the-amazing-spider-man-2-2014-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":102382,"ratings":{"rtCritics":51,"rtCriticsLabel":"Rotten","rtAudience":64,"rtAudienceLabel":"Upright","imdb":6.6,"imdbVotes":611981,"tmdb":6.567,"tmdbVotes":14919}},{"id":"movie-the-animatrix-2003","title":"The Animatrix","year":2003,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Science Fiction"],"overview":"Straight from the creators of the groundbreaking Matrix trilogy, this collection of short animated films from the world's leading anime directors fuses computer graphics and Japanese anime to provide the background\u2026","poster":"assets/movie-the-animatrix-2003-poster.jpg","backdrop":"assets/movie-the-animatrix-2003-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":55931,"ratings":{"rtCritics":86,"rtCriticsLabel":"Fresh","rtAudience":78,"rtAudienceLabel":"Upright","imdb":7.3,"imdbVotes":91929,"tmdb":7.221,"tmdbVotes":1872}},{"id":"movie-ant-man-and-the-wasp-quantumania-2023","title":"Ant-Man and the Wasp: Quantumania","year":2023,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"Super-Hero partners Scott Lang and Hope van Dyne, along with with Hope's parents Janet van Dyne and Hank Pym, and Scott's daughter Cassie Lang, find themselves exploring the Quantum Realm, interacting with strange\u2026","poster":"assets/movie-ant-man-and-the-wasp-quantumania-2023-poster.jpg","backdrop":"assets/movie-ant-man-and-the-wasp-quantumania-2023-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":640146,"ratings":{"rtCritics":46,"rtCriticsLabel":"Rotten","rtAudience":81,"rtAudienceLabel":"Upright","imdb":6,"imdbVotes":281738,"tmdb":6.197,"tmdbVotes":6236}},{"id":"movie-appleseed-alpha-2014","title":"Appleseed Alpha","year":2014,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Science Fiction","Action"],"overview":"The Third World War (2099\u20132126) devastated part of the Earth. Former nations are reforming while new powers emerge, and groups of humans struggle to survive in ruined cities. The story follows Deunan Knute and her\u2026","poster":"assets/movie-appleseed-alpha-2014-poster.jpg","backdrop":"assets/movie-appleseed-alpha-2014-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"center_crop","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":269650,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":59,"rtAudienceLabel":"Spilled","imdb":6.5,"imdbVotes":9689,"tmdb":6.808,"tmdbVotes":436}},{"id":"movie-aquaman-and-the-lost-kingdom-2023","title":"Aquaman and the Lost Kingdom","year":2023,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Fantasy"],"overview":"Black Manta seeks revenge on Aquaman for his father's death. Wielding the Black Trident's power, he becomes a formidable foe. To defend Atlantis, Arthur (Aquaman) forges an alliance with his imprisoned brother. They\u2026","poster":"assets/movie-aquaman-and-the-lost-kingdom-2023-poster.jpg","backdrop":"assets/movie-aquaman-and-the-lost-kingdom-2023-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":572802,"ratings":{"rtCritics":33,"rtCriticsLabel":"Rotten","rtAudience":79,"rtAudienceLabel":"Upright","imdb":5.6,"imdbVotes":122422,"tmdb":6.48,"tmdbVotes":3748}},{"id":"movie-the-avengers-2012","title":"The Avengers","year":2012,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Science Fiction","Action","Adventure"],"overview":"When an unexpected enemy emerges and threatens global safety and security, Nick Fury, director of the international peacekeeping agency known as S.H.I.E.L.D., finds himself in need of a team to pull the world back\u2026","poster":"assets/movie-the-avengers-2012-poster.jpg","backdrop":"assets/movie-the-avengers-2012-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":24428,"ratings":{"rtCritics":91,"rtCriticsLabel":"Certified Fresh","rtAudience":91,"rtAudienceLabel":"Upright","imdb":8,"imdbVotes":1590693,"tmdb":8.084,"tmdbVotes":40262}},{"id":"movie-avengers-confidential-black-widow-punisher-2014","title":"Avengers Confidential: Black Widow & Punisher","year":2014,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Science Fiction","Action"],"overview":"When the Punisher takes out a black-market weapons dealer, he stumbles upon a far-reaching terrorist plot devised by a group known as Leviathan.","poster":"assets/movie-avengers-confidential-black-widow-punisher-2014-poster.jpg","backdrop":"assets/movie-avengers-confidential-black-widow-punisher-2014-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":257346,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":55,"rtAudienceLabel":"Spilled","imdb":5.7,"imdbVotes":5166,"tmdb":6.368,"tmdbVotes":303}},{"id":"movie-avengers-age-of-ultron-2015","title":"Avengers: Age of Ultron","year":2015,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"When Tony Stark tries to jumpstart a dormant peacekeeping program, things go awry and Earth\u2019s Mightiest Heroes are put to the ultimate test as the fate of the planet hangs in the balance. As the villainous Ultron\u2026","poster":"assets/movie-avengers-age-of-ultron-2015-poster.jpg","backdrop":"assets/movie-avengers-age-of-ultron-2015-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":99861,"ratings":{"rtCritics":75,"rtCriticsLabel":"Certified Fresh","rtAudience":82,"rtAudienceLabel":"Upright","imdb":7.3,"imdbVotes":1025876,"tmdb":7.278,"tmdbVotes":25289}},{"id":"movie-avengers-infinity-war-2018","title":"Avengers: Infinity War","year":2018,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Adventure","Action","Science Fiction"],"overview":"As the Avengers and their allies have continued to protect the world from threats too large for any one hero to handle, a new danger has emerged from the cosmic shadows: Thanos. A despot of intergalactic infamy, his\u2026","poster":"assets/movie-avengers-infinity-war-2018-poster.jpg","backdrop":"assets/movie-avengers-infinity-war-2018-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":299536,"ratings":{"rtCritics":85,"rtCriticsLabel":"Certified Fresh","rtAudience":92,"rtAudienceLabel":"Upright","imdb":8.4,"imdbVotes":1421954,"tmdb":8.246,"tmdbVotes":33331}},{"id":"movie-batman-ninja-2018","title":"Batman Ninja","year":2018,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Action","Science Fiction"],"overview":"Batman, along with many of his allies and adversaries, finds himself transported to feudal Japan by Gorilla Grodd's time displacement machine.","poster":"assets/movie-batman-ninja-2018-poster.jpg","backdrop":"assets/movie-batman-ninja-2018-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":485942,"ratings":{"rtCritics":84,"rtCriticsLabel":"Fresh","rtAudience":60,"rtAudienceLabel":"Upright","imdb":5.6,"imdbVotes":24579,"tmdb":5.9,"tmdbVotes":1106}},{"id":"movie-batman-v-superman-dawn-of-justice-2016","title":"Batman v Superman: Dawn of Justice","year":2016,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Fantasy"],"overview":"Fearing the actions of a god-like Super Hero left unchecked, Gotham City\u2019s own formidable, forceful vigilante takes on Metropolis\u2019s most revered, modern-day savior, while the world wrestles with what sort of hero it\u2026","poster":"assets/movie-batman-v-superman-dawn-of-justice-2016-poster.jpg","backdrop":"assets/movie-batman-v-superman-dawn-of-justice-2016-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":209112,"ratings":{"rtCritics":28,"rtCriticsLabel":"Rotten","rtAudience":63,"rtAudienceLabel":"Upright","imdb":6.4,"imdbVotes":801382,"tmdb":6.014,"tmdbVotes":19527}},{"id":"movie-batman-the-dark-knight-returns-part-1-2012","title":"Batman: The Dark Knight Returns, Part 1","year":2012,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Science Fiction","Action","Animation"],"overview":"Batman has not been seen for ten years. A new breed of criminal ravages Gotham City, forcing 55-year-old Bruce Wayne back into the cape and cowl. But, does he still have what it takes to fight crime in a new era?","poster":"assets/movie-batman-the-dark-knight-returns-part-1-2012-poster.jpg","backdrop":"assets/movie-batman-the-dark-knight-returns-part-1-2012-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":123025,"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":93,"rtAudienceLabel":"Upright","imdb":7.9,"imdbVotes":68943,"tmdb":7.742,"tmdbVotes":1766}},{"id":"movie-black-adam-2022","title":"Black Adam","year":2022,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"Nearly 5,000 years after he was bestowed with the almighty powers of the Egyptian gods\u2014and imprisoned just as quickly\u2014Black Adam is freed from his earthly tomb, ready to unleash his unique form of justice on the\u2026","poster":"assets/movie-black-adam-2022-poster.jpg","backdrop":"assets/movie-black-adam-2022-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":436270,"ratings":{"rtCritics":39,"rtCriticsLabel":"Rotten","rtAudience":87,"rtAudienceLabel":"Upright","imdb":6.1,"imdbVotes":309624,"tmdb":6.747,"tmdbVotes":7370}},{"id":"movie-black-panther-2018","title":"Black Panther","year":2018,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"King T'Challa returns home to the reclusive, technologically advanced African nation of Wakanda to serve as his country's new leader. However, T'Challa soon finds that he is challenged for the throne by factions\u2026","poster":"assets/movie-black-panther-2018-poster.jpg","backdrop":"assets/movie-black-panther-2018-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":284054,"ratings":{"rtCritics":96,"rtCriticsLabel":"Certified Fresh","rtAudience":null,"rtAudienceLabel":"Spilled","imdb":7.3,"imdbVotes":931120,"tmdb":7.36,"tmdbVotes":24190}},{"id":"movie-black-panther-wakanda-forever-2022","title":"Black Panther: Wakanda Forever","year":2022,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"Queen Ramonda, Shuri, M\u2019Baku, Okoye and the Dora Milaje fight to protect their nation from intervening world powers in the wake of King T\u2019Challa\u2019s death. As the Wakandans strive to embrace their next chapter, the\u2026","poster":"assets/movie-black-panther-wakanda-forever-2022-poster.jpg","backdrop":"assets/movie-black-panther-wakanda-forever-2022-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":505642,"ratings":{"rtCritics":84,"rtCriticsLabel":"Certified Fresh","rtAudience":93,"rtAudienceLabel":"Upright","imdb":6.6,"imdbVotes":405088,"tmdb":6.984,"tmdbVotes":7920}},{"id":"movie-blame-2017","title":"BLAME!","year":2017,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Drama","Science Fiction"],"overview":"In the distant technological future, civilization has reached its ultimate Net-based form. An \"infection\" in the past caused the automated systems to spiral out of order, resulting in a multi-leveled city structure\u2026","poster":"assets/movie-blame-2017-poster.jpg","backdrop":"assets/movie-blame-2017-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":409421,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":61,"rtAudienceLabel":"Upright","imdb":6.6,"imdbVotes":10258,"tmdb":6.523,"tmdbVotes":370}},{"id":"movie-bleach-the-movie-fade-to-black-2008","title":"Bleach the Movie: Fade to Black","year":2008,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Animation","Fantasy"],"overview":"A strange disturbance in the World of the Living sends Ichigo and Kon to the Soul Society, where the Seireitei has been devastated by an explosion of reishi and Rukia is missing. Stranger still, none of the Soul\u2026","poster":"assets/movie-bleach-the-movie-fade-to-black-2008-poster.jpg","backdrop":"assets/movie-bleach-the-movie-fade-to-black-2008-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":17165,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":67,"rtAudienceLabel":"Upright","imdb":7.1,"imdbVotes":3693,"tmdb":7.1,"tmdbVotes":206}},{"id":"tv-90-day-fiance-before-the-90-days-2017","title":"90 Day Fianc\u00e9: Before the 90 Days","year":2017,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Reality"],"overview":"Follow couples who have an existing relationship online, but haven't met in person. We'll experience their journey as they travel to the other's foreign country for the first time in an attempt to establish an\u2026","poster":"assets/tv-90-day-fiance-before-the-90-days-2017-poster.jpg","backdrop":"assets/tv-90-day-fiance-before-the-90-days-2017-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":73319,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.5,"tmdbVotes":45}},{"id":"tv-90-day-fiance-the-other-way-2019","title":"90 Day Fianc\u00e9: The Other Way","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Reality"],"overview":"Americans move to a foreign country all in the name of love. Will uprooting their lives in the United States pay off, or will they be packing up and moving back to America brokenhearted?","poster":"assets/tv-90-day-fiance-the-other-way-2019-poster.jpg","backdrop":"assets/tv-90-day-fiance-the-other-way-2019-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":90046,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.8,"tmdbVotes":34}},{"id":"tv-the-100-2014","title":"The 100","year":2014,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Sci-Fi & Fantasy","Action & Adventure","Drama"],"overview":"100 years in the future, when the Earth has been abandoned due to radioactivity, the last surviving humans live on an ark orbiting the planet \u2014 but the ark won't last forever. So the repressive regime picks 100\u2026","poster":"assets/tv-the-100-2014-poster.jpg","backdrop":"assets/tv-the-100-2014-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":48866,"ratings":{"rtCritics":93,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.878,"tmdbVotes":8855}},{"id":"tv-the-abbott-and-costello-show-1952","title":"The Abbott and Costello Show","year":1952,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy"],"overview":"Bud and Lou are unemployed actors living in Mr. Fields\u2019 boarding house. Lou\u2019s girlfriend Hillary lives across the hall. Many situations arise leading to slapstick and puns.","poster":"assets/tv-the-abbott-and-costello-show-1952-poster.jpg","backdrop":"assets/tv-the-abbott-and-costello-show-1952-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":11821,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.2,"tmdbVotes":22}},{"id":"tv-accel-world-2012","title":"Accel World","year":2012,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Action & Adventure","Drama"],"overview":"A short, overweight student uses the Neuro Link, an evolution of the Nerve Gear, to escape himself from the reality. But his skills in the virtual world brings him to the attention of Kuroyukihime, a popular student\u2026","poster":"assets/tv-accel-world-2012-poster.jpg","backdrop":"assets/tv-accel-world-2012-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"center_crop","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":43167,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.429,"tmdbVotes":163}},{"id":"tv-ahsoka-2023","title":"Ahsoka","year":2023,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Sci-Fi & Fantasy","Action & Adventure","Drama"],"overview":"Former Jedi Knight Ahsoka Tano investigates an emerging threat to a vulnerable galaxy.","poster":"assets/tv-ahsoka-2023-poster.jpg","backdrop":"assets/tv-ahsoka-2023-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":114461,"ratings":{"rtCritics":85,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.4,"tmdbVotes":1351}},{"id":"tv-american-dad-2005","title":"American Dad!","year":2005,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Comedy"],"overview":"The series focuses on an eccentric motley crew that is the Smith family and their three housemates: Father, husband, and breadwinner Stan Smith; his better half housewife, Francine Smith; their college-aged daughter,\u2026","poster":"assets/tv-american-dad-2005-poster.jpg","backdrop":"assets/tv-american-dad-2005-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1433,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.022,"tmdbVotes":2481}},{"id":"tv-american-gods-2017","title":"American Gods","year":2017,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Mystery","Sci-Fi & Fantasy"],"overview":"An ex-con becomes the traveling partner of a conman who turns out to be one of the older gods trying to recruit troops to battle the upstart deities. Based on Neil Gaiman's fantasy novel.","poster":"assets/tv-american-gods-2017-poster.jpg","backdrop":"assets/tv-american-gods-2017-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":46639,"ratings":{"rtCritics":75,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.1,"tmdbVotes":1852}},{"id":"tv-andromeda-2000","title":"Andromeda","year":2000,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action & Adventure","Drama","Sci-Fi & Fantasy"],"overview":"Captain Dylan Hunt and his crew quest to restore a government that once presided over an extended peace and prosperity.","poster":"assets/tv-andromeda-2000-poster.jpg","backdrop":"assets/tv-andromeda-2000-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":2985,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.068,"tmdbVotes":315}},{"id":"tv-aqua-teen-hunger-force-2000","title":"Aqua Teen Hunger Force","year":2000,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Comedy"],"overview":"The surreal adventures of three anthropomorphic fast food items: Master Shake, Frylock and Meatwad, and their human nextdoor neighbor, Carl Brutananadilewski.","poster":"assets/tv-aqua-teen-hunger-force-2000-poster.jpg","backdrop":"assets/tv-aqua-teen-hunger-force-2000-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":251,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.5,"tmdbVotes":272}},{"id":"tv-arifureta-from-commonplace-to-world-s-strongest-2019","title":"Arifureta: From Commonplace to World's Strongest","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"Hajime Nagumo and his high school class are suddenly summoned to a fantastical land as heroes. But while most of his classmates have powerful stats and abilities, Hajime does not. Underappreciated and unprepared, he\u2026","poster":"assets/tv-arifureta-from-commonplace-to-world-s-strongest-2019-poster.jpg","backdrop":"assets/tv-arifureta-from-commonplace-to-world-s-strongest-2019-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":86034,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.382,"tmdbVotes":662}},{"id":"tv-arrow-2012","title":"Arrow","year":2012,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Crime","Drama","Action & Adventure"],"overview":"Spoiled billionaire playboy Oliver Queen is missing and presumed dead when his yacht is lost at sea. He returns five years later a changed man, determined to clean up the city as a hooded vigilante armed with a bow.","poster":"assets/tv-arrow-2012-poster.jpg","backdrop":"assets/tv-arrow-2012-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1412,"ratings":{"rtCritics":86,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.834,"tmdbVotes":6537}},{"id":"tv-avatar-the-last-airbender-2024","title":"Avatar: The Last Airbender","year":2024,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action & Adventure","Sci-Fi & Fantasy","Drama"],"overview":"A young boy known as the Avatar must master the four elemental powers to save a world at war \u2014 and fight a ruthless enemy bent on stopping him.","poster":"assets/tv-avatar-the-last-airbender-2024-poster.jpg","backdrop":"assets/tv-avatar-the-last-airbender-2024-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":82452,"ratings":{"rtCritics":66,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.8,"tmdbVotes":1406}},{"id":"tv-avatar-seven-havens-2026","title":"Avatar: Seven Havens","year":2026,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"In a world shattered by a devastating cataclysm, a young Earthbender discovers she's the new Avatar after Korra. But in this dangerous era, that title marks her as humanity's destroyer, not its savior. Hunted by both\u2026","poster":"assets/tv-avatar-seven-havens-2026-poster.jpg","backdrop":"assets/tv-avatar-seven-havens-2026-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":284833,"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.9,"tmdbVotes":183}},{"id":"tv-batman-the-brave-and-the-bold-2008","title":"Batman: The Brave and the Bold","year":2008,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"The Caped Crusader is teamed up with Blue Beetle, Green Arrow, Aquaman and countless others in his quest to uphold justice.","poster":"assets/tv-batman-the-brave-and-the-bold-2008-poster.jpg","backdrop":"assets/tv-batman-the-brave-and-the-bold-2008-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":15804,"ratings":{"rtCritics":96,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.6,"tmdbVotes":295}},{"id":"tv-batwoman-2019","title":"Batwoman","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Sci-Fi & Fantasy","Action & Adventure"],"overview":"Kate Kane, armed with a passion for social justice and a flair for speaking her mind, soars onto the streets of Gotham as Batwoman, an out lesbian and highly trained street fighter primed to snuff out the failing\u2026","poster":"assets/tv-batwoman-2019-poster.jpg","backdrop":"assets/tv-batwoman-2019-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":89247,"ratings":{"rtCritics":83,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.8,"tmdbVotes":1619}},{"id":"tv-big-mouth-2017","title":"Big Mouth","year":2017,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy","Animation"],"overview":"Teenage friends find their lives upended by the wonders and horrors of puberty in this edgy comedy from real-life pals Nick Kroll and Andrew Goldberg.","poster":"assets/tv-big-mouth-2017-poster.jpg","backdrop":"assets/tv-big-mouth-2017-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":74204,"ratings":{"rtCritics":95,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.591,"tmdbVotes":1274}},{"id":"tv-black-lightning-2018","title":"Black Lightning","year":2018,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action & Adventure","Sci-Fi & Fantasy","Drama"],"overview":"Jefferson Pierce is a man wrestling with a secret. As the father of two daughters and principal of a charter high school that also serves as a safe haven for young people in a New Orleans neighborhood overrun by gang\u2026","poster":"assets/tv-black-lightning-2018-poster.jpg","backdrop":"assets/tv-black-lightning-2018-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":71663,"ratings":{"rtCritics":92,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.907,"tmdbVotes":991}}]}} diff --git a/wolfskin-design/src/test/resources/wolfskin-requests.json b/wolfskin-design/src/test/resources/wolfskin-requests.json new file mode 100644 index 00000000..507ebd65 --- /dev/null +++ b/wolfskin-design/src/test/resources/wolfskin-requests.json @@ -0,0 +1,217 @@ +{ + "source": "Locked Opus fixture/fixture.json; index.html EXAMPLES() pool order", + "items": [ + { + "id": "x1", + "mediaType": "tv", + "tmdbId": 11821, + "title": "The Abbott and Costello Show", + "requesterKey": "justin", + "requesterName": "Justin", + "actorKey": "justin", + "actorName": "Justin", + "stage": "Downloading", + "poster": "tv-the-abbott-and-costello-show-1952-poster.jpg", + "ratings": [ + { + "mark": "tmdb", + "score": 7.2, + "percentage": false + } + ] + }, + { + "id": "x2", + "mediaType": "movie", + "tmdbId": 13483, + "title": "Awake", + "requesterKey": "mindel", + "requesterName": "Mindel", + "actorKey": "justin", + "actorName": "Justin", + "stage": "AlmostReady", + "poster": "movie-awake-2007-poster.jpg", + "ratings": [ + { + "mark": "rotten", + "score": 23, + "percentage": true + }, + { + "mark": "spilled", + "score": 55, + "percentage": true + }, + { + "mark": "imdb", + "score": 6.5, + "percentage": false + } + ] + }, + { + "id": "x3", + "mediaType": "tv", + "tmdbId": 70785, + "title": "Anne with an E", + "requesterKey": "shared", + "requesterName": "Shared", + "actorKey": "mindel", + "actorName": "Mindel", + "stage": "Searching", + "poster": "tv-anne-with-an-e-2017-poster.jpg", + "ratings": [ + { + "mark": "fresh", + "score": 63, + "percentage": true + } + ] + }, + { + "id": "x4", + "mediaType": "movie", + "tmdbId": 99861, + "title": "Avengers: Age of Ultron", + "requesterKey": "justin", + "requesterName": "Justin", + "actorKey": "justin", + "actorName": "Justin", + "stage": "Waiting", + "poster": "movie-avengers-age-of-ultron-2015-poster.jpg", + "ratings": [ + { + "mark": "fresh", + "score": 75, + "percentage": true + }, + { + "mark": "upright", + "score": 82, + "percentage": true + }, + { + "mark": "imdb", + "score": 7.3, + "percentage": false + } + ] + }, + { + "id": "x5", + "mediaType": "movie", + "tmdbId": 1033148, + "title": "Die My Love", + "requesterKey": "mindel", + "requesterName": "Mindel", + "actorKey": "mindel", + "actorName": "Mindel", + "stage": "Failed", + "poster": "movie-die-my-love-2025-poster.jpg", + "ratings": [ + { + "mark": "fresh", + "score": 74, + "percentage": true + }, + { + "mark": "spilled", + "score": 45, + "percentage": true + }, + { + "mark": "imdb", + "score": 6, + "percentage": false + } + ] + }, + { + "id": "x6", + "mediaType": "movie", + "tmdbId": 416153, + "title": "Arctic Dogs", + "requesterKey": "shared", + "requesterName": "Shared", + "actorKey": "justin", + "actorName": "Justin", + "stage": "Ready", + "poster": "movie-arctic-dogs-2019-poster.jpg", + "ratings": [ + { + "mark": "rotten", + "score": 13, + "percentage": true + }, + { + "mark": "upright", + "score": 60, + "percentage": true + }, + { + "mark": "imdb", + "score": 4.9, + "percentage": false + } + ] + }, + { + "id": "x7", + "mediaType": "movie", + "tmdbId": 76203, + "title": "12 Years a Slave", + "requesterKey": "justin", + "requesterName": "Justin", + "actorKey": "shared", + "actorName": "Shared", + "stage": "Waiting", + "poster": "movie-12-years-a-slave-2013-poster.jpg", + "ratings": [ + { + "mark": "fresh", + "score": 95, + "percentage": true + }, + { + "mark": "upright", + "score": 90, + "percentage": true + }, + { + "mark": "imdb", + "score": 8.1, + "percentage": false + } + ] + }, + { + "id": "x8", + "mediaType": "movie", + "tmdbId": 14069, + "title": "The Girl Who Leapt Through Time", + "requesterKey": "mindel", + "requesterName": "Mindel", + "actorKey": "justin", + "actorName": "Justin", + "stage": "Waiting", + "poster": "movie-the-girl-who-leapt-through-time-2006-poster.jpg", + "ratings": [ + { + "mark": "fresh", + "score": 87, + "percentage": true + }, + { + "mark": "upright", + "score": 90, + "percentage": true + }, + { + "mark": "imdb", + "score": 7.6, + "percentage": false + } + ] + } + ] +} diff --git a/wolfskin-design/src/test/resources/wolfskin-requests/movie-12-years-a-slave-2013-poster.jpg b/wolfskin-design/src/test/resources/wolfskin-requests/movie-12-years-a-slave-2013-poster.jpg new file mode 100644 index 00000000..ff1f55e6 Binary files /dev/null and b/wolfskin-design/src/test/resources/wolfskin-requests/movie-12-years-a-slave-2013-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/wolfskin-requests/movie-arctic-dogs-2019-poster.jpg b/wolfskin-design/src/test/resources/wolfskin-requests/movie-arctic-dogs-2019-poster.jpg new file mode 100644 index 00000000..07552443 Binary files /dev/null and b/wolfskin-design/src/test/resources/wolfskin-requests/movie-arctic-dogs-2019-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/wolfskin-requests/movie-avengers-age-of-ultron-2015-poster.jpg b/wolfskin-design/src/test/resources/wolfskin-requests/movie-avengers-age-of-ultron-2015-poster.jpg new file mode 100644 index 00000000..300840ff Binary files /dev/null and b/wolfskin-design/src/test/resources/wolfskin-requests/movie-avengers-age-of-ultron-2015-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/wolfskin-requests/movie-awake-2007-poster.jpg b/wolfskin-design/src/test/resources/wolfskin-requests/movie-awake-2007-poster.jpg new file mode 100644 index 00000000..32595897 Binary files /dev/null and b/wolfskin-design/src/test/resources/wolfskin-requests/movie-awake-2007-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/wolfskin-requests/movie-die-my-love-2025-poster.jpg b/wolfskin-design/src/test/resources/wolfskin-requests/movie-die-my-love-2025-poster.jpg new file mode 100644 index 00000000..f6eca818 Binary files /dev/null and b/wolfskin-design/src/test/resources/wolfskin-requests/movie-die-my-love-2025-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/wolfskin-requests/movie-the-girl-who-leapt-through-time-2006-poster.jpg b/wolfskin-design/src/test/resources/wolfskin-requests/movie-the-girl-who-leapt-through-time-2006-poster.jpg new file mode 100644 index 00000000..ded6cd2a Binary files /dev/null and b/wolfskin-design/src/test/resources/wolfskin-requests/movie-the-girl-who-leapt-through-time-2006-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/wolfskin-requests/tv-anne-with-an-e-2017-poster.jpg b/wolfskin-design/src/test/resources/wolfskin-requests/tv-anne-with-an-e-2017-poster.jpg new file mode 100644 index 00000000..55d443b4 Binary files /dev/null and b/wolfskin-design/src/test/resources/wolfskin-requests/tv-anne-with-an-e-2017-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/wolfskin-requests/tv-the-abbott-and-costello-show-1952-poster.jpg b/wolfskin-design/src/test/resources/wolfskin-requests/tv-the-abbott-and-costello-show-1952-poster.jpg new file mode 100644 index 00000000..7d6768ea Binary files /dev/null and b/wolfskin-design/src/test/resources/wolfskin-requests/tv-the-abbott-and-costello-show-1952-poster.jpg differ diff --git a/wolfskin-design/src/test/resources/wolfskin-shell.json b/wolfskin-design/src/test/resources/wolfskin-shell.json new file mode 100644 index 00000000..d99323a9 --- /dev/null +++ b/wolfskin-design/src/test/resources/wolfskin-shell.json @@ -0,0 +1,78 @@ +{ + "source": "Locked Opus fixture/fixture.json; shell-only projection; debug/test only", + "profiles": [ + { + "key": "justin", + "name": "Justin", + "initial": "J", + "color": "#62c4ff", + "destinations": { + "Home": { + "kind": "home" + }, + "Movies": { + "kind": "library", + "collection": "movies" + }, + "Shows": { + "kind": "library", + "collection": "shows" + }, + "Anime": { + "kind": "library", + "collection": "anime" + }, + "Discover": { + "kind": "discover", + "collection": "discover" + } + } + }, + { + "key": "mindel", + "name": "Mindel", + "initial": "M", + "color": "#ff9d72", + "destinations": { + "Home": { + "kind": "home" + }, + "Movies": { + "kind": "library", + "collection": "movies" + }, + "Shows": { + "kind": "library", + "collection": "shows" + }, + "Discover": { + "kind": "discover", + "collection": "discover" + } + } + }, + { + "key": "shared", + "name": "Shared", + "initial": "S", + "color": "#9be38f", + "destinations": { + "Home": { + "kind": "home" + }, + "Movies": { + "kind": "library", + "collection": "movies" + }, + "Shows": { + "kind": "library", + "collection": "shows" + }, + "Discover": { + "kind": "discover", + "collection": "discover" + } + } + } + ] +} diff --git a/wolfskin-design/title-tokens.json b/wolfskin-design/title-tokens.json new file mode 100644 index 00000000..156054d0 --- /dev/null +++ b/wolfskin-design/title-tokens.json @@ -0,0 +1,132 @@ +{ + "title": { + "episodeCardHeight": {"dp": 140, "px": 280, "source": "feedback-2026-11-06: episode art height +24.4%"}, + "seriesHeaderGap": {"dp": 20, "px": 40, "source": "feedback-2026-11-06: measured series header to seasons"}, + "lineGap": {"dp": 11, "px": 22, "source": "index.html:214"}, + "lineLift": {"dp": -2, "px": -4, "source": "index.html:214"}, + "overviewGap": {"dp": 17, "px": 34, "source": "index.html:216"}, + "factsGap": {"dp": 11, "px": 22, "source": "index.html:104"}, + "factsBottom": {"dp": 7, "px": 14, "source": "index.html:104"}, + "factsHeight": {"dp": 19, "px": 38, "source": "index.html:104 .h-meta minimum height"}, + "titleBottom": {"dp": 8, "px": 16, "source": "index.html:103"}, + "iconGap": {"dp": 7, "px": 14, "source": "index.html:108"}, + "iconSize": {"dp": 16, "px": 32, "source": "index.html:275"}, + "captionTop": {"dp": 8, "px": 16, "source": "index.html:281"}, + "progressWidth": {"dp": 160, "px": 320, "source": "index.html:218"}, + "progressBottom": {"dp": 15, "px": 30, "source": "index.html:217"}, + "progressGap": {"dp": 9, "px": 18, "source": "index.html:217"}, + "progressLift": {"dp": -7, "px": -14, "source": "index.html:217"}, + "castHeight": {"dp": 231, "px": 462, "source": "index.html:1018"}, + "castNameTop": {"dp": 118, "px": 236, "source": "index.html:289"}, + "castNameSide": {"dp": 6, "px": 12, "source": "index.html:289"}, + "personTop": {"dp": 85, "px": 170, "source": "index.html:293"}, + "personGap": {"dp": 24, "px": 48, "source": "index.html:293"}, + "personNameBottom": {"dp": 5, "px": 10, "source": "index.html:296"}, + "personNoteBottom": {"dp": 3, "px": 6, "source": "index.html:297"}, + "captionWidth": {"dp": 320, "px": 640, "source": "index.html:1242"}, + "sheetPaddingTop": {"dp": 40, "px": 80, "source": "index.html:227"}, + "sheetPaddingSide": {"dp": 32, "px": 64, "source": "index.html:227"}, + "sheetGap": {"dp": 9, "px": 18, "source": "index.html:227"}, + "sheetSubLift": {"dp": -4, "px": -8, "source": "index.html:229"}, + "sheetSubBottom": {"dp": 7, "px": 14, "source": "index.html:229"}, + "sheetLabelTop": {"dp": 4, "px": 8, "source": "index.html:230"}, + "sheetButtonTop": {"dp": 6, "px": 12, "source": "index.html:241 .sheet .btn margin-top"}, + "sheetRowSide": {"dp": 13, "px": 26, "source": "index.html:231"}, + "versionRowVertical": {"dp": 10, "px": 20, "source": "index.html:1936"}, + "trackHeight": {"dp": 42, "px": 84, "source": "index.html:1941"}, + "trackGap": {"dp": 6, "px": 12, "source": "index.html:1941"}, + "radioSize": {"dp": 20, "px": 40, "source": "index.html:234"}, + "radioDot": {"dp": 8, "px": 16, "source": "index.html:236"}, + "radioBorder": {"dp": 1.5, "px": 3, "source": "index.html:234"}, + "infoGap": {"dp": 7, "px": 14, "source": "index.html:1948"}, + "infoLabelWidth": {"dp": 100, "px": 200, "source": "index.html:318"}, + "infoRowGap": {"dp": 3, "px": 6, "source": "index.html:318"}, + "infoColumnGap": {"dp": 10, "px": 20, "source": "index.html:318"}, + "infoKey": {"$type": "typography", "sp": 12, "px": 24, "weight": 650, "$value": {"fontWeight": 650}, "source": "index.html:319"}, + "gridHeight": {"dp": 262, "px": 524, "source": "index.html:1019"}, + "minimumEndPadding": {"dp": 40, "px": 80, "source": "index.html:1042"}, + "ratingGap": {"dp": 10, "px": 20, "source": "index.html:156"}, + "ratingMarkGap": {"dp": 4, "px": 8, "source": "index.html:157"}, + "initialSize": {"$type": "typography", "sp": 32, "px": 64, "weight": 800, "$value": {"fontWeight": 800}, "source": "index.html:288"}, + "personInitialSize": {"$type": "typography", "sp": 48, "px": 96, "weight": 800, "$value": {"fontWeight": 800}, "source": "index.html:1317"}, + "fallbackHue": {"$value": 260, "source": "index.html:130"}, + "skeletonKickerWidth": {"dp": 210, "px": 420, "source": "index.html:1322"}, + "skeletonKickerHeight": {"dp": 17, "px": 34, "source": "index.html:1322"}, + "skeletonTitleGap": {"dp": 13, "px": 26, "source": "index.html:1322"}, + "skeletonFactsGap": {"dp": 12, "px": 24, "source": "index.html:1322"}, + "skeletonOverviewGap": {"dp": 15, "px": 30, "source": "index.html:1322"}, + "skeletonTitleWidth": {"dp": 380, "px": 760, "source": "index.html:1322"}, + "skeletonTitleHeight": {"dp": 46, "px": 92, "source": "index.html:1322"}, + "skeletonFactsWidth": {"dp": 280, "px": 560, "source": "index.html:1322"}, + "skeletonOverviewWidth": {"dp": 490, "px": 980, "source": "index.html:1322"}, + "skeletonOverviewHeight": {"dp": 55, "px": 110, "source": "index.html:1322"}, + "skeletonPlayWidth": {"dp": 110, "px": 220, "source": "index.html:1322"}, + "dLine": {"$type": "typography", "sp": 13, "px": 26, "weight": 400, "$value": {"fontWeight": 400}, "source": "index.html:214"}, + "iconCaption": {"$type": "typography", "sp": 13, "px": 26, "weight": 400, "$value": {"fontWeight": 400}, "source": "index.html:281"}, + "lineIcon": {"dp": 14, "px": 28, "source": "index.html:215"}, + "chipHeight": {"dp": 34, "px": 68, "source": "index.html:185"}, + "chipPadding": {"dp": 14, "px": 28, "source": "index.html:185"}, + "chipGap": {"dp": 8, "px": 16, "source": "index.html:184"}, + "chipTop": {"dp": 8, "px": 16, "source": "index.html:184"}, + "chipBorder": {"dp": 1, "px": 2, "source": "index.html:188"}, + "heroSheetRight": {"dp": 90, "px": 180, "source": "index.html:97"}, + "heroSheetTop": {"dp": 75, "px": 150, "source": "index.html:97"}, + "gridHeadingTop": {"dp": 12, "px": 24, "source": "index.html:1312"}, + "castName": {"$type": "typography", "sp": 13, "px": 26, "weight": 700, "$value": {"fontWeight": 700}, "source": "index.html:290"}, + "receiptSide": {"dp": 15, "px": 30, "source": "index.html:243"}, + "receiptTop": {"dp": 14, "px": 28, "source": "index.html:243"}, + "receiptGap": {"dp": 5, "px": 10, "source": "index.html:243"}, + "receiptIcon": {"dp": 18, "px": 36, "source": "index.html:245"}, + "receiptTitle": {"$type": "typography", "sp": 18, "px": 36, "weight": 700, "$value": {"fontWeight": 700}, "source": "index.html:244"}, + "toastMaxWidth": {"dp": 380, "px": 760, "source": "index.html:270"}, + "toastSide": {"dp": 14, "px": 28, "source": "index.html:270"}, + "toastTop": {"dp": 11, "px": 22, "source": "index.html:270"}, + "heroAmbientBackground": {"$type": "gradient", "$value": "radial-gradient(120% 90% at 70% 20%,var(--surface3),var(--bg))", "source": "index.html:94"}, + "dimScrollPx": {"$value": 1400, "source": "index.html:1541"}, + "dimMaximum": {"$value": 0.7, "source": "index.html:1541"}, + "errorTop": {"dp": 85, "px": 170, "source": "index.html:1457,194"}, + "errorSide": {"dp": 20, "px": 40, "source": "index.html:194"}, + "errorVertical": {"dp": 17, "px": 34, "source": "index.html:194"}, + "errorHeadingBottom": {"dp": 4, "px": 8, "source": "index.html:195"}, + "errorBodyBottom": {"dp": 12, "px": 24, "source": "index.html:198"}, + "errorTitle": {"$type": "typography", "sp": 18, "px": 36, "weight": 700, "$value": {"fontWeight": 700}, "source": "index.html:195"}, + "fallbackGlyph": {"dp": 22, "px": 44, "source": "index.html:131"}, + "fallbackInset": {"dp": 11, "px": 22, "source": "index.html:130-131"}, + "tileSheetInset": {"dp": 9, "px": 18, "source": "index.html:128"}, + "tileSheetLeft": {"dp": 11, "px": 22, "source": "index.html:128"}, + "tileSheetRadius": {"dp": 5, "px": 10, "source": "index.html:128"}, + "tileAmbientBlur": {"dp": 13, "px": 26, "source": "index.html:129"}, + "tileAmbientInset": {"dp": 15, "px": 30, "source": "index.html:129"}, + "tileShadowRadius": {"dp": 15, "px": 30, "source": "index.html:128"}, + "tileShadowY": {"dp": 5, "px": 10, "source": "index.html:128"}, + "tileShadowOpacity": {"$value": 0.5, "source": "index.html:128"}, + "tileAmbientBrightness": {"$value": 0.5, "source": "index.html:129"}, + "cardFallbackGradient": {"$type": "gradient", "$value": "linear-gradient(160deg,hsl(var(--h) 22% 22%),hsl(var(--h) 26% 10%))", "source": "index.html:130"}, + "personFallbackGradient": {"$type": "gradient", "$value": "linear-gradient(160deg,hsl(var(--h) 24% 30%),hsl(var(--h) 26% 14%))", "source": "index.html:288"}, + "initialsColor": {"$type": "color", "$value": "#ffffffd1", "source": "index.html:288"}, + "fallbackGlyphColor": {"$type": "color", "$value": "#ffffff73", "source": "index.html:131"}, + "fallbackTitle": {"$type": "typography", "sp": 14, "px": 28, "weight": 700, "lineHeight": 1.15, "$value": {"fontWeight": 700}, "source": "index.html:132"}, + "tileOverlayTop": {"dp": 27, "px": 54, "source": "index.html:135"}, + "tileOverlaySide": {"dp": 11, "px": 22, "source": "index.html:135"}, + "tileOverlayBottom": {"dp": 9, "px": 18, "source": "index.html:135"}, + "cardOverlayOpacity": {"$value": 0.92, "source": "index.html:135"}, + "cardProgressInset": {"dp": 9, "px": 18, "source": "index.html:138"}, + "tileProgressBottom": {"dp": 5, "px": 10, "source": "index.html:140"}, + "ambientInset": {"dp": 40, "px": 80, "source": "index.html:96"}, + "ambientScale": {"$value": 1.1, "source": "index.html:96"}, + "posterShadowRadius": {"dp": 40, "px": 80, "source": "index.html:97"}, + "posterShadowY": {"dp": 15, "px": 30, "source": "index.html:97"}, + "posterShadowOpacity": {"$value": 0.6, "source": "index.html:97"}, + "sheetShadowRadius": {"dp": 40, "px": 80, "source": "index.html:227"}, + "sheetShadowX": {"dp": -15, "px": -30, "source": "index.html:227"}, + "sheetShadowOpacity": {"$value": 0.5, "source": "index.html:227"} + }, + "savedIndicator": { + "height": {"dp": 26, "px": 52, "source": "index.html:87"}, + "side": {"dp": 10, "px": 20, "source": "index.html:87"}, + "gap": {"dp": 5, "px": 10, "source": "index.html:87"}, + "icon": {"dp": 14, "px": 28, "source": "index.html:88"}, + "border": {"dp": 1, "px": 2, "source": "index.html:87"}, + "borderOpacity": {"$value": 0.5, "source": "index.html:87"} + } +} diff --git a/wolfskin-design/tokens.css b/wolfskin-design/tokens.css new file mode 100644 index 00000000..423a5e4c --- /dev/null +++ b/wolfskin-design/tokens.css @@ -0,0 +1,496 @@ +/* Generated from the locked TV token sources. */ +:root { + --color-bg: #07070a; + --color-surface: #13131a; + --color-surface2: #1b1b25; + --color-surface3: #252532; + --color-fg: #f4f4f6; + --color-muted: #f4f4f6bd; + --color-dim: #f4f4f68f; + --color-border: #ffffff1a; + --color-accent: #cc9fff; + --color-accentSoft: #2c1a3f; + --color-accentText: #e4cbff; + --color-ok: #89da9b; + --color-warn: #f4c26a; + --color-fail: #f97770; + --color-onFocus: #0b0b10; + --color-buttonSecondary: #ffffff24; + --color-buttonSecondaryFocused: #ffffff42; + --color-titleButtonSecondary: #2c2c38c7; + --color-titleButtonSecondaryFocused: #464654e6; + --color-chipRest: #ffffff1a; + --color-navBlobRest: #ffffff1a; + --color-listRowRest: #ffffff0d; + --color-sheetScrim: #0000009e; + --color-badgeRest: #07070abf; + --color-badgeWatched: #f4f4f6d9; + --color-progressTrack: #ffffff38; + --color-railEmpty: #f4f4f638; + --personColor-justin: #62c4ff; + --personColor-mindel: #ff9d72; + --personColor-shared: #9be38f; + --gradient-heroScrim: linear-gradient(90deg,rgb(7 7 10 / .92) 0%,rgb(7 7 10 / .55) 42%,rgb(7 7 10 / 0) 70%),linear-gradient(0deg,var(--bg) 0%,rgb(7 7 10 / .6) 26%,rgb(7 7 10 / 0) 48%); + --gradient-detailScrim: linear-gradient(90deg,rgb(7 7 10 / .94) 0%,rgb(7 7 10 / .6) 46%,rgb(7 7 10 / .05) 75%),linear-gradient(0deg,var(--bg) 0%,rgb(7 7 10 / .35) 34%,rgb(7 7 10 / 0) 55%); + --gradient-navBand: linear-gradient(180deg,rgb(7 7 10 / .72),rgb(7 7 10 / 0)); + --gradient-navBandSolid: linear-gradient(180deg,var(--bg) 0%,var(--bg) 84%,rgb(7 7 10 / 0) 100%); + --gradient-ratingPlate: linear-gradient(180deg,rgb(7 7 10 / .9) 0%,rgb(7 7 10 / .72) 52%,rgb(7 7 10 / 0) 100%); + --gradient-tileOverlay: linear-gradient(0deg,rgb(7 7 10 / .92),rgb(7 7 10 / 0)); + --gradient-fallbackArt: linear-gradient(160deg,hsl(var(--h,260) 22% 22%),hsl(var(--h,260) 26% 10%)); + --gradient-entryTile: linear-gradient(150deg,hsl(var(--h) 30% 26%),hsl(var(--h) 30% 12%)); + --gradient-initialsCircle: linear-gradient(160deg,hsl(var(--h) 24% 30%),hsl(var(--h) 26% 14%)); + --type-displayDetail: 88.0px; + --type-displayHero: 84.0px; + --type-screenTitle: 64.0px; + --type-sectionTitle: 56.0px; + --type-sheetTitle: 44.0px; + --type-wordmark: 36.0px; + --type-stateTitle: 36.0px; + --type-rowHeading: 34.0px; + --type-pickerName: 34.0px; + --type-nav: 30.0px; + --type-listTitle: 30.0px; + --type-body: 28.0px; + --type-button: 28.0px; + --type-label: 26.0px; + --type-caption: 24.0px; + --type-ratingPlate: 28.0px; + --radius-card: 16.0px; + --radius-button: 38.0px; + --radius-chip: 34.0px; + --radius-navItem: 32.0px; + --radius-listRow: 24.0px; + --radius-panel: 22.0px; + --radius-menu: 26.0px; + --radius-heroSheet: 18.0px; + --radius-cardSheet: 10.0px; + --radius-thumb: 12.0px; + --radius-pill: 22.0px; + --radius-progress: 4.0px; + --layout-safeSide: 96.0px; + --layout-contentWidth: 1728.0px; + --layout-safeTopPicker: 54.0px; + --layout-navBand: 150.0px; + --layout-navPaddingTop: 44.0px; + --layout-navItemHeight: 64.0px; + --layout-navItemPaddingX: 26.0px; + --layout-navItemGap: 12.0px; + --layout-wordmarkGap: 40.0px; + --layout-navUnderline: 4.0px; + --layout-navBlobAvatarPad: 8.0px; + --layout-navSolidAfter: 40.0px; + --layout-pinnedLine: 168.0px; + --layout-windowAhead: 700.0px; + --layout-rowHeadingBand: 84.0px; + --layout-rowGapAfter: 68.0px; + --layout-gridRowGap: 44.0px; + --layout-row_hero: 820.0px; + --layout-row_phero: 740.0px; + --layout-row_section: 150.0px; + --layout-row_chips: 104.0px; + --layout-row_note: 84.0px; + --layout-row_state: 300.0px; + --layout-row_lhead: 236.0px; + --layout-row_dhead: 900.0px; + --layout-row_phead: 470.0px; + --layout-row_gh: 84.0px; + --layout-row_endcta: 200.0px; + --layout-row_srow: 124.0px; + --layout-heroCopyLeft: 96.0px; + --layout-heroCopyBottom: 110.0px; + --layout-heroCopyWidth: 1000.0px; + --layout-heroOverviewMax: 960.0px; + --layout-detailCopyTop: 200.0px; + --layout-detailCopyWidth: 1060.0px; + --layout-buttonHeight: 76.0px; + --layout-buttonPaddingX: 38.0px; + --layout-buttonGap: 20.0px; + --layout-buttonIcon: 30.0px; + --layout-iconButton: 76.0px; + --layout-iconCaptionLine: 38.0px; + --layout-sheetWidth: 800.0px; + --layout-sheetRow: 100.0px; + --layout-stateWidth: 1100.0px; + --layout-captionOffset: 38.0px; + --layout-badge: 48.0px; + --layout-badgeInset: 14.0px; + --layout-progressBar: 8.0px; + --layout-pickerAvatar: 220.0px; + --layout-pickerGap: 90.0px; + --layout-pickerStackGap: 70.0px; + --layout-gear: 56.0px; + --layout-navAvatar: 64.0px; + --layout-castCircle: 200.0px; + --layout-personPortrait: 240.0px; + --layout-row_chipsGrid: 136.0px; + --motion-fast: 140ms; + --motion-base: 160ms; + --motion-scroll: 180ms; + --motion-ratingDwell: 350ms; + --motion-toast: 3200ms; + --requests-sheetPaddingY: 80.0px; + --requests-sheetPaddingX: 64.0px; + --requests-sheetGap: 18.0px; + --requests-sheetTitleLineHeight: 56.0px; + --requests-sheetTitleBaseline: 44.0px; + --requests-titleLineHeight: 39.0px; + --requests-titleBaseline: 31.0px; + --requests-factsLineHeight: 31.2px; + --requests-factsBaseline: 24.0px; + --requests-stageLineHeight: 39.6px; + --requests-stageLabelLineHeight: 33.8px; + --requests-stageBaseline: 24.0px; + --requests-emptyMargin: 20.0px; + --requests-rowPaddingY: 14.0px; + --requests-rowPaddingX: 18.0px; + --requests-rowGap: 24.0px; + --requests-listGap: 6.0px; + --requests-stageMargin: 4.0px; + --requests-stageGlyph: 28.0px; + --requests-stageGap: 10.0px; + --requests-readinessGapX: 22.0px; + --requests-readinessGapY: 10.0px; + --requests-readinessMargin: 8.0px; + --requests-readinessGlyph: 26.0px; + --requests-chevron: 30.0px; + --requests-receiptPaddingY: 28.0px; + --requests-receiptPaddingX: 30.0px; + --requests-receiptGap: 10.0px; + --requests-receiptGlyph: 36.0px; + --requests-buttonMargin: 12.0px; + --requests-skeletonTitleWidth: 360.0px; + --requests-skeletonTitleHeight: 34.0px; + --requests-skeletonSubtitleWidth: 240.0px; + --requests-skeletonSubtitleHeight: 26.0px; + --requests-skeletonGap: 12.0px; + --requests-ratingsGap: 20.0px; + --requests-ratingsEntryGap: 8.0px; + --requests-shadowBlur: 80.0px; + --requests-shadowOffset: -30.0px; + --requests-shadowColor: #00000080; + --shell-navLabelFade: 200ms; + --shell-zero: 0.0px; + --shell-stageWidth: 1920.0px; + --shell-stageHeight: 1080.0px; + --shell-wordmarkWidth: 169.0px; + --shell-wordmarkTop: 73.6px; + --shell-navHomeWidth: 149.0px; + --shell-navDiscoverWidth: 198.0px; + --shell-navMoviesWidth: 171.0px; + --shell-navShowsWidth: 161.0px; + --shell-navAnimeWidth: 158.0px; + --shell-navItemTop: 65.0px; + --shell-pickerTitleTop: 272.3px; + --shell-pickerPeopleTop: 425.5px; + --shell-pickerNameGap: 26.0px; + --shell-pickerGearTop: 751.7px; + --shell-pickerGearCaptionTop: 72.0px; + --shell-pickerLetter: 100.0px; + --shell-pickerSkeletonNameWidth: 140.0px; + --shell-pickerSkeletonNameHeight: 40.0px; + --shell-pickerLoadingTitleTop: 247.4px; + --shell-pickerLoadingPeopleTop: 400.6px; + --shell-pickerLoadingButtonTop: 756.6px; + --shell-pickerErrorTitleTop: 297.6px; + --shell-pickerErrorPanelTop: 450.8px; + --shell-pickerErrorPanelWidth: 980.0px; + --shell-pickerErrorPanelHeight: 185.6px; + --shell-pickerErrorButtonTop: 706.4px; + --shell-settingsHeaderTop: 150.0px; + --shell-settingsHeaderAvatar: 52.0px; + --shell-settingsHeaderGap: 24.0px; + --shell-settingsRowTop: 10.0px; + --shell-settingsRowPadding: 30.0px; + --shell-settingsRowGap: 24.0px; + --shell-settingsIcon: 36.0px; + --shell-settingsPillHeight: 40.0px; + --shell-settingsPillStroke: 2.0px; + --shell-settingsPillBorder: #ffffff38; + --shell-settingsPillFocusedBorder: #0b0b1066; + --shell-settingsPillPadding: 16.0px; + --shell-settingsPillGap: 12.0px; + --shell-settingsChevron: 30.0px; + --shell-settingsEndPadding: 620.0px; + --shell-panelPaddingX: 30.0px; + --shell-panelPaddingY: 28.0px; + --shell-panelGap: 10.0px; + --shell-panelHeadingGap: 14.0px; + --shell-panelIcon: 36.0px; + --shell-skeletonRadius: 10.0px; + --library-chipHeight: 68.0px; + --library-chipPadding: 28.0px; + --library-chipGap: 16.0px; + --library-chipTop: 16.0px; + --library-chipIcon: 26.0px; + --library-captionWidth: 640.0px; + --library-captionFactsGap: 18.0px; + --library-badgeIcon: 28.0px; + --library-plateTop: 14.0px; + --library-plateSide: 16.0px; + --library-plateBottom: 46.0px; + --library-plateInlineDescent: 6.6px; + --library-plateFloor: 24.0px; + --library-railWidth: 52.0px; + --library-railLetterHeight: 30.0px; + --library-railFocusHeight: 44.0px; + --library-railLetter: 24.0px; + --library-railFocusedLetter: 28.0px; + --library-panelPaddingX: 40.0px; + --library-panelPaddingY: 34.0px; + --library-panelTextGap: 24.0px; + --library-panelTitleGap: 8.0px; + --library-panelIconGap: 14.0px; + --library-panelTop: 20.0px; + --library-borderWidth: 2.0px; + --library-chipContentGap: 12.0px; + --library-tailIcon: 44.0px; + --library-tailPadding: 20.0px; + --library-tailGap: 10.0px; + --library-badgeRing: 3.0px; + --library-seeAllGap: 8.0px; + --library-tailDescription: 24.0px; + --library-headerSubtitle: 30.0px; + --library-fallbackPadding: 22.0px; + --library-fallbackIcon: 44.0px; + --lift-cardBlur: 50.0px; + --lift-cardY: 18.0px; + --lift-buttonBlur: 40.0px; + --lift-buttonY: 12.0px; + --lift-heroBlur: 80.0px; + --lift-heroY: 30.0px; + --lift-sheetBlur: 80.0px; + --lift-sheetX: -30.0px; + --title-episodeCardHeight: 280.0px; + --title-seriesHeaderGap: 40.0px; + --title-lineGap: 22.0px; + --title-lineLift: -4.0px; + --title-overviewGap: 34.0px; + --title-factsGap: 22.0px; + --title-factsBottom: 14.0px; + --title-factsHeight: 38.0px; + --title-titleBottom: 16.0px; + --title-iconGap: 14.0px; + --title-iconSize: 32.0px; + --title-captionTop: 16.0px; + --title-progressWidth: 320.0px; + --title-progressBottom: 30.0px; + --title-progressGap: 18.0px; + --title-progressLift: -14.0px; + --title-castHeight: 462.0px; + --title-castNameTop: 236.0px; + --title-castNameSide: 12.0px; + --title-personTop: 170.0px; + --title-personGap: 48.0px; + --title-personNameBottom: 10.0px; + --title-personNoteBottom: 6.0px; + --title-captionWidth: 640.0px; + --title-sheetPaddingTop: 80.0px; + --title-sheetPaddingSide: 64.0px; + --title-sheetGap: 18.0px; + --title-sheetSubLift: -8.0px; + --title-sheetSubBottom: 14.0px; + --title-sheetLabelTop: 8.0px; + --title-sheetButtonTop: 12.0px; + --title-sheetRowSide: 26.0px; + --title-versionRowVertical: 20.0px; + --title-trackHeight: 84.0px; + --title-trackGap: 12.0px; + --title-radioSize: 40.0px; + --title-radioDot: 16.0px; + --title-radioBorder: 3.0px; + --title-infoGap: 14.0px; + --title-infoLabelWidth: 200.0px; + --title-infoRowGap: 6.0px; + --title-infoColumnGap: 20.0px; + --title-infoKey: 24.0px; + --title-gridHeight: 524.0px; + --title-minimumEndPadding: 80.0px; + --title-ratingGap: 20.0px; + --title-ratingMarkGap: 8.0px; + --title-initialSize: 64.0px; + --title-personInitialSize: 96.0px; + --title-skeletonKickerWidth: 420.0px; + --title-skeletonKickerHeight: 34.0px; + --title-skeletonTitleGap: 26.0px; + --title-skeletonFactsGap: 24.0px; + --title-skeletonOverviewGap: 30.0px; + --title-skeletonTitleWidth: 760.0px; + --title-skeletonTitleHeight: 92.0px; + --title-skeletonFactsWidth: 560.0px; + --title-skeletonOverviewWidth: 980.0px; + --title-skeletonOverviewHeight: 110.0px; + --title-skeletonPlayWidth: 220.0px; + --title-dLine: 26.0px; + --title-iconCaption: 26.0px; + --title-lineIcon: 28.0px; + --title-chipHeight: 68.0px; + --title-chipPadding: 28.0px; + --title-chipGap: 16.0px; + --title-chipTop: 16.0px; + --title-chipBorder: 2.0px; + --title-heroSheetRight: 180.0px; + --title-heroSheetTop: 150.0px; + --title-gridHeadingTop: 24.0px; + --title-castName: 26.0px; + --title-receiptSide: 30.0px; + --title-receiptTop: 28.0px; + --title-receiptGap: 10.0px; + --title-receiptIcon: 36.0px; + --title-receiptTitle: 36.0px; + --title-toastMaxWidth: 760.0px; + --title-toastSide: 28.0px; + --title-toastTop: 22.0px; + --title-heroAmbientBackground: radial-gradient(120% 90% at 70% 20%,var(--surface3),var(--bg)); + --title-errorTop: 170.0px; + --title-errorSide: 40.0px; + --title-errorVertical: 34.0px; + --title-errorHeadingBottom: 8.0px; + --title-errorBodyBottom: 24.0px; + --title-errorTitle: 36.0px; + --title-fallbackGlyph: 44.0px; + --title-fallbackInset: 22.0px; + --title-tileSheetInset: 18.0px; + --title-tileSheetLeft: 22.0px; + --title-tileSheetRadius: 10.0px; + --title-tileAmbientBlur: 26.0px; + --title-tileAmbientInset: 30.0px; + --title-tileShadowRadius: 30.0px; + --title-tileShadowY: 10.0px; + --title-cardFallbackGradient: linear-gradient(160deg,hsl(var(--h) 22% 22%),hsl(var(--h) 26% 10%)); + --title-personFallbackGradient: linear-gradient(160deg,hsl(var(--h) 24% 30%),hsl(var(--h) 26% 14%)); + --title-initialsColor: #ffffffd1; + --title-fallbackGlyphColor: #ffffff73; + --title-fallbackTitle: 28.0px; + --title-tileOverlayTop: 54.0px; + --title-tileOverlaySide: 22.0px; + --title-tileOverlayBottom: 18.0px; + --title-cardProgressInset: 18.0px; + --title-tileProgressBottom: 10.0px; + --title-ambientInset: 80.0px; + --title-posterShadowRadius: 80.0px; + --title-posterShadowY: 30.0px; + --title-sheetShadowRadius: 80.0px; + --title-sheetShadowX: -30.0px; + --savedIndicator-height: 52.0px; + --savedIndicator-side: 20.0px; + --savedIndicator-gap: 10.0px; + --savedIndicator-icon: 28.0px; + --savedIndicator-border: 2.0px; + --home-kickerGap: 12.0px; + --home-kickerBottom: 10.0px; + --home-titleBottom: 16.0px; + --home-factsHeight: 38.0px; + --home-factsBottom: 14.0px; + --home-factsGap: 22.0px; + --home-overviewBottom: 30.0px; + --home-cardPadding: 22.0px; + --home-cardOverlayTop: 54.0px; + --home-cardOverlayBottom: 18.0px; + --home-sectionTop: 40.0px; + --home-sectionAvatar: 56.0px; + --home-sectionGap: 22.0px; + --home-stateTextBottom: 24.0px; + --home-stateTop: 20.0px; + --home-statePaddingX: 40.0px; + --home-statePaddingY: 34.0px; + --home-stateGap: 8.0px; + --home-stateIcon: 36.0px; + --home-captionWidth: 640.0px; + --home-plateTop: 14.0px; + --home-plateSide: 16.0px; + --home-plateFade: 46.0px; + --home-ratingGap: 20.0px; + --home-ratingMarkGap: 8.0px; + --home-heroSkeletonWidth: 640.0px; + --home-heroSkeletonHeight: 84.0px; + --home-heroSkeletonFactsWidth: 460.0px; + --home-heroSkeletonFactsHeight: 34.0px; + --home-heroSkeletonButtonWidth: 260.0px; + --home-heroSkeletonGap: 22.0px; + --home-heroSkeletonButtonGap: 40.0px; + --home-artGlyph: 44.0px; + --home-cardSheetLeft: 22.0px; + --home-cardSheetTop: 18.0px; + --home-cardSheetInset: 36.0px; + --home-heroSheetRight: 180.0px; + --home-heroSheetTop: 150.0px; + --home-cardBlur: 26.0px; + --home-heroBlur: 60.0px; + --home-progressInset: 18.0px; + --home-progressBottom: 10.0px; + --home-endTop: 40.0px; + --home-frameBottom: 80.0px; + --home-toastPadding: 28.0px; + --home-toastTop: 150.0px; + --home-toastWidth: 760.0px; + --home-toastPaddingY: 22.0px; + --home-toastGap: 14.0px; + --home-toastShadowBlur: 60.0px; + --home-toastShadowY: 20.0px; + --home-cardWashInset: 30.0px; + --home-heroWashInset: 80.0px; + --home-noteTop: 14.0px; + --home-noteGap: 16.0px; + --home-hairline: 2.0px; + --home-savedHeight: 52.0px; + --home-savedPadding: 20.0px; + --home-savedGap: 10.0px; + --home-savedIcon: 28.0px; + --home-savedRadius: 28.0px; + --home-posterProgressBottom: 12.0px; + --discover-shelfHeadingBand: 104.0px; + --discover-sheetPaddingX: 64.0px; + --discover-sheetPaddingY: 80.0px; + --discover-sheetGap: 18.0px; + --discover-sheetSubGap: 14.0px; + --discover-sheetRowPadding: 26.0px; + --discover-sheetAvatar: 60.0px; + --discover-sheetRadio: 40.0px; + --discover-sheetRadioDot: 16.0px; + --discover-sheetPersonGap: 24.0px; + --discover-sheetButtonTop: 12.0px; + --discover-chipTop: 16.0px; + --discover-chipHeight: 68.0px; + --discover-chipPadding: 28.0px; + --discover-chipGap: 16.0px; + --discover-heroTitleGap: 16.0px; + --discover-heroKickerGap: 10.0px; + --discover-heroMetaGap: 14.0px; + --discover-heroOverviewGap: 30.0px; + --discover-heroFactsGap: 22.0px; + --discover-heroFactsHeight: 38.0px; + --discover-heroSheetRight: 180.0px; + --discover-heroSheetTop: 140.0px; + --discover-heroSheetWidth: 360.0px; + --discover-heroSheetHeight: 540.0px; + --discover-dotSize: 14.0px; + --discover-dotActive: 44.0px; + --discover-dotGap: 14.0px; + --discover-dotMargin: 18.0px; + --discover-dotChevron: 40.0px; + --discover-stateTop: 20.0px; + --discover-statePaddingX: 40.0px; + --discover-statePaddingY: 34.0px; + --discover-stateTextGap: 24.0px; + --discover-noteTop: 14.0px; + --discover-headerTop: 150.0px; + --discover-headerGap: 24.0px; + --discover-entryPaddingX: 22.0px; + --discover-entryPaddingY: 20.0px; + --discover-entryText: 30.0px; + --discover-sheetRadioRing: 3.0px; + --discover-skeletonNameWidth: 260.0px; + --discover-skeletonNameHeight: 34.0px; + --discover-sheetSubTop: -8.0px; + --discover-sheetRatingTop: -10.0px; + --discover-sheetRatingBottom: 6.0px; + --discover-sheetLabelTop: 8.0px; + --discover-noteIcon: 30.0px; + --discover-noteGap: 16.0px; + --discover-frameBottomMin: 80.0px; + --discover-anchorFitTop: 700.0px; + --discover-anchorBottomClearance: 40.0px; + --discover-stageGap: 10.0px; + --discover-ambientInset: 80.0px; + --discover-ambientBlur: 60.0px; +} diff --git a/wolfskin-design/tokens.json b/wolfskin-design/tokens.json new file mode 100644 index 00000000..70571dc3 --- /dev/null +++ b/wolfskin-design/tokens.json @@ -0,0 +1,1627 @@ +{ + "$description": "Wolfskin TV design tokens, spec revision 5: approved focus and Level 2 spacing, taller episode cards, roomier Discover headings and panel-resolution hero artwork. CSS px on a 1920x1080 stage at density 2.0; dp/sp = px / 2.", + "meta": { + "density": 2.0, + "stagePx": [ + 1920, + 1080 + ], + "stageDp": [ + 960, + 540 + ], + "source": "data/tv-v2/focus-2026-11-06/index.html", + "sourceSha256": "1bb7f4097a5303a58e45ec5e61cf867fa6cfdcbfe12fc7a196f00acacae5e48d", + "generator": "data/tv-spec-lane/scratch/extract.py + build_tokens.py", + "feedbackDelta": "data/tv-v2/feedback-2026-11-06/delta.json" + }, + "color": { + "bg": { + "$type": "color", + "$value": "#07070a", + "css": "#07070a", + "source": "index.html:10", + "usedBy": "app background; stage; hero/detail scrim end; nav band when solid" + }, + "surface": { + "$type": "color", + "$value": "#13131a", + "css": "#13131a", + "source": "index.html:10", + "usedBy": "sheets (side panels), state panels, See all card, skeleton blocks, Try again/Show more tail cards" + }, + "surface2": { + "$type": "color", + "$value": "#1b1b25", + "css": "#1b1b25", + "source": "index.html:10", + "usedBy": "card art placeholder behind images; menus; toast; person-page portrait placeholder" + }, + "surface3": { + "$type": "color", + "$value": "#252532", + "css": "#252532", + "source": "index.html:10", + "usedBy": "hero radial placeholder; Coming-in thumbnail placeholder" + }, + "fg": { + "$type": "color", + "$value": "#f4f4f6", + "css": "#f4f4f6", + "source": "index.html:11", + "usedBy": "primary text; primary button fill; focused nav blob; focused chip/row/list fill (inverse focus); white focus ring on avatars and the gear" + }, + "muted": { + "$type": "color", + "$value": "#f4f4f6bd", + "css": "rgb(244 244 246 / .74)", + "source": "index.html:11", + "usedBy": "secondary text: facts lines, overviews, captions under cards, unfocused nav labels, row descriptions" + }, + "dim": { + "$type": "color", + "$value": "#f4f4f68f", + "css": "rgb(244 244 246 / .56)", + "source": "index.html:11", + "usedBy": "tertiary text: sheet labels, small print, pill text, the resting gear" + }, + "border": { + "$type": "color", + "$value": "#ffffff1a", + "css": "rgb(255 255 255 / .10)", + "source": "index.html:11", + "usedBy": "hairline borders on See all/tail cards and state panels" + }, + "accent": { + "$type": "color", + "$value": "#cc9fff", + "css": "oklch(78% 0.14 305)", + "source": "index.html:12", + "usedBy": "lilac focus ring on cards and buttons; progress fills; current-destination underline; See all glyph; requested badge ring; Downloading/Almost ready stage words" + }, + "accentSoft": { + "$type": "color", + "$value": "#2c1a3f", + "css": "oklch(26% 0.07 305)", + "source": "index.html:12", + "usedBy": "selected chip fill (chip.on)" + }, + "accentText": { + "$type": "color", + "$value": "#e4cbff", + "css": "oklch(88% 0.08 305)", + "source": "index.html:12", + "usedBy": "kicker line above hero titles; d-line on title pages; availability words in captions; selected chip text; wordmark \"skin\"" + }, + "ok": { + "$type": "color", + "$value": "#89da9b", + "css": "oklch(82% 0.12 150)", + "source": "index.html:13", + "usedBy": "held badge (green check); Ready stage word; readiness Done" + }, + "warn": { + "$type": "color", + "$value": "#f4c26a", + "css": "oklch(84% 0.12 80)", + "source": "index.html:13", + "usedBy": "Saved HH:MM chip; warning state titles and icons; toast icon; Try again tail glyph" + }, + "fail": { + "$type": "color", + "$value": "#f97770", + "css": "oklch(72% 0.16 25)", + "source": "index.html:13", + "usedBy": "failed-request badge ring; Failed stage word; Refused receipt title" + }, + "onFocus": { + "$type": "color", + "$value": "#0b0b10", + "css": "#0b0b10", + "source": "index.html:84", + "usedBy": "text and icons on any white focus fill: focused nav item, primary button label, focused chip/row/list item, avatar letters" + }, + "buttonSecondary": { + "$type": "color", + "$value": "#ffffff24", + "css": "rgb(255 255 255 / .14)", + "source": "index.html:107", + "usedBy": "secondary (non-primary) pill button at rest on hero rows" + }, + "buttonSecondaryFocused": { + "$type": "color", + "$value": "#ffffff42", + "css": "rgb(255 255 255 / .26)", + "source": "index.html:111", + "usedBy": "secondary pill button while focused (plus the accent ring)" + }, + "titleButtonSecondary": { + "$type": "color", + "$value": "#2c2c38c7", + "css": "rgb(44 44 56 / .78)", + "source": "index.html:279", + "usedBy": "title-page secondary and icon buttons at rest (dark backing so labels read over bright art)" + }, + "titleButtonSecondaryFocused": { + "$type": "color", + "$value": "#464654e6", + "css": "rgb(70 70 84 / .9)", + "source": "index.html:280", + "usedBy": "title-page secondary and icon buttons while focused" + }, + "chipRest": { + "$type": "color", + "$value": "#ffffff1a", + "css": "rgb(255 255 255 / .10)", + "source": "index.html:185", + "usedBy": "filter/sort/season chips at rest" + }, + "navBlobRest": { + "$type": "color", + "$value": "#ffffff1a", + "css": "rgb(255 255 255 / .10)", + "source": "index.html:76", + "usedBy": "nav indicator resting behind the current destination while focus is elsewhere" + }, + "listRowRest": { + "$type": "color", + "$value": "#ffffff0d", + "css": "rgb(255 255 255 / .05)", + "source": "index.html:231", + "usedBy": "sheet rows (person, version, track) at rest; Settings rows (.srow) use the same value" + }, + "sheetScrim": { + "$type": "color", + "$value": "#0000009e", + "css": "rgb(0 0 0 / .62)", + "source": "index.html:226", + "usedBy": "full-screen scrim behind every side sheet" + }, + "badgeRest": { + "$type": "color", + "$value": "#07070abf", + "css": "rgb(7 7 10 / .75)", + "source": "index.html:141", + "usedBy": "badge disc behind the requested/failed glyph" + }, + "badgeWatched": { + "$type": "color", + "$value": "#f4f4f6d9", + "css": "rgb(244 244 246 / .85)", + "source": "index.html:145", + "usedBy": "watched badge disc (defined in CSS; no surface draws it in the locked page) [unused]" + }, + "progressTrack": { + "$type": "color", + "$value": "#ffffff38", + "css": "rgb(255 255 255 / .22)", + "source": "index.html:138", + "usedBy": "progress-bar track on wide cards; title-page progress track uses rgb(255 255 255 / .2) (.d-prog i)" + }, + "railEmpty": { + "$type": "color", + "$value": "#f4f4f638", + "css": "rgb(244 244 246 / .22)", + "source": "index.html:206", + "usedBy": "letter rail: letters with no titles (rail shown only at >= 200 titles)" + } + }, + "personColor": { + "justin": { + "$type": "color", + "$value": "#62c4ff", + "initial": "J", + "source": "fixture/fixture.json profiles[].color", + "usedBy": "Justin: avatar fill in Who's watching (220 px), nav avatar (64 px), person rows in sheets (60 px), library/settings headers (52/56 px); letter in color.onFocus" + }, + "mindel": { + "$type": "color", + "$value": "#ff9d72", + "initial": "M", + "source": "fixture/fixture.json profiles[].color", + "usedBy": "Mindel: avatar fill in Who's watching (220 px), nav avatar (64 px), person rows in sheets (60 px), library/settings headers (52/56 px); letter in color.onFocus" + }, + "shared": { + "$type": "color", + "$value": "#9be38f", + "initial": "S", + "source": "fixture/fixture.json profiles[].color", + "usedBy": "Shared: avatar fill in Who's watching (220 px), nav avatar (64 px), person rows in sheets (60 px), library/settings headers (52/56 px); letter in color.onFocus" + } + }, + "gradient": { + "heroScrim": { + "$type": "gradient", + "$value": "linear-gradient(90deg,rgb(7 7 10 / .92) 0%,rgb(7 7 10 / .55) 42%,rgb(7 7 10 / 0) 70%),linear-gradient(0deg,var(--bg) 0%,rgb(7 7 10 / .6) 26%,rgb(7 7 10 / 0) 48%)", + "source": "index.html:99", + "usedBy": "Home hero and Discover picks hero: left-to-right darkening (92% at 0, 55% at 42%, 0 at 70%) plus bottom fade into bg (0..48%)" + }, + "detailScrim": { + "$type": "gradient", + "$value": "linear-gradient(90deg,rgb(7 7 10 / .94) 0%,rgb(7 7 10 / .6) 46%,rgb(7 7 10 / .05) 75%),linear-gradient(0deg,var(--bg) 0%,rgb(7 7 10 / .35) 34%,rgb(7 7 10 / 0) 55%)", + "source": "index.html:210", + "usedBy": "title page backdrop scrim (94% / 60% at 46% / 5% at 75%; bottom fade to 55%)" + }, + "navBand": { + "$type": "gradient", + "$value": "linear-gradient(180deg,rgb(7 7 10 / .72),rgb(7 7 10 / 0))", + "source": "index.html:71", + "usedBy": "nav band at scrollTop <= 40: 72% bg fading to 0 over 150 px" + }, + "navBandSolid": { + "$type": "gradient", + "$value": "linear-gradient(180deg,var(--bg) 0%,var(--bg) 84%,rgb(7 7 10 / 0) 100%)", + "source": "index.html:72", + "usedBy": "nav band once the frame scrolled > 40 px: solid bg to 84% of 150 px, then fades" + }, + "ratingPlate": { + "$type": "gradient", + "$value": "linear-gradient(180deg,rgb(7 7 10 / .9) 0%,rgb(7 7 10 / .72) 52%,rgb(7 7 10 / 0) 100%)", + "source": "index.html:162", + "usedBy": "rating plate on the focused card: 90% bg at top, 72% at 52%, 0 at bottom" + }, + "tileOverlay": { + "$type": "gradient", + "$value": "linear-gradient(0deg,rgb(7 7 10 / .92),rgb(7 7 10 / 0))", + "source": "index.html:135", + "usedBy": "bottom gradient under tile/wide titles" + }, + "fallbackArt": { + "$type": "gradient", + "$value": "linear-gradient(160deg,hsl(var(--h,260) 22% 22%),hsl(var(--h,260) 26% 10%))", + "source": "index.html:130", + "usedBy": "no-art card: 160deg two-tone gradient, hue = hash(title) (hueOf, index.html:744)" + }, + "entryTile": { + "$type": "gradient", + "$value": "linear-gradient(150deg,hsl(var(--h) 30% 26%),hsl(var(--h) 30% 12%))", + "source": "index.html:149", + "usedBy": "service entry tiles: 150deg gradient, hue per service (SERVICE_HUE, index.html:663)" + }, + "initialsCircle": { + "$type": "gradient", + "$value": "linear-gradient(160deg,hsl(var(--h) 24% 30%),hsl(var(--h) 26% 14%))", + "source": "index.html:288", + "usedBy": "cast circle / person portrait without a photo: initials on a 160deg tinted gradient, hue = hash(name)" + }, + "heroAmbientFilter": { + "$type": "filter", + "$value": "blur(60px) saturate(1.2) brightness(.55)", + "inset": "-80px", + "scale": 1.1, + "source": "index.html:96", + "usedBy": "poster-only hero/title art: blurred wash of the poster behind the one-sheet (Compose: RenderEffect blur 30 dp, saturation 1.2, brightness .55) [INFERENCE: Compose mapping]" + }, + "cardAmbientFilter": { + "$type": "filter", + "$value": "blur(26px) brightness(.5)", + "inset": "-30px", + "source": "index.html:129", + "usedBy": "poster-only art in a landscape card: blurred wash behind the one-sheet" + } + }, + "font": { + "family": { + "$type": "fontFamily", + "$value": "system-ui,-apple-system,\"Segoe UI\",Roboto,\"Noto Sans\",Ubuntu,Cantarell,sans-serif", + "source": "index.html:14", + "usedBy": "all stage text", + "note": "On the box this resolves to the platform sans (Roboto) [INFERENCE]; Compose uses the default FontFamily." + }, + "tabularNumbers": { + "$value": "font-variant-numeric: tabular-nums", + "source": "index.html:156", + "usedBy": "every ratings row" + } + }, + "type": { + "displayDetail": { + "$type": "typography", + "px": 88.0, + "sp": 44.0, + "source": "index.html:213", + "usedBy": "title page title (film, series, episode = series name); clamp 2 lines", + "weight": 800, + "weightSource": "index.html:103", + "lineHeight": 1.02, + "lineHeightSource": "index.html:103", + "letterSpacingEm": -0.02, + "$value": { + "fontSize": "44sp", + "fontWeight": 800 + } + }, + "displayHero": { + "$type": "typography", + "px": 84.0, + "sp": 42.0, + "source": "index.html:103", + "usedBy": "Home hero and Discover picks hero title; clamp 2 lines", + "weight": 800, + "weightSource": "index.html:103", + "lineHeight": 1.02, + "lineHeightSource": "index.html:103", + "letterSpacingEm": -0.02, + "$value": { + "fontSize": "42sp", + "fontWeight": 800 + } + }, + "screenTitle": { + "$type": "typography", + "px": 64.0, + "sp": 32.0, + "source": "index.html:200", + "usedBy": "screen headers: library (Movies/Shows/Anime), See all, service page, Settings, Discover-without-hero; also \"Who's watching?\" (.picker h1) and person name (.p-head h1)", + "weight": 800, + "weightSource": "index.html:200", + "letterSpacingEm": -0.02, + "$value": { + "fontSize": "32sp", + "fontWeight": 800 + } + }, + "sectionTitle": { + "$type": "typography", + "px": 56.0, + "sp": 28.0, + "source": "index.html:181", + "usedBy": "the \"Discover\" section header inside Home; player hand-off title (.player h2)", + "weight": 800, + "weightSource": "index.html:181", + "letterSpacingEm": -0.02, + "$value": { + "fontSize": "28sp", + "fontWeight": 800 + } + }, + "sheetTitle": { + "$type": "typography", + "px": 44.0, + "sp": 22.0, + "source": "index.html:228", + "usedBy": "side-sheet titles (Request, Version, Coming in, ...)", + "weight": 800, + "weightSource": "index.html:228", + "letterSpacingEm": -0.01, + "$value": { + "fontSize": "22sp", + "fontWeight": 800 + } + }, + "wordmark": { + "$type": "typography", + "px": 36.0, + "sp": 18.0, + "source": "index.html:79", + "usedBy": "the \"Wolfskin\" wordmark at the left of the nav and in the picker", + "weight": 800, + "weightSource": "index.html:79", + "letterSpacingEm": -0.02, + "$value": { + "fontSize": "18sp", + "fontWeight": 800 + } + }, + "stateTitle": { + "$type": "typography", + "px": 36.0, + "sp": 18.0, + "source": "index.html:195", + "usedBy": "state panel titles and receipt titles (.receipt h3)", + "$value": { + "fontSize": "18sp", + "fontWeight": 400 + } + }, + "rowHeading": { + "$type": "typography", + "px": 34.0, + "sp": 17.0, + "source": "index.html:119", + "usedBy": "row headings above every shelf and grid section", + "weight": 700, + "weightSource": "index.html:119", + "letterSpacingEm": -0.01, + "$value": { + "fontSize": "17sp", + "fontWeight": 700 + } + }, + "pickerName": { + "$type": "typography", + "px": 34.0, + "sp": 17.0, + "source": "index.html:260", + "usedBy": "names under avatars in Who's watching", + "weight": 650, + "weightSource": "index.html:260", + "$value": { + "fontSize": "17sp", + "fontWeight": 650 + } + }, + "nav": { + "$type": "typography", + "px": 30.0, + "sp": 15.0, + "source": "index.html:81", + "usedBy": "nav destination labels", + "weight": 600, + "weightSource": "index.html:81", + "$value": { + "fontSize": "15sp", + "fontWeight": 600 + } + }, + "listTitle": { + "$type": "typography", + "px": 30.0, + "sp": 15.0, + "source": "index.html:231", + "usedBy": "sheet row names; Settings row titles (.srow .tx b); Coming-in row titles (.crow .tx b); See all label; service entry wordmark", + "weight": 650, + "weightSource": "index.html:231", + "$value": { + "fontSize": "15sp", + "fontWeight": 650 + } + }, + "body": { + "$type": "typography", + "px": 28.0, + "sp": 14.0, + "source": "index.html:104", + "usedBy": "facts lines (h-meta), overviews (h-ov, d-ov), card caption title (.cap b), tile titles (.card .ov b), buttons (.btn), state text, receipts, toast, rating numbers and marks", + "$value": { + "fontSize": "14sp", + "fontWeight": 400 + } + }, + "button": { + "$type": "typography", + "px": 28.0, + "sp": 14.0, + "source": "index.html:107", + "usedBy": "pill buttons (primary and secondary)", + "weight": 700, + "weightSource": "index.html:107", + "$value": { + "fontSize": "14sp", + "fontWeight": 700 + } + }, + "label": { + "$type": "typography", + "px": 26.0, + "sp": 13.0, + "source": "index.html:185", + "usedBy": "chips; kicker (26/650); d-line; d-prog; icon-button caption (.bcap); cast name (.pnm b, 26/700); sheet section labels (.sheet .lab)", + "weight": 650, + "weightSource": "index.html:185", + "$value": { + "fontSize": "13sp", + "fontWeight": 650 + } + }, + "caption": { + "$type": "typography", + "px": 24.0, + "sp": 12.0, + "source": "index.html:154", + "usedBy": "THE FLOOR. Card facts under a focused poster; tile second lines; pills; small print; character names; readiness stages; letter rail; rating-plate floor. Nothing on the stage is smaller.", + "$value": { + "fontSize": "12sp", + "fontWeight": 400 + } + }, + "ratingPlate": { + "$type": "typography", + "px": 28.0, + "sp": 14.0, + "floorPx": 24, + "floorSp": 12.0, + "stepPx": 0.5, + "weight": 700, + "lineHeight": 1.2, + "source": "index.html:164", + "floorSource": "index.html:802", + "$value": { + "fontSize": "14sp", + "min": "12sp", + "fontWeight": 700 + }, + "usedBy": "rating plate on the focused card: starts at 28 px (14 sp, the fork titleSmall), shrinks in 0.5 px steps to the 24 px floor, then drops trailing entries (fitRow, D9)" + }, + "avatarLetter": { + "$type": "typography", + "weight": 800, + "source": "index.html:89", + "sizes": { + "picker": 100, + "nav": 30, + "sheetRow": 28, + "shelfHead": 24, + "chip": 24, + "libHeader": 26, + "sectionHeader": 28 + }, + "usedBy": "avatar letters (px per context)" + }, + "stageLineHeight": { + "$type": "number", + "$value": 1.3, + "source": "index.html:61", + "usedBy": "default line height for all stage text (1.3)" + } + }, + "radius": { + "card": { + "$type": "dimension", + "$value": "8dp", + "px": 16.0, + "dp": 8.0, + "source": "index.html:125", + "usedBy": "every card (poster, tile, wide, entry, See all, tail, skeleton)" + }, + "button": { + "$type": "dimension", + "$value": "19dp", + "px": 38.0, + "dp": 19.0, + "source": "index.html:107", + "usedBy": "pill buttons = height / 2" + }, + "chip": { + "$type": "dimension", + "$value": "17dp", + "px": 34.0, + "dp": 17.0, + "source": "index.html:185", + "usedBy": "chips = height / 2" + }, + "navItem": { + "$type": "dimension", + "$value": "16dp", + "px": 32.0, + "dp": 16.0, + "source": "index.html:81", + "usedBy": "nav item and blob = height / 2" + }, + "listRow": { + "$type": "dimension", + "$value": "12dp", + "px": 24.0, + "dp": 12.0, + "source": "index.html:231", + "usedBy": "sheet rows and Settings rows" + }, + "panel": { + "$type": "dimension", + "$value": "11dp", + "px": 22.0, + "dp": 11.0, + "source": "index.html:194", + "usedBy": "state panels, receipts, Coming-in rows, toast" + }, + "menu": { + "$type": "dimension", + "$value": "13dp", + "px": 26.0, + "dp": 13.0, + "source": "index.html:264", + "usedBy": "menu panel" + }, + "heroSheet": { + "$type": "dimension", + "$value": "9dp", + "px": 18.0, + "dp": 9.0, + "source": "index.html:97", + "usedBy": "upright poster (\"one-sheet\") on poster-only hero/title art" + }, + "cardSheet": { + "$type": "dimension", + "$value": "5dp", + "px": 10.0, + "dp": 5.0, + "source": "index.html:128", + "usedBy": "one-sheet inside a landscape card" + }, + "thumb": { + "$type": "dimension", + "$value": "6dp", + "px": 12.0, + "dp": 6.0, + "source": "index.html:248", + "usedBy": "Coming-in poster thumbnail 96x144" + }, + "pill": { + "$type": "dimension", + "$value": "11dp", + "px": 22.0, + "dp": 11.0, + "source": "index.html:122", + "usedBy": "sample/Saved/Wolfskin/upstream pills" + }, + "progress": { + "$type": "dimension", + "$value": "2dp", + "px": 4.0, + "dp": 2.0, + "source": "index.html:138", + "usedBy": "progress bars" + }, + "circle": { + "$type": "dimension", + "$value": "50%", + "source": "index.html:89", + "usedBy": "avatars, badges, person circles, gear" + } + }, + "layout": { + "safeSide": { + "$type": "dimension", + "$value": "48dp", + "px": 96, + "dp": 48.0, + "source": "index.html:654", + "usedBy": "left/right safe inset on every screen (5% of 1920); first card x, all copy blocks" + }, + "contentWidth": { + "$type": "dimension", + "$value": "864dp", + "px": 1728, + "dp": 864.0, + "source": "index.html:1035", + "usedBy": "content measure 1920 - 2 x 96; slotsOf() = floor((1728 + gap + 8) / (w + gap))" + }, + "safeTopPicker": { + "$type": "dimension", + "$value": "27dp", + "px": 54.0, + "dp": 27.0, + "source": "index.html:258", + "usedBy": "top inset of the wordmark in Who's watching (5% of 1080)" + }, + "navBand": { + "$type": "dimension", + "$value": "75dp", + "px": 150.0, + "dp": 75.0, + "source": "index.html:71", + "usedBy": "nav band height on every non-full-screen screen" + }, + "navPaddingTop": { + "$type": "dimension", + "$value": "22dp", + "px": 44.0, + "dp": 22.0, + "source": "index.html:74", + "usedBy": "nav row top padding; items are vertically centred in 44..150, so a 64 px item spans y 65..129 (measured)" + }, + "navItemHeight": { + "$type": "dimension", + "$value": "32dp", + "px": 64.0, + "dp": 32.0, + "source": "index.html:81", + "usedBy": "nav item / blob height; nav avatar is 64x64" + }, + "navItemPaddingX": { + "$type": "dimension", + "$value": "13dp", + "px": 26.0, + "dp": 13.0, + "source": "index.html:81", + "usedBy": "nav item horizontal padding" + }, + "navItemGap": { + "$type": "dimension", + "$value": "6dp", + "px": 12.0, + "dp": 6.0, + "source": "index.html:74", + "usedBy": "gap between nav items" + }, + "wordmarkGap": { + "$type": "dimension", + "$value": "20dp", + "px": 40.0, + "dp": 20.0, + "source": "index.html:79", + "usedBy": "space after the wordmark before Home" + }, + "navUnderline": { + "$type": "dimension", + "$value": "2dp", + "px": 4.0, + "dp": 2.0, + "source": "index.html:83", + "usedBy": "accent underline under the current destination, inset 26 px each side, 4 px above the item bottom; hidden while that item is focused" + }, + "navBlobAvatarPad": { + "$type": "dimension", + "$value": "4dp", + "px": 8, + "dp": 4.0, + "source": "index.html:1681", + "usedBy": "the blob grows 8 px each side around the avatar (80x80 circle)" + }, + "navSolidAfter": { + "$type": "dimension", + "$value": "20dp", + "px": 40, + "dp": 20.0, + "source": "index.html:1668", + "usedBy": "the nav band switches to the solid gradient once the frame scrollTop > 40" + }, + "pinnedLine": { + "$type": "dimension", + "$value": "84dp", + "px": 168, + "dp": 84.0, + "source": "index.html:654", + "usedBy": "focus-follow: a focused row (not hero) scrolls so its heading top sits 168 px from the top; rows whose bottom fits the first screen (y < 700 and y + h <= 1040) do not scroll" + }, + "windowAhead": { + "$type": "dimension", + "$value": "350dp", + "px": 700, + "dp": 350.0, + "source": "index.html:654", + "usedBy": "prototype windowing distance (rows within 700 px of the viewport exist); in Compose lazy layouts do this" + }, + "rowHeadingBand": { + "$type": "dimension", + "$value": "42dp", + "px": 84, + "dp": 42.0, + "source": "index.html:1018", + "usedBy": "shelf row: heading band (44 px heading at top 0, then 40 px) before the cards start at y + 84 (HEAD_OFF)" + }, + "rowGapAfter": { + "$type": "dimension", + "$value": "34dp", + "px": 68, + "dp": 34.0, + "source": "index.html:1018", + "usedBy": "shelf row: space after cards (+ caption band) before the next row; shelf row height = 84 + card h + caption + 68" + }, + "gridRowGap": { + "$type": "dimension", + "$value": "22dp", + "px": 44, + "dp": 22.0, + "source": "index.html:1019", + "usedBy": "grid row height = 384 + 96 + 44 = 524 (library, See all, person)" + }, + "row_hero": { + "$type": "dimension", + "$value": "410dp", + "px": 820, + "dp": 410.0, + "source": "index.html:1016", + "usedBy": "Home hero row height (the hero art itself is 1920x1080 behind it)" + }, + "row_phero": { + "$type": "dimension", + "$value": "370dp", + "px": 740, + "dp": 370.0, + "source": "index.html:1017", + "usedBy": "Discover picks-hero row height" + }, + "row_section": { + "$type": "dimension", + "$value": "75dp", + "px": 150, + "dp": 75.0, + "source": "index.html:1020", + "usedBy": "Home \"Discover\" section header row" + }, + "row_chips": { + "$type": "dimension", + "$value": "52dp", + "px": 104, + "dp": 52.0, + "source": "index.html:1021", + "usedBy": "chip row above shelves (Discover filters, service type chips, seasons); 104 px" + }, + "row_note": { + "$type": "dimension", + "$value": "42dp", + "px": 84, + "dp": 42.0, + "source": "index.html:1022", + "usedBy": "one-line note row (picks unavailable, no seeds, episode note)" + }, + "row_state": { + "$type": "dimension", + "$value": "150dp", + "px": 300, + "dp": 150.0, + "source": "index.html:1023", + "usedBy": "state panel row (calm or warning, with recovery buttons)" + }, + "row_lhead": { + "$type": "dimension", + "$value": "118dp", + "px": 236, + "dp": 118.0, + "source": "index.html:1024", + "usedBy": "screen header row (library, See all, service, Settings, Discover without hero)" + }, + "row_dhead": { + "$type": "dimension", + "$value": "450dp", + "px": 900, + "dp": 450.0, + "source": "index.html:1025", + "usedBy": "title page header row (copy + buttons over the backdrop)" + }, + "row_phead": { + "$type": "dimension", + "$value": "235dp", + "px": 470, + "dp": 235.0, + "source": "index.html:1026", + "usedBy": "person page header row" + }, + "row_gh": { + "$type": "dimension", + "$value": "42dp", + "px": 84, + "dp": 42.0, + "source": "index.html:1027", + "usedBy": "grid section heading row (person page)" + }, + "row_endcta": { + "$type": "dimension", + "$value": "100dp", + "px": 200, + "dp": 100.0, + "source": "index.html:1028", + "usedBy": "Home end action row (\"Explore more in Discover\")" + }, + "row_srow": { + "$type": "dimension", + "$value": "62dp", + "px": 124, + "dp": 62.0, + "source": "index.html:1029", + "usedBy": "Settings list row pitch (row 104 + 20)" + }, + "heroCopyLeft": { + "$type": "dimension", + "$value": "48dp", + "px": 96.0, + "dp": 48.0, + "source": "index.html:100", + "usedBy": "hero copy block x" + }, + "heroCopyBottom": { + "$type": "dimension", + "$value": "55dp", + "px": 110.0, + "dp": 55.0, + "source": "index.html:100", + "usedBy": "hero copy block anchored 110 px above the 1080 art bottom (bottom edge of buttons at y 710 on Home)" + }, + "heroCopyWidth": { + "$type": "dimension", + "$value": "500dp", + "px": 1000.0, + "dp": 500.0, + "source": "index.html:100", + "usedBy": "hero copy block width" + }, + "heroOverviewMax": { + "$type": "dimension", + "$value": "480dp", + "px": 960.0, + "dp": 480.0, + "source": "index.html:105", + "usedBy": "hero overview max width; 2-line clamp" + }, + "detailCopyTop": { + "$type": "dimension", + "$value": "100dp", + "px": 200.0, + "dp": 100.0, + "source": "index.html:212", + "usedBy": "title page copy block top" + }, + "detailCopyWidth": { + "$type": "dimension", + "$value": "530dp", + "px": 1060.0, + "dp": 530.0, + "source": "index.html:212", + "usedBy": "title page copy block width; overview 3-line clamp" + }, + "heroSheet": { + "$type": "dimension", + "px": [ + 420, + 630 + ], + "dp": [ + 210, + 315 + ], + "right": 180, + "top": 150, + "source": "index.html:97", + "usedBy": "one-sheet poster on a poster-only Home hero/title page; Discover picks hero uses 360x540 at top 140 (.row-phero .art-sheet)" + }, + "buttonHeight": { + "$type": "dimension", + "$value": "38dp", + "px": 76.0, + "dp": 38.0, + "source": "index.html:107", + "usedBy": "pill button height (primary, secondary, sheet buttons)" + }, + "buttonPaddingX": { + "$type": "dimension", + "$value": "19dp", + "px": 38.0, + "dp": 19.0, + "source": "index.html:107", + "usedBy": "pill button horizontal padding" + }, + "buttonGap": { + "$type": "dimension", + "$value": "10dp", + "px": 20.0, + "dp": 10.0, + "source": "index.html:106", + "usedBy": "gap between buttons in a row" + }, + "buttonIcon": { + "$type": "dimension", + "$value": "15dp", + "px": 30.0, + "dp": 15.0, + "source": "index.html:108", + "usedBy": "glyph inside a pill button; icon gap 14" + }, + "iconButton": { + "$type": "dimension", + "$value": "38dp", + "px": 76.0, + "dp": 38.0, + "source": "index.html:274", + "usedBy": "round icon button (Restart, Version, Audio and subtitles, Mark watched, Trailer, More); glyph 32" + }, + "iconCaptionLine": { + "$type": "dimension", + "$value": "19dp", + "px": 38.0, + "dp": 19.0, + "source": "index.html:281", + "usedBy": "reserved caption line under a button row that has icon buttons (margin-top 16); shows \"label · note\" of the focused icon button" + }, + "sheetWidth": { + "$type": "dimension", + "$value": "400dp", + "px": 800.0, + "dp": 400.0, + "source": "index.html:227", + "usedBy": "right-side sheet width; padding 80 top/bottom, 64 sides; vertical gap 18" + }, + "sheetRow": { + "$type": "dimension", + "$value": "50dp", + "px": 100.0, + "dp": 50.0, + "source": "index.html:231", + "usedBy": "person row in request sheets (version rows auto height, padding 20/26; track rows 84)" + }, + "settingsRow": { + "$type": "dimension", + "px": [ + 1300, + 104 + ], + "dp": [ + 650, + 52 + ], + "source": "index.html:321", + "usedBy": "Settings list row (icon 36, title 30/700, one-line description 24, pills, chevron)" + }, + "stateWidth": { + "$type": "dimension", + "$value": "550dp", + "px": 1100.0, + "dp": 550.0, + "source": "index.html:194", + "usedBy": "state panel width; padding 34/40; title 36; text 28" + }, + "captionOffset": { + "$type": "dimension", + "px": 38, + "dp": 19, + "source": "index.html:1242", + "usedBy": "caption under a focused poster starts 38 px below the card bottom (capHtml top = headH + h + CAP_OFF; was 18, amended 2026-11-06); width 640" + }, + "plateInset": { + "$type": "dimension", + "px": { + "top": 14, + "side": 16, + "bottomFade": 46 + }, + "available": "card width - 32", + "source": "index.html:162, index.html:1234", + "usedBy": "rating plate padding; the plate row must fit width - 32 px" + }, + "badge": { + "$type": "dimension", + "$value": "24dp", + "px": 48.0, + "dp": 24.0, + "source": "index.html:141", + "usedBy": "state badge circle, 14 px from right and bottom; glyph 28" + }, + "badgeInset": { + "$type": "dimension", + "$value": "7dp", + "px": 14.0, + "dp": 7.0, + "source": "index.html:141", + "usedBy": "badge inset from the card corner" + }, + "progressBar": { + "$type": "dimension", + "$value": "4dp", + "px": 8.0, + "dp": 4.0, + "source": "index.html:138", + "usedBy": "progress bar on wide cards (inset 18 left/right, 12 from bottom); title page bar is 320x8" + }, + "pickerAvatar": { + "$type": "dimension", + "$value": "110dp", + "px": 220.0, + "dp": 110.0, + "source": "index.html:261", + "usedBy": "Who's watching avatar; letter 100 px; avatars 90 px apart; name 26 px below" + }, + "pickerGap": { + "$type": "dimension", + "$value": "45dp", + "px": 90.0, + "dp": 45.0, + "source": "index.html:259", + "usedBy": "gap between people in the picker (also between gear slots)" + }, + "pickerStackGap": { + "$type": "dimension", + "$value": "35dp", + "px": 70.0, + "dp": 35.0, + "source": "index.html:256", + "usedBy": "vertical gap between \"Who's watching?\", the people and the gear row (gear row margin-top -34)" + }, + "gear": { + "$type": "dimension", + "$value": "28dp", + "px": 56.0, + "dp": 28.0, + "source": "index.html:306", + "usedBy": "Settings gear under the last person; glyph 30; caption 24 px, 72 px below the gear top, visible only on focus" + }, + "navAvatar": { + "$type": "dimension", + "$value": "32dp", + "px": 64.0, + "dp": 32.0, + "source": "index.html:90", + "usedBy": "avatar at the right end of the nav" + }, + "castCircle": { + "$type": "dimension", + "$value": "100dp", + "px": 200, + "dp": 100.0, + "source": "index.html:285", + "usedBy": "cast circle; name 26/700 at 216 px; character 24 muted; card 290 tall" + }, + "personPortrait": { + "$type": "dimension", + "$value": "120dp", + "px": 240.0, + "dp": 120.0, + "source": "index.html:294", + "usedBy": "person page portrait circle; header at x 96, y 170, gap 48" + }, + "thumb": { + "$type": "dimension", + "px": [ + 96, + 144 + ], + "dp": [ + 48, + 72 + ], + "source": "index.html:248", + "usedBy": "Coming-in poster thumbnail" + }, + "toast": { + "$type": "dimension", + "px": { + "right": 96, + "top": 150, + "maxWidth": 760 + }, + "source": "index.html:270", + "usedBy": "toast position (top right under the nav)" + }, + "letterRail": { + "$type": "dimension", + "px": { + "width": 52, + "letterHeight": 30, + "focusedLetterHeight": 44, + "right": 96, + "top": 150 + }, + "source": "index.html:204", + "usedBy": "letter rail (only for destinations with >= 200 titles)" + }, + "row_chipsGrid": { + "$type": "dimension", + "$value": "68dp", + "px": 136, + "dp": 68.0, + "source": "index.html:rowH", + "usedBy": "chip row directly above a grid (library toolbar, See all type chips): 136 px, so 52 px from the chips to the first card row (was 104 / 20 px)" + } + }, + "card": { + "poster": { + "$type": "card", + "px": { + "w": 256, + "h": 384, + "gap": 40, + "pitch": 296, + "captionBand": 96 + }, + "dp": { + "w": 128.0, + "h": 192.0, + "gap": 20.0, + "pitch": 148.0, + "captionBand": 48.0 + }, + "ratio": "2:3", + "fullSlotsPerRow": 6, + "source": "index.html:656", + "usedBy": "every title shelf, library grid, See all grid, More like this, person filmography; art only at rest" + }, + "tile": { + "$type": "card", + "px": { + "w": 400, + "h": 225, + "gap": 40, + "pitch": 440, + "captionBand": 0 + }, + "dp": { + "w": 200.0, + "h": 112.5, + "gap": 20.0, + "pitch": 220.0, + "captionBand": 0.0 + }, + "ratio": "16:9", + "fullSlotsPerRow": 4, + "source": "index.html:657", + "usedBy": "Next up, New in your library, season episodes; title + second line on a bottom gradient" + }, + "wide": { + "$type": "card", + "px": { + "w": 544, + "h": 306, + "gap": 40, + "pitch": 584, + "captionBand": 0 + }, + "dp": { + "w": 272.0, + "h": 153.0, + "gap": 20.0, + "pitch": 292.0, + "captionBand": 0.0 + }, + "ratio": "16:9", + "fullSlotsPerRow": 3, + "source": "index.html:658", + "usedBy": "Continue watching (only when a person has more than one resume item; the first resume is the hero)" + }, + "entry": { + "$type": "card", + "px": { + "w": 256, + "h": 144, + "gap": 40, + "pitch": 296, + "captionBand": 0 + }, + "dp": { + "w": 128.0, + "h": 72.0, + "gap": 20.0, + "pitch": 148.0, + "captionBand": 0.0 + }, + "ratio": "16:9", + "fullSlotsPerRow": 6, + "source": "index.html:659", + "usedBy": "Discover Services row (text wordmark on a tinted gradient; no third-party logos)" + }, + "person": { + "$type": "card", + "px": { + "w": 200, + "h": 310, + "gap": 44, + "pitch": 244, + "captionBand": 0 + }, + "dp": { + "w": 100.0, + "h": 155.0, + "gap": 22.0, + "pitch": 122.0, + "captionBand": 0.0 + }, + "ratio": "1:1 circle", + "fullSlotsPerRow": 7, + "source": "index.html:660", + "usedBy": "Cast row: 200 px circle + name + character, 310 tall (name starts 236 below the top)" + } + }, + "focus": { + "scaleCard": { + "$type": "number", + "$value": 1.08, + "source": "index.html:134", + "page": { + ".card.f": 1.08, + ".pp.f (picker person)": 1.08, + ".pgear.f": 1.08, + "person circle": 1.08 + }, + "usedBy": "cards of every kind, cast circles, picker people, the gear" + }, + "scaleControl": { + "$type": "number", + "$value": 1.05, + "source": "index.html:189", + "page": { + ".btn.f": 1.06, + ".chip.f": 1.05 + }, + "usedBy": "pill and icon buttons, chips", + "note": "q1 = 3 scales, settled by Justin 2026-11-05 (corr=c248698d8546e1ab): buttons 1.05. The page button scale 1.06 folds into 1.05 (delta 0.01 = 2 px on a 200 px button). Spec 4.5, 12.4 #1." + }, + "scaleRow": { + "$type": "number", + "$value": 1.02, + "source": "index.html:327", + "page": { + ".prow.f": 1.03, + ".crow.f": 1.02, + ".srow.f": 1.02 + }, + "usedBy": "full-width list rows: sheet rows, Coming-in rows, Settings rows", + "note": "q1, settled by Justin 2026-11-05 (corr=c248698d8546e1ab): sheet rows 1.02. The page sheet-row scale 1.03 folds into 1.02. Spec 4.5, 12.4 #1." + }, + "ringCard": { + "$type": "shadow", + "$value": "0 0 0 2px var(--accent), inset 0 0 0 2px var(--accent) (drawn above the art), 0 18px 50px rgb(0 0 0 / .65)", + "gapPx": 0, + "ringPx": 4, + "gapDp": 0, + "ringDp": 2, + "ringColor": "color.accent", + "liftShadow": "0 18px 50px rgb(0 0 0 / .65)", + "source": "index.html:134", + "usedBy": "focused card (poster, tile, wide, entry) and cast circle: one 4 px lilac ring centred on the art edge, 2 px outside and 2 px over the art, no gap, plus a lift shadow (Justin 2026-11-06: \"2px inset, 4px ring\")", + "outsidePx": 2, + "insidePx": 2 + }, + "ringControl": { + "$type": "shadow", + "$value": "0 0 0 2px var(--accent),inset 0 0 0 2px var(--accent),0 12px 40px rgb(0 0 0 / .5)", + "gapPx": 0, + "ringPx": 4, + "gapDp": 0, + "ringDp": 2, + "outsidePx": 2, + "insidePx": 2, + "alignment": "straddle", + "ringColor": "color.accent", + "liftShadow": "0 12px 40px rgb(0 0 0 / .5)", + "source": "index.html:111", + "usedBy": "focused pill and icon buttons, including hero, title, recovery and sheet actions; existing fills, text colours, scale and lift unchanged" + }, + "ringAvatar": { + "$type": "shadow", + "$value": "0 0 0 2px var(--accent),inset 0 0 0 2px var(--accent)", + "gapPx": 0, + "ringPx": 4, + "gapDp": 0, + "ringDp": 2, + "outsidePx": 2, + "insidePx": 2, + "alignment": "straddle", + "ringColor": "color.accent", + "source": "index.html:92,312", + "usedBy": "focused nav avatar and gear; existing scale unchanged, no lift" + }, + "ringPicker": { + "$type": "shadow", + "$value": "0 0 0 2px var(--accent),inset 0 0 0 2px var(--accent)", + "gapPx": 0, + "ringPx": 4, + "gapDp": 0, + "ringDp": 2, + "outsidePx": 2, + "insidePx": 2, + "alignment": "straddle", + "ringColor": "color.accent", + "source": "index.html:265", + "usedBy": "focused person in Who's watching; scale 1.08, name turns fg, no lift" + }, + "fillInverse": { + "$type": "rule", + "$value": "background color.fg, text color.onFocus", + "source": "index.html:189, index.html:237, index.html:327, index.html:253", + "usedBy": "focused chip, sheet row, Settings row, Coming-in row and nav blob: white fill with dark text (no ring)" + } + }, + "motion": { + "fast": { + "$type": "duration", + "$value": "140ms", + "easing": "ease = cubic-bezier(.25,.1,.25,1)", + "source": "index.html:125", + "page": { + ".card/.btn/.chip/.pp/.prow/.crow/.srow/.pgear transform": "140ms", + ".cap opacity (caption fade-in)": "120ms", + ".dots svg opacity": "140ms" + }, + "usedBy": "focus scale + ring in and out on every focusable; caption fade under a focused poster (page 120 ms folds in)", + "note": "q1, settled by Justin 2026-11-05 (corr=c248698d8546e1ab): the caption fade is 140 ms. Three durations in total (140, 160, 180) plus the blob spring." + }, + "base": { + "$type": "duration", + "$value": "160ms", + "easing": "ease", + "source": "index.html:162", + "page": { + ".rplate opacity": "160ms", + "#nav background": "160ms", + ".track transform (row slide)": "160ms", + ".chips transform": "160ms", + ".dbg .dim opacity": "160ms", + ".toast opacity": "160ms" + }, + "usedBy": "rating plate fade, nav band solid/transparent, row track slide by one card, chip strip shift, title-page dim as it scrolls" + }, + "scroll": { + "$type": "duration", + "$value": "180ms", + "easing": "ease-out cubic: 1 - (1 - t)^3 = cubic-bezier(.33,1,.68,1)", + "source": "index.html:1076", + "usedBy": "vertical frame scroll to the pinned line (Compose BringIntoViewSpec animation spec)" + }, + "navBlob": { + "$type": "transition", + "$value": { + "type": "spring", + "dampingRatio": 1.0, + "stiffness": 650 + }, + "page": { + "transition": "transform .2s ease-out,width .2s ease-out,height .2s ease-out,background-color .2s ease-out", + "measured": "x 478 -> 688 px: 557.4 at 50 ms, 621.8 at 99 ms (68.5% of travel), 668.4 at 150 ms, at rest by 200 ms; width 198 -> 171 animates with it" + }, + "source": "index.html:76", + "usedBy": "nav indicator: x, width, height and colour (white when the nav has focus, rest colour behind the current destination when not); label colour follows (page .ni color .2s ease-out)", + "verified": { + "script": "data/tv-spec/spring_check.py", + "method": "mass 1, x'' = -k(x-1) - 2*zeta*sqrt(k)*x', integrated at 0.01 ms steps; k searched 400-1000 step 10 for least error against the page measurements", + "pxAt50_99_150_200ms": [ + 554.5, + 628.7, + 665.9, + 680.2 + ], + "pageMeasuredPx": [ + 557.4, + 621.8, + 668.4, + 688.0 + ], + "reaches95pctMs": 186, + "reaches99pctMs": 260, + "within1pxMs": 294, + "maxOvershoot": 0, + "previousStiffness": "530 (revision 1, reached 66.4% at 99 ms against the page 68.5%)" + }, + "note": "q1 = spring, settled by Justin 2026-11-05 (corr=c248698d8546e1ab): a no-bounce spring matched to the measured 200 ms ease-out. Damping ratio 1.0 is critically damped, so it cannot overshoot. Stiffness 650 is the best fit to the measured curve (revision 1 had 530, an untested derivation). Visually settled (95%) by 186 ms; the last pixel arrives at about 300 ms. Snap with no animation when the system animator scale is 0." + }, + "ratingDwell": { + "$type": "duration", + "$value": "350ms", + "source": "index.html:860", + "usedBy": "a focused card requests ratings only after 350 ms of dwell; moving on cancels; cached ratings show at once (fork DEBOUNCE_MILLIS 350)" + }, + "toast": { + "$type": "duration", + "$value": "3200ms", + "source": "index.html:1733", + "usedBy": "toast visible time" + }, + "reducedMotion": { + "$type": "rule", + "$value": "all transitions instant", + "source": "index.html:330", + "usedBy": "when Settings.Global ANIMATOR_DURATION_SCALE == 0: no scale animation, no scroll animation, blob snaps" + } + }, + "image": { + "posterRequest": { + "px": [ + 256, + 384 + ], + "decode": "RGB565", + "source": "index.html:548 (memory table), answers p3", + "usedBy": "poster cards; decode at min(source, slot); fixture sources are 300x450" + }, + "tileRequest": { + "px": [ + 416, + 234 + ], + "decode": "ARGB_8888", + "source": "index.html:549", + "usedBy": "tile cards" + }, + "wideRequest": { + "px": [ + 560, + 315 + ], + "decode": "ARGB_8888", + "source": "index.html:401", + "usedBy": "Continue watching cards" + }, + "heroRequest": { + "px": [ + 1920, + 1080 + ], + "decode": "ARGB_8888", + "bytes": 8294400, + "source": "feedback-2026-11-06: panel-resolution artwork", + "usedBy": "Home hero, Discover hero and title backdrop; bounded 1920x1080 decode" + }, + "heroPosterRequest": { + "px": [420, 630], + "decode": "RGB565", + "source": "feedback-2026-11-06: largest upright hero poster slot" + }, + "episodeRequest": { + "px": [400, 280], + "decode": "ARGB_8888", + "source": "feedback-2026-11-06: taller episode cards" + }, + "castRequest": { + "px": [ + 200, + 200 + ], + "source": "index.html:550", + "usedBy": "cast portraits: decode min(source 185x278, slot)" + }, + "crossfade": { + "$value": false, + "source": "index.html:536; answers p3", + "usedBy": "no crossfade on cards (Coil crossfade off)" + }, + "memoryCacheMB": { + "$value": 64, + "source": "index.html:552; answers p3", + "usedBy": "Coil memory cache cap (not a heap percentage)" + }, + "diskCacheMB": { + "$value": 128, + "source": "index.html:553", + "usedBy": "Coil disk cache" + }, + "concurrentReads": { + "$value": { + "catalogue": 2, + "image": 4 + }, + "source": "index.html:489 (Rules row)", + "usedBy": "at most 2 catalogue reads and 4 image reads at once" + } + }, + "behaviour": { + "rowLimit": { + "$value": 20, + "source": "index.html:666", + "usedBy": "cards per shelf before See all (D3)" + }, + "letterRailMin": { + "$value": 200, + "source": "index.html:667", + "usedBy": "the letter rail exists only for a destination of >= 200 titles, sort A-Z (n4)" + }, + "gridColumns": { + "$value": 6, + "source": "index.html:1425", + "usedBy": "library, See all and person grids" + }, + "libraryChunk": { + "$value": 200, + "source": "index.html:469", + "usedBy": "library read in 200-item chunks ahead of focus" + }, + "laneLoadRows": { + "$value": 2, + "source": "index.html:472", + "usedBy": "catalogue lane is read when within 2 rows of focus" + }, + "nextPageCards": { + "$value": 10, + "source": "index.html:472", + "usedBy": "catalogue page 2+ is read when focus is 10 cards from the row end" + }, + "heroSlides": { + "$value": 5, + "source": "index.html:883", + "usedBy": "Discover picks hero slides" + }, + "cacheRatingsHours": { + "$value": 6, + "entries": 300, + "source": "index.html:473", + "usedBy": "ratings cache per (type, tmdb) and per (series, season)" + }, + "laneCacheMinutes": { + "$value": 30, + "source": "index.html:472", + "usedBy": "last-known-good per catalogue lane" + }, + "libraryRefreshMinutes": { + "$value": 10, + "source": "index.html:469", + "usedBy": "library snapshot refreshed on open if older than 10 min" + }, + "requestsPollSeconds": { + "$value": 30, + "source": "index.html:479", + "usedBy": "Requests (Coming in) poll while a request is unsettled or the sheet is open" + }, + "comingInVisibleRows": { + "$value": 5, + "expanded": 4, + "source": "index.html:1907", + "usedBy": "Coming-in sheet shows 5 rows (4 while one is expanded), window follows focus" + } + } +} diff --git a/wolfskin-design/tokens.sha256 b/wolfskin-design/tokens.sha256 new file mode 100644 index 00000000..713ae2f4 --- /dev/null +++ b/wolfskin-design/tokens.sha256 @@ -0,0 +1,8 @@ +b3f096b692c395534177cc82ac68f5b801870bc7e28f0dd5cd7c17e2103920a3 discover-tokens.json +85109c394255f6120a621fbefe8e58df82832974ccd6a6a975a61567f641ea2a generate_tokens.py +25985087986a46f00e2819917d0421ff2b437ec21c54f6ce6412b84c0eb05302 home-tokens.json +be3af0ddcb0d67abef0805d19d7a33cdb55428bcace656b50f0a38935f212028 shell-tokens.json +bb64e45654fce2cd3ba595b505fb8b5a540526cef696dfa974be25f028d3e119 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt +e0a2f5df2f249bce3f6e13f4a0320c7b18b85777eaf04a27a428d6683a6aad28 title-tokens.json +36d5b2646dff2e4055d2683e4c284111a6886c311e9c924b73061fa785bc863d tokens.css +3226c6fb9c7e35c5bde90aaf01630b238cfcb9a768ff60c49461e19a200c9125 tokens.json